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..b38d0a05 --- /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 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. diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx index 6e3f9ec1..e80b44e3 100644 --- a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx +++ b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx @@ -7,9 +7,11 @@ import { type StageMismatchResolution, type OrphanResolution, } from "@/services/scheduleImport/types"; +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 }; @@ -29,6 +31,7 @@ type Props = { committing: boolean; commitError: string | null; canCommit: boolean; + currentRevealLevel: RevealLevel; }; export function DiffReviewStep({ @@ -44,7 +47,11 @@ export function DiffReviewStep({ committing, commitError, canCommit, + currentRevealLevel, }: Props) { + const setsToArchive = Object.values(orphanResolutions).filter( + (r) => r === "archive", + ).length; return ( @@ -67,6 +74,13 @@ export function DiffReviewStep({ onChange={onOrphanChange} /> + + {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/components/Admin/ScheduleImport/ReviewStage.tsx b/src/components/Admin/ScheduleImport/ReviewStage.tsx index 831e9bfc..c4e5d694 100644 --- a/src/components/Admin/ScheduleImport/ReviewStage.tsx +++ b/src/components/Admin/ScheduleImport/ReviewStage.tsx @@ -12,12 +12,14 @@ import { artistsKeys } from "@/hooks/queries/artists/useArtists"; import { setsKeys } from "@/hooks/queries/sets/useSets"; import { stagesKeys } from "@/hooks/queries/stages/types"; import { useStagesByEditionQuery } from "@/hooks/queries/stages/useStagesByEdition"; +import type { RevealLevel } from "@/lib/scheduleReveal"; import { DiffReviewStep } from "./DiffReviewStep"; type Props = { festivalEditionId: string; diff: DiffResult; timezone: string; + currentRevealLevel: RevealLevel; onCommitted: (result: CommitResult) => void; onReset: () => void; }; @@ -26,6 +28,7 @@ export function ReviewStage({ festivalEditionId, diff, timezone, + currentRevealLevel, onCommitted, onReset, }: Props) { @@ -90,6 +93,7 @@ export function ReviewStage({ committing={commitMutation.isPending} commitError={commitMutation.error?.message ?? null} canCommit={canCommit} + currentRevealLevel={currentRevealLevel} /> ); } diff --git a/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx b/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx index d7efa2b4..fb02e1a9 100644 --- a/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx +++ b/src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx @@ -4,18 +4,25 @@ import { type CommitResult, type DiffResult, } from "@/services/scheduleImport/types"; +import type { RevealLevel } from "@/lib/scheduleReveal"; import { CsvUploadStep } from "./CsvUploadStep"; import { ReviewStage } from "./ReviewStage"; import { CommitResultCard } from "./CommitResultCard"; -type Props = { festivalEditionId: string }; +type Props = { + festivalEditionId: string; + currentRevealLevel: RevealLevel; +}; type WizardState = | { step: "upload" } | { step: "review"; diff: DiffResult; timezone: string } | { step: "result"; result: CommitResult }; -export function ScheduleImportWizard({ festivalEditionId }: Props) { +export function ScheduleImportWizard({ + festivalEditionId, + currentRevealLevel, +}: Props) { const [state, setState] = useState({ step: "upload" }); function reset() { @@ -46,6 +53,7 @@ export function ScheduleImportWizard({ festivalEditionId }: Props) { festivalEditionId={festivalEditionId} diff={state.diff} timezone={state.timezone} + currentRevealLevel={currentRevealLevel} onCommitted={(result) => setState({ step: "result", result })} onReset={reset} /> 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..c2129f48 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,18 +32,11 @@ 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() }); + queryClient.invalidateQueries({ queryKey: ["festival-editions"] }); toast({ title: "Success", description: "Festival edition updated successfully", diff --git a/src/hooks/useScheduleReveal.ts b/src/hooks/useScheduleReveal.ts new file mode 100644 index 00000000..1a7ecc80 --- /dev/null +++ b/src/hooks/useScheduleReveal.ts @@ -0,0 +1,24 @@ +import { useFestivalEdition } from "@/contexts/FestivalEditionContext"; +import { + type MaskableSet, + type RevealLevel, + canShowDay, + canShowStage, + canShowTime, + maskSetForReveal, +} from "@/lib/scheduleReveal"; + +export function useScheduleReveal() { + const { edition } = useFestivalEdition(); + const level: RevealLevel = edition?.schedule_reveal_level ?? "draft"; + + return { + level, + canShowDay: canShowDay(level), + canShowStage: canShowStage(level), + canShowTime: canShowTime(level), + maskSet(set: T): T { + return maskSetForReveal(set, level); + }, + }; +} 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/src/lib/scheduleReveal.test.ts b/src/lib/scheduleReveal.test.ts new file mode 100644 index 00000000..46ee4d96 --- /dev/null +++ b/src/lib/scheduleReveal.test.ts @@ -0,0 +1,87 @@ +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("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")).toBe(true); + expect(canShowStage("days")).toBe(false); + expect(canShowTime("days")).toBe(false); + }); + + it("stages exposes day + stage, hides time", () => { + expect(canShowDay("stages")).toBe(true); + expect(canShowStage("stages")).toBe(true); + expect(canShowTime("stages")).toBe(false); + }); + + it("full exposes everything", () => { + expect(canShowDay("full")).toBe(true); + expect(canShowStage("full")).toBe(true); + expect(canShowTime("full")).toBe(true); + }); +}); + +describe("maskSetForReveal", () => { + it("returns the set untouched at full", () => { + expect(maskSetForReveal(baseSet, "full")).toEqual(baseSet); + }); + + 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(); + expect(masked.name).toBe(baseSet.name); + }); + + it("keeps 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_end).toBeNull(); + expect(masked.stage_id).toBeNull(); + }); + + it("keeps 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_end).toBeNull(); + expect(masked.stage_id).toBe(baseSet.stage_id); + }); + + it("does not mutate the original set", () => { + const original = { ...baseSet }; + maskSetForReveal(baseSet, "draft"); + expect(baseSet).toEqual(original); + }); +}); diff --git a/src/lib/scheduleReveal.ts b/src/lib/scheduleReveal.ts new file mode 100644 index 00000000..b5f9476f --- /dev/null +++ b/src/lib/scheduleReveal.ts @@ -0,0 +1,46 @@ +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): boolean { + return isAtLeast(level, "days"); +} + +export function canShowStage(level: RevealLevel): boolean { + return isAtLeast(level, "stages"); +} + +export function canShowTime(level: RevealLevel): boolean { + return 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, +): T { + if (level === "full") return set; + + return { + ...set, + stage_id: canShowStage(level) ? set.stage_id : null, + time_start: canShowDay(level) ? set.time_start : null, + time_end: canShowTime(level) ? set.time_end : null, + }; +} 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/TabNavigation/DesktopTabButton.tsx b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx index 75e46fc5..6b9adab2 100644 --- a/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx +++ b/src/pages/EditionView/TabNavigation/DesktopTabButton.tsx @@ -38,7 +38,10 @@ export function DesktopTabButton({ config }: TabButtonProps) { ), }} > - + + + {config.Indicator && } + {config.label} ); diff --git a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx index a4408c51..1fadabed 100644 --- a/src/pages/EditionView/TabNavigation/MobileTabButton.tsx +++ b/src/pages/EditionView/TabNavigation/MobileTabButton.tsx @@ -26,9 +26,12 @@ export function MobileTabButton({ config }: TabButtonProps) { py-2 px-1 transition-colors duration-200 min-h-16 ${isActive ? "text-purple-400" : "text-gray-400 active:text-purple-300"}`} > - + + + {config.Indicator && } + diff --git a/src/pages/EditionView/TabNavigation/ScheduleTabIndicator.tsx b/src/pages/EditionView/TabNavigation/ScheduleTabIndicator.tsx new file mode 100644 index 00000000..923f5a63 --- /dev/null +++ b/src/pages/EditionView/TabNavigation/ScheduleTabIndicator.tsx @@ -0,0 +1,13 @@ +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; + +export function ScheduleTabIndicator() { + const { canShowTime } = useScheduleReveal(); + if (canShowTime) return null; + return ( + + ); +} diff --git a/src/pages/EditionView/TabNavigation/config.ts b/src/pages/EditionView/TabNavigation/config.ts index c50754b5..356a0c91 100644 --- a/src/pages/EditionView/TabNavigation/config.ts +++ b/src/pages/EditionView/TabNavigation/config.ts @@ -7,6 +7,7 @@ import { MessageSquareIcon, } from "lucide-react"; import { TabConfig } from "./types"; +import { ScheduleTabIndicator } from "./ScheduleTabIndicator"; export const config: TabConfig[] = [ { @@ -22,6 +23,7 @@ export const config: TabConfig[] = [ label: "Schedule", shortLabel: "Schedule", enabled: true, + Indicator: ScheduleTabIndicator, }, { icon: HeartIcon, diff --git a/src/pages/EditionView/TabNavigation/types.ts b/src/pages/EditionView/TabNavigation/types.ts index 81940b7c..428a454b 100644 --- a/src/pages/EditionView/TabNavigation/types.ts +++ b/src/pages/EditionView/TabNavigation/types.ts @@ -1,5 +1,6 @@ import { FestivalInfo } from "@/hooks/queries/festival-info/useFestivalInfo"; import { LucideIcon } from "lucide-react"; +import { ComponentType } from "react"; export type MainTab = | "sets" @@ -15,6 +16,7 @@ export type TabConfig = { label: string; shortLabel: string; enabled: boolean | ((festivalInfo?: FestivalInfo) => boolean); + Indicator?: ComponentType; }; export interface TabButtonProps { 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/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 */}
diff --git a/src/pages/EditionView/tabs/ScheduleTab/ScheduleNotRevealedPlaceholder.tsx b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNotRevealedPlaceholder.tsx new file mode 100644 index 00000000..146911bf --- /dev/null +++ b/src/pages/EditionView/tabs/ScheduleTab/ScheduleNotRevealedPlaceholder.tsx @@ -0,0 +1,17 @@ +import { useScheduleReveal } from "@/hooks/useScheduleReveal"; + +const COPY: Record<"draft" | "days" | "stages", string> = { + draft: "Schedule not yet published.", + days: "Day-by-day lineup is out — exact times coming soon.", + stages: "Stages assigned — exact times coming soon.", +}; + +export function ScheduleNotRevealedPlaceholder() { + const { level } = useScheduleReveal(); + const copy = level === "full" ? COPY.draft : COPY[level]; + return ( +
+

{copy}

+
+ ); +} diff --git a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx index a05b4d89..46fc6007 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx @@ -8,9 +8,12 @@ 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"; +import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; export function Timeline() { const { edition } = useFestivalEdition(); + const { canShowTime } = useScheduleReveal(); const { data: editionSets = [], isLoading: setsLoading } = useEditionSetsQuery(edition?.id); const stagesQuery = useStagesByEditionQuery(edition?.id); @@ -112,13 +115,8 @@ export function Timeline() { ); } - // Check if schedule is published - if (!edition?.published) { - return ( -
-

Schedule not yet published.

-
- ); + if (!canShowTime) { + return ; } return ( diff --git a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx index 896d2fa0..69faddd0 100644 --- a/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx +++ b/src/pages/EditionView/tabs/ScheduleTab/list/ListSchedule.tsx @@ -7,6 +7,8 @@ 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"; +import { ScheduleNotRevealedPlaceholder } from "../ScheduleNotRevealedPlaceholder"; interface TimeSlot { time: Date; @@ -15,6 +17,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,12 +144,8 @@ export function ListSchedule() { ); } - if (!edition?.published) { - return ( -
-

Schedule not yet published.

-
- ); + if (!canShowTime) { + return ; } if (!timeSlots.length) { 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}
)}
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/FestivalEditionManagement.tsx b/src/pages/admin/festivals/FestivalEditionManagement.tsx index 8d58e5dc..ed78f5c4 100644 --- a/src/pages/admin/festivals/FestivalEditionManagement.tsx +++ b/src/pages/admin/festivals/FestivalEditionManagement.tsx @@ -30,6 +30,10 @@ import { Switch } from "@/components/ui/switch"; 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"; +import { ScheduleRevealLevelField } from "./ScheduleRevealLevelField"; + +type RevealLevel = Database["public"]["Enums"]["schedule_reveal_level"]; interface EditionFormData { name: string; @@ -38,6 +42,7 @@ interface EditionFormData { start_date?: string; end_date?: string; published: boolean; + schedule_reveal_level: RevealLevel; } export function FestivalEditionManagement({ @@ -69,6 +74,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 +108,7 @@ export function FestivalEditionManagement({ start_date: "", end_date: "", published: false, + schedule_reveal_level: "draft", }); setEditingEdition(null); setSlugError(""); @@ -120,6 +127,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 +360,13 @@ export function FestivalEditionManagement({ : "Only visible to admins"}

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

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

+ )} +
+ ); +} diff --git a/src/pages/admin/festivals/ScheduleRevealLevelField.tsx b/src/pages/admin/festivals/ScheduleRevealLevelField.tsx new file mode 100644 index 00000000..089c4226 --- /dev/null +++ b/src/pages/admin/festivals/ScheduleRevealLevelField.tsx @@ -0,0 +1,50 @@ +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { RevealLevel } from "@/lib/scheduleReveal"; + +type Props = { + value: RevealLevel; + onChange: (level: RevealLevel) => void; + editionPublished: boolean; +}; + +export function ScheduleRevealLevelField({ + value, + onChange, + editionPublished, +}: Props) { + return ( +
+ + + {!editionPublished && value !== "draft" && ( +

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

+ )} +
+ ); +} diff --git a/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx b/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx index 8eb9a033..2ee2121a 100644 --- a/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx +++ b/src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx @@ -20,5 +20,10 @@ export const Route = createFileRoute( function FestivalScheduleImport() { const edition = Route.useLoaderData(); - return ; + return ( + + ); } 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;