diff --git a/docs/adr/0002-festival-timezone-display.md b/docs/adr/0002-festival-timezone-display.md index 222db9e5..10c7a2d7 100644 --- a/docs/adr/0002-festival-timezone-display.md +++ b/docs/adr/0002-festival-timezone-display.md @@ -13,6 +13,6 @@ Set `time_start`/`time_end` are stored as UTC `timestamptz`, but were formatted - `festivals.timezone` is `NOT NULL DEFAULT 'Europe/Lisbon'` (the value the CSV import wizard already defaulted to). Every festival always has a concrete zone, so the display path never branches on "unset" — no viewer-local fallback anywhere. - Display formatting routes through the existing `formatInTimeZone`-based helpers in `src/lib/timeUtils.ts` (which already accepted an optional `timezone`). Day-grouping in `useScheduleData` and the Schedule **list view** compute in the festival zone, so a post-midnight set groups by the **festival** calendar day, not the viewer's. The horizontal **timeline** view (`TimeScale`, `TimeDisplay`, `SetBlock`) now also renders in the festival zone — all schedule surfaces are consistent, so there is no dedicated "which zone" badge; that would only restate what every displayed time already shows. -- Post-midnight sets group by the festival's calendar-day boundary (midnight). A 01:00 set lands under the next day, not "the previous night." A custom per-festival day-start hour was considered and deferred as a separate feature. +- Post-midnight sets group by the festival's calendar-day boundary, which defaults to midnight but is configurable per festival via `festivals.day_start_hour` (0-23, `NOT NULL DEFAULT 0`). A festival can set e.g. `6` so a 02:00 set folds into the previous night instead of splitting under the next day. The cutoff widens the same day-key seam (`getFestivalDayKey`/`getFestivalDayLabel` in `src/lib/timeUtils.ts`) that grouping, the day filter, the `days` reveal level, and the horizontal timeline's day boundaries all already route through, so every surface stays consistent without a second implementation. It lives on `festivals` (not `festival_editions`) for the same reason as `timezone` above. - Admin set-time edit inputs (`datetime-local`) also operate in the festival zone with a visible label, so what an admin types matches what displays. This required festival-zone variants of `toDatetimeLocal`/`toISOString`, which previously hardcoded the browser zone. - The CSV import wizard defaults its timezone picker to the festival's zone (still overridable per import), keeping import interpretation and display consistent by default. diff --git a/src/api/festivals/useCreateFestival.ts b/src/api/festivals/useCreateFestival.ts index 36d3bf24..6560f7c2 100644 --- a/src/api/festivals/useCreateFestival.ts +++ b/src/api/festivals/useCreateFestival.ts @@ -10,6 +10,7 @@ async function createFestival(festivalData: { published?: boolean; logo_url?: string | null; timezone?: string; + day_start_hour?: number; }) { const { data, error } = await supabase .from("festivals") diff --git a/src/api/festivals/useUpdateFestival.ts b/src/api/festivals/useUpdateFestival.ts index 012a0722..f2f67d58 100644 --- a/src/api/festivals/useUpdateFestival.ts +++ b/src/api/festivals/useUpdateFestival.ts @@ -12,6 +12,7 @@ async function updateFestival( published?: boolean; logo_url?: string | null; timezone?: string; + day_start_hour?: number; }>, ) { const { data, error } = await supabase @@ -42,6 +43,7 @@ export function useUpdateFestivalMutation() { published?: boolean; logo_url?: string | null; timezone?: string; + day_start_hour?: number; }>; }) => updateFestival(festivalId, festivalData), onSuccess: () => { diff --git a/src/hooks/useActiveTimelineDay.ts b/src/hooks/useActiveTimelineDay.ts index 4c22d3f0..b59f9564 100644 --- a/src/hooks/useActiveTimelineDay.ts +++ b/src/hooks/useActiveTimelineDay.ts @@ -11,6 +11,7 @@ interface UseActiveTimelineDayOptions { scrollContainerRef: RefObject; days: ScheduleDay[]; timezone: string; + dayStartHour: number; festivalStart: Date; } @@ -23,6 +24,7 @@ export function useActiveTimelineDay({ scrollContainerRef, days, timezone, + dayStartHour, festivalStart, }: UseActiveTimelineDayOptions) { const [activeDate, setActiveDate] = useState( @@ -41,8 +43,10 @@ export function useActiveTimelineDay({ date: day.date, offset: Math.max( 0, - timeToOffset(getDayJumpMoment(day, timezone), festivalStart) - - DAY_JUMP_START_GUTTER_PX, + timeToOffset( + getDayJumpMoment(day, timezone, dayStartHour), + festivalStart, + ) - DAY_JUMP_START_GUTTER_PX, ), })) .sort((a, b) => a.offset - b.offset); @@ -60,7 +64,7 @@ export function useActiveTimelineDay({ updateActiveDay(); container.addEventListener("scroll", updateActiveDay, { passive: true }); return () => container.removeEventListener("scroll", updateActiveDay); - }, [scrollContainerRef, days, timezone, festivalStart]); + }, [scrollContainerRef, days, timezone, dayStartHour, festivalStart]); return activeDate; } diff --git a/src/hooks/useScheduleData.ts b/src/hooks/useScheduleData.ts index 4eee08cb..63db3694 100644 --- a/src/hooks/useScheduleData.ts +++ b/src/hooks/useScheduleData.ts @@ -50,6 +50,7 @@ interface UseScheduleDataOptions { stages: Array | undefined; use24Hour?: boolean; timezone?: string; + dayStartHour?: number; } export function useScheduleData({ @@ -57,6 +58,7 @@ export function useScheduleData({ stages, use24Hour = false, timezone, + dayStartHour = 0, }: UseScheduleDataOptions) { const scheduleDays = useMemo(() => { if (!sets || !stages || !Array.isArray(sets) || sets.length === 0) { @@ -69,7 +71,11 @@ export function useScheduleData({ const performingSets = sets .filter((set) => set.time_start && set.stage_id) .flatMap((set) => { - const dayKey = getFestivalDayKey(set.time_start, timezone); + const dayKey = getFestivalDayKey( + set.time_start, + timezone, + dayStartHour, + ); return dayKey ? [{ set, dayKey }] : []; }); @@ -164,7 +170,7 @@ export function useScheduleData({ }); return scheduleDays; - }, [sets, use24Hour, stages, timezone]); + }, [sets, use24Hour, stages, timezone, dayStartHour]); const allStages = useMemo(() => { const stageSet = new Set(); diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index d2d58c7e..5ba0d4ed 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -20,6 +20,7 @@ interface UseTimelineScrollSyncOptions { festivalStart: Date; scheduleWindow: ScheduleWindow | null; timezone: string; + dayStartHour: number; now: Date; } @@ -32,6 +33,7 @@ export function useTimelineScrollSync({ festivalStart, scheduleWindow, timezone, + dayStartHour, now, }: UseTimelineScrollSyncOptions) { const route = @@ -57,6 +59,7 @@ export function useTimelineScrollSync({ scrollTo, day, timezone, + dayStartHour, festivalStart, scheduleWindow, now, diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 128f8490..ef2a52ae 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -355,6 +355,7 @@ export type Database = { Row: { archived: boolean; created_at: string; + day_start_hour: number; description: string | null; id: string; logo_url: string | null; @@ -367,6 +368,7 @@ export type Database = { Insert: { archived?: boolean; created_at?: string; + day_start_hour?: number; description?: string | null; id?: string; logo_url?: string | null; @@ -379,6 +381,7 @@ export type Database = { Update: { archived?: boolean; created_at?: string; + day_start_hour?: number; description?: string | null; id?: string; logo_url?: string | null; diff --git a/src/lib/dayFilterOptions.test.ts b/src/lib/dayFilterOptions.test.ts new file mode 100644 index 00000000..1c181e13 --- /dev/null +++ b/src/lib/dayFilterOptions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { buildDayFilterOptions } from "./dayFilterOptions"; + +describe("buildDayFilterOptions", () => { + it("returns one option per calendar day the edition spans", () => { + const options = buildDayFilterOptions("2025-07-12", "2025-07-14"); + + expect(options).toEqual([ + { value: "2025-07-12", label: "Saturday" }, + { value: "2025-07-13", label: "Sunday" }, + { value: "2025-07-14", label: "Monday" }, + ]); + }); + + it("returns no options when either date is missing", () => { + expect(buildDayFilterOptions(undefined, "2025-07-14")).toEqual([]); + expect(buildDayFilterOptions("2025-07-12", undefined)).toEqual([]); + }); + + it("returns no options for invalid dates", () => { + expect(buildDayFilterOptions("not-a-date", "2025-07-14")).toEqual([]); + }); + + it("with dayStartHour 0 (or omitted) is unaffected - the default renders identically", () => { + const withDefault = buildDayFilterOptions("2025-07-12", "2025-07-14"); + const withExplicitZero = buildDayFilterOptions( + "2025-07-12", + "2025-07-14", + 0, + ); + + expect(withExplicitZero).toEqual(withDefault); + }); + + it("adds a leading day before start_date when dayStartHour is set", () => { + const options = buildDayFilterOptions("2025-07-12", "2025-07-14", 6); + + expect(options[0]).toEqual({ value: "2025-07-11", label: "Friday" }); + expect(options).toHaveLength(4); + }); +}); diff --git a/src/lib/dayFilterOptions.ts b/src/lib/dayFilterOptions.ts new file mode 100644 index 00000000..a7c12780 --- /dev/null +++ b/src/lib/dayFilterOptions.ts @@ -0,0 +1,40 @@ +import { format, isValid, parseISO } from "date-fns"; + +export interface DayFilterOption { + value: string; + label: string; +} + +/** + * One option per calendar day the edition runs, plus one extra leading day + * when `dayStartHour` is set: a festival day starting before midnight can + * fold a pre-cutoff set on the edition's first calendar day back onto the + * previous day's key, so that day needs to be offered too. + */ +export function buildDayFilterOptions( + startDateStr: string | null | undefined, + endDateStr: string | null | undefined, + dayStartHour: number = 0, +): DayFilterOption[] { + if (!startDateStr || !endDateStr) return []; + + const startDate = parseISO(startDateStr); + const endDate = parseISO(endDateStr); + if (!isValid(startDate) || !isValid(endDate)) return []; + + const currentDate = new Date(startDate); + if (dayStartHour) { + currentDate.setDate(currentDate.getDate() - 1); + } + + const options: DayFilterOption[] = []; + while (currentDate <= endDate) { + options.push({ + value: format(currentDate, "yyyy-MM-dd"), + label: format(currentDate, "EEEE"), // e.g., "Friday" + }); + currentDate.setDate(currentDate.getDate() + 1); + } + + return options; +} diff --git a/src/lib/timeUtils.test.ts b/src/lib/timeUtils.test.ts index ff823f3b..83f32e82 100644 --- a/src/lib/timeUtils.test.ts +++ b/src/lib/timeUtils.test.ts @@ -3,6 +3,7 @@ import { formatTimeRange, formatDateTime, formatTimeOnly, + formatDayOnly, toDatetimeLocal, toISOString, toDatetimeLocalInTimeZone, @@ -422,6 +423,58 @@ describe("getFestivalDayKey", () => { it("falls back to UTC calendar day when no timezone is given", () => { expect(getFestivalDayKey("2024-12-15T23:30:00Z")).toBe("2024-12-15"); }); + + it("with dayStartHour 0 behaves identically to the default (no cutoff)", () => { + const dayKey = getFestivalDayKey( + "2024-07-15T23:30:00Z", + "Europe/Lisbon", + 0, + ); + expect(dayKey).toBe("2024-07-16"); + }); + + it("folds a set before the cutoff into the previous festival day", () => { + // 02:00 Lisbon time on Jul 16 (01:00 UTC), with a 06:00 cutoff, groups + // under Jul 15 (the previous night) instead of Jul 16. + const dayKey = getFestivalDayKey( + "2024-07-16T01:00:00Z", + "Europe/Lisbon", + 6, + ); + expect(dayKey).toBe("2024-07-15"); + }); + + it("keeps a set at/after the cutoff on its own calendar day", () => { + // 06:00 Lisbon time on Jul 16 (05:00 UTC), with a 06:00 cutoff, is + // exactly the start of the new festival day. + const dayKey = getFestivalDayKey( + "2024-07-16T05:00:00Z", + "Europe/Lisbon", + 6, + ); + expect(dayKey).toBe("2024-07-16"); + }); +}); + +describe("formatDayOnly", () => { + it("returns null for null input", () => { + expect(formatDayOnly(null, "Europe/Lisbon")).toBeNull(); + }); + + it("returns null for invalid input", () => { + expect(formatDayOnly("invalid", "Europe/Lisbon")).toBeNull(); + }); + + it("formats the festival-timezone calendar day", () => { + expect(formatDayOnly("2024-07-15T22:55:00Z", "Europe/Lisbon")).toBe( + "Mon, Jul 15", + ); + }); + + it("folds a set before the cutoff into the previous day's label", () => { + const label = formatDayOnly("2024-07-16T01:00:00Z", "Europe/Lisbon", 6); + expect(label).toBe("Mon, Jul 15"); + }); }); describe("getFestivalDayLabel", () => { diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts index bddbd6d7..e1524769 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -4,6 +4,7 @@ import { parseISO, isSameDay, differenceInCalendarDays, + subHours, } from "date-fns"; import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz"; @@ -178,30 +179,58 @@ export function formatDateOnly( return format(date, dateFormat); } +// Shifts an instant back by the festival's day-start cutoff hour, so +// grouping/formatting that runs on the result treats a pre-cutoff instant +// as still belonging to the previous festival day. A no-op at cutoff 0. +function shiftForDayStart(date: Date, dayStartHour: number): Date { + return dayStartHour ? subHours(date, dayStartHour) : date; +} + export function formatDayOnly( dateTime: string | null, timezone?: string, + dayStartHour: number = 0, ): string | null { if (!dateTime) return null; const date = parseISO(dateTime); if (!isValid(date)) return null; + const shifted = shiftForDayStart(date, dayStartHour); const dayFormat = "EEE, MMM d"; - if (timezone) return formatInTimeZone(date, timezone, dayFormat); - return format(date, dayFormat); + if (timezone) return formatInTimeZone(shifted, timezone, dayFormat); + return format(shifted, dayFormat); } // The festival calendar day (yyyy-MM-dd) a UTC timestamp falls on, computed in // the festival's own timezone so a post-midnight set groups under the -// festival's day rather than the viewer's. +// festival's day rather than the viewer's. `dayStartHour` (0-23, the +// festival's configured day-start cutoff) shifts the instant back by that +// many hours first, so sets before the cutoff fold into the previous +// festival day instead of splitting at exact midnight. export function getFestivalDayKey( dateTime: string | null, timezone?: string, + dayStartHour: number = 0, ): string | null { if (!dateTime) return null; const date = parseISO(dateTime); if (!isValid(date)) return null; - if (timezone) return formatInTimeZone(date, timezone, "yyyy-MM-dd"); - return format(date, "yyyy-MM-dd"); + const shifted = shiftForDayStart(date, dayStartHour); + if (timezone) return formatInTimeZone(shifted, timezone, "yyyy-MM-dd"); + return format(shifted, "yyyy-MM-dd"); +} + +// The UTC instant at which a given festival day-key begins, honoring the +// festival's day-start cutoff hour (defaults to local midnight). The +// counterpart to getFestivalDayKey: where that derives a day-key from an +// instant, this derives the boundary instant from a day-key - used to +// position day boundaries/jump targets on the horizontal timeline. +export function festivalDayStart( + dayKey: string, + timezone: string, + dayStartHour: number = 0, +): Date { + const hour = String(dayStartHour).padStart(2, "0"); + return fromZonedTime(`${dayKey}T${hour}:00:00`, timezone); } // Human-readable label for a day-key produced by getFestivalDayKey. diff --git a/src/lib/timelineDayJump.test.ts b/src/lib/timelineDayJump.test.ts index 834f7488..ac46a730 100644 --- a/src/lib/timelineDayJump.test.ts +++ b/src/lib/timelineDayJump.test.ts @@ -58,6 +58,14 @@ describe("getDayJumpMoment", () => { // Midnight in Europe/Lisbon (UTC+1 in July) is 23:00 UTC the prior day. expect(moment.getTime()).toBe(new Date("2025-07-12T23:00:00Z").getTime()); }); + + it("falls back to the configured dayStartHour, not midnight, when given", () => { + const day = buildDay("2025-07-13", []); + + const moment = getDayJumpMoment(day, TIMEZONE, 6); + // 06:00 in Europe/Lisbon (UTC+1 in July) is 05:00 UTC. + expect(moment.getTime()).toBe(new Date("2025-07-13T05:00:00Z").getTime()); + }); }); function buildDay(date: string, startTimes: (Date | undefined)[]) { diff --git a/src/lib/timelineDayJump.ts b/src/lib/timelineDayJump.ts index ff5c974c..4473ee02 100644 --- a/src/lib/timelineDayJump.ts +++ b/src/lib/timelineDayJump.ts @@ -1,6 +1,6 @@ -import { fromZonedTime } from "date-fns-tz"; import { timeToOffset } from "@/lib/timelineCalculator"; import { roundToNearestMinutes } from "@/lib/timelineMountMoment"; +import { festivalDayStart } from "@/lib/timeUtils"; import type { ScheduleDay } from "@/hooks/useScheduleData"; // Clears the pinned StageLabels column (absolute, up to ~180px wide for long @@ -51,7 +51,11 @@ export function jumpToTimelineMoment( * jump on dead timeline at the far-left edge. Falls back to festival-timezone * midnight when the day has no sets. */ -export function getDayJumpMoment(day: ScheduleDay, timezone: string): Date { +export function getDayJumpMoment( + day: ScheduleDay, + timezone: string, + dayStartHour: number = 0, +): Date { const stageOpenings = day.stages .map((stage) => stage.sets.reduce( @@ -66,7 +70,7 @@ export function getDayJumpMoment(day: ScheduleDay, timezone: string): Date { return ( mostCommonStart(stageOpenings) ?? - fromZonedTime(`${day.date}T00:00:00`, timezone) + festivalDayStart(day.date, timezone, dayStartHour) ); } diff --git a/src/lib/timelineMountMoment.test.ts b/src/lib/timelineMountMoment.test.ts index e9f0613b..01d2b1d3 100644 --- a/src/lib/timelineMountMoment.test.ts +++ b/src/lib/timelineMountMoment.test.ts @@ -22,6 +22,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: "2025-07-13T22:00:00.000Z", day: "2025-07-12", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -37,6 +38,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "2025-07-13", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -48,11 +50,29 @@ describe("resolveTimelineMountMoment", () => { ); }); + it("honors a non-zero dayStartHour for the day filter's start", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "2025-07-13", + timezone: TIMEZONE, + dayStartHour: 6, + festivalStart: FESTIVAL_START, + scheduleWindow: SCHEDULE_WINDOW, + now: NOW_INSIDE_WINDOW, + }); + + // 06:00 in Europe/Lisbon (UTC+1 in July) is 05:00 UTC. + expect(moment.getTime()).toBe( + new Date("2025-07-13T05:00:00.000Z").getTime(), + ); + }); + it("falls back to the day filter's start when scrollTo is an invalid date string", () => { const moment = resolveTimelineMountMoment({ scrollTo: "not-a-date", day: "2025-07-13", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -68,6 +88,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "all", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -85,6 +106,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "all", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: { start: windowStart, end: FESTIVAL_END }, now: nowNearWindowStart, @@ -98,6 +120,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "all", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: null, now: NOW_INSIDE_WINDOW, @@ -111,6 +134,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "all", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_BEFORE_WINDOW, @@ -124,6 +148,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "all", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_AFTER_WINDOW, @@ -137,6 +162,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: "garbage", day: "all", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_AFTER_WINDOW, @@ -150,6 +176,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: "2025-07-14T12:00:00.000Z", day: "2025-07-13", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -165,6 +192,7 @@ describe("resolveTimelineMountMoment", () => { scrollTo: undefined, day: "2025-07-13", timezone: TIMEZONE, + dayStartHour: 0, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts index 653b7173..abc5d96f 100644 --- a/src/lib/timelineMountMoment.ts +++ b/src/lib/timelineMountMoment.ts @@ -1,11 +1,12 @@ import { isValid, parseISO } from "date-fns"; -import { fromZonedTime } from "date-fns-tz"; +import { festivalDayStart } from "@/lib/timeUtils"; import type { ScheduleWindow } from "@/lib/timelineCalculator"; export interface TimelineMountMomentInput { scrollTo?: string | undefined; day: string; timezone: string; + dayStartHour: number; festivalStart: Date; scheduleWindow: ScheduleWindow | null; now: Date; @@ -19,7 +20,7 @@ export function resolveTimelineMountMoment( ): Date { return ( momentFromScrollTo(input.scrollTo) ?? - momentFromDayFilter(input.day, input.timezone) ?? + momentFromDayFilter(input.day, input.timezone, input.dayStartHour) ?? momentFromNow(input.now, input.scheduleWindow) ?? input.festivalStart ); @@ -46,10 +47,14 @@ function momentFromScrollTo(scrollTo: string | undefined): Date | null { return isValid(parsed) ? parsed : null; } -function momentFromDayFilter(day: string, timezone: string): Date | null { +function momentFromDayFilter( + day: string, + timezone: string, + dayStartHour: number, +): Date | null { if (!day || day === "all") return null; try { - const dayStart = fromZonedTime(`${day}T00:00:00`, timezone); + const dayStart = festivalDayStart(day, timezone, dayStartHour); return isValid(dayStart) ? dayStart : null; } catch { return null; diff --git a/src/lib/timelineOverviewGeometry.test.ts b/src/lib/timelineOverviewGeometry.test.ts index acc2b974..e9ba0148 100644 --- a/src/lib/timelineOverviewGeometry.test.ts +++ b/src/lib/timelineOverviewGeometry.test.ts @@ -55,6 +55,7 @@ describe("calculateDayBoundaries", () => { const boundaries = calculateDayBoundaries({ days, timezone: TIMEZONE, + dayStartHour: 0, festivalStart, totalWidth, }); @@ -64,6 +65,26 @@ describe("calculateDayBoundaries", () => { ]); }); + it("places a boundary at the configured dayStartHour instead of midnight", () => { + const festivalStart = new Date("2025-07-12T14:00:00Z"); + const days = [{ date: "2025-07-12" }, { date: "2025-07-13" }]; + + // Day 2 at 06:00 (Europe/Lisbon) is 2025-07-13T05:00:00Z, 15h after start. + // PX_PER_MINUTE is 2, so offset = 15 * 60 * 2 = 1800. + const totalWidth = 2000; + const boundaries = calculateDayBoundaries({ + days, + timezone: TIMEZONE, + dayStartHour: 6, + festivalStart, + totalWidth, + }); + + expect(boundaries).toEqual([ + { date: "2025-07-13", leftPercent: offsetToPercent(1800, totalWidth) }, + ]); + }); + it("drops boundaries outside the rendered [0, totalWidth] range", () => { const festivalStart = new Date("2025-07-12T14:00:00Z"); const days = [{ date: "2025-07-01" }, { date: "2025-12-25" }]; @@ -72,6 +93,7 @@ describe("calculateDayBoundaries", () => { calculateDayBoundaries({ days, timezone: TIMEZONE, + dayStartHour: 0, festivalStart, totalWidth: 2000, }), @@ -83,6 +105,7 @@ describe("calculateDayBoundaries", () => { calculateDayBoundaries({ days: [{ date: "2025-07-12" }], timezone: TIMEZONE, + dayStartHour: 0, festivalStart: new Date("2025-07-12T00:00:00Z"), totalWidth: 0, }), @@ -161,6 +184,7 @@ describe("the shared ruler", () => { const boundaries = calculateDayBoundaries({ days, timezone: "Europe/Lisbon", + dayStartHour: 0, festivalStart, totalWidth, }); diff --git a/src/lib/timelineOverviewGeometry.ts b/src/lib/timelineOverviewGeometry.ts index 57dda58d..4652d27c 100644 --- a/src/lib/timelineOverviewGeometry.ts +++ b/src/lib/timelineOverviewGeometry.ts @@ -1,5 +1,5 @@ -import { fromZonedTime } from "date-fns-tz"; import { timeToOffset } from "./timelineCalculator"; +import { festivalDayStart } from "@/lib/timeUtils"; import type { HorizontalTimelineSet } from "./timelineCalculator"; /** @@ -52,20 +52,23 @@ export interface OverviewDayBoundary { interface CalculateDayBoundariesParams { days: Array<{ date: string }>; timezone: string; + dayStartHour: number; festivalStart: Date; totalWidth: number; } /** - * Proportional position of each day's local midnight, for the vertical - * boundary lines drawn on the map. A day whose midnight falls outside the - * currently rendered `[0, totalWidth]` range (e.g. every other day, when a - * `day` filter has narrowed the strip to a single day) is dropped - the map - * only ever shows what the strip already shows. + * Proportional position of each day's start (the festival's configured + * day-start hour, or local midnight when unset), for the vertical boundary + * lines drawn on the map. A day whose boundary falls outside the currently + * rendered `[0, totalWidth]` range (e.g. every other day, when a `day` + * filter has narrowed the strip to a single day) is dropped - the map only + * ever shows what the strip already shows. */ export function calculateDayBoundaries({ days, timezone, + dayStartHour, festivalStart, totalWidth, }: CalculateDayBoundariesParams): OverviewDayBoundary[] { @@ -73,8 +76,8 @@ export function calculateDayBoundaries({ return days .map((day) => { - const midnight = fromZonedTime(`${day.date}T00:00:00`, timezone); - const offset = timeToOffset(midnight, festivalStart); + const dayStart = festivalDayStart(day.date, timezone, dayStartHour); + const offset = timeToOffset(dayStart, festivalStart); return { date: day.date, leftPercent: offsetToPercent(offset, totalWidth), diff --git a/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx b/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx index f8e458e8..1e42b1bf 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/DayFilterSelect.tsx @@ -7,7 +7,7 @@ import { } from "@/components/ui/select"; import { Calendar } from "lucide-react"; import { useRouteContext } from "@tanstack/react-router"; -import { format, parseISO, isValid } from "date-fns"; +import { buildDayFilterOptions } from "@/lib/dayFilterOptions"; interface DayFilterSelectProps { selectedDay: string; @@ -18,31 +18,18 @@ export function DayFilterSelect({ selectedDay, onDayChange, }: DayFilterSelectProps) { - const { edition } = useRouteContext({ + const { festival, edition } = useRouteContext({ from: "/festivals/$festivalSlug/editions/$editionSlug", }); - // Generate day options from edition dates - const dayOptions = []; - dayOptions.push({ value: "all", label: "All Days" }); - - if (edition?.start_date && edition?.end_date) { - const startDate = parseISO(edition.start_date); - const endDate = parseISO(edition.end_date); - - if (isValid(startDate) && isValid(endDate)) { - const currentDate = new Date(startDate); - while (currentDate <= endDate) { - const dateStr = format(currentDate, "yyyy-MM-dd"); - const dayLabel = format(currentDate, "EEEE"); // e.g., "Friday" - dayOptions.push({ - value: dateStr, - label: dayLabel, - }); - currentDate.setDate(currentDate.getDate() + 1); - } - } - } + const dayOptions = [ + { value: "all", label: "All Days" }, + ...buildDayFilterOptions( + edition?.start_date, + edition?.end_date, + festival?.day_start_hour, + ), + ]; return (
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx index 9125d638..4b90bbe6 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx @@ -8,6 +8,7 @@ interface DayJumpButtonsProps { days: ScheduleDay[]; activeDay: string | null; timezone: string; + dayStartHour: number; onJumpToDay: (moment: Date) => void; } @@ -15,6 +16,7 @@ export function DayJumpButtons({ days, activeDay, timezone, + dayStartHour, onJumpToDay, }: DayJumpButtonsProps) { const activeButtonRef = useRef(null); @@ -48,7 +50,9 @@ export function DayJumpButtons({ role="radio" aria-checked={isActive} ref={isActive ? activeButtonRef : undefined} - onClick={() => onJumpToDay(getDayJumpMoment(day, timezone))} + onClick={() => + onJumpToDay(getDayJumpMoment(day, timezone, dayStartHour)) + } className={cn( "group relative shrink-0 rounded-md px-3 pb-1 pt-1.5 text-center transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx index 6870a390..c71b4e84 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx @@ -9,6 +9,7 @@ interface TimeScaleProps { timeSlots: Date[]; totalWidth: number; timezone: string; + dayStartHour: number; scrollLeft: number; } @@ -16,9 +17,10 @@ export function TimeScale({ timeSlots, totalWidth, timezone, + dayStartHour, scrollLeft, }: TimeScaleProps) { - const dateChanges = computeDateChanges(timeSlots, timezone); + const dateChanges = computeDateChanges(timeSlots, timezone, dayStartHour); const geometry = computeDateLabelGeometry( dateChanges, scrollLeft, diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx index 5e0c9039..3195da53 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx @@ -6,12 +6,14 @@ import { cn } from "@/lib/utils"; interface TimeScaleContainerProps { timelineData: TimelineData; timezone: string; + dayStartHour: number; scrollLeft: number; } export function TimeScaleContainer({ timelineData, timezone, + dayStartHour, scrollLeft, }: TimeScaleContainerProps) { return ( @@ -31,6 +33,7 @@ export function TimeScaleContainer({ timeSlots={timelineData.timeSlots} totalWidth={timelineData.totalWidth} timezone={timezone} + dayStartHour={dayStartHour} scrollLeft={scrollLeft} />
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index 53553d52..f967fc7d 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -20,6 +20,7 @@ import { useScrollLeft } from "./useScrollLeft"; interface TimelineContainerProps { timelineData: TimelineData; timezone: string; + dayStartHour: number; scheduleDays: ScheduleDay[]; selectedDay: string; scheduleWindow: ScheduleWindow | null; @@ -29,6 +30,7 @@ interface TimelineContainerProps { export function TimelineContainer({ timelineData, timezone, + dayStartHour, scheduleDays, selectedDay, scheduleWindow, @@ -43,6 +45,7 @@ export function TimelineContainer({ festivalStart: timelineData.festivalStart, scheduleWindow, timezone, + dayStartHour, now, }); @@ -50,6 +53,7 @@ export function TimelineContainer({ scrollContainerRef, days: scheduleDays, timezone, + dayStartHour, festivalStart: timelineData.festivalStart, }); @@ -76,6 +80,7 @@ export function TimelineContainer({ selectedDay={selectedDay} activeDay={activeDay} timezone={timezone} + dayStartHour={dayStartHour} onJumpToDay={(moment) => jumpTo(moment, "start")} isOverviewExpanded={isOverviewExpanded} onToggleOverview={() => setIsOverviewExpanded((prev) => !prev)} @@ -87,6 +92,7 @@ export function TimelineContainer({ timelineData={timelineData} scheduleDays={scheduleDays} timezone={timezone} + dayStartHour={dayStartHour} scrollContainerRef={scrollContainerRef} onJump={(moment) => jumpTo(moment, "center")} /> @@ -94,6 +100,7 @@ export function TimelineContainer({ diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx index 65ac981a..9c8bedb7 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx @@ -25,6 +25,7 @@ interface TimelineOverviewProps { timelineData: TimelineData; scheduleDays: ScheduleDay[]; timezone: string; + dayStartHour: number; scrollContainerRef: RefObject; onJump: (moment: Date) => void; } @@ -40,6 +41,7 @@ export function TimelineOverview({ timelineData, scheduleDays, timezone, + dayStartHour, scrollContainerRef, onJump, }: TimelineOverviewProps) { @@ -52,6 +54,7 @@ export function TimelineOverview({ const dayBoundaries = calculateDayBoundaries({ days: scheduleDays, timezone, + dayStartHour, festivalStart: timelineData.festivalStart, totalWidth: timelineData.totalWidth, }); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx index 198494e3..15a6e040 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -15,6 +15,7 @@ interface TimelineToolbarProps { selectedDay: string; activeDay: string | null; timezone: string; + dayStartHour: number; onJumpToDay: (moment: Date) => void; isOverviewExpanded: boolean; onToggleOverview: () => void; @@ -34,6 +35,7 @@ export function TimelineToolbar({ selectedDay, activeDay, timezone, + dayStartHour, onJumpToDay, isOverviewExpanded, onToggleOverview, @@ -78,6 +80,7 @@ export function TimelineToolbar({ days={visibleDays} activeDay={activeDay} timezone={timezone} + dayStartHour={dayStartHour} onJumpToDay={onJumpToDay} /> diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts b/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts index f8b3d8e1..1b8510e8 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts @@ -57,6 +57,21 @@ describe("computeDateChanges", () => { const changesBerlinSummer = computeDateChanges(timeSlots, "Europe/Berlin"); expect(changesBerlinSummer).toHaveLength(2); }); + + it("folds a slot before the dayStartHour cutoff into the previous day", () => { + // Without a cutoff, 01:00 is already the next UTC calendar day. + const timeSlots = [ + new Date("2024-07-01T22:00:00Z"), + new Date("2024-07-02T01:00:00Z"), + ]; + + const noCutoff = computeDateChanges(timeSlots, timezone); + expect(noCutoff).toHaveLength(2); + + // With a 6h cutoff, 01:00 is still "yesterday" - no boundary crossed. + const withCutoff = computeDateChanges(timeSlots, timezone, 6); + expect(withCutoff).toHaveLength(1); + }); }); describe("computeDateLabelGeometry", () => { diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.ts b/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.ts index 52ee0be2..ed746f2c 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.ts +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.ts @@ -1,5 +1,5 @@ -import { formatInTimeZone } from "date-fns-tz"; import { timeToOffset } from "@/lib/timelineCalculator"; +import { getFestivalDayKey } from "@/lib/timeUtils"; export const DAY_GAP_PX = 5; @@ -15,18 +15,19 @@ export interface DateChange { export function computeDateChanges( timeSlots: Date[], timezone: string, + dayStartHour: number = 0, ): DateChange[] { + function festivalDate(date: Date): string | null { + return getFestivalDayKey(date.toISOString(), timezone, dayStartHour); + } + return timeSlots.reduce((changes, timeSlot, index) => { if (index === 0) { changes.push({ date: timeSlot, position: 0 }); return changes; } - const prevDate = formatInTimeZone( - timeSlots[index - 1], - timezone, - "yyyy-MM-dd", - ); - const currentDate = formatInTimeZone(timeSlot, timezone, "yyyy-MM-dd"); + const prevDate = festivalDate(timeSlots[index - 1]); + const currentDate = festivalDate(timeSlot); if (prevDate !== currentDate) { changes.push({ date: timeSlot, diff --git a/src/pages/EditionView/tabs/VoteTab/SetCard/SetMetadata.tsx b/src/pages/EditionView/tabs/VoteTab/SetCard/SetMetadata.tsx index e945e83c..d7fec1d7 100644 --- a/src/pages/EditionView/tabs/VoteTab/SetCard/SetMetadata.tsx +++ b/src/pages/EditionView/tabs/VoteTab/SetCard/SetMetadata.tsx @@ -33,7 +33,11 @@ export function SetMetadata() { const dayOnlyFormatted = canShowDay && !canShowTime - ? formatDayOnly(set.time_start, festival.timezone) + ? formatDayOnly( + set.time_start, + festival.timezone, + festival.day_start_hour, + ) : null; return ( diff --git a/src/pages/SetDetails/MultiArtistSetInfoCard.tsx b/src/pages/SetDetails/MultiArtistSetInfoCard.tsx index 9027de37..faa0393b 100644 --- a/src/pages/SetDetails/MultiArtistSetInfoCard.tsx +++ b/src/pages/SetDetails/MultiArtistSetInfoCard.tsx @@ -50,7 +50,11 @@ export function MultiArtistSetInfoCard({ : null; const dayOnlyFormatted = canShowDay && !canShowTime - ? formatDayOnly(set.time_start, festival.timezone) + ? formatDayOnly( + set.time_start, + festival.timezone, + festival.day_start_hour, + ) : null; return ( diff --git a/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx b/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx index 46f98156..6739f445 100644 --- a/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx +++ b/src/pages/SetDetails/NonMusicSetDetail/NonMusicSetBanner.tsx @@ -35,7 +35,11 @@ export function NonMusicSetBanner({ : null; const dayOnlyFormatted = canShowDay && !canShowTime - ? formatDayOnly(set.time_start, festival.timezone) + ? formatDayOnly( + set.time_start, + festival.timezone, + festival.day_start_hour, + ) : null; return ( diff --git a/src/pages/SetDetails/SetInfoCard.tsx b/src/pages/SetDetails/SetInfoCard.tsx index f7e68c12..46e9bccc 100644 --- a/src/pages/SetDetails/SetInfoCard.tsx +++ b/src/pages/SetDetails/SetInfoCard.tsx @@ -43,7 +43,11 @@ export function SetInfoCard({ : null; const dayOnlyFormatted = canShowDay && !canShowTime - ? formatDayOnly(set.time_start, festival.timezone) + ? formatDayOnly( + set.time_start, + festival.timezone, + festival.day_start_hour, + ) : null; return (
diff --git a/src/pages/admin/festivals/FestivalDialog.tsx b/src/pages/admin/festivals/FestivalDialog.tsx index 62a885b8..b45ffe34 100644 --- a/src/pages/admin/festivals/FestivalDialog.tsx +++ b/src/pages/admin/festivals/FestivalDialog.tsx @@ -20,6 +20,7 @@ import { generateSlug, isValidSlug, sanitizeSlug } from "@/lib/slug"; import { TimezonePicker } from "@/components/Admin/ScheduleImport/TimezonePicker"; const DEFAULT_FESTIVAL_TIMEZONE = "Europe/Lisbon"; +const DEFAULT_DAY_START_HOUR = 0; interface FestivalFormData { name: string; @@ -27,6 +28,7 @@ interface FestivalFormData { description?: string; published: boolean; timezone: string; + day_start_hour: number; } interface FestivalDialogProps { @@ -50,6 +52,7 @@ export function FestivalDialog({ description: "", published: false, timezone: DEFAULT_FESTIVAL_TIMEZONE, + day_start_hour: DEFAULT_DAY_START_HOUR, }); const [isSubmitting, setIsSubmitting] = useState(false); const [slugError, setSlugError] = useState(""); @@ -64,6 +67,8 @@ export function FestivalDialog({ description: editingFestival.description || "", published: editingFestival.published || false, timezone: editingFestival.timezone || DEFAULT_FESTIVAL_TIMEZONE, + day_start_hour: + editingFestival.day_start_hour ?? DEFAULT_DAY_START_HOUR, }); } else { setFormData({ @@ -72,6 +77,7 @@ export function FestivalDialog({ description: "", published: false, timezone: DEFAULT_FESTIVAL_TIMEZONE, + day_start_hour: DEFAULT_DAY_START_HOUR, }); } setSlugError(""); @@ -217,6 +223,30 @@ export function FestivalDialog({ } description="All schedule times for this festival are displayed in this timezone." /> +
+ + { + const parsed = Number(e.target.value); + const clamped = Number.isNaN(parsed) + ? DEFAULT_DAY_START_HOUR + : Math.min(23, Math.max(0, Math.trunc(parsed))); + setFormData((prev) => ({ + ...prev, + day_start_hour: clamped, + })); + }} + /> +

+ Sets before this hour (in the festival timezone) group under the + previous festival day. 0 splits days at midnight. +

+
= 0 AND day_start_hour <= 23); + +COMMENT ON COLUMN public.festivals.day_start_hour IS + 'Hour (0-23, in the festival timezone) at which a new festival day begins; sets before this hour group under the previous festival day.';