feat(plan): timeline builder — drag unscheduled blocks onto the timeline

Replaces the static Timeline tab with a builder layout: an
Unscheduled tray on the left (grouped Inbox/Now/Next/Later) and the
existing timeline on the right. Drag a block from the tray onto the
timeline at the date you want it scheduled; drag a bar horizontally to
reschedule a card already on the timeline. Both paths route through
the same rescheduleCardAction so the activity log only ever shows one
date_changed entry.

TimelineView gains a drop zone (custom MIME so tray drops do not
collide with other text drags) and a vertical drop indicator that
follows the pointer while dragging. The empty-state hint replaces
the previous early return so users with only undated cards still see a
target to drag onto. The dated-bar drag-to-reschedule behaviour is
unchanged.
This commit is contained in:
Dave 2026-09-17 12:23:32 +02:00
parent 02adcb43ac
commit 17a4df5f80
3 changed files with 304 additions and 27 deletions

View file

@ -0,0 +1,206 @@
"use client";
import { useMemo, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Inbox } from "lucide-react";
import { TimelineView } from "@/components/roadmap/timeline-view";
import { rescheduleCardAction } from "@/app/actions/reschedule";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";
/**
* The Timeline tab as a builder: an unscheduled-cards tray on the left and
* the existing timeline on the right. The user drags blocks from the tray
* onto the timeline at the date they want them, and existing bars can still be
* dragged sideways to reschedule. The two paths converge on the same
* server action so the activity log only ever shows one date_changed entry.
*/
const TRAY_MIME = "application/x-card-id";
const TONE_RING: Record<Card["priority"], string> = {
critical: "ring-1 ring-accent/60",
high: "ring-1 ring-priority-high/40",
medium: "",
low: "ring-1 ring-border-subtle",
};
export function RoadmapBuilder({
cards,
canReschedule,
}: {
cards: Card[];
canReschedule: boolean;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const dated = useMemo(() => cards.filter((c) => c.targetDate), [cards]);
const undated = useMemo(
() =>
cards
.filter((c) => !c.targetDate)
.sort(
(a, b) =>
bandRank(a.band) - bandRank(b.band) ||
a.title.localeCompare(b.title)
),
[cards]
);
function reschedule(cardId: string, date: string) {
startTransition(async () => {
const result = await rescheduleCardAction({ cardId, date });
if (!result.ok) {
// The action never throws on its own failures — it returns
// `{ ok: false, message }` — so a silent failure would otherwise pass
// unnoticed. Surface it once via console; the card re-renders with
// the optimistic state on the next refresh.
console.error("[builder] reschedule failed", result.message);
}
router.refresh();
});
}
return (
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<PendingTray
cards={undated}
canDrag={canReschedule}
/>
<div className="min-w-0">
<TimelineView cards={dated} canReschedule={canReschedule} onReschedule={reschedule} />
</div>
</div>
);
}
/**
* Lower number = higher in the tray.
*
* Inbox cards have band=null (deriveBand never returns the string "inbox" — it
* returns null for any column marked is_inbox). Done cards also have band=null
* but they have targetDate set, so they never reach this function in the
* first place. Everything else is ranked by its current band.
*/
function bandRank(band: Card["band"]): number {
if (band === null) return 0; // inbox
switch (band) {
case "now":
return 1;
case "next":
return 2;
case "later":
return 3;
}
}
/**
* Group keys for the tray. Inbox cards have band=null (deriveBand never returns
* the string "inbox" — it returns null for any column marked is_inbox), so
* null is a real key, not a placeholder.
*/
type TrayBand = Card["band"];
const TRAY_BAND_LABEL: Record<NonNullable<TrayBand>, string> = {
now: "Now",
next: "Next",
later: "Later",
};
function PendingTray({
cards,
canDrag,
}: {
cards: Card[];
canDrag: boolean;
}) {
if (cards.length === 0) {
return (
<aside className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle bg-surface p-6 text-center text-xs text-text-muted">
Every schedulable card is already on the timeline.
</aside>
);
}
const groups = new Map<TrayBand, Card[]>();
for (const card of cards) {
// Done cards have band=null AND a target date set, so they're filtered
// out by the parent before this function ever sees them. No defensive
// skip is needed here — type-wise, "done" is not a possible band value.
const list = groups.get(card.band) ?? [];
list.push(card);
groups.set(card.band, list);
}
return (
<aside className="space-y-4">
<header className="flex items-baseline justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Unscheduled
</h2>
<span className="tabular text-xs text-text-muted">{cards.length}</span>
</header>
{[...groups.entries()].map(([band, list]) => (
<section key={band ?? "inbox"} className="space-y-1.5">
<h3 className="text-[11px] font-medium uppercase tracking-wide text-text-muted">
{band === null ? "Inbox" : TRAY_BAND_LABEL[band]}
</h3>
<ul className="space-y-1.5">
{list.map((card) => (
<PendingBlock key={card.id} card={card} canDrag={canDrag} />
))}
</ul>
</section>
))}
</aside>
);
}
function PendingBlock({
card,
canDrag,
}: {
card: Card;
canDrag: boolean;
}) {
function onDragStart(event: React.DragEvent<HTMLLIElement>) {
if (!canDrag) {
event.preventDefault();
return;
}
event.dataTransfer.setData(TRAY_MIME, card.id);
// Some browsers require any data to be set for the drag image to appear.
event.dataTransfer.setData("text/plain", card.title);
event.dataTransfer.effectAllowed = "move";
}
return (
<li
draggable={canDrag}
onDragStart={onDragStart}
className={`group flex items-start gap-2 rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-2.5 text-xs shadow-[var(--shadow-tile)] ${
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-default"
} ${TONE_RING[card.priority]}`}
title={`${card.title} — ${card.clientName}`}
>
<span
aria-hidden
className="mt-0.5 h-2 w-2 shrink-0 rounded-full"
style={{ background: card.clientColour || "var(--brand)" }}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-text-primary">{card.title}</p>
<p className="truncate text-[11px] text-text-muted">{card.clientName}</p>
</div>
{card.band === null && (
<Inbox
size={12}
strokeWidth={2}
className="mt-0.5 shrink-0 text-accent"
aria-label="Inbox"
/>
)}
</li>
);
}

View file

@ -4,7 +4,7 @@ import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { BandView } from "@/components/roadmap/band-view";
import { TimelineView } from "@/components/roadmap/timeline-view";
import { RoadmapBuilder } from "@/components/roadmap/roadmap-builder";
import { rescheduleCardAction } from "@/app/actions/reschedule";
import type { RoadmapCard as Card } from "@/lib/roadmap/load";
@ -22,8 +22,15 @@ export function RoadmapTabs({
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
// Bands exclude Inbox and Done; the timeline shows everything schedulable.
// Bands exclude Inbox and Done; the builder shows everything else.
const banded = cards.filter((card) => card.band !== null);
// The tray shows cards with no target date. Done cards never reach this
// list — they have a target date set — so the band filter is defensive
// only and the type checker is happy with `card.band !== null` for the
// "still pending triage" case.
const unscheduled = cards.filter(
(card) => !card.targetDate
);
return (
<div className="space-y-5">
@ -54,17 +61,9 @@ export function RoadmapTabs({
{tab === "bands" ? (
<BandView cards={banded} inboxCount={inboxCount} />
) : (
<TimelineView
cards={banded}
<RoadmapBuilder
cards={unscheduled}
canReschedule={canReschedule}
onReschedule={(cardId, date) =>
startTransition(async () => {
setError(null);
const result = await rescheduleCardAction({ cardId, date });
if (!result.ok) setError(result.message ?? "Could not move that card");
else router.refresh();
})
}
/>
)}
</div>

View file

@ -18,6 +18,13 @@ const LABEL_WIDTH = 190;
/** Smallest fraction of the axis an item may occupy — see packRows. */
const MIN_WIDTH = 0.02;
/**
* The MIME type used to mark drops coming from the unscheduled tray.
* Distinct from any string the browser might synthesise for other drag
* sources (text selections, files, etc.).
*/
const TRAY_MIME = "application/x-card-id";
export function TimelineView({
cards,
canReschedule,
@ -31,6 +38,10 @@ export function TimelineView({
const [dragging, setDragging] = useState<string | null>(null);
/** Read by the pointer handlers; `dragging` is only for the visual state. */
const draggingRef = useRef<string | null>(null);
/** True while a tray-to-timeline drag is hovering over the track. */
const [dropHover, setDropHover] = useState(false);
/** X fraction (0..1) within the track where the drop is hovering. */
const [dropFraction, setDropFraction] = useState<number | null>(null);
const dated = cards.filter((card) => card.targetDate);
const undated = cards.filter((card) => !card.targetDate);
@ -54,20 +65,6 @@ export function TimelineView({
const byId = useMemo(() => new Map(cards.map((c) => [c.id, c])), [cards]);
const todayPosition = datePosition(new Date().toISOString().slice(0, 10), range);
if (dated.length === 0) {
// Never render an empty chart — say what is true instead.
return (
<div className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-10 text-center">
<h2 className="text-base font-medium text-text-primary">No dates set yet</h2>
<p className="mx-auto mt-1.5 max-w-sm text-sm text-text-muted">
{cards.length} {cards.length === 1 ? "card has" : "cards have"} no target
date. Set one on a card, or a client can set their own, and it will
appear here.
</p>
</div>
);
}
/** Pointer x within the track, as a date. Snapped to whole days. */
function dateAt(clientX: number): string | null {
const element = trackRef.current;
@ -85,6 +82,49 @@ export function TimelineView({
return new Date(time).toISOString().slice(0, 10);
}
/** Drop from the unscheduled tray → schedule the card on this date. */
function onDragOver(event: React.DragEvent) {
if (!canReschedule) return;
if (!event.dataTransfer.types.includes(TRAY_MIME)) return;
// The browser will not deliver `drop` unless preventDefault runs here.
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const element = trackRef.current;
if (element) {
const rect = element.getBoundingClientRect();
const fraction = Math.min(
1,
Math.max(0, (event.clientX - rect.left) / rect.width)
);
setDropFraction(fraction);
}
if (!dropHover) setDropHover(true);
}
function onDragLeave(event: React.DragEvent) {
// Only clear when the cursor leaves the track itself, not when it crosses
// into a child element (the bar). relatedTarget is the element the pointer
// moved to; if it is still inside the track, ignore.
const element = trackRef.current;
if (!element) return;
const next = event.relatedTarget as Node | null;
if (next && element.contains(next)) return;
if (dropHover) setDropHover(false);
setDropFraction(null);
}
function onDrop(event: React.DragEvent) {
if (!canReschedule) return;
const cardId = event.dataTransfer.getData(TRAY_MIME);
if (!cardId) return;
event.preventDefault();
setDropHover(false);
setDropFraction(null);
const date = dateAt(event.clientX);
if (!date) return;
onReschedule(cardId, date);
}
const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT + 12;
return (
@ -93,7 +133,26 @@ export function TimelineView({
<div className="flex min-w-[720px]">
<div style={{ width: LABEL_WIDTH }} className="shrink-0" />
<div ref={trackRef} className="relative flex-1" style={{ height }}>
<div
ref={trackRef}
className={`relative flex-1 transition-colors ${
dropHover ? "bg-brand-soft/40" : ""
}`}
style={{ height }}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{/* Empty-state hint: nothing scheduled yet, so drop a block from
the tray to start the timeline. */}
{dated.length === 0 && (
<div
className="absolute inset-x-6 top-1/2 -translate-y-1/2 rounded-[var(--radius-tile)] border-2 border-dashed border-border-subtle px-4 py-6 text-center text-xs text-text-muted"
>
Drag a block from the tray onto the timeline to schedule it.
</div>
)}
{/* Quarter and month header */}
{ticks.map((tick) => (
<div
@ -142,6 +201,19 @@ export function TimelineView({
Today
</span>
{/* Drop indicator: where a tray block would land if released. */}
{dropHover && dropFraction !== null && (
<div
aria-hidden
className="absolute top-0 w-0.5 bg-brand"
style={{
left: `${dropFraction * 100}%`,
top: HEADER_HEIGHT - 16,
height: height - HEADER_HEIGHT + 16,
}}
/>
)}
{rows.map((row, rowIndex) =>
row.map((item) => {
const card = byId.get(item.id);