Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0002-festival-timezone-display.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/api/festivals/useCreateFestival.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions src/api/festivals/useUpdateFestival.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ async function updateFestival(
published?: boolean;
logo_url?: string | null;
timezone?: string;
day_start_hour?: number;
}>,
) {
const { data, error } = await supabase
Expand Down Expand Up @@ -42,6 +43,7 @@ export function useUpdateFestivalMutation() {
published?: boolean;
logo_url?: string | null;
timezone?: string;
day_start_hour?: number;
}>;
}) => updateFestival(festivalId, festivalData),
onSuccess: () => {
Expand Down
10 changes: 7 additions & 3 deletions src/hooks/useActiveTimelineDay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface UseActiveTimelineDayOptions {
scrollContainerRef: RefObject<HTMLDivElement>;
days: ScheduleDay[];
timezone: string;
dayStartHour: number;
festivalStart: Date;
}

Expand All @@ -23,6 +24,7 @@ export function useActiveTimelineDay({
scrollContainerRef,
days,
timezone,
dayStartHour,
festivalStart,
}: UseActiveTimelineDayOptions) {
const [activeDate, setActiveDate] = useState<string | null>(
Expand All @@ -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);
Expand All @@ -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;
}
10 changes: 8 additions & 2 deletions src/hooks/useScheduleData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ interface UseScheduleDataOptions {
stages: Array<Stage> | undefined;
use24Hour?: boolean;
timezone?: string;
dayStartHour?: number;
}

export function useScheduleData({
sets,
stages,
use24Hour = false,
timezone,
dayStartHour = 0,
}: UseScheduleDataOptions) {
const scheduleDays = useMemo(() => {
if (!sets || !stages || !Array.isArray(sets) || sets.length === 0) {
Expand All @@ -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 }] : [];
});

Expand Down Expand Up @@ -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<string>();
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useTimelineScrollSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface UseTimelineScrollSyncOptions {
festivalStart: Date;
scheduleWindow: ScheduleWindow | null;
timezone: string;
dayStartHour: number;
now: Date;
}

Expand All @@ -32,6 +33,7 @@ export function useTimelineScrollSync({
festivalStart,
scheduleWindow,
timezone,
dayStartHour,
now,
}: UseTimelineScrollSyncOptions) {
const route =
Expand All @@ -57,6 +59,7 @@ export function useTimelineScrollSync({
scrollTo,
day,
timezone,
dayStartHour,
festivalStart,
scheduleWindow,
now,
Expand Down
3 changes: 3 additions & 0 deletions src/integrations/supabase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions src/lib/dayFilterOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions src/lib/dayFilterOptions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 53 additions & 0 deletions src/lib/timeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
formatTimeRange,
formatDateTime,
formatTimeOnly,
formatDayOnly,
toDatetimeLocal,
toISOString,
toDatetimeLocalInTimeZone,
Expand Down Expand Up @@ -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", () => {
Expand Down
39 changes: 34 additions & 5 deletions src/lib/timeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseISO,
isSameDay,
differenceInCalendarDays,
subHours,
} from "date-fns";
import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz";

Expand Down Expand Up @@ -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);
Comment on lines 196 to +200
}

// 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.
Expand Down
8 changes: 8 additions & 0 deletions src/lib/timelineDayJump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[]) {
Expand Down
Loading
Loading