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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ In this codebase, it is acceptable and preferred to define helper functions (suc

## Git Workflow

- **Auto-commit Rule**: For every user message that requests code changes, automatically commit the changes after implementation with an appropriate commit message, if there are no other staged files
- **Auto-commit Rule**: For every user message that requests code changes, automatically commit the changes after implementation with an appropriate commit message

- never run "supabase db push"
- don't run supabase db reset
Expand Down
19 changes: 0 additions & 19 deletions src/__tests__/fixtures.ts

This file was deleted.

66 changes: 0 additions & 66 deletions src/hooks/useActiveTimelineDay.ts

This file was deleted.

33 changes: 30 additions & 3 deletions src/hooks/useTimelineScrollSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ interface UseTimelineScrollSyncOptions {

/**
* One-way sync between the timeline viewport and the `scrollTo` URL param:
* URL -> scroll only on mount; user scroll -> URL only (debounced, 5-min
* rounded, history replace). Never loops. See `jumpToTimelineMoment` for
* imperative jumps.
* URL -> scroll only on mount and via `jumpTo`; user scroll -> URL only
* (debounced, 5-min rounded, history replace). Never loops.
*/
export function useTimelineScrollSync({
scrollContainerRef,
Expand All @@ -33,6 +32,7 @@ export function useTimelineScrollSync({
const { scrollTo, day } = useSearch({
from: route,
select: (search) => ({ scrollTo: search.scrollTo, day: search.day }),
structuralSharing: true,
});
const navigate = useNavigate({ from: route });

Expand Down Expand Up @@ -109,4 +109,31 @@ export function useTimelineScrollSync({
}, SCROLL_DEBOUNCE_MS);
}
}, [scrollContainerRef, festivalStart, navigate]);

function jumpTo(moment: Date) {
const container = scrollContainerRef.current;
if (!container) return;

const rounded = roundToNearestMinutes(moment, SCROLL_ROUND_MINUTES);
const targetScrollLeft = Math.max(
0,
timeToOffset(rounded, festivalStart) - container.clientWidth / 2,
);
// A clamped target (e.g. first-day jump) centers on a different moment
// than requested; write the one the viewport actually settles on.
const settledMoment = roundToNearestMinutes(
offsetToTime(targetScrollLeft + container.clientWidth / 2, festivalStart),
SCROLL_ROUND_MINUTES,
);

container.scrollTo({ left: targetScrollLeft, behavior: "smooth" });

navigate({
to: ".",
search: (prev) => ({ ...prev, scrollTo: settledMoment.toISOString() }),
replace: true,
});
}

return { jumpTo };
}
4 changes: 4 additions & 0 deletions src/hooks/useTimelineUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ export type TimeFilter = TimelineSearch["time"];
export function useTimelineUrlState(tab: "timeline" | "list" = "timeline") {
const route =
`/festivals/$festivalSlug/editions/$editionSlug/schedule/${tab}` as const;
// Select only the filter params this hook exposes, with structural sharing,
// so a `scrollTo` write (from scroll syncing) doesn't change this object's
// identity and trigger consumers to recompute the filtered schedule.
const state = useSearch({
from: route,
select: (search) => ({
day: search.day,
time: search.time,
stages: search.stages,
}),
structuralSharing: true,
});
const navigate = useNavigate({ from: route });

Expand Down
43 changes: 12 additions & 31 deletions src/lib/timeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import {
convertLocalTimeToUTC,
getFestivalDayKey,
getFestivalDayLabel,
getFestivalDayParts,
areFestivalDaysAdjacent,
getFestivalDayShortLabel,
getFestivalHour,
} from "./timeUtils";

Expand Down Expand Up @@ -438,43 +437,22 @@ describe("getFestivalDayLabel", () => {
});
});

describe("getFestivalDayParts", () => {
describe("getFestivalDayShortLabel", () => {
it("returns null for null input", () => {
expect(getFestivalDayParts(null)).toBeNull();
expect(getFestivalDayShortLabel(null)).toBeNull();
});

it("returns null for invalid input", () => {
expect(getFestivalDayParts("invalid")).toBeNull();
expect(getFestivalDayShortLabel("invalid")).toBeNull();
});

it("splits a day-key into weekday and day-of-month", () => {
expect(getFestivalDayParts("2024-12-16")).toEqual({
weekday: "Mon",
dayOfMonth: "16",
});
it("formats a day-key into a short weekday + date label", () => {
expect(getFestivalDayShortLabel("2024-12-16")).toBe("Mon 16");
});

it("disambiguates repeated weekdays across a multi-weekend festival", () => {
expect(getFestivalDayParts("2024-12-13")?.dayOfMonth).toBe("13");
expect(getFestivalDayParts("2024-12-20")?.dayOfMonth).toBe("20");
});
});

describe("areFestivalDaysAdjacent", () => {
it("is true for consecutive calendar days", () => {
expect(areFestivalDaysAdjacent("2024-12-16", "2024-12-17")).toBe(true);
});

it("is true across a month boundary", () => {
expect(areFestivalDaysAdjacent("2024-11-30", "2024-12-01")).toBe(true);
});

it("is false across a gap between festival weekends", () => {
expect(areFestivalDaysAdjacent("2024-12-19", "2024-12-23")).toBe(false);
});

it("is false for invalid input", () => {
expect(areFestivalDaysAdjacent("invalid", "2024-12-17")).toBe(false);
expect(getFestivalDayShortLabel("2024-12-13")).toBe("Fri 13");
expect(getFestivalDayShortLabel("2024-12-20")).toBe("Fri 20");
});
});

Expand All @@ -494,7 +472,10 @@ describe("getFestivalHour", () => {

it("is independent of the machine's local zone", () => {
const lisbon = getFestivalHour("2024-07-15T23:30:00Z", "Europe/Lisbon");
const newYork = getFestivalHour("2024-07-15T23:30:00Z", "America/New_York");
const newYork = getFestivalHour(
"2024-07-15T23:30:00Z",
"America/New_York",
);
expect(lisbon).toBe(0);
expect(newYork).toBe(19);
});
Expand Down
29 changes: 5 additions & 24 deletions src/lib/timeUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
format,
isValid,
parseISO,
isSameDay,
differenceInCalendarDays,
} from "date-fns";
import { format, isValid, parseISO, isSameDay } from "date-fns";
import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz";

export function formatTimeRange(
Expand Down Expand Up @@ -200,26 +194,13 @@ export function getFestivalDayLabel(dayKey: string | null): string | null {
return format(date, "EEEE, MMM d");
}

// The two halves of a day-jump label, rendered as a stacked pair.
export function getFestivalDayParts(
dayKey: string | null,
): { weekday: string; dayOfMonth: string } | null {
// Short weekday + date label, e.g. "Thu 13"; the date disambiguates repeated
// weekday names at multi-weekend festivals.
export function getFestivalDayShortLabel(dayKey: string | null): string | null {
if (!dayKey) return null;
const date = parseISO(dayKey);
if (!isValid(date)) return null;
return { weekday: format(date, "EEE"), dayOfMonth: format(date, "d") };
}

// True when two day keys are calendar-adjacent. A false result marks a break in
// the festival's run, which the day rail renders as a divider.
export function areFestivalDaysAdjacent(
earlierDayKey: string,
laterDayKey: string,
): boolean {
const earlier = parseISO(earlierDayKey);
const later = parseISO(laterDayKey);
if (!isValid(earlier) || !isValid(later)) return false;
return differenceInCalendarDays(later, earlier) === 1;
return format(date, "EEE d");
}

// The wall-clock hour (0-23) a UTC timestamp falls on in the festival's
Expand Down
Loading
Loading