feat(plan): show work item details and wrap titles

This commit is contained in:
Dave 2026-09-22 20:55:22 +02:00
parent ab81f32cdf
commit 78fc06555d
6 changed files with 215 additions and 26 deletions

View file

@ -86,38 +86,41 @@
.plan-axis-days { position: absolute; top: 34px; right: 0; left: 0; height: 33px; }
.plan-axis-days span { position: absolute; top: 0; bottom: 0; display: grid; place-items: center; border-right: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
.plan-axis-days span.is-today { background: var(--brand-soft); color: var(--brand); font-weight: 800; }
.plan-timeline-row { height: 61px; border-bottom: 1px solid var(--border-subtle); }
.plan-timeline-row { min-height: 61px; border-bottom: 1px solid var(--border-subtle); }
.plan-timeline-row:last-child { border-bottom: 0; }
.plan-timeline-row:hover .plan-card-label { background: var(--surface); }
.plan-card-label { display: flex; flex-direction: column; justify-content: center; padding: 0 37px 0 17px; transition: background .15s; }
.plan-card-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
.plan-priority-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--plan-cyan); }
.plan-card-label { display: flex; flex-direction: column; justify-content: center; min-height: 61px; padding: 10px 37px 10px 17px; transition: background .15s; }
.plan-card-details-button { display: block; width: 100%; min-width: 0; border-radius: 5px; color: inherit; text-align: left; }
.plan-card-details-button:hover .plan-card-title { color: var(--brand); }
.plan-card-details-button:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
.plan-card-name { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
.plan-priority-dot { width: 7px; height: 7px; flex: none; margin-top: 5px; border-radius: 50%; background: var(--plan-cyan); }
.plan-priority-dot.is-critical { background: var(--plan-coral); }
.plan-priority-dot.is-high { background: var(--plan-amber); }
.plan-priority-dot.is-low { background: var(--plan-olive); }
.plan-card-title { overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.plan-card-subline { display: flex; gap: 8px; margin: 5px 0 0 15px; color: var(--text-muted); font-size: 10px; white-space: nowrap; }
.plan-card-title { min-width: 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 700; line-height: 1.35; }
.plan-card-subline { display: flex; flex-wrap: wrap; gap: 4px 8px; margin: 5px 0 0 15px; color: var(--text-muted); font-size: 10px; }
.plan-card-subline span + span::before { content: "·"; margin-right: 8px; color: var(--border-strong); }
.plan-card-subline .plan-needs-change { color: var(--accent); font-weight: 800; }
.plan-row-edit { position: absolute; right: 11px; top: 19px; display: grid; width: 24px; height: 24px; place-items: center; border-radius: 6px; color: var(--text-muted); opacity: .55; }
.plan-row-edit { position: absolute; right: 11px; top: 50%; display: grid; width: 24px; height: 24px; place-items: center; border-radius: 6px; color: var(--text-muted); opacity: .55; transform: translateY(-50%); }
.plan-row-edit:hover { background: var(--surface-raised); color: var(--brand); opacity: 1; }
.plan-row-track { position: relative; flex: none; background: color-mix(in srgb, var(--surface) 38%, var(--panel)); }
.plan-row-track { position: relative; min-height: 61px; flex: none; background: color-mix(in srgb, var(--surface) 38%, var(--panel)); }
.plan-row-track.is-weeks { background-image: repeating-linear-gradient(to right, transparent 0, transparent 30px, color-mix(in srgb, var(--border-subtle) 52%, transparent) 30px, color-mix(in srgb, var(--border-subtle) 52%, transparent) 31px); }
.plan-track-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border-subtle); }
.plan-today-line { position: absolute; z-index: 1; top: 0; bottom: 0; width: 2px; background: var(--brand); opacity: .65; pointer-events: none; }
.plan-bar { position: absolute; z-index: 2; top: 18px; height: 25px; overflow: hidden; border-radius: 99px; background: var(--plan-cyan); box-shadow: inset 0 1px 1px #ffffff9c, 0 2px 5px #051a2d1d; color: #082938; cursor: pointer; }
.plan-bar { position: absolute; z-index: 2; top: calc(50% - 12px); height: 25px; overflow: hidden; border-radius: 99px; background: var(--plan-cyan); box-shadow: inset 0 1px 1px #ffffff9c, 0 2px 5px #051a2d1d; color: #082938; cursor: pointer; }
.plan-bar.is-critical { background: var(--plan-coral); }
.plan-bar.is-high { background: var(--plan-amber); }
.plan-bar.is-low { background: var(--plan-olive); }
.plan-bar:disabled { cursor: default; }
.plan-bar:hover:not(:disabled) { filter: brightness(1.06); transform: translateY(-1px); }
.plan-bar.is-approved-ghost { z-index: 1; top: 13px; height: 35px; border: 1px dashed var(--brand); background: var(--brand-soft); box-shadow: none; opacity: .9; pointer-events: none; }
.plan-bar.is-approved-ghost { z-index: 1; top: calc(50% - 17px); height: 35px; border: 1px dashed var(--brand); background: var(--brand-soft); box-shadow: none; opacity: .9; pointer-events: none; }
.plan-bar-progress { position: absolute; top: 2px; bottom: 2px; left: 2px; max-width: calc(100% - 4px); border-radius: 99px; background: #ffffff6e; pointer-events: none; }
.plan-bar-title { position: relative; display: block; overflow: hidden; padding: 0 11px; font-size: 10px; font-weight: 800; line-height: 25px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.plan-set-dates, .plan-awaiting-dates { position: absolute; top: 17px; left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border: 1px dashed var(--border-strong); border-radius: 99px; background: var(--panel); color: var(--text-muted); font-size: 10px; font-weight: 700; white-space: nowrap; }
.plan-set-dates, .plan-awaiting-dates { position: absolute; top: calc(50% - 13px); left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border: 1px dashed var(--border-strong); border-radius: 99px; background: var(--panel); color: var(--text-muted); font-size: 10px; font-weight: 700; white-space: nowrap; }
.plan-set-dates:hover { border-color: var(--brand); color: var(--brand); }
.plan-awaiting-dates { border: 0; background: transparent; font-weight: 500; }
.plan-outside-range { position: absolute; top: 17px; left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border-radius: 99px; background: var(--brand-soft); color: var(--brand); font-size: 10px; font-weight: 800; white-space: nowrap; }
.plan-outside-range { position: absolute; top: calc(50% - 13px); left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border-radius: 99px; background: var(--brand-soft); color: var(--brand); font-size: 10px; font-weight: 800; white-space: nowrap; }
.plan-outside-range:hover { filter: brightness(1.08); }
.plan-project-footer { gap: 12px; min-height: 61px; padding: 11px 19px; border-top: 1px solid var(--border-subtle); }
@ -169,6 +172,26 @@
.plan-calendar-day.is-between { border-radius: 0; background: var(--brand-soft); }
.plan-calendar-day.is-selected { background: var(--brand); color: var(--brand-fg); font-weight: 800; }
.plan-dialog-bottom { align-items: flex-end; gap: 14px; padding: 20px 25px 24px; }
.plan-item-dialog { width: min(100%, 560px); max-height: calc(100vh - 40px); overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 17px; background: var(--panel); box-shadow: 0 22px 70px #0017285c; }
.plan-item-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 24px 27px 18px; border-bottom: 1px solid var(--border-subtle); }
.plan-item-top p { margin-top: 6px; color: var(--text-muted); font-size: 12px; }
.plan-item-body { padding: 22px 27px 24px; }
.plan-item-body h2 { overflow-wrap: anywhere; font-family: Georgia, "Times New Roman", serif; font-size: clamp(22px, 4vw, 29px); font-weight: 600; line-height: 1.15; }
.plan-item-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 17px; }
.plan-item-tags > span { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface); color: var(--text-secondary); font-size: 11px; font-weight: 700; }
.plan-item-priority i { width: 7px; height: 7px; border-radius: 50%; background: var(--plan-cyan); }
.plan-item-priority.is-critical i { background: var(--plan-coral); }
.plan-item-priority.is-high i { background: var(--plan-amber); }
.plan-item-priority.is-low i { background: var(--plan-olive); }
.plan-item-section { margin-top: 25px; }
.plan-item-section h3 { margin-bottom: 9px; color: var(--text-muted); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.plan-item-section p { overflow-wrap: anywhere; color: var(--text-secondary); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
.plan-item-section p.is-muted { color: var(--text-muted); }
.plan-item-facts { display: grid; gap: 9px; }
.plan-item-facts div { display: flex; justify-content: space-between; gap: 15px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: 8px; background: var(--surface); font-size: 12px; }
.plan-item-facts dt { color: var(--text-muted); }
.plan-item-facts dd { color: var(--text-primary); font-weight: 700; text-align: right; }
.plan-item-footer { display: flex; justify-content: space-between; gap: 9px; padding: 18px 27px 23px; border-top: 1px solid var(--border-subtle); }
.plan-quick-actions { display: flex; flex-wrap: wrap; gap: 6px 11px; }
.plan-quick-actions button { color: var(--brand); font-size: 10px; font-weight: 750; }
.plan-quick-actions button:hover { text-decoration: underline; }
@ -188,6 +211,10 @@
}
@media (max-width: 530px) {
.plan-item-top { padding: 20px 20px 16px; }
.plan-item-body { padding: 20px; }
.plan-item-footer { flex-direction: column-reverse; padding: 16px 20px 20px; }
.plan-item-footer > * { width: 100%; }
.plan-toolbar-filters { align-items: stretch; flex-direction: column; }
.plan-project-filter select { min-width: 0; width: 100%; }
.plan-project-filter { width: 100%; }

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
@ -21,6 +21,7 @@ import {
submitProjectPlanAction,
} from "@/app/actions/project-plan";
import { DateRangeDialog } from "@/components/planning/date-range-dialog";
import { WorkItemDialog } from "@/components/planning/work-item-dialog";
import { useToast } from "@/components/ui/toast";
import {
calendarAxis,
@ -70,6 +71,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
const [zoom, setZoom] = useState<CalendarZoom>("weeks");
const [anchor, setAnchor] = useState(todayLocalIso());
const [editing, setEditing] = useState<EditingItem | null>(null);
const [details, setDetails] = useState<EditingItem | null>(null);
const [pending, setPending] = useState(false);
const [feedbackFor, setFeedbackFor] = useState<string | null>(null);
const [feedback, setFeedback] = useState("");
@ -89,6 +91,8 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
const waiting = board.filter((project) => project.plan.status === "submitted").length;
const editProject = board.find((project) => project.id === editing?.projectId);
const editCard = editProject?.cards.find((card) => card.id === editing?.cardId);
const detailsProject = board.find((project) => project.id === details?.projectId);
const detailsCard = detailsProject?.cards.find((card) => card.id === details?.cardId);
function applyPlan(projectId: string, plan: ProjectPlan) {
setBoard((current) => current.map((project) => {
@ -264,7 +268,11 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
sortOrder={sortOrder}
canEdit={canEdit}
onJump={setAnchor}
onEdit={(cardId) => setEditing({ projectId: project.id, cardId })}
onDetails={(cardId) => setDetails({ projectId: project.id, cardId })}
onEdit={(cardId) => {
setDetails(null);
setEditing({ projectId: project.id, cardId });
}}
/>
<footer className="plan-project-footer">
@ -324,6 +332,18 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
onClose={() => !pending && setEditing(null)}
/>
)}
{detailsProject && detailsCard && (
<WorkItemDialog
card={detailsCard}
projectName={`${detailsProject.clientName} · ${detailsProject.name}`}
canEdit={!(role === "client_manager" && detailsProject.plan.status === "submitted")}
onClose={() => setDetails(null)}
onPlan={() => {
setDetails(null);
setEditing({ projectId: detailsProject.id, cardId: detailsCard.id });
}}
/>
)}
</div>
);
}
@ -335,6 +355,7 @@ function ProjectTimeline({
sortOrder,
canEdit,
onJump,
onDetails,
onEdit,
}: {
project: PlanningProject;
@ -343,6 +364,7 @@ function ProjectTimeline({
sortOrder: PlanningSort;
canEdit: boolean;
onJump: (date: string) => void;
onDetails: (cardId: string) => void;
onEdit: (cardId: string) => void;
}) {
const today = todayLocalIso();
@ -404,6 +426,7 @@ function ProjectTimeline({
canEdit={canEdit}
needsChange={Boolean(project.plan.feedback) && project.plan.feedbackCardId === card.id}
onJump={onJump}
onDetails={() => onDetails(card.id)}
onEdit={() => onEdit(card.id)}
/>
))}
@ -420,6 +443,7 @@ function TimelineRow({
canEdit,
needsChange,
onJump,
onDetails,
onEdit,
}: {
card: PlanningCard;
@ -429,8 +453,32 @@ function TimelineRow({
canEdit: boolean;
needsChange: boolean;
onJump: (date: string) => void;
onDetails: () => void;
onEdit: () => void;
}) {
const detailsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (detailsTimer.current) clearTimeout(detailsTimer.current);
}, []);
function openDetailsAfterClick(detail: number) {
if (detailsTimer.current) clearTimeout(detailsTimer.current);
if (detail === 0 || !canEdit) {
onDetails();
return;
}
detailsTimer.current = setTimeout(() => {
detailsTimer.current = null;
onDetails();
}, 240);
}
function openDateEditorOnDoubleClick() {
if (detailsTimer.current) clearTimeout(detailsTimer.current);
detailsTimer.current = null;
onEdit();
}
const draft = card.draftRange ? rangePixels(card.draftRange, axis) : null;
const approved = card.approvedRange && !sameRange(card.approvedRange, card.draftRange)
? rangePixels(card.approvedRange, axis)
@ -438,18 +486,20 @@ function TimelineRow({
const progress = card.subtaskTotal > 0 ? Math.round((card.subtaskDone / card.subtaskTotal) * 100) : null;
return (
<div className="plan-timeline-row" onDoubleClick={canEdit ? onEdit : undefined}>
<div className="plan-timeline-row" onDoubleClick={canEdit ? openDateEditorOnDoubleClick : undefined}>
<div className="plan-sticky-label plan-card-label">
<div className="plan-card-name">
<span className={`plan-priority-dot is-${card.priority}`} />
<span className="plan-card-title" title={card.title}>{card.title}</span>
</div>
<div className="plan-card-subline">
<span>{card.columnName}</span>
{card.number > 0 && <span>#{card.number}</span>}
{progress !== null && <span>{progress}% complete</span>}
{needsChange && <span className="plan-needs-change">Needs a change</span>}
</div>
<button type="button" className="plan-card-details-button" onClick={(event) => openDetailsAfterClick(event.detail)} aria-label={`View details for ${card.title}`}>
<span className="plan-card-name">
<span className={`plan-priority-dot is-${card.priority}`} />
<span className="plan-card-title">{card.title}</span>
</span>
<span className="plan-card-subline">
<span>{card.columnName}</span>
{card.number > 0 && <span>#{card.number}</span>}
{progress !== null && <span>{progress}% complete</span>}
{needsChange && <span className="plan-needs-change">Needs a change</span>}
</span>
</button>
{canEdit && <button type="button" className="plan-row-edit" onClick={onEdit} aria-label={`Plan dates for ${card.title}`}><Pencil size={14} /></button>}
</div>
<div className={`plan-row-track is-${zoom}`} style={{ width: axis.width }}>

View file

@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef } from "react";
import Link from "next/link";
import { ArrowUpRight, CalendarDays, X } from "lucide-react";
import type { PlanningCard } from "@/lib/planning/load";
const priorityLabel: Record<PlanningCard["priority"], string> = {
critical: "Critical priority",
high: "High priority",
medium: "Medium priority",
low: "Low priority",
};
const dateText = (value: string) =>
new Date(`${value}T00:00:00.000Z`).toLocaleDateString("en", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: "UTC",
});
export function WorkItemDialog({
card,
projectName,
canEdit,
onClose,
onPlan,
}: {
card: PlanningCard;
projectName: string;
canEdit: boolean;
onClose: () => void;
onPlan: () => void;
}) {
const closeButton = useRef<HTMLButtonElement>(null);
const dialog = useRef<HTMLElement>(null);
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement | null;
closeButton.current?.focus();
return () => previousFocus?.focus();
}, []);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
if (event.key !== "Tab" || !dialog.current) return;
const focusable = Array.from(dialog.current.querySelectorAll<HTMLElement>("button, a[href]"));
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<div className="plan-dialog-backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<section ref={dialog} className="plan-item-dialog" role="dialog" aria-modal="true" aria-label={`Details for ${card.title}`}>
<header className="plan-item-top">
<div>
<span className="plan-eyebrow">WORK ITEM{card.number > 0 ? ` · #${card.number}` : ""}</span>
<p>{projectName}</p>
</div>
<button ref={closeButton} type="button" className="plan-icon-button" onClick={onClose} aria-label="Close work item details"><X size={18} /></button>
</header>
<div className="plan-item-body">
<h2>{card.title}</h2>
<div className="plan-item-tags">
<span className={`plan-item-priority is-${card.priority}`}><i />{priorityLabel[card.priority]}</span>
<span>{card.columnName}</span>
</div>
<section className="plan-item-section">
<h3>What needs doing</h3>
<p className={card.description ? "" : "is-muted"}>{card.description || "No description has been added yet."}</p>
</section>
<section className="plan-item-section">
<h3>Dates and progress</h3>
<dl className="plan-item-facts">
<div><dt>Planned dates</dt><dd>{card.draftRange ? `${dateText(card.draftRange.start)} – ${dateText(card.draftRange.end)}` : "Not planned yet"}</dd></div>
{card.boardTargetDate && <div><dt>Board target</dt><dd>{dateText(card.boardTargetDate)}</dd></div>}
{card.subtaskTotal > 0 && <div><dt>Subtasks</dt><dd>{card.subtaskDone} of {card.subtaskTotal} complete</dd></div>}
</dl>
</section>
</div>
<footer className="plan-item-footer">
<Link href={card.href} className="plan-secondary-button" onClick={onClose}>Open full work item <ArrowUpRight size={15} /></Link>
{canEdit && <button type="button" className="plan-primary-button" onClick={onPlan}><CalendarDays size={15} /> {card.draftRange ? "Change dates" : "Set dates"}</button>}
</footer>
</section>
</div>
);
}

View file

@ -28,7 +28,7 @@ describe("planning board loader", () => {
column("done", "Done", true),
],
cards: [
card("one", "inbox", 1),
{ ...card("one", "inbox", 1), description: "The full client request with details." },
card("two", "backlog", 2),
card("three", "todo", 3),
card("four", "progress", 4),
@ -40,5 +40,6 @@ describe("planning board loader", () => {
expect(board).toHaveLength(1);
expect(board[0].cards.map((item) => item.id)).toEqual(["one", "two", "three", "four"]);
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
expect(board[0].cards[0].description).toBe("The full client request with details.");
});
});

View file

@ -16,6 +16,7 @@ export interface PlanningCard {
created: string;
number: number;
title: string;
description: string;
priority: CardRecord["priority"];
columnName: string;
boardTargetDate: string;
@ -88,6 +89,7 @@ export function buildPlanningBoard({
created: card.created,
number: card.number ?? 0,
title: card.title,
description: card.description ?? "",
priority: card.priority,
columnName: columnsById.get(card.column)?.name ?? "",
boardTargetDate: (card.target_date ?? "").slice(0, 10),

View file

@ -9,6 +9,7 @@ function card(id: string, number: number, priority: PlanningCard["priority"], st
created: `2026-09-${String(number).padStart(2, "0")} 10:00:00.000Z`,
number,
title: id,
description: "",
priority,
columnName: "To Do",
boardTargetDate: "",