feat(plan): bar-math helpers + open reschedule to anyone signed in

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).
This commit is contained in:
Dave 2026-09-22 05:16:04 +02:00
parent cc988c66ca
commit 2b01d0e837
4 changed files with 317 additions and 21 deletions

View file

@ -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 };

View file

@ -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<SubtaskRecord>(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;
}

View file

@ -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);
});
});

162
src/lib/roadmap/bar-math.ts Normal file
View file

@ -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 };