Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e44d034
feat(schedule): sticky Now/Timeline/List switcher + shared layout con…
claude Jul 25, 2026
2cc6f17
feat(timeline): sticky header strip synced to horizontal scroll + unb…
claude Jul 25, 2026
7380a74
refactor(timeline): trim TimeScale comment to non-obvious behavior only
claude Jul 25, 2026
8a1a3bd
fix(schedule): un-stick the view switcher per spec
claude Jul 25, 2026
c9e72da
fix(timeline): read initial scroll position before first scroll event
claude Jul 25, 2026
5f61ae8
fix(timeline): restore solid date-band background
claude Jul 25, 2026
3a9433c
fix(timeline): fix header-strip overlap, restore date fade, fix label…
claude Jul 25, 2026
85ab3d9
refactor(timeline): use native sticky instead of scroll-mirroring for…
claude Jul 25, 2026
f46eb4d
Revert "refactor(timeline): use native sticky instead of scroll-mirro…
claude Jul 25, 2026
c8ac5a3
refactor(timeline): use cn for toolbar className
claude Jul 25, 2026
12625fd
refactor(timeline): drop stale comment, center stage labels
claude Jul 25, 2026
c0f6505
refactor(timeline): extract useScrollLeft hook and TimeScaleContainer
claude Jul 25, 2026
734f112
docs(timeline): trim TimeScale comment to non-obvious behavior
claude Jul 25, 2026
e8b01d1
style(timeline): round bottom corners of header strip
claude Jul 25, 2026
72cc830
refactor(schedule): drop own padding, rely on parent's spacing
claude Jul 25, 2026
216eb17
docs(layout-constants): note toolbar height is measured, not derived
claude Jul 25, 2026
34192b1
docs(layout-constants): reference components, trim measurement note
claude Jul 25, 2026
00945f5
refactor(timeline): move header strip offset calc into TimeScaleConta…
claude Jul 25, 2026
e5fd7b4
docs(useScrollLeft): convert to JSDoc for hook consumers
claude Jul 25, 2026
cdc9d6e
fix(timeline): avoid mobile offset flash, make header strip fully opaque
claude Jul 25, 2026
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 CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The set of artists associated with an edition. Visible to voters once the editio
_Avoid_: Roster, bill

**Schedule**:
The arrangement of an edition's **sets** across **stages** and time. Presented to users via the Timeline and List views on the Schedule tab. Not a stored entity — derived from sets + stages of an edition.
The arrangement of an edition's **sets** across **stages** and time. Presented to users via the Now, Timeline, and List views on the Schedule tab. Not a stored entity — derived from sets + stages of an edition.
_Avoid_: Lineup (lineup = who; schedule = when/where), program, timetable

**Festival phase**:
Expand Down
31 changes: 31 additions & 0 deletions src/lib/layout-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Shared sticky-offset constants so sticky bars (timeline toolbar/header
Comment thread
chiptus marked this conversation as resolved.
// strip, list-view day headers) derive their `top` from one source instead
// of scattered magic numbers.

export const TOP_BAR_HEIGHT_PX = { mobile: 64, desktop: 80 } as const;

// Tailwind utility classes matching TOP_BAR_HEIGHT_PX (the fixed top bar's
// `h-16 md:h-20` spacer in TopBar.tsx) — used by sticky elements docking
// directly below it (timeline toolbar/header strip, list day headers).
export const STICKY_TOP_BELOW_TOP_BAR_CLASS = "top-16 md:top-20";

// The timeline toolbar (TimelineToolbar.tsx) sits at the very top of its
// scroll region (above everything else), so the header strip below it
// stacks on top of the toolbar's own height.
// Measured from the rendered toolbar (padding + border + content).
export const TIMELINE_TOOLBAR_HEIGHT_PX = { mobile: 63, desktop: 63 } as const;

// Used by the timeline header strip (TimeScaleContainer.tsx) to dock below
// the toolbar. Measured from the rendered toolbar (padding + border +
// content).
export const HEADER_STRIP_TOP_PX = {
mobile: TOP_BAR_HEIGHT_PX.mobile + TIMELINE_TOOLBAR_HEIGHT_PX.mobile,
desktop: TOP_BAR_HEIGHT_PX.desktop + TIMELINE_TOOLBAR_HEIGHT_PX.desktop,
} as const;

// Tailwind classes matching HEADER_STRIP_TOP_PX, expressed responsively so
// the offset doesn't depend on a JS media-query hook (which starts at
// `false` and would flash the desktop offset on mobile before settling).
// Written as a literal string (not interpolated from the px constants
// above) so Tailwind's static class scanner can pick it up.
export const HEADER_STRIP_TOP_CLASS = "top-[127px] md:top-[143px]";
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ interface StageLabelsProps {

export function StageLabels({ stages }: StageLabelsProps) {
return (
<div className="absolute top-12 z-20 space-y-16">
<div className="absolute top-0 z-20 space-y-24">
{stages.map((stage) => (
<div key={stage.name} className="h-20 flex items-center">
<div key={stage.name} className="h-12 flex items-center">
<div
className="text-sm font-medium text-white px-2 py-1 rounded"
style={{
Expand Down
220 changes: 86 additions & 134 deletions src/pages/EditionView/tabs/ScheduleTab/horizontal/TimeScale.tsx
Original file line number Diff line number Diff line change
@@ -1,78 +1,55 @@
import { formatInTimeZone, fromZonedTime } from "date-fns-tz";
import { useEffect, useState, useRef } from "react";
import { formatInTimeZone } from "date-fns-tz";
Comment thread
chiptus marked this conversation as resolved.
Comment thread
chiptus marked this conversation as resolved.
import { timeToOffset } from "@/lib/timelineCalculator";

interface TimeScaleProps {
timeSlots: Date[];
totalWidth: number;
scrollContainerRef: React.RefObject<HTMLDivElement>;
timezone: string;
scrollLeft: number;
}

const dateFormat = "MMMM d";

// Width of the day-boundary gap between adjacent date backgrounds.
const DAY_GAP_PX = 5;

// Distance (px) from the next day boundary at which its label starts fading in.
const UPCOMING_FADE_THRESHOLD_PX = 100;

// `scrollLeft` keeps the current day's label pinned to the strip's left
// edge while scrolling through that day, fading in the next day's label
// as its boundary nears.
export function TimeScale({
timeSlots,
totalWidth,
scrollContainerRef,
timezone,
scrollLeft,
}: TimeScaleProps) {
const [scrollLeft, setScrollLeft] = useState(0);
const timeScaleRef = useRef<HTMLDivElement>(null);

// Find where dates change to position floating date labels
const dateChanges = timeSlots.reduce(
(changes, timeSlot, index) => {
if (index === 0) {
changes.push({ date: timeSlot, position: 0 });
} else {
const prevDate = formatInTimeZone(
timeSlots[index - 1],
timezone,
"yyyy-MM-dd",
);
const currentDate = formatInTimeZone(timeSlot, timezone, "yyyy-MM-dd");
if (prevDate !== currentDate) {
// Position based on festival-timezone midnight of the new date
const midnightOfNewDate = fromZonedTime(
`${currentDate}T00:00:00`,
timezone,
);

// Calculate position relative to festival start
const festivalStart = timeSlots[0];
const position = timeToOffset(midnightOfNewDate, festivalStart) + 20;

changes.push({ date: midnightOfNewDate, position });
}
return changes;
}
const prevDate = formatInTimeZone(
timeSlots[index - 1],
timezone,
"yyyy-MM-dd",
);
const currentDate = formatInTimeZone(timeSlot, timezone, "yyyy-MM-dd");
if (prevDate !== currentDate) {
changes.push({
date: timeSlot,
position: timeToOffset(timeSlot, timeSlots[0]),
});
}
return changes;
},
[] as Array<{ date: Date; position: number }>,
);

// Track scroll position for sticky date labels
useEffect(() => {
if (!scrollContainerRef.current) return;

function handleScroll() {
setScrollLeft(scrollContainerRef.current?.scrollLeft || 0);
}

const scrollContainer = scrollContainerRef.current;
scrollContainer.addEventListener("scroll", handleScroll);

return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
};
}, [scrollContainerRef]);

// Find which dates should be visible based on scroll position
const visiblePosition = scrollLeft;

// Find current day (the day we're currently viewing)
const currentDateIndex = dateChanges.findLastIndex(
(change) => change.position <= visiblePosition,
(change) => change.position <= scrollLeft,
);
const currentDate =
currentDateIndex >= 0 ? dateChanges[currentDateIndex] : dateChanges[0];
Expand All @@ -81,109 +58,84 @@ export function TimeScale({
? dateChanges[currentDateIndex + 1]
: null;

// Calculate positions for sticky dates
const currentDayEndPosition = nextDate ? nextDate.position - 5 : totalWidth; // -5 for the gap
const currentDayEndPosition = nextDate
? nextDate.position - DAY_GAP_PX
: totalWidth;
const currentDayWidth = currentDayEndPosition - currentDate.position;
const scrolledIntoCurrentDay = visiblePosition - currentDate.position;

// When to show upcoming date (e.g., when within 100px of next day)
const showUpcomingThreshold = 100;
const distanceToNextDay = nextDate
? nextDate.position - visiblePosition
? nextDate.position - scrollLeft
: Infinity;
const shouldShowUpcoming =
nextDate && distanceToNextDay <= showUpcomingThreshold;
nextDate !== null && distanceToNextDay <= UPCOMING_FADE_THRESHOLD_PX;

// Position for current day label (constrained to its day block)
// Pinned label stays within its own day block: not before the day starts,
// not past the day's end (leaving room for the label's own width).
const currentDateStickyLeft = Math.min(
Math.max(0, scrollLeft - currentDate.position), // Don't go before day start
currentDayWidth - 120, // Don't go past day end (120px for label width)
Math.max(0, scrollLeft - currentDate.position),
Math.max(0, currentDayWidth - 120),
);

return (
<div className="relative">
{/* Current day sticky label - stays within its day block */}
<div
className="absolute top-0 z-30 text-sm font-medium px-3 py-1 rounded shadow-lg text-purple-200 whitespace-nowrap"
style={{
left: `${currentDate.position + currentDateStickyLeft}px`,
opacity: scrolledIntoCurrentDay >= 0 ? 1 : 0,
}}
>
{currentDate
? formatInTimeZone(currentDate.date, timezone, dateFormat)
: "Loading..."}
</div>
<div className="relative" style={{ minWidth: totalWidth }}>
<div className="relative h-8">
{dateChanges.map((dateChange, index) => {
const nextDateChange = dateChanges[index + 1];
const fullWidth = nextDateChange
? nextDateChange.position - dateChange.position
: totalWidth - dateChange.position;
const width = fullWidth - DAY_GAP_PX;

return (
<div
key={`date-bg-${index}`}
className="absolute top-0 h-full bg-purple-900/60 border border-purple-400/30"
style={{
left: `${dateChange.position}px`,
width: `${width}px`,
}}
/>
);
})}

{/* Upcoming day sticky label - appears when approaching next day */}
{shouldShowUpcoming && nextDate && (
<div
className="absolute top-0 z-30 text-sm font-medium px-3 py-1 rounded shadow-lg text-purple-100 whitespace-nowrap "
className="absolute top-0 z-10 flex h-full items-center px-3 text-sm font-medium text-purple-100 whitespace-nowrap"
style={{
left: `${nextDate.position}px`, // Show to the right of current view
opacity: Math.min(
1,
(showUpcomingThreshold - distanceToNextDay) / 50,
), // Fade in effect
left: `${currentDate.position + currentDateStickyLeft}px`,
opacity: scrollLeft - currentDate.position >= 0 ? 1 : 0,
}}
>
{formatInTimeZone(nextDate.date, timezone, dateFormat)}
{formatInTimeZone(currentDate.date, timezone, dateFormat)}
</div>
)}

<div
ref={timeScaleRef}
className="flex items-center mb-[72px] relative"
style={{ minWidth: totalWidth }}
>
<div className="flex-1 relative">
{/* Floating date labels spanning the width of each day */}
{dateChanges.map((dateChange, index) => {
// Calculate width from this date to the next date (or end of timeline)
const nextDateChange = dateChanges[index + 1];
const fullWidth = nextDateChange
? nextDateChange.position - dateChange.position
: totalWidth - dateChange.position;

const space = 5;
const width = fullWidth - space;
const left = dateChange.position;

return (
<div
key={`date-${index}`}
className="absolute top-0 text-sm font-medium text-purple-200 bg-purple-900/60 px-2 py-1 border border-purple-400/30 flex items-center justify-center"
style={{
left: `${left}px`,
width: `${width}px`,
minWidth: "100px", // Ensure minimum readability
}}
>
{/* {format(dateChange.date, dateFormat)} */}
&nbsp;
</div>
);
})}

{/* Hour markers */}
<div className="hour-markers">
{timeSlots.map((timeSlot, index) => (
<div
key={index}
className="absolute flex flex-col items-center"
style={{ left: `${timeToOffset(timeSlot, timeSlots[0])}px` }}
>
<div className="text-sm font-medium text-purple-300 mb-2 mt-10">
{formatInTimeZone(timeSlot, timezone, "HH:mm")}
</div>
<div className="w-px h-4 bg-purple-400/30"></div>
</div>
))}
{shouldShowUpcoming && nextDate && (
<div
className="absolute top-0 z-10 flex h-full items-center px-3 text-sm font-medium text-purple-50 whitespace-nowrap"
style={{
left: `${nextDate.position}px`,
opacity: Math.min(
1,
(UPCOMING_FADE_THRESHOLD_PX - distanceToNextDay) / 50,
),
}}
>
{formatInTimeZone(nextDate.date, timezone, dateFormat)}
</div>
)}
</div>

{/* Horizontal grid line */}
<div className="absolute top-16 left-0 right-0 h-px bg-purple-400/20"></div>
</div>
<div className="hour-markers relative h-10">
{timeSlots.map((timeSlot, index) => (
<div
key={index}
className="absolute flex flex-col items-center"
style={{ left: `${timeToOffset(timeSlot, timeSlots[0])}px` }}
>
<div className="text-sm font-medium text-purple-300">
{formatInTimeZone(timeSlot, timezone, "HH:mm")}
</div>
<div className="w-px h-4 bg-purple-400/30" />
</div>
))}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { TimeScale } from "./TimeScale";
import type { TimelineData } from "@/lib/timelineCalculator";
import { HEADER_STRIP_TOP_CLASS } from "@/lib/layout-constants";
import { cn } from "@/lib/utils";

interface TimeScaleContainerProps {
timelineData: TimelineData;
timezone: string;
scrollLeft: number;
}

export function TimeScaleContainer({
timelineData,
timezone,
scrollLeft,
}: TimeScaleContainerProps) {
return (
<div
className={cn(
"sticky z-30 overflow-hidden rounded-b-lg bg-gray-900",
HEADER_STRIP_TOP_CLASS,
)}
>
<div
style={{
transform: `translateX(-${scrollLeft}px)`,
width: timelineData.totalWidth,
}}
>
<TimeScale
timeSlots={timelineData.timeSlots}
totalWidth={timelineData.totalWidth}
timezone={timezone}
scrollLeft={scrollLeft}
/>
</div>
</div>
);
}
20 changes: 8 additions & 12 deletions src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,13 @@ export function Timeline() {
}

return (
<div className="space-y-8">
<div className="bg-white/5 rounded-lg p-4">
<TimelineContainer
timelineData={timelineData}
timezone={festival.timezone}
scheduleDays={scheduleDays}
selectedDay={selectedDay}
scheduleWindow={scheduleWindow}
now={now}
/>
</div>
</div>
<TimelineContainer
timelineData={timelineData}
timezone={festival.timezone}
scheduleDays={scheduleDays}
selectedDay={selectedDay}
scheduleWindow={scheduleWindow}
now={now}
/>
);
}
Loading