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:
parent
cc988c66ca
commit
2b01d0e837
4 changed files with 317 additions and 21 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
48
src/app/actions/subtask-dates.ts
Normal file
48
src/app/actions/subtask-dates.ts
Normal 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;
|
||||
}
|
||||
89
src/lib/roadmap/bar-math.test.ts
Normal file
89
src/lib/roadmap/bar-math.test.ts
Normal 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
162
src/lib/roadmap/bar-math.ts
Normal 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 };
|
||||
Loading…
Reference in a new issue