From dffcdec87cd4511559bfd716869e0a2aa92a9b91 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 07:51:22 +0000 Subject: [PATCH 1/3] feat(schedule): configurable festival day-end hour [UPL-7] Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CJJoJtCsdZVQLeGBiN5Vnb --- docs/adr/0002-festival-timezone-display.md | 2 +- src/api/festivals/useCreateFestival.ts | 1 + src/api/festivals/useUpdateFestival.ts | 1 + src/hooks/useActiveTimelineDay.ts | 9 ++- src/hooks/useScheduleData.ts | 22 ++++-- src/hooks/useScheduleReveal.ts | 5 +- src/hooks/useTimelineScrollSync.ts | 7 +- src/integrations/supabase/types.ts | 3 + src/lib/dayFilterOptions.test.ts | 41 ++++++++++ src/lib/dayFilterOptions.ts | 40 ++++++++++ src/lib/scheduleReveal.test.ts | 22 +++--- src/lib/scheduleReveal.ts | 14 ++-- src/lib/timeUtils.test.ts | 78 ++++++++++++++++--- src/lib/timeUtils.ts | 47 ++++++++--- src/lib/timelineDayJump.test.ts | 26 ++++++- src/lib/timelineDayJump.ts | 12 +-- src/lib/timelineMountMoment.test.ts | 38 ++++++--- src/lib/timelineMountMoment.ts | 13 ++-- src/lib/timelineOverviewGeometry.test.ts | 27 ++++++- src/lib/timelineOverviewGeometry.ts | 21 ++--- .../tabs/ScheduleTab/DayFilterSelect.tsx | 33 +++----- .../ScheduleTab/horizontal/DayJumpButtons.tsx | 7 +- .../tabs/ScheduleTab/horizontal/TimeScale.tsx | 11 +-- .../horizontal/TimeScaleContainer.tsx | 7 +- .../horizontal/TimelineContainer.tsx | 17 ++-- .../horizontal/TimelineOverview.tsx | 7 +- .../horizontal/TimelineToolbar.tsx | 7 +- .../horizontal/timeScaleGeometry.test.ts | 43 ++++++++-- .../horizontal/timeScaleGeometry.ts | 16 ++-- .../ScheduleTab/lineup/ScheduleLineupView.tsx | 6 +- .../admin/festivals/CreateFestivalForm.tsx | 2 + .../admin/festivals/EditFestivalForm.tsx | 2 + .../FestivalForm/DayEndHourField.tsx | 42 ++++++++++ .../festivals/FestivalForm/FestivalForm.tsx | 2 + .../FestivalForm/festivalFormSchema.ts | 6 ++ .../editions/$editionSlug/schedule/list.tsx | 9 ++- .../$editionSlug/schedule/timeline.tsx | 11 ++- supabase/functions/_shared/database.types.ts | 3 + ...261008060000_add_festival_day_end_hour.sql | 15 ++++ 39 files changed, 519 insertions(+), 156 deletions(-) create mode 100644 src/lib/dayFilterOptions.test.ts create mode 100644 src/lib/dayFilterOptions.ts create mode 100644 src/pages/admin/festivals/FestivalForm/DayEndHourField.tsx create mode 100644 supabase/migrations/20261008060000_add_festival_day_end_hour.sql diff --git a/docs/adr/0002-festival-timezone-display.md b/docs/adr/0002-festival-timezone-display.md index 222db9e53..6decf5c0d 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_end_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 36d3bf245..1f06183b1 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_end_hour?: number; }) { const { data, error } = await supabase .from("festivals") diff --git a/src/api/festivals/useUpdateFestival.ts b/src/api/festivals/useUpdateFestival.ts index 803c60b23..28db7832a 100644 --- a/src/api/festivals/useUpdateFestival.ts +++ b/src/api/festivals/useUpdateFestival.ts @@ -10,6 +10,7 @@ export type FestivalUpdateData = Partial<{ published?: boolean; logo_url?: string | null; timezone?: string; + day_end_hour?: number; }>; export async function updateFestival( diff --git a/src/hooks/useActiveTimelineDay.ts b/src/hooks/useActiveTimelineDay.ts index 4c22d3f0c..a34542f50 100644 --- a/src/hooks/useActiveTimelineDay.ts +++ b/src/hooks/useActiveTimelineDay.ts @@ -5,12 +5,13 @@ import { DAY_JUMP_START_GUTTER_PX, getDayJumpMoment, } from "@/lib/timelineDayJump"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; import type { ScheduleDay } from "./useScheduleData"; interface UseActiveTimelineDayOptions { scrollContainerRef: RefObject; days: ScheduleDay[]; - timezone: string; + dayConfig: FestivalDayConfig; festivalStart: Date; } @@ -22,7 +23,7 @@ interface UseActiveTimelineDayOptions { export function useActiveTimelineDay({ scrollContainerRef, days, - timezone, + dayConfig, festivalStart, }: UseActiveTimelineDayOptions) { const [activeDate, setActiveDate] = useState( @@ -41,7 +42,7 @@ export function useActiveTimelineDay({ date: day.date, offset: Math.max( 0, - timeToOffset(getDayJumpMoment(day, timezone), festivalStart) - + timeToOffset(getDayJumpMoment(day, dayConfig), festivalStart) - DAY_JUMP_START_GUTTER_PX, ), })) @@ -60,7 +61,7 @@ export function useActiveTimelineDay({ updateActiveDay(); container.addEventListener("scroll", updateActiveDay, { passive: true }); return () => container.removeEventListener("scroll", updateActiveDay); - }, [scrollContainerRef, days, timezone, festivalStart]); + }, [scrollContainerRef, days, dayConfig, festivalStart]); return activeDate; } diff --git a/src/hooks/useScheduleData.ts b/src/hooks/useScheduleData.ts index 063ec190c..5e505bea9 100644 --- a/src/hooks/useScheduleData.ts +++ b/src/hooks/useScheduleData.ts @@ -3,6 +3,7 @@ import { formatDateTime, getFestivalDayKey, getFestivalDayLabel, + type FestivalDayConfig, } from "@/lib/timeUtils"; import type { FestivalSet, SetType } from "@/api/sets/types"; import type { Stage } from "@/api/stages/types"; @@ -48,15 +49,20 @@ type EnhancedSet = ScheduleSet & { dayKey: string }; interface UseScheduleDataOptions { sets: FestivalSet[] | undefined; stages: Array | undefined; - use24Hour?: boolean; - timezone?: string; + display?: ScheduleDisplayOptions; } +type ScheduleDisplayOptions = Partial & { + use24Hour?: boolean; +}; + +// Stable identity so the default doesn't invalidate the scheduleDays memo. +const NO_DISPLAY_OPTIONS: ScheduleDisplayOptions = {}; + export function useScheduleData({ sets, stages, - use24Hour = false, - timezone, + display = NO_DISPLAY_OPTIONS, }: UseScheduleDataOptions) { const scheduleDays = useMemo(() => { if (!sets || !stages || !Array.isArray(sets) || sets.length === 0) { @@ -66,7 +72,7 @@ export function useScheduleData({ const performingSets = sets .filter((set) => set.time_start && set.stage_id && set.status !== "tba") .flatMap((set) => { - const dayKey = getFestivalDayKey(set.time_start, timezone); + const dayKey = getFestivalDayKey(set.time_start, display); return dayKey ? [{ set, dayKey }] : []; }); @@ -86,8 +92,8 @@ export function useScheduleData({ votes: set.votes || [], formattedTimeRange: formatDateTime( set.time_start, - use24Hour, - timezone, + display.use24Hour ?? false, + display.timezone, ), dayKey, setType: set.set_type, @@ -161,7 +167,7 @@ export function useScheduleData({ }); return scheduleDays; - }, [sets, use24Hour, stages, timezone]); + }, [sets, stages, display]); const allStages = useMemo(() => { const stageSet = new Set(); diff --git a/src/hooks/useScheduleReveal.ts b/src/hooks/useScheduleReveal.ts index 68d0c74a0..2fc2e7fa9 100644 --- a/src/hooks/useScheduleReveal.ts +++ b/src/hooks/useScheduleReveal.ts @@ -26,8 +26,11 @@ export function useScheduleReveal() { ): RevealLabels { return computeRevealLabels(set, { level, - timezone: festival.timezone, use24Hour, + dayConfig: { + timezone: festival.timezone, + dayEndHour: festival.day_end_hour, + }, }); }, }; diff --git a/src/hooks/useTimelineScrollSync.ts b/src/hooks/useTimelineScrollSync.ts index d2d58c7eb..5c23c2294 100644 --- a/src/hooks/useTimelineScrollSync.ts +++ b/src/hooks/useTimelineScrollSync.ts @@ -11,6 +11,7 @@ import { roundToNearestMinutes, } from "@/lib/timelineMountMoment"; import { TIMELINE_START_SCROLL_GUTTER_PX } from "@/lib/timelineDayJump"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; const SCROLL_DEBOUNCE_MS = 300; const SCROLL_ROUND_MINUTES = 5; @@ -19,7 +20,7 @@ interface UseTimelineScrollSyncOptions { scrollContainerRef: RefObject; festivalStart: Date; scheduleWindow: ScheduleWindow | null; - timezone: string; + dayConfig: FestivalDayConfig; now: Date; } @@ -31,7 +32,7 @@ export function useTimelineScrollSync({ scrollContainerRef, festivalStart, scheduleWindow, - timezone, + dayConfig, now, }: UseTimelineScrollSyncOptions) { const route = @@ -56,7 +57,7 @@ export function useTimelineScrollSync({ const moment = resolveTimelineMountMoment({ scrollTo, day, - timezone, + dayConfig, festivalStart, scheduleWindow, now, diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 10571ae2f..5d3c6f96b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -360,6 +360,7 @@ export type Database = { Row: { archived: boolean; created_at: string; + day_end_hour: number; description: string | null; id: string; logo_url: string | null; @@ -372,6 +373,7 @@ export type Database = { Insert: { archived?: boolean; created_at?: string; + day_end_hour?: number; description?: string | null; id?: string; logo_url?: string | null; @@ -384,6 +386,7 @@ export type Database = { Update: { archived?: boolean; created_at?: string; + day_end_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 000000000..868fdba14 --- /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 dayEndHour 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 dayEndHour 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 000000000..24950f9c8 --- /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 `dayEndHour` is set: a festival day ending after 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, + dayEndHour: 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 (dayEndHour) { + 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/scheduleReveal.test.ts b/src/lib/scheduleReveal.test.ts index 286ce0d87..23728a3d9 100644 --- a/src/lib/scheduleReveal.test.ts +++ b/src/lib/scheduleReveal.test.ts @@ -73,7 +73,7 @@ describe("computeRevealLabels", () => { it("shows the exact time range at full reveal for a timed set", () => { const { timeLabel, dayLabel } = computeRevealLabels(timedSet, { level: "full", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(timeLabel).toContain("20:00"); @@ -85,7 +85,7 @@ describe("computeRevealLabels", () => { (level) => { const { dayLabel, timeLabel } = computeRevealLabels(timedSet, { level: level, - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel).toBeDefined(); @@ -97,7 +97,7 @@ describe("computeRevealLabels", () => { it("shows neither label when day isn't revealed", () => { const { dayLabel, timeLabel } = computeRevealLabels(timedSet, { level: "draft", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel).toBeUndefined(); @@ -107,7 +107,7 @@ describe("computeRevealLabels", () => { it("shows day + TBA at full reveal for a TBA set, never the midnight placeholder time", () => { const { dayLabel, timeLabel } = computeRevealLabels(tbaSet, { level: "full", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel).toContain("TBA"); @@ -118,7 +118,7 @@ describe("computeRevealLabels", () => { it("shows day + TBA below full reveal too, for a TBA set", () => { const { dayLabel } = computeRevealLabels(tbaSet, { level: "days", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel).toContain("TBA"); @@ -128,7 +128,7 @@ describe("computeRevealLabels", () => { it("shows neither label for a TBA set when day isn't revealed", () => { const { dayLabel, timeLabel } = computeRevealLabels(tbaSet, { level: "draft", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel).toBeUndefined(); @@ -139,7 +139,7 @@ describe("computeRevealLabels", () => { expect( computeRevealLabels(dateless, { level: "full", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }).dayLabel, ).toBe("Time TBA"); @@ -148,7 +148,7 @@ describe("computeRevealLabels", () => { it("shows neither label for a dateless TBA set when day isn't revealed", () => { const { dayLabel, timeLabel } = computeRevealLabels(dateless, { level: "draft", - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel).toBeUndefined(); @@ -160,7 +160,7 @@ describe("computeRevealLabels", () => { for (const set of [timedSet, tbaSet, dateless]) { const { dayLabel, timeLabel } = computeRevealLabels(set, { level: level, - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }); expect(dayLabel && timeLabel).toBeFalsy(); @@ -175,14 +175,14 @@ describe("computeRevealLabels", () => { expect( computeRevealLabels(timedSet, { level: level, - timezone: "UTC", + dayConfig: { timezone: "UTC" }, use24Hour: true, }).stageId, ).toBe(expectedWhenPresent); expect( computeRevealLabels( { ...timedSet, stage_id: null }, - { level: level, timezone: "UTC", use24Hour: true }, + { level: level, dayConfig: { timezone: "UTC" }, use24Hour: true }, ).stageId, ).toBeNull(); }, diff --git a/src/lib/scheduleReveal.ts b/src/lib/scheduleReveal.ts index c78fa6e52..bc5808cac 100644 --- a/src/lib/scheduleReveal.ts +++ b/src/lib/scheduleReveal.ts @@ -1,5 +1,9 @@ import type { Database } from "@/integrations/supabase/types"; -import { formatDayOnly, formatTimeRange } from "@/lib/timeUtils"; +import { + formatDayOnly, + formatTimeRange, + type FestivalDayConfig, +} from "@/lib/timeUtils"; export type RevealLevel = Database["public"]["Enums"]["schedule_reveal_level"]; @@ -51,12 +55,12 @@ export function computeRevealLabels( set: RevealableSet, { level, - timezone, use24Hour, + dayConfig, }: { level: RevealLevel; - timezone: string | undefined; use24Hour: boolean; + dayConfig: Partial; }, ): RevealLabels { const stageId = canShowStage(level) ? set.stage_id : null; @@ -67,14 +71,14 @@ export function computeRevealLabels( set.time_start, set.time_end, use24Hour, - timezone, + dayConfig.timezone, ); return timeLabel ? { timeLabel, stageId } : { stageId }; } if (!canShowDay(level)) return { stageId }; - const day = formatDayOnly(set.time_start, timezone); + const day = formatDayOnly(set.time_start, dayConfig); if (!day) return isTba ? { dayLabel: "Time TBA", stageId } : { stageId }; return { dayLabel: isTba ? `${day} · TBA` : day, stageId }; } diff --git a/src/lib/timeUtils.test.ts b/src/lib/timeUtils.test.ts index 3a8f41b5b..2bea7ddd1 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, @@ -402,31 +403,38 @@ describe("toDatetimeLocalInTimeZone", () => { describe("getFestivalDayKey", () => { it("returns null for null input", () => { - expect(getFestivalDayKey(null, "Europe/Lisbon")).toBeNull(); + expect(getFestivalDayKey(null, { timezone: "Europe/Lisbon" })).toBeNull(); }); it("returns null for invalid input", () => { - expect(getFestivalDayKey("invalid", "Europe/Lisbon")).toBeNull(); + expect( + getFestivalDayKey("invalid", { timezone: "Europe/Lisbon" }), + ).toBeNull(); }); it("groups a post-midnight set under the festival's calendar day", () => { // Lisbon observes WEST (UTC+1) in July: 23:30 UTC on Jul 15 is 00:30 on Jul 16 locally. - const dayKey = getFestivalDayKey("2024-07-15T23:30:00Z", "Europe/Lisbon"); + const dayKey = getFestivalDayKey("2024-07-15T23:30:00Z", { + timezone: "Europe/Lisbon", + }); expect(dayKey).toBe("2024-07-16"); }); it("groups a near-midnight set under the correct festival day", () => { // 22:55 UTC on Jul 15 is 23:55 in Lisbon (UTC+1) on Jul 15 - still Jul 15. - const dayKey = getFestivalDayKey("2024-07-15T22:55:00Z", "Europe/Lisbon"); + const dayKey = getFestivalDayKey("2024-07-15T22:55:00Z", { + timezone: "Europe/Lisbon", + }); expect(dayKey).toBe("2024-07-15"); }); it("is independent of the machine's local zone", () => { - const lisbon = getFestivalDayKey("2024-07-15T23:30:00Z", "Europe/Lisbon"); - const newYork = getFestivalDayKey( - "2024-07-15T23:30:00Z", - "America/New_York", - ); + const lisbon = getFestivalDayKey("2024-07-15T23:30:00Z", { + timezone: "Europe/Lisbon", + }); + const newYork = getFestivalDayKey("2024-07-15T23:30:00Z", { + timezone: "America/New_York", + }); expect(lisbon).toBe("2024-07-16"); expect(newYork).toBe("2024-07-15"); }); @@ -434,6 +442,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 dayEndHour 0 behaves identically to the default (no cutoff)", () => { + const dayKey = getFestivalDayKey("2024-07-15T23:30:00Z", { + timezone: "Europe/Lisbon", + dayEndHour: 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", { + timezone: "Europe/Lisbon", + dayEndHour: 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", { + timezone: "Europe/Lisbon", + dayEndHour: 6, + }); + expect(dayKey).toBe("2024-07-16"); + }); +}); + +describe("formatDayOnly", () => { + it("returns null for null input", () => { + expect(formatDayOnly(null, { timezone: "Europe/Lisbon" })).toBeNull(); + }); + + it("returns null for invalid input", () => { + expect(formatDayOnly("invalid", { timezone: "Europe/Lisbon" })).toBeNull(); + }); + + it("formats the festival-timezone calendar day", () => { + expect( + formatDayOnly("2024-07-15T22:55:00Z", { timezone: "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", { + timezone: "Europe/Lisbon", + dayEndHour: 6, + }); + expect(label).toBe("Mon, Jul 15"); + }); }); describe("getFestivalDayLabel", () => { diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts index e134ebc82..7fe93957a 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -1,6 +1,17 @@ -import { format, isValid, parseISO, differenceInCalendarDays } from "date-fns"; +import { + format, + isValid, + parseISO, + differenceInCalendarDays, + subHours, +} from "date-fns"; import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz"; +export interface FestivalDayConfig { + timezone: string; + dayEndHour: number; +} + export function formatTimeRange( startTime: string | null, endTime: string | null, @@ -176,30 +187,46 @@ export function formatDateOnly( return format(date, dateFormat); } +/** Shifts an instant back by dayEndHour so pre-cutoff times fall on the previous festival day. */ +function shiftForDayEnd(date: Date, dayEndHour: number): Date { + return dayEndHour ? subHours(date, dayEndHour) : date; +} + export function formatDayOnly( dateTime: string | null, - timezone?: string, + config: Partial = {}, ): string | null { + const { timezone, dayEndHour = 0 } = config; if (!dateTime) return null; const date = parseISO(dateTime); if (!isValid(date)) return null; + const shifted = shiftForDayEnd(date, dayEndHour); 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-timezone calendar day (yyyy-MM-dd) of an instant; times before dayEndHour fall on the previous day. */ export function getFestivalDayKey( dateTime: string | null, - timezone?: string, + config: Partial = {}, ): string | null { + const { timezone, dayEndHour = 0 } = config; 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 = shiftForDayEnd(date, dayEndHour); + if (timezone) return formatInTimeZone(shifted, timezone, "yyyy-MM-dd"); + return format(shifted, "yyyy-MM-dd"); +} + +/** The UTC instant a festival day-key starts: the day-end hour in the festival timezone. */ +export function festivalDayStart( + dayKey: string, + { timezone, dayEndHour }: FestivalDayConfig, +): Date { + const hour = String(dayEndHour).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 834f74889..a8cb1f945 100644 --- a/src/lib/timelineDayJump.test.ts +++ b/src/lib/timelineDayJump.test.ts @@ -37,7 +37,10 @@ describe("getDayJumpMoment", () => { }, ]); - const moment = getDayJumpMoment(day, TIMEZONE); + const moment = getDayJumpMoment(day, { + timezone: TIMEZONE, + dayEndHour: 0, + }); expect(moment.getTime()).toBe(new Date("2025-07-13T15:00:00Z").getTime()); }); @@ -47,17 +50,34 @@ describe("getDayJumpMoment", () => { new Date("2025-07-13T20:00:00Z"), ]); - const moment = getDayJumpMoment(day, TIMEZONE); + const moment = getDayJumpMoment(day, { + timezone: TIMEZONE, + dayEndHour: 0, + }); expect(moment.getTime()).toBe(new Date("2025-07-13T20:00:00Z").getTime()); }); it("falls back to festival-timezone midnight when the day has no timed sets", () => { const day = buildDay("2025-07-13", []); - const moment = getDayJumpMoment(day, TIMEZONE); + const moment = getDayJumpMoment(day, { + timezone: TIMEZONE, + dayEndHour: 0, + }); // 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 dayEndHour, not midnight, when given", () => { + const day = buildDay("2025-07-13", []); + + const moment = getDayJumpMoment(day, { + timezone: TIMEZONE, + dayEndHour: 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 ff5c974c5..9299f1dd0 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, type FestivalDayConfig } from "@/lib/timeUtils"; import type { ScheduleDay } from "@/hooks/useScheduleData"; // Clears the pinned StageLabels column (absolute, up to ~180px wide for long @@ -51,7 +51,10 @@ 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, + config: FestivalDayConfig, +): Date { const stageOpenings = day.stages .map((stage) => stage.sets.reduce( @@ -64,10 +67,7 @@ export function getDayJumpMoment(day: ScheduleDay, timezone: string): Date { ) .filter((start): start is Date => start !== null); - return ( - mostCommonStart(stageOpenings) ?? - fromZonedTime(`${day.date}T00:00:00`, timezone) - ); + return mostCommonStart(stageOpenings) ?? festivalDayStart(day.date, config); } // The start time shared by the most stages, tie-broken by the earliest time. diff --git a/src/lib/timelineMountMoment.test.ts b/src/lib/timelineMountMoment.test.ts index e9f0613bd..1dcc16491 100644 --- a/src/lib/timelineMountMoment.test.ts +++ b/src/lib/timelineMountMoment.test.ts @@ -21,7 +21,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: "2025-07-13T22:00:00.000Z", day: "2025-07-12", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -36,7 +36,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "2025-07-13", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -48,11 +48,27 @@ describe("resolveTimelineMountMoment", () => { ); }); + it("honors a non-zero dayEndHour for the day filter's start", () => { + const moment = resolveTimelineMountMoment({ + scrollTo: undefined, + day: "2025-07-13", + dayConfig: { timezone: TIMEZONE, dayEndHour: 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, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -67,7 +83,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -84,7 +100,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: { start: windowStart, end: FESTIVAL_END }, now: nowNearWindowStart, @@ -97,7 +113,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: null, now: NOW_INSIDE_WINDOW, @@ -110,7 +126,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_BEFORE_WINDOW, @@ -123,7 +139,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "all", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_AFTER_WINDOW, @@ -136,7 +152,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: "garbage", day: "all", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_AFTER_WINDOW, @@ -149,7 +165,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: "2025-07-14T12:00:00.000Z", day: "2025-07-13", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, @@ -164,7 +180,7 @@ describe("resolveTimelineMountMoment", () => { const moment = resolveTimelineMountMoment({ scrollTo: undefined, day: "2025-07-13", - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: FESTIVAL_START, scheduleWindow: SCHEDULE_WINDOW, now: NOW_INSIDE_WINDOW, diff --git a/src/lib/timelineMountMoment.ts b/src/lib/timelineMountMoment.ts index 653b71734..c1668853c 100644 --- a/src/lib/timelineMountMoment.ts +++ b/src/lib/timelineMountMoment.ts @@ -1,11 +1,11 @@ import { isValid, parseISO } from "date-fns"; -import { fromZonedTime } from "date-fns-tz"; +import { festivalDayStart, type FestivalDayConfig } from "@/lib/timeUtils"; import type { ScheduleWindow } from "@/lib/timelineCalculator"; export interface TimelineMountMomentInput { scrollTo?: string | undefined; day: string; - timezone: string; + dayConfig: FestivalDayConfig; festivalStart: Date; scheduleWindow: ScheduleWindow | null; now: Date; @@ -19,7 +19,7 @@ export function resolveTimelineMountMoment( ): Date { return ( momentFromScrollTo(input.scrollTo) ?? - momentFromDayFilter(input.day, input.timezone) ?? + momentFromDayFilter(input.day, input.dayConfig) ?? momentFromNow(input.now, input.scheduleWindow) ?? input.festivalStart ); @@ -46,10 +46,13 @@ function momentFromScrollTo(scrollTo: string | undefined): Date | null { return isValid(parsed) ? parsed : null; } -function momentFromDayFilter(day: string, timezone: string): Date | null { +function momentFromDayFilter( + day: string, + dayConfig: FestivalDayConfig, +): Date | null { if (!day || day === "all") return null; try { - const dayStart = fromZonedTime(`${day}T00:00:00`, timezone); + const dayStart = festivalDayStart(day, dayConfig); return isValid(dayStart) ? dayStart : null; } catch { return null; diff --git a/src/lib/timelineOverviewGeometry.test.ts b/src/lib/timelineOverviewGeometry.test.ts index acc2b974f..8faf4d631 100644 --- a/src/lib/timelineOverviewGeometry.test.ts +++ b/src/lib/timelineOverviewGeometry.test.ts @@ -54,7 +54,7 @@ describe("calculateDayBoundaries", () => { const totalWidth = 2000; const boundaries = calculateDayBoundaries({ days, - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart, totalWidth, }); @@ -64,6 +64,25 @@ describe("calculateDayBoundaries", () => { ]); }); + it("places a boundary at the configured dayEndHour 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, + dayConfig: { timezone: TIMEZONE, dayEndHour: 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" }]; @@ -71,7 +90,7 @@ describe("calculateDayBoundaries", () => { expect( calculateDayBoundaries({ days, - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart, totalWidth: 2000, }), @@ -82,7 +101,7 @@ describe("calculateDayBoundaries", () => { expect( calculateDayBoundaries({ days: [{ date: "2025-07-12" }], - timezone: TIMEZONE, + dayConfig: { timezone: TIMEZONE, dayEndHour: 0 }, festivalStart: new Date("2025-07-12T00:00:00Z"), totalWidth: 0, }), @@ -160,7 +179,7 @@ describe("the shared ruler", () => { const boundaries = calculateDayBoundaries({ days, - timezone: "Europe/Lisbon", + dayConfig: { timezone: "Europe/Lisbon", dayEndHour: 0 }, festivalStart, totalWidth, }); diff --git a/src/lib/timelineOverviewGeometry.ts b/src/lib/timelineOverviewGeometry.ts index 57dda58d7..7a86f87cc 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, type FestivalDayConfig } from "@/lib/timeUtils"; import type { HorizontalTimelineSet } from "./timelineCalculator"; /** @@ -51,21 +51,22 @@ export interface OverviewDayBoundary { interface CalculateDayBoundariesParams { days: Array<{ date: string }>; - timezone: string; + dayConfig: FestivalDayConfig; 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-end 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, + dayConfig, festivalStart, totalWidth, }: CalculateDayBoundariesParams): OverviewDayBoundary[] { @@ -73,8 +74,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, dayConfig); + 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 f8e458e83..902a04f83 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_end_hour, + ), + ]; return (
diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx index 9125d6382..97c2f0e5d 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/DayJumpButtons.tsx @@ -3,18 +3,19 @@ import { cn } from "@/lib/utils"; import { areFestivalDaysAdjacent, getFestivalDayParts } from "@/lib/timeUtils"; import { getDayJumpMoment } from "@/lib/timelineDayJump"; import type { ScheduleDay } from "@/hooks/useScheduleData"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; interface DayJumpButtonsProps { days: ScheduleDay[]; activeDay: string | null; - timezone: string; + dayConfig: FestivalDayConfig; onJumpToDay: (moment: Date) => void; } export function DayJumpButtons({ days, activeDay, - timezone, + dayConfig, onJumpToDay, }: DayJumpButtonsProps) { const activeButtonRef = useRef(null); @@ -48,7 +49,7 @@ export function DayJumpButtons({ role="radio" aria-checked={isActive} ref={isActive ? activeButtonRef : undefined} - onClick={() => onJumpToDay(getDayJumpMoment(day, timezone))} + onClick={() => onJumpToDay(getDayJumpMoment(day, dayConfig))} 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 6870a3903..706a30e88 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx @@ -4,21 +4,22 @@ import { } from "./timeScaleGeometry"; import { DateBand } from "./DateBand"; import { HourMarkers } from "./HourMarkers"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; interface TimeScaleProps { timeSlots: Date[]; totalWidth: number; - timezone: string; + dayConfig: FestivalDayConfig; scrollLeft: number; } export function TimeScale({ timeSlots, totalWidth, - timezone, + dayConfig, scrollLeft, }: TimeScaleProps) { - const dateChanges = computeDateChanges(timeSlots, timezone); + const dateChanges = computeDateChanges(timeSlots, dayConfig); const geometry = computeDateLabelGeometry( dateChanges, scrollLeft, @@ -31,9 +32,9 @@ export function TimeScale({ dateChanges={dateChanges} geometry={geometry} totalWidth={totalWidth} - timezone={timezone} + timezone={dayConfig.timezone} /> - +
); } diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx index 5e0c90398..57e72ba8a 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScaleContainer.tsx @@ -2,16 +2,17 @@ import { TimeScale } from "./TimeScale"; import type { TimelineData } from "@/lib/timelineCalculator"; import { HEADER_STRIP_TOP_CLASS } from "@/lib/layout-constants"; import { cn } from "@/lib/utils"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; interface TimeScaleContainerProps { timelineData: TimelineData; - timezone: string; + dayConfig: FestivalDayConfig; scrollLeft: number; } export function TimeScaleContainer({ timelineData, - timezone, + dayConfig, scrollLeft, }: TimeScaleContainerProps) { return ( @@ -30,7 +31,7 @@ export function TimeScaleContainer({ diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx index 53553d525..9dec9c749 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineContainer.tsx @@ -16,10 +16,11 @@ import { useTimelineScrollSync } from "@/hooks/useTimelineScrollSync"; import { jumpToTimelineMoment } from "@/lib/timelineDayJump"; import { useActiveTimelineDay } from "@/hooks/useActiveTimelineDay"; import { useScrollLeft } from "./useScrollLeft"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; interface TimelineContainerProps { timelineData: TimelineData; - timezone: string; + dayConfig: FestivalDayConfig; scheduleDays: ScheduleDay[]; selectedDay: string; scheduleWindow: ScheduleWindow | null; @@ -28,7 +29,7 @@ interface TimelineContainerProps { export function TimelineContainer({ timelineData, - timezone, + dayConfig, scheduleDays, selectedDay, scheduleWindow, @@ -42,14 +43,14 @@ export function TimelineContainer({ scrollContainerRef, festivalStart: timelineData.festivalStart, scheduleWindow, - timezone, + dayConfig, now, }); const activeDay = useActiveTimelineDay({ scrollContainerRef, days: scheduleDays, - timezone, + dayConfig, festivalStart: timelineData.festivalStart, }); @@ -75,7 +76,7 @@ export function TimelineContainer({ days={scheduleDays} selectedDay={selectedDay} activeDay={activeDay} - timezone={timezone} + dayConfig={dayConfig} onJumpToDay={(moment) => jumpTo(moment, "start")} isOverviewExpanded={isOverviewExpanded} onToggleOverview={() => setIsOverviewExpanded((prev) => !prev)} @@ -86,14 +87,14 @@ export function TimelineContainer({ jumpTo(moment, "center")} /> )} @@ -111,7 +112,7 @@ export function TimelineContainer({ key={stage.name} stage={stage} totalWidth={timelineData.totalWidth} - timezone={timezone} + timezone={dayConfig.timezone} /> ))} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx index 65ac981a5..f9308ef67 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineOverview.tsx @@ -14,6 +14,7 @@ import { import { OverviewStageRow } from "./OverviewStageRow"; import { OverviewViewportWindow } from "./OverviewViewportWindow"; import { useTimelineViewportSize } from "./useTimelineViewportSize"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; // Fixed regardless of stage count: rows shrink to fit rather than the map // growing taller on festivals with many stages (which made it unusably tall @@ -24,7 +25,7 @@ const LABEL_HEIGHT_PX = 16; interface TimelineOverviewProps { timelineData: TimelineData; scheduleDays: ScheduleDay[]; - timezone: string; + dayConfig: FestivalDayConfig; scrollContainerRef: RefObject; onJump: (moment: Date) => void; } @@ -39,7 +40,7 @@ interface TimelineOverviewProps { export function TimelineOverview({ timelineData, scheduleDays, - timezone, + dayConfig, scrollContainerRef, onJump, }: TimelineOverviewProps) { @@ -51,7 +52,7 @@ export function TimelineOverview({ const dayBoundaries = calculateDayBoundaries({ days: scheduleDays, - timezone, + dayConfig, 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 198494e3f..1b1ed7b67 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/TimelineToolbar.tsx @@ -9,12 +9,13 @@ import type { ScheduleDay } from "@/hooks/useScheduleData"; import { useScrollEdgeFade } from "./useScrollEdgeFade"; import { STICKY_TOP_BELOW_TOP_BAR_CLASS } from "@/lib/layout-constants"; import { cn } from "@/lib/utils"; +import type { FestivalDayConfig } from "@/lib/timeUtils"; interface TimelineToolbarProps { days: ScheduleDay[]; selectedDay: string; activeDay: string | null; - timezone: string; + dayConfig: FestivalDayConfig; onJumpToDay: (moment: Date) => void; isOverviewExpanded: boolean; onToggleOverview: () => void; @@ -33,7 +34,7 @@ export function TimelineToolbar({ days, selectedDay, activeDay, - timezone, + dayConfig, onJumpToDay, isOverviewExpanded, onToggleOverview, @@ -77,7 +78,7 @@ export function TimelineToolbar({ diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts b/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts index f8b3d8e1c..d9db8412a 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/timeScaleGeometry.test.ts @@ -19,7 +19,10 @@ describe("computeDateChanges", () => { new Date("2024-07-01T12:00:00Z"), ]; - const changes = computeDateChanges(timeSlots, timezone); + const changes = computeDateChanges(timeSlots, { + timezone, + dayEndHour: 0, + }); expect(changes).toEqual([{ date: timeSlots[0], position: 0 }]); }); @@ -32,7 +35,10 @@ describe("computeDateChanges", () => { new Date("2024-07-02T01:00:00Z"), ]; - const changes = computeDateChanges(timeSlots, timezone); + const changes = computeDateChanges(timeSlots, { + timezone, + dayEndHour: 0, + }); expect(changes).toHaveLength(2); expect(changes[0]).toEqual({ date: timeSlots[0], position: 0 }); @@ -41,7 +47,7 @@ describe("computeDateChanges", () => { }); it("returns an empty array for an empty slot list", () => { - expect(computeDateChanges([], timezone)).toEqual([]); + expect(computeDateChanges([], { timezone, dayEndHour: 0 })).toEqual([]); }); it("respects the given timezone when detecting the day boundary", () => { @@ -51,12 +57,39 @@ describe("computeDateChanges", () => { new Date("2024-07-01T22:00:00Z"), ]; - const changesUtc = computeDateChanges(timeSlots, "UTC"); + const changesUtc = computeDateChanges(timeSlots, { + timezone: "UTC", + dayEndHour: 0, + }); expect(changesUtc).toHaveLength(1); - const changesBerlinSummer = computeDateChanges(timeSlots, "Europe/Berlin"); + const changesBerlinSummer = computeDateChanges(timeSlots, { + timezone: "Europe/Berlin", + dayEndHour: 0, + }); expect(changesBerlinSummer).toHaveLength(2); }); + + it("folds a slot before the dayEndHour 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, + dayEndHour: 0, + }); + expect(noCutoff).toHaveLength(2); + + // With a 6h cutoff, 01:00 is still "yesterday" - no boundary crossed. + const withCutoff = computeDateChanges(timeSlots, { + timezone, + dayEndHour: 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 52ee0be2f..94486aca5 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, type FestivalDayConfig } from "@/lib/timeUtils"; export const DAY_GAP_PX = 5; @@ -14,19 +14,19 @@ export interface DateChange { export function computeDateChanges( timeSlots: Date[], - timezone: string, + config: FestivalDayConfig, ): DateChange[] { + function festivalDate(date: Date): string | null { + return getFestivalDayKey(date.toISOString(), config); + } + 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/ScheduleTab/lineup/ScheduleLineupView.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx index 0b388505b..d575a5d6b 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx @@ -34,10 +34,14 @@ function ScheduleLineupContent({ tab }: ScheduleLineupViewProps) { setsByEditionQuery(edition.id), ); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + const display = useMemo( + () => ({ timezone: festival.timezone }), + [festival.timezone], + ); const { scheduleDays } = useScheduleData({ sets: editionSets, stages, - timezone: festival.timezone, + display, }); const { day: selectedDay, diff --git a/src/pages/admin/festivals/CreateFestivalForm.tsx b/src/pages/admin/festivals/CreateFestivalForm.tsx index 475cef575..c7d772928 100644 --- a/src/pages/admin/festivals/CreateFestivalForm.tsx +++ b/src/pages/admin/festivals/CreateFestivalForm.tsx @@ -1,6 +1,7 @@ import { useCreateFestivalMutation } from "@/api/festivals/useCreateFestival"; import { FestivalForm } from "./FestivalForm/FestivalForm"; import { + DEFAULT_DAY_END_HOUR, DEFAULT_FESTIVAL_TIMEZONE, FestivalFormData, } from "./FestivalForm/festivalFormSchema"; @@ -35,5 +36,6 @@ function getCreateDefaults(): FestivalFormData { description: "", published: false, timezone: DEFAULT_FESTIVAL_TIMEZONE, + day_end_hour: DEFAULT_DAY_END_HOUR, }; } diff --git a/src/pages/admin/festivals/EditFestivalForm.tsx b/src/pages/admin/festivals/EditFestivalForm.tsx index e7a306e7e..55781b9cc 100644 --- a/src/pages/admin/festivals/EditFestivalForm.tsx +++ b/src/pages/admin/festivals/EditFestivalForm.tsx @@ -2,6 +2,7 @@ import { useUpdateFestivalMutation } from "@/api/festivals/useUpdateFestival"; import { Festival } from "@/api/festivals/types"; import { FestivalForm } from "./FestivalForm/FestivalForm"; import { + DEFAULT_DAY_END_HOUR, DEFAULT_FESTIVAL_TIMEZONE, FestivalFormData, } from "./FestivalForm/festivalFormSchema"; @@ -41,5 +42,6 @@ function getEditDefaults(festival: Festival): FestivalFormData { description: festival.description || "", published: festival.published || false, timezone: festival.timezone || DEFAULT_FESTIVAL_TIMEZONE, + day_end_hour: festival.day_end_hour ?? DEFAULT_DAY_END_HOUR, }; } diff --git a/src/pages/admin/festivals/FestivalForm/DayEndHourField.tsx b/src/pages/admin/festivals/FestivalForm/DayEndHourField.tsx new file mode 100644 index 000000000..9335a554b --- /dev/null +++ b/src/pages/admin/festivals/FestivalForm/DayEndHourField.tsx @@ -0,0 +1,42 @@ +import { useFormContext } from "react-hook-form"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import type { FestivalFormData } from "./festivalFormSchema"; + +export function DayEndHourField() { + const { control } = useFormContext(); + + return ( + ( + + Day end hour + + field.onChange(e.target.valueAsNumber)} + /> + + + The hour (in the festival timezone) the festival day ends. Sets + before it belong to the previous day. 0 ends days at midnight. + + + + )} + /> + ); +} diff --git a/src/pages/admin/festivals/FestivalForm/FestivalForm.tsx b/src/pages/admin/festivals/FestivalForm/FestivalForm.tsx index e4ef1f10d..fc687526a 100644 --- a/src/pages/admin/festivals/FestivalForm/FestivalForm.tsx +++ b/src/pages/admin/festivals/FestivalForm/FestivalForm.tsx @@ -13,6 +13,7 @@ import { Loader2 } from "lucide-react"; import { TimezonePicker } from "@/components/Admin/ScheduleImport/TimezonePicker"; import { FestivalNameField } from "./FestivalNameField"; import { FestivalSlugField } from "./FestivalSlugField"; +import { DayEndHourField } from "./DayEndHourField"; import { FestivalPublishedField } from "./FestivalPublishedField"; import { festivalFormSchema, FestivalFormData } from "./festivalFormSchema"; @@ -72,6 +73,7 @@ export function FestivalForm({ /> )} /> +