From 8975e81d65b598153a5f9ad1192b884cd0060d5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:20:18 +0000 Subject: [PATCH 1/3] refactor(timeline): geometry owns time-pixel mapping Extract timeToOffset/offsetToTime as the single place the 2px/minute timeline scale lives; TimeScale and set positioning now consume them instead of re-deriving the 2/120 constants. Closes #191 --- src/lib/timelineCalculator.test.ts | 265 ++++++++++++++++++ src/lib/timelineCalculator.ts | 43 ++- .../tabs/ScheduleTab/horizontal/TimeScale.tsx | 10 +- 3 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 src/lib/timelineCalculator.test.ts diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts new file mode 100644 index 00000000..df0c9452 --- /dev/null +++ b/src/lib/timelineCalculator.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { + calculateTimelineData, + offsetToTime, + timeToOffset, +} from "./timelineCalculator"; +import type { ScheduleDay, ScheduleSet } from "@/hooks/useScheduleData"; +import type { Stage } from "@/api/stages/types"; + +const PX_PER_MINUTE = 2; + +function makeSet(overrides: Partial = {}): ScheduleSet { + return { + id: "set-1", + name: "Artist", + artists: [], + ...overrides, + }; +} + +function makeStage(overrides: Partial = {}): Stage { + return { + id: "stage-1", + name: "Main Stage", + color: "#ff0000", + stage_order: 0, + ...overrides, + } as unknown as Stage; +} + +describe("timeToOffset", () => { + it("returns 0 when moment equals origin", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + expect(timeToOffset(origin, origin)).toBe(0); + }); + + it("converts minutes after origin at 2px per minute", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + const moment = new Date("2024-07-01T11:00:00Z"); // +60 minutes + expect(timeToOffset(moment, origin)).toBe(60 * PX_PER_MINUTE); + }); + + it("returns a negative offset when moment is before origin", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + const moment = new Date("2024-07-01T09:30:00Z"); // -30 minutes + expect(timeToOffset(moment, origin)).toBe(-30 * PX_PER_MINUTE); + }); + + it("matches the legacy hour-scale constant (120px per hour)", () => { + const origin = new Date("2024-07-01T00:00:00Z"); + const moment = new Date("2024-07-01T03:00:00Z"); // 3 hours + expect(timeToOffset(moment, origin)).toBe(3 * 120); + }); +}); + +describe("offsetToTime", () => { + it("returns the origin when offset is 0", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + expect(offsetToTime(0, origin).getTime()).toBe(origin.getTime()); + }); + + it("converts a pixel offset back into minutes after origin", () => { + const origin = new Date("2024-07-01T10:00:00Z"); + const offset = 60 * PX_PER_MINUTE; // 60 minutes + const result = offsetToTime(offset, origin); + expect(result.getTime()).toBe( + origin.getTime() + 60 * 60 * 1000, + ); + }); + + it("is the inverse of timeToOffset for whole-minute moments", () => { + const origin = new Date("2024-07-01T08:00:00Z"); + const moment = new Date("2024-07-01T10:37:00Z"); + const offset = timeToOffset(moment, origin); + expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); + }); + + it("round-trips through timeToOffset for negative offsets", () => { + const origin = new Date("2024-07-01T12:00:00Z"); + const moment = new Date("2024-07-01T10:15:00Z"); + const offset = timeToOffset(moment, origin); + expect(offset).toBeLessThan(0); + expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); + }); +}); + +describe("calculateTimelineData", () => { + it("returns null when there are no schedule days", () => { + expect( + calculateTimelineData(new Date(), new Date(), [], []), + ).toBeNull(); + }); + + it("returns null when festival dates are missing", () => { + const days: ScheduleDay[] = [ + { date: "2024-07-01", displayDate: "Jul 1", stages: [] }, + ]; + expect( + calculateTimelineData( + null as unknown as Date, + null as unknown as Date, + days, + [], + ), + ).toBeNull(); + }); + + it("rounds the earliest set time down to the top of the hour", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + startTime: new Date("2024-07-01T10:37:00Z"), + endTime: new Date("2024-07-01T11:37:00Z"), + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + expect(data).not.toBeNull(); + // Earliest set starts at 10:37 -> rounded down to 10:00 + expect(data!.festivalStart.getTime()).toBe( + new Date("2024-07-01T10:00:00Z").getTime(), + ); + // The set itself is offset from that rounded-down origin (37 minutes in) + const set = data!.stages[0].sets[0]; + expect(set.horizontalPosition?.left).toBe(37 * PX_PER_MINUTE); + }); + + it("rounds the latest set time up to the end of the hour", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + startTime: new Date("2024-07-01T10:00:00Z"), + endTime: new Date("2024-07-01T11:15:00Z"), + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + expect(data).not.toBeNull(); + // Latest set ends at 11:15 -> rounded up to 11:59:59.999 + expect(data!.festivalEnd.getTime()).toBe( + new Date("2024-07-01T11:59:59.999Z").getTime(), + ); + // totalWidth spans the full rounded-up hour boundary (2 hours from 10:00 to 12:00) + expect(data!.totalWidth).toBe(timeToOffset( + new Date("2024-07-01T12:00:00Z"), + new Date("2024-07-01T10:00:00Z"), + )); + }); + + it("applies the 100px minimum width to short sets without affecting the scale", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + id: "short-set", + startTime: new Date("2024-07-01T10:00:00Z"), + endTime: new Date("2024-07-01T10:10:00Z"), // 10 minutes -> 20px raw + }), + makeSet({ + id: "long-set", + startTime: new Date("2024-07-01T11:00:00Z"), + endTime: new Date("2024-07-01T12:00:00Z"), // 60 minutes -> 120px raw + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + const sets = data!.stages[0].sets; + const shortSet = sets.find((s) => s.id === "short-set"); + const longSet = sets.find((s) => s.id === "long-set"); + + expect(shortSet?.horizontalPosition?.width).toBe(100); // clamped to the minimum + expect(longSet?.horizontalPosition?.width).toBe(60 * PX_PER_MINUTE); // scale-derived, not clamped + }); + + it("positions the last time slot at the total width (axis upper boundary)", () => { + const stage = makeStage(); + const days: ScheduleDay[] = [ + { + date: "2024-07-01", + displayDate: "Jul 1", + stages: [ + { + id: stage.id, + name: stage.name, + stage_order: 0, + sets: [ + makeSet({ + startTime: new Date("2024-07-01T10:00:00Z"), + endTime: new Date("2024-07-01T11:00:00Z"), + }), + ], + }, + ], + }, + ]; + + const data = calculateTimelineData( + new Date("2024-07-01T00:00:00Z"), + new Date("2024-07-02T00:00:00Z"), + days, + [stage], + ); + + const lastSlot = data!.timeSlots[data!.timeSlots.length - 1]; + expect(timeToOffset(lastSlot, data!.festivalStart)).toBe( + data!.totalWidth, + ); + }); +}); diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index 9eefac13..df1c35b0 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -3,6 +3,17 @@ import type { ScheduleSet, ScheduleDay } from "@/hooks/useScheduleData"; import type { Stage } from "@/api/stages/types"; import { sortStagesByOrder } from "@/lib/stageUtils"; +const PX_PER_MINUTE = 2; + +export function timeToOffset(moment: Date, origin: Date): number { + return differenceInMinutes(moment, origin) * PX_PER_MINUTE; +} + +export function offsetToTime(offset: number, origin: Date): Date { + const minutes = offset / PX_PER_MINUTE; + return new Date(origin.getTime() + minutes * 60 * 1000); +} + export interface HorizontalTimelineSet extends ScheduleSet { horizontalPosition?: { left: number; @@ -48,15 +59,11 @@ export function calculateTimelineData( scheduleDays, stages, earliestTime, - ( - set: ScheduleSet, - startMinutes: number, - duration: number, - ): HorizontalTimelineSet => { + (set: ScheduleSet, origin: Date): HorizontalTimelineSet => { if (!set.startTime || !set.endTime) return set; - const left = startMinutes * 2; - const width = Math.max(duration * 2, 100); + const left = timeToOffset(set.startTime, origin); + const width = Math.max(timeToOffset(set.endTime, set.startTime), 100); return { ...set, @@ -68,10 +75,12 @@ export function calculateTimelineData( }, ); + const lastTimeSlot = timeSlots[timeSlots.length - 1]; + return { timeSlots, stages: unifiedStages, - totalWidth: totalHours * 120, + totalWidth: timeToOffset(lastTimeSlot, earliestTime), festivalStart: earliestTime, festivalEnd: latestTime, }; @@ -161,11 +170,7 @@ function processStageGroups( scheduleDays: ScheduleDay[], stages: Stage[], earliestTime: Date, - positionCalculator: ( - set: ScheduleSet, - startMinutes: number, - duration: number, - ) => T, + positionCalculator: (set: ScheduleSet, origin: Date) => T, ): Array<{ name: string; color: string | undefined; @@ -180,15 +185,9 @@ function processStageGroups( allStageGroups[stage.id] = []; } - const enhancedSets = stage.sets.map((set): T => { - if (!set.startTime || !set.endTime) - return positionCalculator(set, 0, 0); - - const startMinutes = differenceInMinutes(set.startTime, earliestTime); - const duration = differenceInMinutes(set.endTime, set.startTime); - - return positionCalculator(set, startMinutes, duration); - }); + const enhancedSets = stage.sets.map((set): T => + positionCalculator(set, earliestTime), + ); allStageGroups[stage.id].push(...enhancedSets); }); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx index ccd06353..f77a691f 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx @@ -1,6 +1,6 @@ -import { differenceInMinutes } from "date-fns"; import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; import { useEffect, useState, useRef } from "react"; +import { timeToOffset } from "@/lib/timelineCalculator"; interface TimeScaleProps { timeSlots: Date[]; @@ -41,11 +41,7 @@ export function TimeScale({ // Calculate position relative to festival start const festivalStart = timeSlots[0]; - const minutesFromStart = differenceInMinutes( - midnightOfNewDate, - festivalStart, - ); - const position = minutesFromStart * 2 + 20; // 2px per minute + const position = timeToOffset(midnightOfNewDate, festivalStart) + 20; changes.push({ date: midnightOfNewDate, position }); } @@ -175,7 +171,7 @@ export function TimeScale({
{formatInTimeZone(timeSlot, timezone, "HH:mm")} From 60d54ba72ecd72ac934cbd73c4420b970565a005 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 18:26:20 +0000 Subject: [PATCH 2/3] fix(timeline): make time-pixel mapping continuous differenceInMinutes truncates to whole minutes, so timeToOffset and offsetToTime did not round-trip for sub-minute moments. Base the conversion on epoch milliseconds; output is unchanged for whole-minute inputs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/timelineCalculator.test.ts | 7 +++++++ src/lib/timelineCalculator.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts index df0c9452..ae868e7b 100644 --- a/src/lib/timelineCalculator.test.ts +++ b/src/lib/timelineCalculator.test.ts @@ -75,6 +75,13 @@ describe("offsetToTime", () => { expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); }); + it("is the inverse of timeToOffset for sub-minute moments", () => { + const origin = new Date("2024-07-01T08:00:00Z"); + const moment = new Date("2024-07-01T10:37:42.500Z"); + const offset = timeToOffset(moment, origin); + expect(offsetToTime(offset, origin).getTime()).toBe(moment.getTime()); + }); + it("round-trips through timeToOffset for negative offsets", () => { const origin = new Date("2024-07-01T12:00:00Z"); const moment = new Date("2024-07-01T10:15:00Z"); diff --git a/src/lib/timelineCalculator.ts b/src/lib/timelineCalculator.ts index df1c35b0..9edec888 100644 --- a/src/lib/timelineCalculator.ts +++ b/src/lib/timelineCalculator.ts @@ -6,7 +6,8 @@ import { sortStagesByOrder } from "@/lib/stageUtils"; const PX_PER_MINUTE = 2; export function timeToOffset(moment: Date, origin: Date): number { - return differenceInMinutes(moment, origin) * PX_PER_MINUTE; + const minutes = (moment.getTime() - origin.getTime()) / (60 * 1000); + return minutes * PX_PER_MINUTE; } export function offsetToTime(offset: number, origin: Date): Date { From f64e35cc9ffd6050f0cd79f840cc64051d6f9b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:20:54 +0000 Subject: [PATCH 3/3] test(timeline): move helpers below the test cases Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AziTqr3f12fxALYD6jhrW8 --- src/lib/timelineCalculator.test.ts | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/lib/timelineCalculator.test.ts b/src/lib/timelineCalculator.test.ts index ae868e7b..e503feea 100644 --- a/src/lib/timelineCalculator.test.ts +++ b/src/lib/timelineCalculator.test.ts @@ -9,25 +9,6 @@ import type { Stage } from "@/api/stages/types"; const PX_PER_MINUTE = 2; -function makeSet(overrides: Partial = {}): ScheduleSet { - return { - id: "set-1", - name: "Artist", - artists: [], - ...overrides, - }; -} - -function makeStage(overrides: Partial = {}): Stage { - return { - id: "stage-1", - name: "Main Stage", - color: "#ff0000", - stage_order: 0, - ...overrides, - } as unknown as Stage; -} - describe("timeToOffset", () => { it("returns 0 when moment equals origin", () => { const origin = new Date("2024-07-01T10:00:00Z"); @@ -270,3 +251,22 @@ describe("calculateTimelineData", () => { ); }); }); + +function makeSet(overrides: Partial = {}): ScheduleSet { + return { + id: "set-1", + name: "Artist", + artists: [], + ...overrides, + }; +} + +function makeStage(overrides: Partial = {}): Stage { + return { + id: "stage-1", + name: "Main Stage", + color: "#ff0000", + stage_order: 0, + ...overrides, + } as unknown as Stage; +}