From c281092d0ed5303e8e4a250dfbb1818701fa7a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:58:00 +0000 Subject: [PATCH 1/2] feat(schedule): render Schedule tab progressively at days/stages reveal levels Adds a shared Lineup view (day-grouped list, or stage x day grid) rendered by both Timeline and List sub-tabs when schedule_reveal_level is below full, replacing the generic placeholder. Filters follow the level: day only at "days", day + stage at "stages". draft and full are unchanged. Closes #47 --- .../tabs/ScheduleTab/TimelineTab.tsx | 6 +- .../tabs/ScheduleTab/horizontal/Timeline.tsx | 7 -- .../ScheduleTab/lineup/DaysLineupView.tsx | 42 +++++++++++ .../tabs/ScheduleTab/lineup/LineupFilters.tsx | 67 +++++++++++++++++ .../tabs/ScheduleTab/lineup/LineupSetItem.tsx | 29 ++++++++ .../ScheduleTab/lineup/ScheduleLineupView.tsx | 72 +++++++++++++++++++ .../ScheduleTab/lineup/StagesLineupGrid.tsx | 44 ++++++++++++ .../tabs/ScheduleTab/list/ListSchedule.tsx | 7 -- .../tabs/ScheduleTab/list/ListTab.tsx | 15 +++- 9 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 src/pages/EditionView/tabs/ScheduleTab/lineup/DaysLineupView.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/lineup/LineupFilters.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/lineup/LineupSetItem.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx create mode 100644 src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx diff --git a/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx b/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx index 922c529a..ad7a871b 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx @@ -1,11 +1,15 @@ import { FestivalTimeBadge } from "./FestivalTimeBadge"; import { Timeline } from "./horizontal/Timeline"; +import { ScheduleLineupView } from "./lineup/ScheduleLineupView"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; export function ScheduleTabTimeline() { + const { canShowTime } = useScheduleReveal(); + return ( <> - + {canShowTime ? : } ); } diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index 5cd0c0f4..827709a3 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -10,15 +10,12 @@ import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSets import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { getFestivalHour } from "@/lib/timeUtils"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; -import { useScheduleReveal } from "@/hooks/useScheduleReveal"; -import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; export function Timeline() { const { festival } = useFestivalEdition(); const { edition } = useRouteContext({ from: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline", }); - const { canShowTime } = useScheduleReveal(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); @@ -123,10 +120,6 @@ export function Timeline() { ); } - if (!canShowTime) { - return ; - } - return (
diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/DaysLineupView.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/DaysLineupView.tsx new file mode 100644 index 00000000..06f13293 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/DaysLineupView.tsx @@ -0,0 +1,42 @@ +import { LineupSetItem } from "./LineupSetItem"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +interface DaysLineupViewProps { + scheduleDays: ScheduleDay[]; +} + +export function DaysLineupView({ scheduleDays }: DaysLineupViewProps) { + const daysWithSets = scheduleDays + .map((day) => ({ + ...day, + sets: day.stages + .flatMap((stage) => stage.sets) + .sort((a, b) => a.name.localeCompare(b.name)), + })) + .filter((day) => day.sets.length > 0); + + if (!daysWithSets.length) { + return ( +
+

No scheduled sets found.

+
+ ); + } + + return ( +
+ {daysWithSets.map((day) => ( +
+

+ {day.displayDate} +

+
+ {day.sets.map((set) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/LineupFilters.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/LineupFilters.tsx new file mode 100644 index 00000000..a59ff022 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/LineupFilters.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { DayFilterSelect } from "../DayFilterSelect"; +import { StageFilterButtons } from "../StageFilterButtons"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; +import { FilterToggle } from "@/components/filters/FilterToggle"; +import { FilterContainer } from "@/components/filters/FilterContainer"; +import { cn } from "@/lib/utils"; + +interface LineupFiltersProps { + tab: "timeline" | "list"; +} + +export function LineupFilters({ tab }: LineupFiltersProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { canShowStage } = useScheduleReveal(); + const { day, stages, updateDay, updateStages, clearFilters } = + useTimelineUrlState(tab); + + function handleStageToggle(stageId: string) { + const newStages = stages.includes(stageId) + ? stages.filter((id) => id !== stageId) + : [...stages, stageId]; + updateStages(newStages); + } + + const activeFilterCount = + (day !== "all" ? 1 : 0) + (canShowStage ? stages.length : 0); + const hasActiveFilters = activeFilterCount > 0; + + return ( + +
+

Filters

+
+ + setIsExpanded(!isExpanded)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + label="Filters" + onClearFilters={hasActiveFilters ? clearFilters : undefined} + /> +
+ + {isExpanded && ( +
+
+ + {canShowStage && ( + + )} +
+
+ )} + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/LineupSetItem.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/LineupSetItem.tsx new file mode 100644 index 00000000..c7d2501c --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/LineupSetItem.tsx @@ -0,0 +1,29 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { Card, CardContent } from "@/components/ui/card"; +import { VoteButtons } from "../VoteButtons"; +import type { ScheduleSet } from "@/hooks/useScheduleData"; + +interface LineupSetItemProps { + set: ScheduleSet; +} + +export function LineupSetItem({ set }: LineupSetItemProps) { + const { festivalSlug, editionSlug } = useParams({ + from: "/festivals/$festivalSlug/editions/$editionSlug", + }); + + return ( + + + + {set.name} + + + + + ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx new file mode 100644 index 00000000..7e4e6c62 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx @@ -0,0 +1,72 @@ +import { useMemo } from "react"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useSetsByEditionQuery as useEditionSetsQuery } from "@/api/sets/useSetsByEdition"; +import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; +import { useScheduleData } from "@/hooks/useScheduleData"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; +import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; +import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; +import { LineupFilters } from "./LineupFilters"; +import { DaysLineupView } from "./DaysLineupView"; +import { StagesLineupGrid } from "./StagesLineupGrid"; + +interface ScheduleLineupViewProps { + tab: "timeline" | "list"; +} + +export function ScheduleLineupView({ tab }: ScheduleLineupViewProps) { + const { festival } = useFestivalEdition(); + const { level, canShowStage } = useScheduleReveal(); + const route = + `/festivals/$festivalSlug/editions/$editionSlug/schedule/${tab}` as const; + const { edition } = useRouteContext({ from: route }); + const { data: editionSets = [], isLoading: setsLoading } = + useEditionSetsQuery(edition.id); + const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); + const { scheduleDays, loading } = useScheduleData({ + sets: editionSets, + stages, + timezone: festival.timezone, + }); + const { day: selectedDay, stages: selectedStages } = + useTimelineUrlState(tab); + + const filteredScheduleDays = useMemo(() => { + return scheduleDays + .filter((day) => selectedDay === "all" || day.date === selectedDay) + .map((day) => ({ + ...day, + stages: day.stages.filter( + (stage) => + !canShowStage || + selectedStages.length === 0 || + selectedStages.includes(stage.id), + ), + })); + }, [scheduleDays, selectedDay, selectedStages, canShowStage]); + + if (loading || setsLoading) { + return ( +
+

Loading schedule...

+
+ ); + } + + if (level === "draft") { + return ; + } + + return ( +
+ + {canShowStage ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx new file mode 100644 index 00000000..31231be9 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx @@ -0,0 +1,44 @@ +import { LineupSetItem } from "./LineupSetItem"; +import type { ScheduleDay } from "@/hooks/useScheduleData"; + +interface StagesLineupGridProps { + scheduleDays: ScheduleDay[]; +} + +export function StagesLineupGrid({ scheduleDays }: StagesLineupGridProps) { + const daysWithStages = scheduleDays.filter((day) => day.stages.length > 0); + + if (!daysWithStages.length) { + return ( +
+

No scheduled sets found.

+
+ ); + } + + return ( +
+ {daysWithStages.map((day) => ( +
+

+ {day.displayDate} +

+
+ {day.stages.map((stage) => ( +
+

+ {stage.name} +

+
+ {stage.sets.map((set) => ( + + ))} +
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index 5667bee9..dc16b729 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -9,8 +9,6 @@ import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { stagesByEditionQuery } from "@/api/stages/useStagesByEdition"; -import { useScheduleReveal } from "@/hooks/useScheduleReveal"; -import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; interface TimeSlot { time: Date; @@ -22,7 +20,6 @@ export function ListSchedule() { const { edition } = useRouteContext({ from: "/festivals/$festivalSlug/editions/$editionSlug/schedule/list", }); - const { canShowTime } = useScheduleReveal(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition.id); const { data: stages } = useSuspenseQuery(stagesByEditionQuery(edition.id)); @@ -158,10 +155,6 @@ export function ListSchedule() { ); } - if (!canShowTime) { - return ; - } - if (!timeSlots.length) { return (
diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx index fe3146ca..21894446 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx @@ -1,14 +1,23 @@ import { FestivalTimeBadge } from "../FestivalTimeBadge"; import { ListSchedule } from "./ListSchedule"; import { ListFilters } from "./ListFilters"; +import { ScheduleLineupView } from "../lineup/ScheduleLineupView"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; export function ScheduleTabList() { + const { canShowTime } = useScheduleReveal(); + return ( <> - - - + {canShowTime ? ( + <> + + + + ) : ( + + )} ); } From 7a95abfd7144b52536f93eef00d9eefbae820d01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:03:00 +0000 Subject: [PATCH 2/2] fix(schedule): close self-review findings on lineup views Fix stage-grid set order leaking real performance sequence (sort alphabetically like the days view instead of by real start time), hide the "all times" badge when times aren't revealed yet, and gate the placeholder on canShowDay instead of an implicit level check. --- src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx | 2 +- .../tabs/ScheduleTab/lineup/ScheduleLineupView.tsx | 4 ++-- .../tabs/ScheduleTab/lineup/StagesLineupGrid.tsx | 9 ++++++--- src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx b/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx index ad7a871b..4d474236 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/TimelineTab.tsx @@ -8,7 +8,7 @@ export function ScheduleTabTimeline() { return ( <> - + {canShowTime && } {canShowTime ? : } ); diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx index 7e4e6c62..b34a3715 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/ScheduleLineupView.tsx @@ -18,7 +18,7 @@ interface ScheduleLineupViewProps { export function ScheduleLineupView({ tab }: ScheduleLineupViewProps) { const { festival } = useFestivalEdition(); - const { level, canShowStage } = useScheduleReveal(); + const { canShowDay, canShowStage } = useScheduleReveal(); const route = `/festivals/$festivalSlug/editions/$editionSlug/schedule/${tab}` as const; const { edition } = useRouteContext({ from: route }); @@ -55,7 +55,7 @@ export function ScheduleLineupView({ tab }: ScheduleLineupViewProps) { ); } - if (level === "draft") { + if (!canShowDay) { return ; } diff --git a/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx b/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx index 31231be9..f2898ff7 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/lineup/StagesLineupGrid.tsx @@ -30,9 +30,12 @@ export function StagesLineupGrid({ scheduleDays }: StagesLineupGridProps) { {stage.name}
- {stage.sets.map((set) => ( - - ))} + {stage.sets + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map((set) => ( + + ))}
))} diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx index 21894446..ecb92c06 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListTab.tsx @@ -9,7 +9,7 @@ export function ScheduleTabList() { return ( <> - + {canShowTime && } {canShowTime ? ( <>