Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
10 changes: 9 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ A single scheduled happening within an edition, with a stage, a start/end time,
_Avoid_: Show, gig, slot, performance (a performance is a _type_ of set, not a synonym for one)

**Set type**:
What kind of happening a **set** is: music, workshop, performance, or other. `null` on a set means it predates typing and awaits backfill — never "chose not to say". Voting is identical across types.
What kind of happening a **set** is: music, workshop, performance, or other. `null` on a set means it is not yet typed (it predates typing, or was imported without a type) and awaits backfill — never "chose not to say". Voting is identical across types.
_Avoid_: Category, kind

**Roster**:
The artists on a single **set** — one, or several for a B2B. Per-set, where **lineup** is per-edition. A set's roster is its import identity: schedule re-imports match a roster set by its artists (order-insensitive), not by its name. See ADR-0008.
_Avoid_: Lineup (that's the whole edition), billing

**Artist-less set**:
A **set** with an empty **roster** (e.g. a fire show or an unhosted workshop). Its import identity is its name plus date/stage, unlike a roster set, which is identified by its artists — so adding an artist to a set changes how re-imports match it. See ADR-0008.
_Avoid_: Empty set, unassigned set

**Stage**:
A named venue/space within an edition where sets take place.
_Avoid_: Venue, room
Expand Down
31 changes: 31 additions & 0 deletions docs/adr/0008-artist-less-set-matching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Artist-less sets match strictly; roster sets match fuzzily

Schedule-import matching (issue #433) needed an identity for sets with no
artists, where the roster key doesn't exist. We decided identity differs by
kind: roster rows are identified by their sorted artist slugs, with stage/date
as mere tie-breakers (a set whose day moved is an update); artist-less rows are
identified by name only, so every supplied discriminator must actually hold —
a stored stage or date that contradicts the row excludes the candidate, and no
survivor means a create. The alternative (one fuzzy rule for both) silently
updated the wrong set whenever a name like "Fire Show" recurred across days.

Settled in design review (2026-08-28) alongside three boundary decisions: a
roster _change_ is a new identity (a solo set gaining a B2B partner creates a
new set and orphans the old one — votes do not carry over); renaming an
artist-less set must happen in the app, since a CSV rename reads as
create-new + orphan-old; and the artist-less/roster boundary is hard in both
directions (crediting a performer to a formerly artist-less set, or removing
the last artist, changes identity). The orphan review step is the safety net
for all three.

## Consequences

- The two index spaces never cross: a roster row cannot match a 0-artist set,
nor the reverse — adding an artist to a set changes its import identity.
- Candidates with no stored time/stage still match, so time-less rows survive
re-import without duplicating; the cost is that such sets can't coexist with
a dated same-name set unambiguously.
- A fuzzy-matched CSV stage provisionally stands in for its closest DB stage
during matching, before the user resolves the mismatch — wrong-set selection
is possible if they map it elsewhere (#447, with an ignored marker test in
`computeDiff.artistless.test.ts`).
113 changes: 113 additions & 0 deletions docs/schedule-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Schedule import: how it works

The schedule import wizard (`Admin → Schedule import`) takes a CSV and turns it
into creates/updates/archives of an edition's sets. This doc explains the
pipeline end-to-end, the matching rules, and what adding a new CSV column costs.

## The pipeline, end to end

```
CSV file
│ parseScheduleCsv (client) src/services/scheduleImport/parseCsv.ts
CsvRow[] — parsed, validated rows
│ diff-schedule (edge function) supabase/functions/diff-schedule/
DiffResult — creates / updates / orphans / conflicts
│ DiffReviewStep (client UI) src/components/Admin/ScheduleImport/
│ user resolves stage mismatches and orphan handling
CommitPayload
│ buildCommitPayload (client) → commit-schedule (edge function)
commit_schedule RPC (Postgres) supabase/migrations/…commit_schedule…
```

1. **Parse (client).** `parseScheduleCsv` reads the CSV with papaparse.
Recognized columns: `Artists` (pipe-separated for B2B), `Set Name`, `Stage`,
`Date`, `Start Time`, `End Time`, `Description`, `Type`. Rows with neither
artists nor a set name are discarded. Validation (unknown `Type` values,
un-sluggable names) runs only on rows that survive the discard filter.
2. **Diff (edge).** `diff-schedule` loads the edition's current sets, stages,
and artists, then walks the CSV rows through `computeDiff`. Each row either
matches an existing set (→ update) or doesn't (→ create). DB sets no CSV row
matched become _orphans_ (the user chooses archive/keep). Stage names that
only fuzzy-match a DB stage become _mismatches_ for the user to resolve.
3. **Review (client).** The diff is shown before anything is written: summary
counts, new artists, typed-set chips (stored → incoming), orphans, and stage
mismatches.
4. **Commit.** The confirmed operations go through `commit-schedule` into the
`commit_schedule` RPC, which applies everything in one transaction.

## Matching rules: which DB set does a row update?

Matching is the heart of the diff and the only genuinely subtle part. There are
two modes, chosen by whether the row has artists (see ADR-0008):

**Roster rows (has artists) — fuzzy.** Identity is the _artist roster_: rows
and sets are keyed by their sorted artist slugs, so "Carl Cox" finds the Carl
Cox set no matter how the name is spelled. Stage and date are only
_tie-breakers_ when several sets share a roster (narrow by stage, then by date
within the stage matches, then by set name as a last resort — names are the
most volatile column; a tie-breaker matching nothing is skipped rather than
emptying the pool). A roster row whose stage or date changed still matches —
that's an update, not a new set.

**Artist-less rows (no artists) — strict.** Identity is the _name_ (trimmed,
case-insensitive), which is weak — "Fire Show" can legitimately exist twice on
different days. So a supplied stage or date must actually hold: a candidate
whose stored stage or date contradicts the row is excluded outright, and if
nothing survives the row becomes a create. Candidates with _no_ stored
time/stage still match, so re-importing a time-less row doesn't duplicate it.
A CSV stage that is _new_ excludes every staged candidate; a _fuzzy-matched_
stage provisionally stands in for its closest DB stage (known limitation:
issue #447).

The two index spaces never cross: a roster row can't match a 0-artist set and
vice versa. Within one import, each DB set is matched at most once.

Boundary consequences (all deliberate, see ADR-0008): a roster _change_ is a
new identity — "Carl Cox" becoming "Carl Cox | Peggy Gou" creates a new set
and orphans the solo one, votes don't carry; renaming an artist-less set must
happen in the app, not the CSV (a CSV rename is create + orphan); crediting a
performer to a formerly artist-less set (or removing the last artist) also
changes identity. The orphan review is the safety net in every case.

A CSV import is a **full snapshot** of the schedule, never a partial add: any
DB set absent from the CSV is surfaced as an orphan and you choose archive or
keep, one by one.

## Type semantics

- `Type` blank or column absent → `null`; invalid value → parse error.
- On commit, an explicit type overwrites the stored one; `null` preserves it
(`COALESCE` in the RPC). Consequence: an import can never clear a type back
to `null` — clearing (if ever needed) is an in-app action, deliberately not
a CSV sentinel value.

## Adding a new CSV column: the checklist

A plain passthrough column (parsed, carried, written — no matching semantics)
is mechanical. It touches the contract in seven places; missing the client Zod
schema is the classic mistake (non-strict `z.object` silently strips unknown
keys):

1. `src/services/scheduleImport/parseCsv.ts` — parse + validate (+ tests)
2. `src/services/scheduleImport/types.ts` — `CsvRow` + `setPayloadSchema`
(+ `diffResultSchema` if the diff returns it)
3. `supabase/functions/diff-schedule/types.ts` — `CsvRow`, `SetPayload`,
`DbSet` if read back
4. `supabase/functions/diff-schedule/index.ts` — request schema + DB select
5. `supabase/functions/diff-schedule/computeDiff.ts` — into the payload
6. `supabase/functions/commit-schedule/index.ts` — payload schema
7. `supabase/migrations/` — new migration redefining the `commit_schedule__*`
helpers that write the column
(+ UI in `src/components/Admin/ScheduleImport/` if it should be visible)

Issue #448 tracks collapsing the duplicated halves of this contract so the
list gets shorter.

A column that participates in _identity_ (affects which set a row matches) or
has overwrite/preserve semantics is a different kind of change: it lands in
`resolvers.ts`/`computeDiff.ts` and needs the same red-green treatment the
artist-less matching got. Budget accordingly.
6 changes: 5 additions & 1 deletion src/components/Admin/ScheduleImport/CsvDropZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,13 @@ export function CsvDropZone({ fileName, rowCount, onFileSelected }: Props) {
<p className="text-xs text-muted-foreground">
Required column: <code>Artists</code> (use <code>|</code> for B2B, e.g.{" "}
<code>Carl Cox | Peggy Gou</code>). Optional: <code>Set Name</code>,{" "}
<code>Type</code> (music, workshop, performance or other),{" "}
<code>Stage</code>, <code>Date</code> (YYYY-MM-DD),{" "}
<code>Start Time</code> (HH:MM), <code>End Time</code> (HH:MM),{" "}
<code>Description</code>.
<code>Description</code>. Rows without artists are kept when they have a{" "}
<code>Set Name</code> (e.g. workshops). The CSV is treated as the
complete schedule: existing sets missing from it are flagged for
archiving in the review step.
</p>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions src/components/Admin/ScheduleImport/DiffReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@/services/scheduleImport/types";
import type { RevealLevel } from "@/lib/scheduleReveal";
import { DiffSummaryBanner } from "./DiffSummaryBanner";
import { TypedSetsPanel } from "./TypedSetsPanel";
import { StageMismatchResolver } from "./StageMismatchResolver";
import { OrphanedSetsPanel } from "./OrphanedSetsPanel";
import { LiveCommitWarning } from "./LiveCommitWarning";
Expand Down Expand Up @@ -60,6 +61,11 @@ export function DiffReviewStep({
<CardContent className="space-y-6">
<DiffSummaryBanner diff={diff} />

<TypedSetsPanel
setsToCreate={diff.cleanOperations.setsToCreate}
setsToUpdate={diff.cleanOperations.setsToUpdate}
/>

<StageMismatchResolver
mismatches={diff.conflicts.stageNameMismatches}
dbStages={dbStages}
Expand Down
88 changes: 88 additions & 0 deletions src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { TypedSetsPanel } from "./TypedSetsPanel";
import type { SetPayload } from "@/services/scheduleImport/types";

describe("TypedSetsPanel", () => {
it("renders nothing when no set carries a type", () => {
const { container } = render(
<TypedSetsPanel
setsToCreate={[makePayload("Carl Cox")]}
setsToUpdate={[
{ ...makePayload("Peggy Gou"), id: "set-1", previousSetType: null },
]}
/>,
);
expect(container).toBeEmptyDOMElement();
});

it("lists only sets whose CSV row carries a type", () => {
render(
<TypedSetsPanel
setsToCreate={[
{ ...makePayload("Morning Yoga"), setType: "workshop" },
makePayload("Carl Cox"),
]}
setsToUpdate={[
{
...makePayload("Fire Show"),
setType: "performance",
id: "set-1",
previousSetType: null,
},
]}
/>,
);
expect(screen.getByText("2 sets with a type from the CSV")).toBeVisible();
expect(screen.getByText("Morning Yoga")).toBeVisible();
expect(screen.getByText("Fire Show")).toBeVisible();
expect(screen.queryByText("Carl Cox")).not.toBeInTheDocument();
});

it("shows stored and incoming chips when the type changes", () => {
render(
<TypedSetsPanel
setsToCreate={[]}
setsToUpdate={[
{
...makePayload("Fire Show"),
setType: "performance",
id: "set-1",
previousSetType: "music",
},
]}
/>,
);
expect(screen.getByText("Music")).toBeVisible();
expect(screen.getByText("Performance")).toBeVisible();
});

it("shows a single chip when the stored type is kept", () => {
render(
<TypedSetsPanel
setsToCreate={[]}
setsToUpdate={[
{
...makePayload("Fire Show"),
setType: "performance",
id: "set-1",
previousSetType: "performance",
},
]}
/>,
);
expect(screen.getAllByText("Performance")).toHaveLength(1);
});
});

function makePayload(name: string): SetPayload {
return {
name,
setType: null,
description: null,
stageName: null,
timeStart: null,
timeEnd: null,
artistSlugs: [],
};
}
Loading
Loading