Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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`.
16 changes: 16 additions & 0 deletions docs/adr/0001-schedule-reveal-level.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions src/components/Admin/ScheduleImport/DiffReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -29,6 +31,7 @@ type Props = {
committing: boolean;
commitError: string | null;
canCommit: boolean;
currentRevealLevel: RevealLevel;
};

export function DiffReviewStep({
Expand All @@ -44,7 +47,11 @@ export function DiffReviewStep({
committing,
commitError,
canCommit,
currentRevealLevel,
}: Props) {
const setsToArchive = Object.values(orphanResolutions).filter(
(r) => r === "archive",
).length;
return (
<Card>
<CardHeader>
Expand All @@ -67,6 +74,13 @@ export function DiffReviewStep({
onChange={onOrphanChange}
/>

<LiveCommitWarning
level={currentRevealLevel}
setsToCreate={diff.summary.setsToCreate}
setsToUpdate={diff.cleanOperations.setsToUpdate.length}
setsToArchive={setsToArchive}
/>

{commitError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
Expand Down
36 changes: 36 additions & 0 deletions src/components/Admin/ScheduleImport/LiveCommitWarning.tsx
Original file line number Diff line number Diff line change
@@ -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<RevealLevel, string> = {
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 (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Schedule is {LEVEL_DESCRIPTION[level]}.</AlertTitle>
<AlertDescription>
Committing will update what the public sees immediately: {setsToCreate}{" "}
new · {setsToUpdate} updated · {setsToArchive} archived.
</AlertDescription>
</Alert>
);
}
4 changes: 4 additions & 0 deletions src/components/Admin/ScheduleImport/ReviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -26,6 +28,7 @@ export function ReviewStage({
festivalEditionId,
diff,
timezone,
currentRevealLevel,
onCommitted,
onReset,
}: Props) {
Expand Down Expand Up @@ -90,6 +93,7 @@ export function ReviewStage({
committing={commitMutation.isPending}
commitError={commitMutation.error?.message ?? null}
canCommit={canCommit}
currentRevealLevel={currentRevealLevel}
/>
);
}
12 changes: 10 additions & 2 deletions src/components/Admin/ScheduleImport/ScheduleImportWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WizardState>({ step: "upload" });

function reset() {
Expand Down Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand Down
25 changes: 7 additions & 18 deletions src/hooks/queries/festivals/editions/useUpdateFestivalEdition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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() });
Comment thread
chiptus marked this conversation as resolved.
queryClient.invalidateQueries({ queryKey: ["festival-editions"] });
toast({
title: "Success",
description: "Festival edition updated successfully",
Expand Down
24 changes: 24 additions & 0 deletions src/hooks/useScheduleReveal.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
chiptus marked this conversation as resolved.

return {
level,
canShowDay: canShowDay(level),
canShowStage: canShowStage(level),
canShowTime: canShowTime(level),
maskSet<T extends MaskableSet>(set: T): T {
return maskSetForReveal(set, level);
},
Comment thread
chiptus marked this conversation as resolved.
};
}
Comment thread
chiptus marked this conversation as resolved.
4 changes: 4 additions & 0 deletions src/integrations/supabase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading