From c28afc0af654f4732b8d55f9c4d73e667ce2db04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:19:56 +0000 Subject: [PATCH 01/16] docs: seed CONTEXT.md and ADR-0001 for schedule reveal level Captures the grilling outcome for "make schedules publishable" (#46): - CONTEXT.md glossary with festival/edition/lineup/schedule/set/stage terminology and the resolved publish-state model (edition.published is separate from the schedule reveal level) - ADR-0001 records the decision to model schedule visibility as an ordered enum (draft < days < stages < full) on festival_editions, with the rejected alternatives (boolean, independent flags, versioned schedules, per-day granularity) https://claude.ai/code/session_01D6SJEwugBTwcYZt4ZzDQVC --- CONTEXT.md | 69 ++++++++++++++++++++++++++ docs/adr/0001-schedule-reveal-level.md | 16 ++++++ 2 files changed, 85 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-schedule-reveal-level.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..5d5afbf1 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,69 @@ +# UpLine + +Collaborative festival voting platform. Core Team curates a lineup; festival-goers vote on artists and (later) coordinate around set times. + +## Language + +**Festival**: +A recurring music festival (e.g. Boom). Has many editions over time. +_Avoid_: Event + +**Edition**: +A single instance of a festival in a given year (e.g. Boom 2025). Owns its own lineup, schedule, stages, and voting. +_Avoid_: Year, instance, event + +**Lineup**: +The set of artists associated with an edition. Visible to voters once the edition is published. +_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. +_Avoid_: Lineup (lineup = who; schedule = when/where), program, timetable + +**Set**: +A single scheduled performance within an edition, with one or more artists, a stage, and a start/end time. +_Avoid_: Show, gig, slot, performance + +**Stage**: +A named venue/space within an edition where sets take place. +_Avoid_: Venue, room + +**Vote**: +A user's reaction to an artist within a group context. Three values: "Must Go" (+2), "Interested" (+1), "Won't Go" (-1). +_Avoid_: Rating, like + +**Group**: +A collection of users who share votes and notes for collaborative decision-making within an edition. +_Avoid_: Team, party + +**Core Team**: +Admin users who curate editions, manage the lineup, and import the schedule. +_Avoid_: Staff, organizers, moderators + +### Publish states + +An edition has two independent publish states. + +**Edition published**: +The edition's lineup is publicly visible and voting is open. Users can read artists and vote. A boolean. +_Avoid_: Live, released + +**Schedule reveal level**: +How much of the **schedule** (set times + stage assignments) is exposed to the public for this edition. Independent of edition published — the lineup can be live while the schedule is still hidden. An ordered enum, not a boolean. Levels (low → high): + +1. **`draft`** — no schedule info revealed beyond the artist/set existing. +2. **`days`** — the day each set takes place is visible. Stage and time-of-day are hidden. +3. **`stages`** — day + stage are visible ("Artist X plays Main Stage on Friday"). Exact start/end time hidden. Implies `days`. +4. **`full`** — exact start/end times are visible. Implies `stages`. + +Each level reveals strictly more than the previous. There is no "stages without days" state. +_Avoid_: Schedule released, schedule published (as a boolean), timetable live + +## Example dialogue + +> **Dev**: For Boom 2025, when do users first see the artists? +> **Domain**: As soon as the **edition** is **published**. They can read the **lineup** and start voting. The schedule reveal level is still `draft` at that point. +> **Dev**: When do set times show up? +> **Domain**: We move the **schedule reveal level** in steps. First we bump to `days` so people see which day each artist plays. Closer to the festival we bump to `stages` — now they know which stage too. The day before, we go to `full` and exact start/end times are public. +> **Dev**: So `stages` doesn't reveal times? +> **Domain**: Right. You see "Artist X, Friday, Main Stage" but not "18:00". Times only appear at `full`. diff --git a/docs/adr/0001-schedule-reveal-level.md b/docs/adr/0001-schedule-reveal-level.md new file mode 100644 index 00000000..5a97a0f3 --- /dev/null +++ b/docs/adr/0001-schedule-reveal-level.md @@ -0,0 +1,16 @@ +# Schedule visibility uses an ordered reveal level, not a boolean + +Festivals progressively reveal their schedule in phases (lineup → days → stages → exact times), so a single `schedule_published` boolean was insufficient. We model schedule visibility as an ordered enum `schedule_reveal_level` on `festival_editions` with values `draft < days < stages < full`. Each level reveals strictly more set fields (`time_start` date → `stage_id` → time-of-day + `time_end`) to non-admins. The level is independent of `edition.published` — that flag governs the lineup and voting; this enum governs the "when/where" layer of the lineup. + +## Considered Options + +- **Boolean `schedule_published`.** Rejected: didn't fit phases like "stages known, times not yet" that the domain expert called out. +- **Independent booleans (`days_published`, `stages_published`, `times_published`).** Rejected: gives 8 combinations, half nonsensical (e.g. times without days). Ordered enum captures the natural reveal progression and prevents bad states. +- **Separate `schedules` entity with versioned drafts.** Rejected as out of scope: heavy data-model change for a workflow festivals rarely use (re-publishing a different version), when live-writes with a commit warning cover the same need. +- **Per-day or per-stage granularity (publish Friday before Saturday).** Considered but not adopted: orthogonal axis to the reveal level, can be added later without breaking the enum. + +## Consequences + +- Reading set timing/stage fields for non-admins must be column-masked based on the edition's reveal level. RLS alone can't mask columns, so the implementation will need a view (or RPC) that returns nulled fields when the viewer is non-admin and the level doesn't yet expose them. +- "Stages always implies days" is enforced by the ordering. If we ever need "stages without days," the enum must be replaced with independent flags — that's a breaking change. +- The Schedule tab's UI for `days` and `stages` is deliberately deferred: it shows the existing "coming soon" placeholder until `full`, while the progressive reveal manifests on Artist-tab SetCards. A multi-mode Schedule tab is tracked as a separate task. From f5cd61ccd6328ae84cae3cd5bb1ef441d3c47fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:04:08 +0000 Subject: [PATCH 02/16] docs(adr-0001): record client-side masking trade-off We chose client-side hide over a public_sets view for simplicity. The wire leak is acknowledged; server-side hardening is the documented upgrade path. --- docs/adr/0001-schedule-reveal-level.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0001-schedule-reveal-level.md b/docs/adr/0001-schedule-reveal-level.md index 5a97a0f3..b38d0a05 100644 --- a/docs/adr/0001-schedule-reveal-level.md +++ b/docs/adr/0001-schedule-reveal-level.md @@ -11,6 +11,6 @@ Festivals progressively reveal their schedule in phases (lineup → days → sta ## Consequences -- Reading set timing/stage fields for non-admins must be column-masked based on the edition's reveal level. RLS alone can't mask columns, so the implementation will need a view (or RPC) that returns nulled fields when the viewer is non-admin and the level doesn't yet expose them. +- Reading set timing/stage fields for non-admins is masked **on the client** based on the edition's reveal level. We deliberately chose this over a server-side view for simplicity. The trade-off: the embargoed fields (`time_start` time-of-day, `time_end`, `stage_id`) are still sent on the wire — anyone with devtools or direct Supabase access can read them. Acceptable because the schedule is embargoed, not secret. If we ever need true leak-proofness, the upgrade path is a `public_sets` view + revoke of public SELECT on `sets`. - "Stages always implies days" is enforced by the ordering. If we ever need "stages without days," the enum must be replaced with independent flags — that's a breaking change. - The Schedule tab's UI for `days` and `stages` is deliberately deferred: it shows the existing "coming soon" placeholder until `full`, while the progressive reveal manifests on Artist-tab SetCards. A multi-mode Schedule tab is tracked as a separate task. From 99fc176c5f65f8905425ca391b53d50ff40c865a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:07:55 +0000 Subject: [PATCH 03/16] feat(schedule): add schedule_reveal_level enum + column (#46) Ordered enum draft < days < stages < full on festival_editions. Defaults to draft for new editions. Existing editions with published = true are backfilled to full so the migration does not silently hide currently-visible schedules. --- src/integrations/supabase/types.ts | 4 +++ ...260530000000_add_schedule_reveal_level.sql | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 supabase/migrations/20260530000000_add_schedule_reveal_level.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index b70e1952..9f9941e7 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -257,6 +257,7 @@ export type Database = { location: string | null; name: string; published: boolean | null; + schedule_reveal_level: Database["public"]["Enums"]["schedule_reveal_level"]; slug: string; start_date: string | null; updated_at: string; @@ -273,6 +274,7 @@ export type Database = { location?: string | null; name: string; published?: boolean | null; + schedule_reveal_level?: Database["public"]["Enums"]["schedule_reveal_level"]; slug: string; start_date?: string | null; updated_at?: string; @@ -289,6 +291,7 @@ export type Database = { location?: string | null; name?: string; published?: boolean | null; + schedule_reveal_level?: Database["public"]["Enums"]["schedule_reveal_level"]; slug?: string; start_date?: string | null; updated_at?: string; @@ -833,6 +836,7 @@ export type Database = { Enums: { admin_role: "super_admin" | "admin" | "moderator"; link_type: "website" | "tickets" | "custom"; + schedule_reveal_level: "draft" | "days" | "stages" | "full"; }; CompositeTypes: { [_ in never]: never; diff --git a/supabase/migrations/20260530000000_add_schedule_reveal_level.sql b/supabase/migrations/20260530000000_add_schedule_reveal_level.sql new file mode 100644 index 00000000..44092277 --- /dev/null +++ b/supabase/migrations/20260530000000_add_schedule_reveal_level.sql @@ -0,0 +1,27 @@ +-- Add schedule_reveal_level enum and column to festival_editions. +-- Controls how much of the schedule (set times + stage assignments) is exposed +-- to non-admins, independently of festival_editions.published. +-- See docs/adr/0001-schedule-reveal-level.md. + +-- Ordered enum: order of values in the declaration is the comparison order. +-- draft < days < stages < full +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'schedule_reveal_level') THEN + CREATE TYPE public.schedule_reveal_level AS ENUM ('draft', 'days', 'stages', 'full'); + END IF; +END$$; + +ALTER TABLE public.festival_editions + ADD COLUMN IF NOT EXISTS schedule_reveal_level public.schedule_reveal_level NOT NULL DEFAULT 'draft'; + +COMMENT ON COLUMN public.festival_editions.schedule_reveal_level IS + 'Controls how much of the schedule (set times + stage assignments) is exposed to non-admins. Ordered enum: draft < days < stages < full. Independent of festival_editions.published.'; + +-- Preserve existing public visibility: editions currently marked published had +-- their full schedule visible under the old all-or-nothing model. Promote them +-- to ''full'' so the migration is non-regressive. New editions still default +-- to ''draft''. +UPDATE public.festival_editions + SET schedule_reveal_level = 'full' + WHERE published = true; From c36ae12e1083edfb6ccf3520f3f38788fd627145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:11:00 +0000 Subject: [PATCH 04/16] feat(schedule): add reveal-level masking util (#46) scheduleReveal exposes: - isAtLeast(level, threshold) for ordered comparison - canShowDay / canShowStage / canShowTime predicates for per-field visibility checks (used where the time-of-day vs date distinction matters and a null check isn't enough) - maskSetForReveal for the common case of nulling embargoed fields on a set object before rendering --- src/lib/scheduleReveal.test.ts | 98 ++++++++++++++++++++++++++++++++++ src/lib/scheduleReveal.ts | 47 ++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/lib/scheduleReveal.test.ts create mode 100644 src/lib/scheduleReveal.ts diff --git a/src/lib/scheduleReveal.test.ts b/src/lib/scheduleReveal.test.ts new file mode 100644 index 00000000..53e9d801 --- /dev/null +++ b/src/lib/scheduleReveal.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + canShowDay, + canShowStage, + canShowTime, + isAtLeast, + maskSetForReveal, +} from "./scheduleReveal"; + +const baseSet = { + id: "s1", + time_start: "2025-08-01T18:00:00", + time_end: "2025-08-01T19:00:00", + stage_id: "stage-1", + name: "A set", +}; + +describe("isAtLeast", () => { + it("compares levels in declared order", () => { + expect(isAtLeast("draft", "draft")).toBe(true); + expect(isAtLeast("draft", "days")).toBe(false); + expect(isAtLeast("days", "draft")).toBe(true); + expect(isAtLeast("stages", "days")).toBe(true); + expect(isAtLeast("full", "stages")).toBe(true); + expect(isAtLeast("stages", "full")).toBe(false); + }); +}); + +describe("canShow predicates", () => { + it("admins always see everything", () => { + expect(canShowDay("draft", true)).toBe(true); + expect(canShowStage("draft", true)).toBe(true); + expect(canShowTime("draft", true)).toBe(true); + }); + + it("draft hides everything for non-admins", () => { + expect(canShowDay("draft", false)).toBe(false); + expect(canShowStage("draft", false)).toBe(false); + expect(canShowTime("draft", false)).toBe(false); + }); + + it("days exposes day only", () => { + expect(canShowDay("days", false)).toBe(true); + expect(canShowStage("days", false)).toBe(false); + expect(canShowTime("days", false)).toBe(false); + }); + + it("stages exposes day + stage, hides time", () => { + expect(canShowDay("stages", false)).toBe(true); + expect(canShowStage("stages", false)).toBe(true); + expect(canShowTime("stages", false)).toBe(false); + }); + + it("full exposes everything", () => { + expect(canShowDay("full", false)).toBe(true); + expect(canShowStage("full", false)).toBe(true); + expect(canShowTime("full", false)).toBe(true); + }); +}); + +describe("maskSetForReveal", () => { + it("returns the set untouched for admins regardless of level", () => { + expect(maskSetForReveal(baseSet, "draft", true)).toEqual(baseSet); + expect(maskSetForReveal(baseSet, "days", true)).toEqual(baseSet); + }); + + it("returns the set untouched at full for non-admins", () => { + expect(maskSetForReveal(baseSet, "full", false)).toEqual(baseSet); + }); + + it("nulls everything at draft for non-admins", () => { + const masked = maskSetForReveal(baseSet, "draft", false); + expect(masked.time_start).toBeNull(); + expect(masked.time_end).toBeNull(); + expect(masked.stage_id).toBeNull(); + expect(masked.name).toBe(baseSet.name); + }); + + it("keeps time_start, nulls time_end and stage_id at days", () => { + const masked = maskSetForReveal(baseSet, "days", false); + expect(masked.time_start).toBe(baseSet.time_start); + expect(masked.time_end).toBeNull(); + expect(masked.stage_id).toBeNull(); + }); + + it("keeps time_start and stage_id, nulls time_end at stages", () => { + const masked = maskSetForReveal(baseSet, "stages", false); + expect(masked.time_start).toBe(baseSet.time_start); + expect(masked.time_end).toBeNull(); + expect(masked.stage_id).toBe(baseSet.stage_id); + }); + + it("does not mutate the original set", () => { + const original = { ...baseSet }; + maskSetForReveal(baseSet, "draft", false); + expect(baseSet).toEqual(original); + }); +}); diff --git a/src/lib/scheduleReveal.ts b/src/lib/scheduleReveal.ts new file mode 100644 index 00000000..8e1799e0 --- /dev/null +++ b/src/lib/scheduleReveal.ts @@ -0,0 +1,47 @@ +import type { Database } from "@/integrations/supabase/types"; + +export type RevealLevel = Database["public"]["Enums"]["schedule_reveal_level"]; + +const ORDER: Record = { + draft: 0, + days: 1, + stages: 2, + full: 3, +}; + +export function isAtLeast(level: RevealLevel, threshold: RevealLevel): boolean { + return ORDER[level] >= ORDER[threshold]; +} + +export function canShowDay(level: RevealLevel, isAdmin: boolean): boolean { + return isAdmin || isAtLeast(level, "days"); +} + +export function canShowStage(level: RevealLevel, isAdmin: boolean): boolean { + return isAdmin || isAtLeast(level, "stages"); +} + +export function canShowTime(level: RevealLevel, isAdmin: boolean): boolean { + return isAdmin || isAtLeast(level, "full"); +} + +export type MaskableSet = { + time_start: string | null; + time_end: string | null; + stage_id: string | null; +}; + +export function maskSetForReveal( + set: T, + level: RevealLevel, + isAdmin: boolean, +): T { + if (isAdmin || level === "full") return set; + + return { + ...set, + stage_id: canShowStage(level, isAdmin) ? set.stage_id : null, + time_start: canShowDay(level, isAdmin) ? set.time_start : null, + time_end: canShowTime(level, isAdmin) ? set.time_end : null, + }; +} From c352c7e5d1ee541a805de31ef47f202f6d25f240 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:16:10 +0000 Subject: [PATCH 05/16] feat(schedule): apply reveal level to set/stage rendering (#46) - New useScheduleReveal hook combining edition + admin status into canShowDay/canShowStage/canShowTime predicates - formatDayOnly helper in timeUtils for the day-only display path - SetMetadata, SetInfoCard, MultiArtistSetInfoCard, SetCardHeader, Timeline, ListSchedule now honour the level: stage badge appears at stages+, time range at full, date-only label at days/stages - Schedule tab placeholder is now gated by canShowTime instead of edition.published (decoupling the lineup-published gate from the schedule-revealed gate) --- src/hooks/useScheduleReveal.ts | 33 +++++++++++++++++++ src/lib/timeUtils.ts | 12 +++++++ .../tabs/ArtistsTab/SetCard/SetMetadata.tsx | 25 +++++++++----- .../tabs/ScheduleTab/horizontal/Timeline.tsx | 7 ++-- .../tabs/ScheduleTab/list/ListSchedule.tsx | 4 ++- .../SetExploreCard/SetCardHeader.tsx | 27 +++++++++------ .../SetDetails/MultiArtistSetInfoCard.tsx | 23 +++++++++---- src/pages/SetDetails/SetInfoCard.tsx | 23 +++++++++---- 8 files changed, 121 insertions(+), 33 deletions(-) create mode 100644 src/hooks/useScheduleReveal.ts diff --git a/src/hooks/useScheduleReveal.ts b/src/hooks/useScheduleReveal.ts new file mode 100644 index 00000000..e0555a58 --- /dev/null +++ b/src/hooks/useScheduleReveal.ts @@ -0,0 +1,33 @@ +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUserPermissionsQuery } from "@/hooks/queries/auth/useUserPermissions"; +import { + type MaskableSet, + type RevealLevel, + canShowDay, + canShowStage, + canShowTime, + maskSetForReveal, +} from "@/lib/scheduleReveal"; + +export function useScheduleReveal() { + const { edition } = useFestivalEdition(); + const { user } = useAuth(); + const { data: isAdmin = false } = useUserPermissionsQuery( + user?.id, + "is_admin", + ); + + const level: RevealLevel = edition?.schedule_reveal_level ?? "draft"; + + return { + level, + isAdmin, + canShowDay: canShowDay(level, isAdmin), + canShowStage: canShowStage(level, isAdmin), + canShowTime: canShowTime(level, isAdmin), + maskSet(set: T): T { + return maskSetForReveal(set, level, isAdmin); + }, + }; +} diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts index b41a82c0..91dfffa7 100644 --- a/src/lib/timeUtils.ts +++ b/src/lib/timeUtils.ts @@ -135,6 +135,18 @@ export function combineDateAndTime( return `${datePart} ${timePart}`; } +export function formatDayOnly( + dateTime: string | null, + timezone?: string, +): string | null { + if (!dateTime) return null; + const date = parseISO(dateTime); + if (!isValid(date)) return null; + const dayFormat = "EEE, MMM d"; + if (timezone) return formatInTimeZone(date, timezone, dayFormat); + return format(date, dayFormat); +} + export function convertLocalTimeToUTC( timeString: string | undefined, timezone: string, diff --git a/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx b/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx index 39150193..68b8bcb9 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx +++ b/src/pages/EditionView/tabs/ArtistsTab/SetCard/SetMetadata.tsx @@ -1,13 +1,15 @@ import { Clock } from "lucide-react"; -import { formatTimeRange } from "@/lib/timeUtils"; +import { formatDayOnly, formatTimeRange } from "@/lib/timeUtils"; import { GenreBadge } from "@/components/GenreBadge"; import { StageBadge } from "@/components/StageBadge"; import { useFestivalSet } from "../FestivalSetContext"; import { useStageQuery } from "@/hooks/queries/stages/useStageQuery"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; export function SetMetadata() { const { set, use24Hour } = useFestivalSet(); - const stageQuery = useStageQuery(set?.stage_id); + const { canShowStage, canShowDay, canShowTime } = useScheduleReveal(); + const stageQuery = useStageQuery(canShowStage ? set?.stage_id : null); const uniqueGenres = set.artists ?.flatMap((a) => a.artist_music_genres || []) .filter( @@ -16,11 +18,12 @@ export function SetMetadata() { index, ); - const timeRangeFormatted = formatTimeRange( - set.time_start, - set.time_end, - use24Hour, - ); + const timeRangeFormatted = canShowTime + ? formatTimeRange(set.time_start, set.time_end, use24Hour) + : null; + + const dayOnlyFormatted = + canShowDay && !canShowTime ? formatDayOnly(set.time_start) : null; return (
@@ -39,7 +42,7 @@ export function SetMetadata() { {/* Stage and Time Information */}
- {stageQuery.data && ( + {canShowStage && stageQuery.data && ( {timeRangeFormatted}
)} + {dayOnlyFormatted && ( +
+ + {dayOnlyFormatted} +
+ )}
); diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index a05b4d89..6d46e9ff 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -8,9 +8,11 @@ import { useSetsByEditionQuery as useEditionSetsQuery } from "@/hooks/queries/se import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { format } from "date-fns"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; export function Timeline() { const { edition } = useFestivalEdition(); + const { canShowTime } = useScheduleReveal(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition?.id); const stagesQuery = useStagesByEditionQuery(edition?.id); @@ -112,8 +114,9 @@ export function Timeline() { ); } - // Check if schedule is published - if (!edition?.published) { + // The timeline view is only meaningful at the 'full' reveal level (exact times revealed). + // At lower levels show the placeholder; multi-mode rendering for days/stages is tracked separately. + if (!canShowTime) { return (

Schedule not yet published.

diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index 896d2fa0..5c4dba57 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -7,6 +7,7 @@ import { TimeSlotGroup } from "./TimeSlotGroup"; import type { ScheduleSet } from "@/hooks/useScheduleData"; import { useTimelineUrlState } from "@/hooks/useTimelineUrlState"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; interface TimeSlot { time: Date; @@ -15,6 +16,7 @@ interface TimeSlot { export function ListSchedule() { const { edition } = useFestivalEdition(); + const { canShowTime } = useScheduleReveal(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition?.id); const stagesQuery = useStagesByEditionQuery(edition?.id); @@ -141,7 +143,7 @@ export function ListSchedule() { ); } - if (!edition?.published) { + if (!canShowTime) { return (

Schedule not yet published.

diff --git a/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx b/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx index 75c2d100..d64e5cb3 100644 --- a/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx +++ b/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx @@ -2,6 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Clock } from "lucide-react"; import { StageBadge } from "@/components/StageBadge"; import { useStageQuery } from "@/hooks/queries/stages/useStageQuery"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; interface SetCardHeaderProps { stageId?: string; @@ -9,7 +10,8 @@ interface SetCardHeaderProps { } export function SetCardHeader({ stageId, timeStart }: SetCardHeaderProps) { - const stageQuery = useStageQuery(stageId); + const { canShowStage, canShowDay, canShowTime } = useScheduleReveal(); + const stageQuery = useStageQuery(canShowStage ? stageId : null); function formatTime(dateString: string | null) { if (!dateString) return ""; @@ -31,24 +33,29 @@ export function SetCardHeader({ stageId, timeStart }: SetCardHeaderProps) { }); } + const dateLabel = canShowDay ? formatDate(timeStart) : ""; + const timeLabel = canShowTime && timeStart ? formatTime(timeStart) : ""; + return (
- - {formatDate(timeStart)} - - {timeStart && ( + {dateLabel && ( + + {dateLabel} + + )} + {timeLabel && (
- {formatTime(timeStart)} + {timeLabel}
)}
- {stageQuery.data && ( + {canShowStage && stageQuery.data && ( g.music_genre_id === genre.music_genre_id), ); + const { canShowStage, canShowDay, canShowTime } = useScheduleReveal(); + const timeRangeFormatted = canShowTime + ? formatTimeRange(set.time_start, set.time_end, use24Hour) + : null; + const dayOnlyFormatted = + canShowDay && !canShowTime ? formatDayOnly(set.time_start) : null; return (
@@ -81,13 +88,17 @@ export function MultiArtistSetInfoCard({ {/* Performance Information */}
- - {formatTimeRange(set.time_start, set.time_end, use24Hour) && ( + {canShowStage && } + {timeRangeFormatted && (
- - {formatTimeRange(set.time_start, set.time_end, use24Hour)} - + {timeRangeFormatted} +
+ )} + {dayOnlyFormatted && ( +
+ + {dayOnlyFormatted}
)}
diff --git a/src/pages/SetDetails/SetInfoCard.tsx b/src/pages/SetDetails/SetInfoCard.tsx index b491b0fe..a2f144d5 100644 --- a/src/pages/SetDetails/SetInfoCard.tsx +++ b/src/pages/SetDetails/SetInfoCard.tsx @@ -10,10 +10,11 @@ import { Badge } from "@/components/ui/badge"; import { Clock, ExternalLink, Music, Play } from "lucide-react"; import { SetVotingButtons } from "./SetVotingButtons"; import { FestivalSet } from "@/hooks/queries/sets/useSets"; -import { formatTimeRange } from "@/lib/timeUtils"; +import { formatDayOnly, formatTimeRange } from "@/lib/timeUtils"; import { GenreBadge } from "@/components/GenreBadge"; import { StagePin } from "@/components/StagePin"; import { MarkdownText } from "@/components/ui/markdown-text"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; interface SetInfoCardProps { set: FestivalSet; @@ -27,6 +28,12 @@ export function SetInfoCard({ use24Hour = false, }: SetInfoCardProps) { const artist = set.artists[0]; + const { canShowStage, canShowDay, canShowTime } = useScheduleReveal(); + const timeRangeFormatted = canShowTime + ? formatTimeRange(set.time_start, set.time_end, use24Hour) + : null; + const dayOnlyFormatted = + canShowDay && !canShowTime ? formatDayOnly(set.time_start) : null; return (
@@ -60,13 +67,17 @@ export function SetInfoCard({ {/* Performance Information */}
- - {formatTimeRange(set.time_start, set.time_end, use24Hour) && ( + {canShowStage && } + {timeRangeFormatted && (
- - {formatTimeRange(set.time_start, set.time_end, use24Hour)} - + {timeRangeFormatted} +
+ )} + {dayOnlyFormatted && ( +
+ + {dayOnlyFormatted}
)}
From 37490304436f421557c2ecc6ba00550a42b596fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:19:37 +0000 Subject: [PATCH 06/16] feat(schedule): admin controls for reveal level (#46) - Schedule reveal dropdown in the edition edit dialog (FestivalEditionManagement) with all four levels and a hint when the edition isn't published yet - ScheduleRevealAdminPanel on top of the Schedule tab with progressive Reveal Days / Reveal Stages / Reveal Times buttons. Each revealed step becomes a status badge with an undo button for demotion. Hidden for non-admins. - Loosen useUpdateFestivalEdition's editionData type to the generated Update shape so callers can patch a single field --- .../editions/useCreateFestivalEdition.ts | 4 + .../editions/useUpdateFestivalEdition.ts | 24 +--- src/pages/EditionView/tabs/ScheduleTab.tsx | 2 + .../ScheduleTab/ScheduleRevealAdminPanel.tsx | 110 ++++++++++++++++++ .../festivals/FestivalEditionManagement.tsx | 50 ++++++++ 5 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 src/pages/EditionView/tabs/ScheduleTab/ScheduleRevealAdminPanel.tsx diff --git a/src/hooks/queries/festivals/editions/useCreateFestivalEdition.ts b/src/hooks/queries/festivals/editions/useCreateFestivalEdition.ts index ac27c21e..78a48678 100644 --- a/src/hooks/queries/festivals/editions/useCreateFestivalEdition.ts +++ b/src/hooks/queries/festivals/editions/useCreateFestivalEdition.ts @@ -2,6 +2,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import { festivalsKeys } from "../types"; +import type { Database } from "@/integrations/supabase/types"; + +type RevealLevel = Database["public"]["Enums"]["schedule_reveal_level"]; async function createFestivalEdition(editionData: { name: string; @@ -12,6 +15,7 @@ async function createFestivalEdition(editionData: { description?: string | null; year: number; published?: boolean; + schedule_reveal_level?: RevealLevel; }) { const { data, error } = await supabase .from("festival_editions") diff --git a/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts b/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts index 6a6901ae..2bbd52bd 100644 --- a/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts +++ b/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts @@ -2,18 +2,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import { festivalsKeys } from "../types"; +import type { Database } from "@/integrations/supabase/types"; + +type UpdateEditionData = + Database["public"]["Tables"]["festival_editions"]["Update"]; async function updateFestivalEdition( editionId: string, - editionData: { - name: string; - slug: string; - start_date: string | null; - end_date: string | null; - description?: string | null; - year?: number; - published?: boolean; - }, + editionData: UpdateEditionData, ) { const { data, error } = await supabase .from("festival_editions") @@ -36,15 +32,7 @@ export function useUpdateFestivalEditionMutation() { editionData, }: { editionId: string; - editionData: { - name: string; - slug: string; - start_date: string | null; - end_date: string | null; - description?: string | null; - year?: number; - published?: boolean; - }; + editionData: UpdateEditionData; }) => updateFestivalEdition(editionId, editionData), onSuccess: () => { queryClient.invalidateQueries({ queryKey: festivalsKeys.all() }); diff --git a/src/pages/EditionView/tabs/ScheduleTab.tsx b/src/pages/EditionView/tabs/ScheduleTab.tsx index 184af590..cced0dbc 100644 --- a/src/pages/EditionView/tabs/ScheduleTab.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab.tsx @@ -2,6 +2,7 @@ import { ScheduleNavigation } from "./ScheduleTab/ScheduleNavigation"; import { Outlet } from "@tanstack/react-router"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; import { PageTitle } from "@/components/PageTitle/PageTitle"; +import { ScheduleRevealAdminPanel } from "./ScheduleTab/ScheduleRevealAdminPanel"; export function ScheduleTab() { const { festival } = useFestivalEdition(); @@ -10,6 +11,7 @@ export function ScheduleTab() { <>
+ diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleRevealAdminPanel.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleRevealAdminPanel.tsx new file mode 100644 index 00000000..70f63f71 --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleRevealAdminPanel.tsx @@ -0,0 +1,110 @@ +import { Check, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUserPermissionsQuery } from "@/hooks/queries/auth/useUserPermissions"; +import { useUpdateFestivalEditionMutation } from "@/hooks/queries/festivals/editions/useUpdateFestivalEdition"; +import { isAtLeast, type RevealLevel } from "@/lib/scheduleReveal"; + +type Step = { + threshold: RevealLevel; + revealLabel: string; + revealedLabel: string; + demoteTo: RevealLevel; +}; + +const STEPS: Step[] = [ + { + threshold: "days", + revealLabel: "Reveal days", + revealedLabel: "Days revealed", + demoteTo: "draft", + }, + { + threshold: "stages", + revealLabel: "Reveal stages", + revealedLabel: "Stages revealed", + demoteTo: "days", + }, + { + threshold: "full", + revealLabel: "Reveal times", + revealedLabel: "Times revealed", + demoteTo: "stages", + }, +]; + +export function ScheduleRevealAdminPanel() { + const { edition } = useFestivalEdition(); + const { user } = useAuth(); + const { data: isAdmin = false } = useUserPermissionsQuery( + user?.id, + "is_admin", + ); + const updateMutation = useUpdateFestivalEditionMutation(); + + if (!isAdmin || !edition) return null; + + const level: RevealLevel = edition.schedule_reveal_level ?? "draft"; + const isPending = updateMutation.isPending; + + function applyLevel(next: RevealLevel) { + if (!edition) return; + updateMutation.mutate({ + editionId: edition.id, + editionData: { schedule_reveal_level: next }, + }); + } + + return ( + + + + Schedule reveal: + + + {STEPS.map((step) => { + const revealed = isAtLeast(level, step.threshold); + if (revealed) { + return ( +
+ + + {step.revealedLabel} + + +
+ ); + } + return ( + + ); + })} + + {!edition.published && level !== "draft" && ( +

+ Configured but not active — edition is not published. +

+ )} +
+
+ ); +} diff --git a/src/pages/admin/festivals/FestivalEditionManagement.tsx b/src/pages/admin/festivals/FestivalEditionManagement.tsx index 8d58e5dc..4c5eb92d 100644 --- a/src/pages/admin/festivals/FestivalEditionManagement.tsx +++ b/src/pages/admin/festivals/FestivalEditionManagement.tsx @@ -27,9 +27,19 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Loader2, Plus, Edit2, Trash2, CalendarDays } from "lucide-react"; import { cn } from "@/lib/utils"; import { generateSlug, isValidSlug, sanitizeSlug } from "@/lib/slug"; +import type { Database } from "@/integrations/supabase/types"; + +type RevealLevel = Database["public"]["Enums"]["schedule_reveal_level"]; interface EditionFormData { name: string; @@ -38,6 +48,7 @@ interface EditionFormData { start_date?: string; end_date?: string; published: boolean; + schedule_reveal_level: RevealLevel; } export function FestivalEditionManagement({ @@ -69,6 +80,7 @@ export function FestivalEditionManagement({ start_date: "", end_date: "", published: false, + schedule_reveal_level: "draft", }); const [isSubmitting, setIsSubmitting] = useState(false); const [slugError, setSlugError] = useState(""); @@ -102,6 +114,7 @@ export function FestivalEditionManagement({ start_date: "", end_date: "", published: false, + schedule_reveal_level: "draft", }); setEditingEdition(null); setSlugError(""); @@ -120,6 +133,7 @@ export function FestivalEditionManagement({ start_date: edition.start_date || "", end_date: edition.end_date || "", published: edition.published || false, + schedule_reveal_level: edition.schedule_reveal_level ?? "draft", }); setEditingEdition(edition); setSlugError(""); @@ -352,6 +366,42 @@ export function FestivalEditionManagement({ : "Only visible to admins"}

+
+ + + {!formData.published && + formData.schedule_reveal_level !== "draft" && ( +

+ Configured but not active — edition is not published. +

+ )} +
-
- - - {!formData.published && - formData.schedule_reveal_level !== "draft" && ( -

- Configured but not active — edition is not published. -

- )} -
+ + setFormData({ ...formData, schedule_reveal_level: level }) + } + editionPublished={formData.published} + />
-
- ); - } - return ( - - ); - })} - - {!edition.published && level !== "draft" && ( -

- Configured but not active — edition is not published. -

- )} - -
- ); -} diff --git a/src/pages/admin/festivals/FestivalEdition.tsx b/src/pages/admin/festivals/FestivalEdition.tsx index e4a96aa3..61b7e86f 100644 --- a/src/pages/admin/festivals/FestivalEdition.tsx +++ b/src/pages/admin/festivals/FestivalEdition.tsx @@ -3,6 +3,7 @@ import { Loader2, MapPin, Music, Upload } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useFestivalEditionBySlugQuery } from "@/hooks/queries/festivals/editions/useFestivalEditionBySlug"; import { cn } from "@/lib/utils"; +import { ScheduleRevealControl } from "./ScheduleRevealControl"; export default function FestivalEdition() { const { festivalSlug, editionSlug } = useParams({ @@ -50,10 +51,15 @@ export default function FestivalEdition() {
- + Edition: {currentEdition.name} + diff --git a/src/pages/admin/festivals/ScheduleRevealControl.tsx b/src/pages/admin/festivals/ScheduleRevealControl.tsx new file mode 100644 index 00000000..43da38c8 --- /dev/null +++ b/src/pages/admin/festivals/ScheduleRevealControl.tsx @@ -0,0 +1,89 @@ +import { Check, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useUpdateFestivalEditionMutation } from "@/hooks/queries/festivals/editions/useUpdateFestivalEdition"; +import type { RevealLevel } from "@/lib/scheduleReveal"; + +type Props = { + editionId: string; + level: RevealLevel; + editionPublished: boolean; +}; + +const NEXT: Record, RevealLevel> = { + draft: "days", + days: "stages", + stages: "full", +}; + +const PREVIOUS: Record, RevealLevel> = { + days: "draft", + stages: "days", + full: "stages", +}; + +const STATUS_LABEL: Record = { + draft: "Schedule: draft", + days: "Schedule: days revealed", + stages: "Schedule: stages revealed", + full: "Schedule: fully revealed", +}; + +const ADVANCE_LABEL: Record, string> = { + draft: "Reveal days", + days: "Reveal stages", + stages: "Reveal times", +}; + +export function ScheduleRevealControl({ + editionId, + level, + editionPublished, +}: Props) { + const mutation = useUpdateFestivalEditionMutation(); + const isPending = mutation.isPending; + + function setLevel(next: RevealLevel) { + mutation.mutate({ + editionId, + editionData: { schedule_reveal_level: next }, + }); + } + + const advance = level === "full" ? null : NEXT[level]; + const previous = level === "draft" ? null : PREVIOUS[level]; + + return ( +
+ + {level === "full" && } + {STATUS_LABEL[level]} + + {advance && ( + + )} + {previous && ( + + )} + {!editionPublished && level !== "draft" && ( +

+ Configured but not active — edition is not published. +

+ )} +
+ ); +} From b82486dd55da08c825ceb7efdb22b11f76d6d9ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:58:47 +0000 Subject: [PATCH 10/16] fix(schedule): invalidate edition-by-slug cache; extract LiveCommitWarning (#46) - useUpdateFestivalEdition was only invalidating festivalsKeys.all(); the editions-by-slug cache lives under ["festival-editions"], so changing schedule_reveal_level from the admin control kept the old level visible until reload - Extract the import-wizard live-commit alert into LiveCommitWarning --- .../Admin/ScheduleImport/DiffReviewStep.tsx | 38 ++++++------------- .../ScheduleImport/LiveCommitWarning.tsx | 36 ++++++++++++++++++ .../editions/useUpdateFestivalEdition.ts | 1 + 3 files changed, 48 insertions(+), 27 deletions(-) create mode 100644 src/components/Admin/ScheduleImport/LiveCommitWarning.tsx diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx index d017e9a6..e80b44e3 100644 --- a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx +++ b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, AlertTriangle, Loader2 } from "lucide-react"; +import { AlertCircle, Loader2 } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -11,6 +11,7 @@ import type { RevealLevel } from "@/lib/scheduleReveal"; import { DiffSummaryBanner } from "./DiffSummaryBanner"; import { StageMismatchResolver } from "./StageMismatchResolver"; import { OrphanedSetsPanel } from "./OrphanedSetsPanel"; +import { LiveCommitWarning } from "./LiveCommitWarning"; type DbStage = { id: string; name: string }; @@ -33,13 +34,6 @@ type Props = { currentRevealLevel: RevealLevel; }; -const LEVEL_DESCRIPTION: Record = { - draft: "draft (not visible to the public)", - days: "days revealed", - stages: "stages revealed", - full: "full schedule revealed", -}; - export function DiffReviewStep({ diff, timezone, @@ -55,7 +49,9 @@ export function DiffReviewStep({ canCommit, currentRevealLevel, }: Props) { - const showLiveWarning = currentRevealLevel !== "draft"; + const setsToArchive = Object.values(orphanResolutions).filter( + (r) => r === "archive", + ).length; return ( @@ -78,24 +74,12 @@ export function DiffReviewStep({ onChange={onOrphanChange} /> - {showLiveWarning && ( - - - - Schedule is {LEVEL_DESCRIPTION[currentRevealLevel]}. - - - Committing will update what the public sees immediately:{" "} - {diff.summary.setsToCreate} new ·{" "} - {diff.cleanOperations.setsToUpdate.length} updated ·{" "} - { - Object.values(orphanResolutions).filter((r) => r === "archive") - .length - }{" "} - archived. - - - )} + {commitError && ( diff --git a/src/components/Admin/ScheduleImport/LiveCommitWarning.tsx b/src/components/Admin/ScheduleImport/LiveCommitWarning.tsx new file mode 100644 index 00000000..33376646 --- /dev/null +++ b/src/components/Admin/ScheduleImport/LiveCommitWarning.tsx @@ -0,0 +1,36 @@ +import { AlertTriangle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import type { RevealLevel } from "@/lib/scheduleReveal"; + +const LEVEL_DESCRIPTION: Record = { + draft: "draft (not visible to the public)", + days: "days revealed", + stages: "stages revealed", + full: "full schedule revealed", +}; + +type Props = { + level: RevealLevel; + setsToCreate: number; + setsToUpdate: number; + setsToArchive: number; +}; + +export function LiveCommitWarning({ + level, + setsToCreate, + setsToUpdate, + setsToArchive, +}: Props) { + if (level === "draft") return null; + return ( + + + Schedule is {LEVEL_DESCRIPTION[level]}. + + Committing will update what the public sees immediately: {setsToCreate}{" "} + new · {setsToUpdate} updated · {setsToArchive} archived. + + + ); +} diff --git a/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts b/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts index 2bbd52bd..c2129f48 100644 --- a/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts +++ b/src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts @@ -36,6 +36,7 @@ export function useUpdateFestivalEditionMutation() { }) => updateFestivalEdition(editionId, editionData), onSuccess: () => { queryClient.invalidateQueries({ queryKey: festivalsKeys.all() }); + queryClient.invalidateQueries({ queryKey: ["festival-editions"] }); toast({ title: "Success", description: "Festival edition updated successfully", From 18ffc184f8807b3e022b076d66f4472483d9691e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:07:33 +0000 Subject: [PATCH 11/16] refactor(schedule): drop unused isAdmin param from mask helpers (#46) useScheduleReveal no longer threads admin status, so canShowDay/ canShowStage/canShowTime/maskSetForReveal no longer need the parameter. Cleaner call sites and tests. --- src/hooks/useScheduleReveal.ts | 8 +++--- src/lib/scheduleReveal.test.ts | 51 +++++++++++++--------------------- src/lib/scheduleReveal.ts | 21 +++++++------- 3 files changed, 34 insertions(+), 46 deletions(-) diff --git a/src/hooks/useScheduleReveal.ts b/src/hooks/useScheduleReveal.ts index 3af14bf7..1a7ecc80 100644 --- a/src/hooks/useScheduleReveal.ts +++ b/src/hooks/useScheduleReveal.ts @@ -14,11 +14,11 @@ export function useScheduleReveal() { return { level, - canShowDay: canShowDay(level, false), - canShowStage: canShowStage(level, false), - canShowTime: canShowTime(level, false), + canShowDay: canShowDay(level), + canShowStage: canShowStage(level), + canShowTime: canShowTime(level), maskSet(set: T): T { - return maskSetForReveal(set, level, false); + return maskSetForReveal(set, level); }, }; } diff --git a/src/lib/scheduleReveal.test.ts b/src/lib/scheduleReveal.test.ts index 53e9d801..46ee4d96 100644 --- a/src/lib/scheduleReveal.test.ts +++ b/src/lib/scheduleReveal.test.ts @@ -27,49 +27,38 @@ describe("isAtLeast", () => { }); describe("canShow predicates", () => { - it("admins always see everything", () => { - expect(canShowDay("draft", true)).toBe(true); - expect(canShowStage("draft", true)).toBe(true); - expect(canShowTime("draft", true)).toBe(true); - }); - - it("draft hides everything for non-admins", () => { - expect(canShowDay("draft", false)).toBe(false); - expect(canShowStage("draft", false)).toBe(false); - expect(canShowTime("draft", false)).toBe(false); + it("draft hides everything", () => { + expect(canShowDay("draft")).toBe(false); + expect(canShowStage("draft")).toBe(false); + expect(canShowTime("draft")).toBe(false); }); it("days exposes day only", () => { - expect(canShowDay("days", false)).toBe(true); - expect(canShowStage("days", false)).toBe(false); - expect(canShowTime("days", false)).toBe(false); + expect(canShowDay("days")).toBe(true); + expect(canShowStage("days")).toBe(false); + expect(canShowTime("days")).toBe(false); }); it("stages exposes day + stage, hides time", () => { - expect(canShowDay("stages", false)).toBe(true); - expect(canShowStage("stages", false)).toBe(true); - expect(canShowTime("stages", false)).toBe(false); + expect(canShowDay("stages")).toBe(true); + expect(canShowStage("stages")).toBe(true); + expect(canShowTime("stages")).toBe(false); }); it("full exposes everything", () => { - expect(canShowDay("full", false)).toBe(true); - expect(canShowStage("full", false)).toBe(true); - expect(canShowTime("full", false)).toBe(true); + expect(canShowDay("full")).toBe(true); + expect(canShowStage("full")).toBe(true); + expect(canShowTime("full")).toBe(true); }); }); describe("maskSetForReveal", () => { - it("returns the set untouched for admins regardless of level", () => { - expect(maskSetForReveal(baseSet, "draft", true)).toEqual(baseSet); - expect(maskSetForReveal(baseSet, "days", true)).toEqual(baseSet); - }); - - it("returns the set untouched at full for non-admins", () => { - expect(maskSetForReveal(baseSet, "full", false)).toEqual(baseSet); + it("returns the set untouched at full", () => { + expect(maskSetForReveal(baseSet, "full")).toEqual(baseSet); }); - it("nulls everything at draft for non-admins", () => { - const masked = maskSetForReveal(baseSet, "draft", false); + it("nulls everything at draft", () => { + const masked = maskSetForReveal(baseSet, "draft"); expect(masked.time_start).toBeNull(); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBeNull(); @@ -77,14 +66,14 @@ describe("maskSetForReveal", () => { }); it("keeps time_start, nulls time_end and stage_id at days", () => { - const masked = maskSetForReveal(baseSet, "days", false); + const masked = maskSetForReveal(baseSet, "days"); expect(masked.time_start).toBe(baseSet.time_start); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBeNull(); }); it("keeps time_start and stage_id, nulls time_end at stages", () => { - const masked = maskSetForReveal(baseSet, "stages", false); + const masked = maskSetForReveal(baseSet, "stages"); expect(masked.time_start).toBe(baseSet.time_start); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBe(baseSet.stage_id); @@ -92,7 +81,7 @@ describe("maskSetForReveal", () => { it("does not mutate the original set", () => { const original = { ...baseSet }; - maskSetForReveal(baseSet, "draft", false); + maskSetForReveal(baseSet, "draft"); expect(baseSet).toEqual(original); }); }); diff --git a/src/lib/scheduleReveal.ts b/src/lib/scheduleReveal.ts index 8e1799e0..b5f9476f 100644 --- a/src/lib/scheduleReveal.ts +++ b/src/lib/scheduleReveal.ts @@ -13,16 +13,16 @@ export function isAtLeast(level: RevealLevel, threshold: RevealLevel): boolean { return ORDER[level] >= ORDER[threshold]; } -export function canShowDay(level: RevealLevel, isAdmin: boolean): boolean { - return isAdmin || isAtLeast(level, "days"); +export function canShowDay(level: RevealLevel): boolean { + return isAtLeast(level, "days"); } -export function canShowStage(level: RevealLevel, isAdmin: boolean): boolean { - return isAdmin || isAtLeast(level, "stages"); +export function canShowStage(level: RevealLevel): boolean { + return isAtLeast(level, "stages"); } -export function canShowTime(level: RevealLevel, isAdmin: boolean): boolean { - return isAdmin || isAtLeast(level, "full"); +export function canShowTime(level: RevealLevel): boolean { + return isAtLeast(level, "full"); } export type MaskableSet = { @@ -34,14 +34,13 @@ export type MaskableSet = { export function maskSetForReveal( set: T, level: RevealLevel, - isAdmin: boolean, ): T { - if (isAdmin || level === "full") return set; + if (level === "full") return set; return { ...set, - stage_id: canShowStage(level, isAdmin) ? set.stage_id : null, - time_start: canShowDay(level, isAdmin) ? set.time_start : null, - time_end: canShowTime(level, isAdmin) ? set.time_end : null, + stage_id: canShowStage(level) ? set.stage_id : null, + time_start: canShowDay(level) ? set.time_start : null, + time_end: canShowTime(level) ? set.time_end : null, }; } From e0a66bf023e771cbfd1584183c527be558bc0ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:11:26 +0000 Subject: [PATCH 12/16] refactor(tabs): move tab indicator into TabConfig (#46) TabConfig grows an optional Indicator component. Schedule tab wires ScheduleTabIndicator there; Desktop/Mobile buttons render config.Indicator generically instead of branching on config.key. --- src/pages/EditionView/TabNavigation/DesktopTabButton.tsx | 3 +-- src/pages/EditionView/TabNavigation/MobileTabButton.tsx | 3 +-- src/pages/EditionView/TabNavigation/config.ts | 2 ++ src/pages/EditionView/TabNavigation/types.ts | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx index fc52d765..6b9adab2 100644 --- a/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx +++ b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx @@ -1,7 +1,6 @@ import { cn } from "@/lib/utils"; import { Link, useParams } from "@tanstack/react-router"; import { TabButtonProps } from "./types"; -import { ScheduleTabIndicator } from "./ScheduleTabIndicator"; const tabRoutes = { sets: "/festivals/$festivalSlug/editions/$editionSlug/sets", @@ -41,7 +40,7 @@ export function DesktopTabButton({ config }: TabButtonProps) { > - {config.key === "schedule" && } + {config.Indicator && } {config.label} diff --git a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx index 960f02be..1fadabed 100644 --- a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx +++ b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx @@ -1,6 +1,5 @@ import { Link, useParams, useMatchRoute } from "@tanstack/react-router"; import { TabButtonProps } from "./types"; -import { ScheduleTabIndicator } from "./ScheduleTabIndicator"; const tabRoutes = { sets: "/festivals/$festivalSlug/editions/$editionSlug/sets", @@ -31,7 +30,7 @@ export function MobileTabButton({ config }: TabButtonProps) { - {config.key === "schedule" && } + {config.Indicator && } boolean); + Indicator?: ComponentType; }; export interface TabButtonProps { From d326db9850e503d0dcfcdd6b594882e4458018eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 02:50:29 +0000 Subject: [PATCH 13/16] fix(schedule): apply reveal mask at the Artists-tab read boundary (#46) The stage filter and date-asc sort in useSetFiltering still read set.stage_id and set.time_start directly, so the embargoed fields leaked through filter/sort even after the badges and time labels were hidden in the cards. - maskSetForReveal now truncates time_start to date-only at days/stages so sorting can't infer time-of-day ordering - ArtistsTab masks the sets array before passing it into useSetFiltering, so the stage filter sees nulled stage_id at draft/days and the date sort sees day-granularity timestamps at days/stages --- src/lib/scheduleReveal.test.ts | 8 ++++---- src/lib/scheduleReveal.ts | 12 +++++++++++- src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx | 10 +++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/lib/scheduleReveal.test.ts b/src/lib/scheduleReveal.test.ts index 46ee4d96..b11d481b 100644 --- a/src/lib/scheduleReveal.test.ts +++ b/src/lib/scheduleReveal.test.ts @@ -65,16 +65,16 @@ describe("maskSetForReveal", () => { expect(masked.name).toBe(baseSet.name); }); - it("keeps time_start, nulls time_end and stage_id at days", () => { + it("keeps date portion of time_start, nulls time_end and stage_id at days", () => { const masked = maskSetForReveal(baseSet, "days"); - expect(masked.time_start).toBe(baseSet.time_start); + expect(masked.time_start).toBe("2025-08-01T00:00:00"); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBeNull(); }); - it("keeps time_start and stage_id, nulls time_end at stages", () => { + it("keeps date portion of time_start and stage_id, nulls time_end at stages", () => { const masked = maskSetForReveal(baseSet, "stages"); - expect(masked.time_start).toBe(baseSet.time_start); + expect(masked.time_start).toBe("2025-08-01T00:00:00"); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBe(baseSet.stage_id); }); diff --git a/src/lib/scheduleReveal.ts b/src/lib/scheduleReveal.ts index b5f9476f..c9ff63b0 100644 --- a/src/lib/scheduleReveal.ts +++ b/src/lib/scheduleReveal.ts @@ -40,7 +40,17 @@ export function maskSetForReveal( return { ...set, stage_id: canShowStage(level) ? set.stage_id : null, - time_start: canShowDay(level) ? set.time_start : null, + time_start: maskTimeStart(set.time_start, level), time_end: canShowTime(level) ? set.time_end : null, }; } + +function maskTimeStart( + timeStart: string | null, + level: RevealLevel, +): string | null { + if (timeStart === null) return null; + if (!canShowDay(level)) return null; + if (canShowTime(level)) return timeStart; + return timeStart.slice(0, 10) + "T00:00:00"; +} diff --git a/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx b/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx index cf3c310d..91453bf9 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx +++ b/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx @@ -4,18 +4,26 @@ import { useUrlState } from "@/hooks/useUrlState"; import { SetsPanel } from "./SetsPanel"; import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { PageTitle } from "@/components/PageTitle/PageTitle"; +import { useMemo } from "react"; +import { maskSetForReveal } from "@/lib/scheduleReveal"; export function ArtistsTab() { const { state: urlState, updateUrlState, clearFilters } = useUrlState(); const { edition, festival } = useFestivalEdition(); + const { level } = useScheduleReveal(); // Fetch sets for the current edition const { data: sets = [], isLoading: setsLoading } = useSetsByEditionQuery( edition?.id, ); + const maskedSets = useMemo( + () => (sets ?? []).map((s) => maskSetForReveal(s, level)), + [sets, level], + ); const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering( - sets || [], + maskedSets, urlState, ); From b01f9ee436c385d5d02dee317707420c24a78c7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 04:16:26 +0000 Subject: [PATCH 14/16] Revert "fix(schedule): apply reveal mask at the Artists-tab read boundary (#46)" This reverts commit d326db9850e503d0dcfcdd6b594882e4458018eb. --- src/lib/scheduleReveal.test.ts | 8 ++++---- src/lib/scheduleReveal.ts | 12 +----------- src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx | 10 +--------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/lib/scheduleReveal.test.ts b/src/lib/scheduleReveal.test.ts index b11d481b..46ee4d96 100644 --- a/src/lib/scheduleReveal.test.ts +++ b/src/lib/scheduleReveal.test.ts @@ -65,16 +65,16 @@ describe("maskSetForReveal", () => { expect(masked.name).toBe(baseSet.name); }); - it("keeps date portion of time_start, nulls time_end and stage_id at days", () => { + it("keeps time_start, nulls time_end and stage_id at days", () => { const masked = maskSetForReveal(baseSet, "days"); - expect(masked.time_start).toBe("2025-08-01T00:00:00"); + expect(masked.time_start).toBe(baseSet.time_start); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBeNull(); }); - it("keeps date portion of time_start and stage_id, nulls time_end at stages", () => { + it("keeps time_start and stage_id, nulls time_end at stages", () => { const masked = maskSetForReveal(baseSet, "stages"); - expect(masked.time_start).toBe("2025-08-01T00:00:00"); + expect(masked.time_start).toBe(baseSet.time_start); expect(masked.time_end).toBeNull(); expect(masked.stage_id).toBe(baseSet.stage_id); }); diff --git a/src/lib/scheduleReveal.ts b/src/lib/scheduleReveal.ts index c9ff63b0..b5f9476f 100644 --- a/src/lib/scheduleReveal.ts +++ b/src/lib/scheduleReveal.ts @@ -40,17 +40,7 @@ export function maskSetForReveal( return { ...set, stage_id: canShowStage(level) ? set.stage_id : null, - time_start: maskTimeStart(set.time_start, level), + time_start: canShowDay(level) ? set.time_start : null, time_end: canShowTime(level) ? set.time_end : null, }; } - -function maskTimeStart( - timeStart: string | null, - level: RevealLevel, -): string | null { - if (timeStart === null) return null; - if (!canShowDay(level)) return null; - if (canShowTime(level)) return timeStart; - return timeStart.slice(0, 10) + "T00:00:00"; -} diff --git a/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx b/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx index 91453bf9..cf3c310d 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx +++ b/src/pages/EditionView/tabs/ArtistsTab/ArtistsTab.tsx @@ -4,26 +4,18 @@ import { useUrlState } from "@/hooks/useUrlState"; import { SetsPanel } from "./SetsPanel"; import { useSetsByEditionQuery } from "@/hooks/queries/sets/useSetsByEdition"; import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; -import { useScheduleReveal } from "@/hooks/useScheduleReveal"; import { PageTitle } from "@/components/PageTitle/PageTitle"; -import { useMemo } from "react"; -import { maskSetForReveal } from "@/lib/scheduleReveal"; export function ArtistsTab() { const { state: urlState, updateUrlState, clearFilters } = useUrlState(); const { edition, festival } = useFestivalEdition(); - const { level } = useScheduleReveal(); // Fetch sets for the current edition const { data: sets = [], isLoading: setsLoading } = useSetsByEditionQuery( edition?.id, ); - const maskedSets = useMemo( - () => (sets ?? []).map((s) => maskSetForReveal(s, level)), - [sets, level], - ); const { filteredAndSortedSets, lockCurrentOrder } = useSetFiltering( - maskedSets, + sets || [], urlState, ); From 1dc07feab5182099eb80eed0085271388493c1ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 04:17:58 +0000 Subject: [PATCH 15/16] fix(schedule): hide stage filter when stages aren't revealed (#46) Reverted d326db9's data-layer mask in favour of a UI-layer fix: DesktopFilters and MobileFilters now hide the stage filter when canShowStage is false. Stays consistent with the user-facing hide-in-display rule, and the stage filter no longer renders a control that would silently return empty results at draft/days. --- .../ArtistsTab/filters/DesktopFilters.tsx | 54 ++++++++-------- .../tabs/ArtistsTab/filters/MobileFilters.tsx | 62 +++++++++++-------- 2 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/pages/EditionView/tabs/ArtistsTab/filters/DesktopFilters.tsx b/src/pages/EditionView/tabs/ArtistsTab/filters/DesktopFilters.tsx index e43fe5e3..3c5587b0 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/filters/DesktopFilters.tsx +++ b/src/pages/EditionView/tabs/ArtistsTab/filters/DesktopFilters.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; import type { FilterSortState } from "@/hooks/useUrlState"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; interface DesktopFiltersProps { state: FilterSortState; @@ -15,6 +16,7 @@ export function DesktopFilters({ onStateChange, editionId, }: DesktopFiltersProps) { + const { canShowStage } = useScheduleReveal(); const { data: stages = [], isLoading: stagesLoading } = useStagesByEditionQuery(editionId); @@ -35,32 +37,34 @@ export function DesktopFilters({ return (
{/* Stage Filter */} -
-

Stages

-
- {stagesLoading ? ( -
Loading stages...
- ) : ( - stages.map((stage) => ( - - )) - )} + {canShowStage && ( +
+

Stages

+
+ {stagesLoading ? ( +
Loading stages...
+ ) : ( + stages.map((stage) => ( + + )) + )} +
-
+ )} {/* Genre Filter */}
diff --git a/src/pages/EditionView/tabs/ArtistsTab/filters/MobileFilters.tsx b/src/pages/EditionView/tabs/ArtistsTab/filters/MobileFilters.tsx index 67c50369..fd9b5e77 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/filters/MobileFilters.tsx +++ b/src/pages/EditionView/tabs/ArtistsTab/filters/MobileFilters.tsx @@ -7,6 +7,7 @@ import { } from "@/components/ui/select"; import type { FilterSortState } from "@/hooks/useUrlState"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; interface MobileFiltersProps { state: FilterSortState; @@ -21,6 +22,7 @@ export function MobileFilters({ onStateChange, editionId, }: MobileFiltersProps) { + const { canShowStage } = useScheduleReveal(); const { data: stages = [], isLoading: stagesLoading } = useStagesByEditionQuery(editionId); @@ -43,37 +45,43 @@ export function MobileFilters({ return (
{/* Stage Filter Select */} -
-

Stage

- + + + + + + All Stages - ) : ( - stages.map((stage) => ( + {stagesLoading ? ( - {stage.name} + Loading stages... - )) - )} - - -
+ ) : ( + stages.map((stage) => ( + + {stage.name} + + )) + )} + + +
+ )} {/* Genre Filter Select */}
From bc9e21849fe71b3821d71804b5beaa63988befe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 05:34:34 +0000 Subject: [PATCH 16/16] fix(schedule): make reveal status pill readable on admin background (#46) text-purple-200 disappears on the white admin CardHeader. Swap to bg-purple-100 / text-purple-900 so the current-level label is visible. --- src/pages/admin/festivals/ScheduleRevealControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/admin/festivals/ScheduleRevealControl.tsx b/src/pages/admin/festivals/ScheduleRevealControl.tsx index 43da38c8..696bc295 100644 --- a/src/pages/admin/festivals/ScheduleRevealControl.tsx +++ b/src/pages/admin/festivals/ScheduleRevealControl.tsx @@ -54,7 +54,7 @@ export function ScheduleRevealControl({ return (
- + {level === "full" && } {STATUS_LABEL[level]}