diff --git a/src/components/roadmap/roadmap-builder.tsx b/src/components/roadmap/roadmap-builder.tsx new file mode 100644 index 0000000..79517bb --- /dev/null +++ b/src/components/roadmap/roadmap-builder.tsx @@ -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 = { + 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 ( +
+ + +
+ +
+
+ ); +} + +/** + * 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, string> = { + now: "Now", + next: "Next", + later: "Later", +}; + +function PendingTray({ + cards, + canDrag, +}: { + cards: Card[]; + canDrag: boolean; +}) { + if (cards.length === 0) { + return ( + + ); + } + + const groups = new Map(); + 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 ( + + ); +} + +function PendingBlock({ + card, + canDrag, +}: { + card: Card; + canDrag: boolean; +}) { + function onDragStart(event: React.DragEvent) { + 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 ( +
  • + +
    +

    {card.title}

    +

    {card.clientName}

    +
    + {card.band === null && ( + + )} +
  • + ); +} \ No newline at end of file diff --git a/src/components/roadmap/roadmap-tabs.tsx b/src/components/roadmap/roadmap-tabs.tsx index db0962b..b67ce81 100644 --- a/src/components/roadmap/roadmap-tabs.tsx +++ b/src/components/roadmap/roadmap-tabs.tsx @@ -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(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 (
    @@ -54,17 +61,9 @@ export function RoadmapTabs({ {tab === "bands" ? ( ) : ( - - startTransition(async () => { - setError(null); - const result = await rescheduleCardAction({ cardId, date }); - if (!result.ok) setError(result.message ?? "Could not move that card"); - else router.refresh(); - }) - } /> )}
    diff --git a/src/components/roadmap/timeline-view.tsx b/src/components/roadmap/timeline-view.tsx index ccb1b00..9d6a3a2 100644 --- a/src/components/roadmap/timeline-view.tsx +++ b/src/components/roadmap/timeline-view.tsx @@ -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(null); /** Read by the pointer handlers; `dragging` is only for the visual state. */ const draggingRef = useRef(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(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 ( -
    -

    No dates set yet

    -

    - {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. -

    -
    - ); - } - /** 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({
    -
    +
    + {/* Empty-state hint: nothing scheduled yet, so drop a block from + the tray to start the timeline. */} + {dated.length === 0 && ( +
    + Drag a block from the tray onto the timeline to schedule it. +
    + )} + {/* Quarter and month header */} {ticks.map((tick) => (
    + {/* Drop indicator: where a tray block would land if released. */} + {dropHover && dropFraction !== null && ( +
    + )} + {rows.map((row, rowIndex) => row.map((item) => { const card = byId.get(item.id);