From 2b01d0e837667baf1106bc3b0f737eb80ab74521 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 05:16:04 +0200 Subject: [PATCH] feat(plan): bar-math helpers + open reschedule to anyone signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure helpers for the Gantt's drag/double-click math: - fractionToDate(fraction, range): what ISO date a fractional position in the chart represents, snapped to whole UTC days. - shiftDatesByFraction: move a bar's start and target by the same delta, clamped to the visible range, with start dropped when clamping would invert or collapse the span. - resizeEndToFraction: what the new target date becomes when the user drags a right edge. - moveCardByFraction: alias that produces the action's input shape. 11 unit tests cover the corners (no dates set, point bar, clamping past the right edge, inversion guard). rescheduleCardAction also drops its owner-only gate. Any signed-in user — owner or client manager — can move dates now. PocketBase's existing row-level rules govern which cards a given user can write (a client manager still only writes their own client's cards); the activity-log row is unchanged and attributes the move to the actual user either way. Adds setSubtaskDatesAction so the same drag/double-click gestures work on subtask rows. No audit log on subtasks — matches the existing pattern (subtask toggles don't log). --- src/app/actions/reschedule.ts | 39 ++++---- src/app/actions/subtask-dates.ts | 48 +++++++++ src/lib/roadmap/bar-math.test.ts | 89 +++++++++++++++++ src/lib/roadmap/bar-math.ts | 162 +++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 21 deletions(-) create mode 100644 src/app/actions/subtask-dates.ts create mode 100644 src/lib/roadmap/bar-math.test.ts create mode 100644 src/lib/roadmap/bar-math.ts diff --git a/src/app/actions/reschedule.ts b/src/app/actions/reschedule.ts index 3165f08..3ce56a1 100644 --- a/src/app/actions/reschedule.ts +++ b/src/app/actions/reschedule.ts @@ -14,30 +14,28 @@ import { import type { ActivityAction } from "@/lib/pb/types"; /** - * Move a card's target date from the timeline, resize a bar from the - * right edge, or unschedule it. Owner only. + * Reschedule a card from the planning view: drag-to-move, drag-right-edge + * to resize, double-click-to-edit, or unschedule (date === null). Available + * to any signed-in user — the owner, the client manager for their own + * client's cards — because the activity row makes every change visible to + * whoever owns the card. * - * `target_date` is a CLIENT-OWNED field. The owner may write it — PocketBase - * permits it — but doing so silently would mean a developer moving a date the - * client chose with no record of it. The activity row below is what makes the - * client's card history read "Dave moved the target date from 1 Oct to 15 Nov", - * in their own view, without anyone having to tell them. + * `target_date` is a CLIENT-OWNED field on `cards`. Both owners and client + * managers legitimately write it: owners when rescheduling the work, client + * managers when adjusting a date they originally set. PocketBase's row-level + * rules (Phase 1) restrict a client manager to their own client's cards, so + * the call below only succeeds when the caller's token can read the card — + * the existing security boundary. * - * Plan Puzzle v1: the Done-column guard returns {ok: false, message} when the - * card lives in a column flagged is_done. The owner used to drag Done cards - * sideways off the Timeline; that gesture is replaced by the explicit - * "Return to scheduled" server action, which moves the card out of the Done - * column rather than rewriting a date it never had. + * The activity row records actor = the signed-in user, so the card history + * reads "Bob moved the target date from 1 Oct to 15 Nov" in the right + * direction regardless of who triggered the move. * - * `date === null` clears the card's `target_date` ("unschedule"). The same - * guard and audit-row shape apply; the only difference is the patch payload. + * `date === null` clears the card's `target_date` ("unschedule"); the same + * audit-row shape applies, only the patch payload differs. * - * Resize support: when the caller drags the right edge of a bar, they want - * the bar to grow a duration. The Puzzle always passes `startDate` - * undefined so behaviour is unchanged; the Timeline view passes - * `startDate` as the previous target date when the card had no - * `start_date`, so the resize creates a span instead of a point. From the - * second resize onward the existing `start_date` is reused. + * Resize support: passing `startDate` is how a drag converts a point bar into + * a span. From the second resize onward the existing `start_date` is reused. */ export async function rescheduleCardAction(input: { cardId: string; @@ -53,7 +51,6 @@ export async function rescheduleCardAction(input: { startDate?: string | null; }): Promise<{ ok: boolean; message?: string }> { const session = await requireSession(); - if (session.role !== "owner") return { ok: false, message: "Not found" }; const validationError = validateRescheduleInput(input); if (validationError) return { ok: false, message: validationError }; diff --git a/src/app/actions/subtask-dates.ts b/src/app/actions/subtask-dates.ts new file mode 100644 index 0000000..a8ce90a --- /dev/null +++ b/src/app/actions/subtask-dates.ts @@ -0,0 +1,48 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import type { SubtaskRecord } from "@/lib/pb/types"; + +const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +export async function setSubtaskDatesAction(input: { + subtaskId: string; + startDate: string | null; + targetDate: string | null; +}): Promise<{ ok: boolean; message?: string }> { + await requireSession(); + + const startErr = invalid(input.startDate); + if (startErr) return { ok: false, message: `startDate ${startErr}` }; + const endErr = invalid(input.targetDate); + if (endErr) return { ok: false, message: `targetDate ${endErr}` }; + + if (input.startDate && input.targetDate && input.startDate > input.targetDate) { + return { ok: false, message: "startDate must be on or before targetDate" }; + } + + const pb = await getServerPb(); + const subtask = await pb + .collection("subtasks") + .getOne(input.subtaskId) + .catch(() => null); + if (!subtask) return { ok: false, message: "Subtask not found" }; + + await pb.collection("subtasks").update(subtask.id, { + start_date: input.startDate ?? "", + target_date: input.targetDate ?? "", + }); + + revalidatePath("/plan"); + return { ok: true }; +} + +function invalid(value: string | null): string | null { + if (value === null) return null; + if (value === "") return null; + if (!ISO_DATE.test(value)) return "must be YYYY-MM-DD or empty"; + return null; +} diff --git a/src/lib/roadmap/bar-math.test.ts b/src/lib/roadmap/bar-math.test.ts new file mode 100644 index 0000000..0a68641 --- /dev/null +++ b/src/lib/roadmap/bar-math.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; + +import { + fractionToDate, + shiftDatesByFraction, + resizeEndToFraction, + moveCardByFraction, +} from "@/lib/roadmap/bar-math"; +import type { TimelineRange } from "@/lib/roadmap/timeline"; + +const RANGE: TimelineRange = { from: "2026-02-01", to: "2026-02-22" }; + +describe("fractionToDate", () => { + it("maps 0 to range.from", () => { + expect(fractionToDate(0, RANGE)).toBe("2026-02-01"); + }); + + it("maps 1 to range.to (rounded)", () => { + expect(fractionToDate(1, RANGE)).toBe("2026-02-22"); + }); + + it("maps 0.5 to the midpoint", () => { + expect(fractionToDate(0.5, RANGE)).toBe("2026-02-11"); + // 21 days span (Feb 1 to Feb 22 inclusive diff = 21); half = ~10.5 → 11 + }); + + it("clamps out-of-range inputs", () => { + expect(fractionToDate(-0.5, RANGE)).toBe("2026-02-01"); + expect(fractionToDate(1.5, RANGE)).toBe("2026-02-22"); + }); +}); + +describe("shiftDatesByFraction — moving a bar", () => { + it("shifts both dates by the same number of days when both are set", () => { + const next = shiftDatesByFraction( + "2026-02-05", + "2026-02-10", + 21 / 21 / 2, // half the visible range ≈ 10.5 days → 11 + RANGE + ); + expect(next.startDate).toBe("2026-02-16"); + expect(next.targetDate).toBe("2026-02-21"); + }); + + it("returns nulls when neither date is set", () => { + const next = shiftDatesByFraction("", "", 0.5, RANGE); + expect(next).toEqual({ startDate: null, targetDate: null }); + }); + + it("moves only the target when only the target is set", () => { + const next = shiftDatesByFraction("", "2026-02-10", 0.1, RANGE); + expect(next.startDate).toBeNull(); + expect(next.targetDate).toBe("2026-02-12"); + }); + + it("clamps the target to range.to when dragged past the right edge", () => { + const next = shiftDatesByFraction( + "2026-02-18", + "2026-02-20", + 10, // way past the right edge + RANGE + ); + // Both dates would clamp to range.to and collide; start is dropped + // so the bar still renders as a point at the right edge. + expect(next.startDate).toBeNull(); + expect(next.targetDate).toBe("2026-02-22"); + }); + + it("keeps a 1-day span when only the target clamps", () => { + // start=18 target=20 (2-day span). Drag 0.2 of the range (~4 days) right. + // Target=20+4 = 24 → clamps to 22. Start=18+4 = 22, equal to target → drop. + const next = shiftDatesByFraction("2026-02-18", "2026-02-20", 0.2, RANGE); + expect(next.targetDate).toBe("2026-02-22"); + }); +}); + +describe("resizeEndToFraction — right-edge resize", () => { + it("returns the date at the new fraction", () => { + expect(resizeEndToFraction("2026-02-05", 0.5, RANGE)).toBe("2026-02-11"); + }); +}); + +describe("moveCardByFraction — action shape", () => { + it("mirrors shiftDatesByFraction so the action gets the same shape", () => { + const a = shiftDatesByFraction("2026-02-05", "2026-02-10", 0.1, RANGE); + const b = moveCardByFraction("2026-02-05", "2026-02-10", 0.1, RANGE); + expect(a).toEqual(b); + }); +}); diff --git a/src/lib/roadmap/bar-math.ts b/src/lib/roadmap/bar-math.ts new file mode 100644 index 0000000..1c8df67 --- /dev/null +++ b/src/lib/roadmap/bar-math.ts @@ -0,0 +1,162 @@ +/** + * Pure helpers for converting between pixel/fraction positions on a Gantt + * track and ISO date strings. Lives outside any component so the math is + * testable without a running browser. + * + * All functions are pure: same input → same output. Fraction inputs are + * clamped to [0, 1] before they are converted, mirroring the behaviour + * of `datePosition` in the timeline module — so a bar dragged past the + * chart's right edge sits at the right edge (toDate), never past it. + */ + +import { datePosition, type TimelineRange } from "./timeline"; + +const ISO_DATE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +/** Parse an ISO date string to a UTC ms number. NaN on any other input. */ +function parse(value: string): number { + if (!value) return Number.NaN; + const iso = value.replace(" ", "T"); + const t = Date.UTC( + Number(iso.slice(0, 4)), + Number(iso.slice(5, 7)) - 1, + Number(iso.slice(8, 10)) + ); + return Number.isFinite(t) ? t : Number.NaN; +} + +function isoUtc(time: number): string { + return new Date(time).toISOString().slice(0, 10); +} + +function clamp(value: number, lo: number, hi: number): number { + if (Number.isNaN(value)) return lo; + return Math.min(hi, Math.max(lo, value)); +} + +/** + * ISO-date clamp: assumes "YYYY-MM-DD" lexicographic order matches + * chronological order (it does, by ISO 8601 design). Returns the + * input string if it is already inside the range; otherwise the + * nearer of `lo` or `hi`. + */ +function clampIso(value: string, lo: string, hi: string): string { + if (value >= lo && value <= hi) return value; + if (value < lo) return lo; + return hi; +} + +/** + * What date does this fractional position inside the chart represent? + * Snapped to a whole UTC day — sub-day precision on a field nobody sets by + * the hour would be a false-precision lie. + */ +export function fractionToDate(fraction: number, range: TimelineRange): string { + const fromTime = parse(range.from); + const toTime = parse(range.to); + if (!Number.isFinite(fromTime) || !Number.isFinite(toTime) || toTime <= fromTime) { + return range.from; + } + const span = toTime - fromTime; + const f = clamp(fraction, 0, 1); + const time = Math.round(fromTime + f * span); + return isoUtc(time); +} + +/** + * Shift both dates by the same fractional delta. Used when the user drags + * a bar body horizontally to move it. + * + * Behaviour: + * - If neither date is set, returns `{ startDate: null, targetDate: null }` + * so the caller can persist an unscheduled card unchanged. + * - If only `targetDate` is set, only the target shifts; `startDate` + * stays null (the bar remains a point). + * - If both are set, both shift by the same number of days so the bar's + * duration is preserved. + * - The result is clamped to the visible range so a drag past the edge + * saturates at the edge instead of producing an absurd date. + */ +export function shiftDatesByFraction( + startDate: string, + targetDate: string, + deltaFraction: number, + range: TimelineRange +): { startDate: string | null; targetDate: string | null } { + if (!startDate && !targetDate) { + return { startDate: null, targetDate: null }; + } + const days = Math.round(deltaFraction * rangeDays(range)); + if (!targetDate) { + return { startDate: null, targetDate: null }; + } + const newTarget = clampIso(addDays(targetDate, days), range.from, range.to); + if (!startDate) { + return { startDate: null, targetDate: newTarget }; + } + const newStart = clampIso(addDays(startDate, days), range.from, range.to); + // After clamping, the start can land on or past the target if both + // dates were dragged past the right edge. Drop the start in that case + // so the bar still renders (as a point, not a degenerate span). + if (newStart >= newTarget) { + return { startDate: null, targetDate: newTarget }; + } + return { startDate: newStart, targetDate: newTarget }; +} + +/** + * The new target date when the user drags the right edge of a bar. + * The left edge stays put; only the end moves. Used by both card and + * subtask right-edge resize handles. + */ +export function resizeEndToFraction( + currentEnd: string, + newFraction: number, + range: TimelineRange +): string { + if (!currentEnd) return ""; + return fractionToDate(newFraction, range); +} + +/** + * The number of days between range.from and range.to — used to convert + * a fractional delta into a day count. + */ +function rangeDays(range: TimelineRange): number { + const from = parse(range.from); + const to = parse(range.to); + if (!Number.isFinite(from) || !Number.isFinite(to)) return 1; + return Math.max(1, Math.round((to - from) / (24 * 3600 * 1000))); +} + +/** Add `days` (may be negative) to an ISO date string. Empty stays empty. */ +function addDays(iso: string, days: number): string { + if (!iso || !ISO_DATE.test(iso)) return iso; + const time = parse(iso); + if (!Number.isFinite(time)) return iso; + return isoUtc(time + days * 24 * 3600 * 1000); +} + +/** + * Compose a card-row event from a drag: given a fraction delta and the + * existing dates, returns the new patch shape for `rescheduleCardAction`. + * Mirrors `shiftDatesByFraction` but produces the action's input shape. + */ +export function moveCardByFraction( + currentStartDate: string, + currentTargetDate: string, + deltaFraction: number, + range: TimelineRange +): { startDate: string | null; targetDate: string | null } { + return shiftDatesByFraction( + currentStartDate, + currentTargetDate, + deltaFraction, + range + ); +} + +/** + * Re-export for tests and callers that already know the timeline module. + */ +export { datePosition };