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:
parent
02adcb43ac
commit
17a4df5f80
3 changed files with 304 additions and 27 deletions
206
src/components/roadmap/roadmap-builder.tsx
Normal file
206
src/components/roadmap/roadmap-builder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue