diff --git a/CONTEXT.md b/CONTEXT.md
index ca4ee14d0..2c8647fe9 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -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
diff --git a/docs/adr/0008-artist-less-set-matching.md b/docs/adr/0008-artist-less-set-matching.md
new file mode 100644
index 000000000..6b16acd6d
--- /dev/null
+++ b/docs/adr/0008-artist-less-set-matching.md
@@ -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`).
diff --git a/docs/schedule-import.md b/docs/schedule-import.md
new file mode 100644
index 000000000..1210c4553
--- /dev/null
+++ b/docs/schedule-import.md
@@ -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.
diff --git a/src/components/Admin/ScheduleImport/CsvDropZone.tsx b/src/components/Admin/ScheduleImport/CsvDropZone.tsx
index 43734ba38..3716acc52 100644
--- a/src/components/Admin/ScheduleImport/CsvDropZone.tsx
+++ b/src/components/Admin/ScheduleImport/CsvDropZone.tsx
@@ -50,9 +50,13 @@ export function CsvDropZone({ fileName, rowCount, onFileSelected }: Props) {
Required column: Artists (use | for B2B, e.g.{" "}
Carl Cox | Peggy Gou). Optional: Set Name,{" "}
+ Type (music, workshop, performance or other),{" "}
Stage, Date (YYYY-MM-DD),{" "}
Start Time (HH:MM), End Time (HH:MM),{" "}
- Description.
+ Description. Rows without artists are kept when they have a{" "}
+ Set Name (e.g. workshops). The CSV is treated as the
+ complete schedule: existing sets missing from it are flagged for
+ archiving in the review step.
);
diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx
index e80b44e3e..bad1893ca 100644
--- a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx
+++ b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx
@@ -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";
@@ -60,6 +61,11 @@ export function DiffReviewStep({
+
+
{
+ it("renders nothing when no set carries a type", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("lists only genuine type changes, with stored and incoming chips", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("1 set changing type")).toBeVisible();
+ expect(screen.getByText("Fire Show")).toBeVisible();
+ expect(screen.getByText("Music")).toBeVisible();
+ expect(screen.getByText("Performance")).toBeVisible();
+ expect(screen.queryByText("Morning Yoga")).not.toBeInTheDocument();
+ expect(screen.queryByText("Peggy Gou")).not.toBeInTheDocument();
+ expect(screen.getByText(/2 more rows carry a type/)).toBeVisible();
+ });
+
+ it("summarizes typed rows that change nothing without listing them", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Set types from the CSV")).toBeVisible();
+ expect(screen.getByText(/2 rows carry a type/)).toBeVisible();
+ expect(screen.queryByText("Morning Yoga")).not.toBeInTheDocument();
+ expect(screen.queryByText("Fire Show")).not.toBeInTheDocument();
+ });
+
+ it("shows a first-time type as changing nothing", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Set types from the CSV")).toBeVisible();
+ expect(screen.getByText(/1 row carries a type/)).toBeVisible();
+ expect(screen.queryByText("Fire Show")).not.toBeInTheDocument();
+ });
+});
+
+function makePayload(name: string): SetPayload {
+ return {
+ name,
+ setType: null,
+ description: null,
+ stageName: null,
+ timeStart: null,
+ timeEnd: null,
+ artistSlugs: [],
+ };
+}
diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx
new file mode 100644
index 000000000..089423027
--- /dev/null
+++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx
@@ -0,0 +1,77 @@
+import { ArrowRight, Tags } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import type { SetType } from "@/api/sets/types";
+import { cn } from "@/lib/utils";
+import { getSetTypeLabel } from "@/lib/setTypeLabels";
+import { type DiffResult } from "@/services/scheduleImport/types";
+
+type SetToUpdate = DiffResult["cleanOperations"]["setsToUpdate"][number];
+
+type Props = {
+ setsToCreate: DiffResult["cleanOperations"]["setsToCreate"];
+ setsToUpdate: DiffResult["cleanOperations"]["setsToUpdate"];
+};
+
+export function TypedSetsPanel({ setsToCreate, setsToUpdate }: Props) {
+ const typedCreateCount = setsToCreate.filter(
+ (s) => s.setType !== null,
+ ).length;
+ const typedUpdates = setsToUpdate.filter((s) => s.setType !== null);
+ const typeChanges = typedUpdates.filter(isTypeChange);
+ const keptCount = typedCreateCount + typedUpdates.length - typeChanges.length;
+
+ if (typedCreateCount + typedUpdates.length === 0) return null;
+
+ return (
+
+
+
+ {typeChanges.length > 0
+ ? `${typeChanges.length} set${typeChanges.length !== 1 ? "s" : ""} changing type`
+ : "Set types from the CSV"}
+
+
+
+ {keptCount > 0 &&
+ `${keptCount} ${typeChanges.length > 0 ? "more " : ""}${keptCount !== 1 ? "rows carry" : "row carries"} a type that changes nothing (new sets, or updates matching the stored type). `}
+ Rows with a blank type keep whatever type the matched set already has.
+
+
+ {typeChanges.length > 0 && (
+
+ {typeChanges.map((set) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * A stored type differing from the incoming one is the change worth
+ * verifying; new sets and first-time types just take the CSV value.
+ */
+function isTypeChange(set: SetToUpdate): boolean {
+ return set.previousSetType !== null && set.previousSetType !== set.setType;
+}
+
+function SetTypeChip({ setType }: { setType: SetType | null }) {
+ const { icon: Icon, label, color } = getSetTypeLabel(setType);
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/services/scheduleImport/buildCommitPayload.test.ts b/src/services/scheduleImport/buildCommitPayload.test.ts
index 1290480d2..708140f3a 100644
--- a/src/services/scheduleImport/buildCommitPayload.test.ts
+++ b/src/services/scheduleImport/buildCommitPayload.test.ts
@@ -50,6 +50,7 @@ describe("buildCommitPayload", () => {
setsToCreate: [
{
name: "Carl Cox",
+ setType: null,
description: null,
stageName: "Mainstage",
timeStart: null,
@@ -88,6 +89,7 @@ describe("buildCommitPayload", () => {
setsToCreate: [
{
name: "Carl Cox",
+ setType: null,
description: null,
stageName: "Main Stage",
timeStart: null,
@@ -103,6 +105,44 @@ describe("buildCommitPayload", () => {
expect(payload.setsToCreate[0].stageName).toBe("Main Stage");
});
+ it("passes setType and empty rosters through for creates and updates", () => {
+ const diff = makeDiff({
+ cleanOperations: {
+ artistsToCreate: [],
+ stagesToCreate: [],
+ setsToCreate: [
+ {
+ name: "Morning Yoga",
+ setType: "workshop",
+ description: null,
+ stageName: null,
+ timeStart: null,
+ timeEnd: null,
+ artistSlugs: [],
+ },
+ ],
+ setsToUpdate: [
+ {
+ id: "set-1",
+ name: "Fire Show",
+ setType: null,
+ previousSetType: "performance",
+ description: null,
+ stageName: null,
+ timeStart: null,
+ timeEnd: null,
+ artistSlugs: [],
+ },
+ ],
+ },
+ });
+
+ const payload = buildCommitPayload(diff, {}, {});
+ expect(payload.setsToCreate[0].setType).toBe("workshop");
+ expect(payload.setsToCreate[0].artistSlugs).toEqual([]);
+ expect(payload.setsToUpdate[0].setType).toBeNull();
+ });
+
it("filters orphan archive ids based on resolutions", () => {
const diff = makeDiff({
conflicts: {
diff --git a/src/services/scheduleImport/parseCsv.test.ts b/src/services/scheduleImport/parseCsv.test.ts
index e93d8aa5d..7528c124c 100644
--- a/src/services/scheduleImport/parseCsv.test.ts
+++ b/src/services/scheduleImport/parseCsv.test.ts
@@ -11,6 +11,7 @@ describe("parseScheduleCsv", () => {
expect(parseScheduleCsv(csv)).toEqual([
{
artists: ["Carl Cox"],
+ setType: null,
setName: "Carl Cox Live",
stage: "Main Stage",
date: "2026-07-11",
@@ -31,6 +32,7 @@ describe("parseScheduleCsv", () => {
expect(parseScheduleCsv(csv)).toEqual([
{
artists: ["DJ Tennis"],
+ setType: null,
setName: undefined,
stage: undefined,
date: "2026-07-12",
@@ -66,6 +68,63 @@ describe("parseScheduleCsv", () => {
expect(parseScheduleCsv(csv)[0].artists).toEqual(["Carl Cox", "Peggy Gou"]);
});
+ it("parses a valid type value", () => {
+ const csv = ["Artists,Type", "Carl Cox,workshop"].join("\n");
+ expect(parseScheduleCsv(csv)[0].setType).toBe("workshop");
+ });
+
+ it("normalizes type casing and whitespace", () => {
+ const csv = ["Artists,Type", "Carl Cox, Workshop "].join("\n");
+ expect(parseScheduleCsv(csv)[0].setType).toBe("workshop");
+ });
+
+ it("parses a blank type as null", () => {
+ const csv = ["Artists,Type", "Carl Cox,"].join("\n");
+ expect(parseScheduleCsv(csv)[0].setType).toBeNull();
+ });
+
+ it("parses a missing type column as null", () => {
+ const csv = ["Artists,Stage", "Carl Cox,Main"].join("\n");
+ expect(parseScheduleCsv(csv)[0].setType).toBeNull();
+ });
+
+ it("ignores an invalid type on a row that is skipped anyway", () => {
+ const csv = ["Artists,Set Name,Type", "Carl Cox,,music", ",,concert"].join(
+ "\n",
+ );
+ expect(parseScheduleCsv(csv)).toHaveLength(1);
+ });
+
+ it("throws on an invalid type value", () => {
+ const csv = ["Artists,Type", "Carl Cox,concert"].join("\n");
+ expect(() => parseScheduleCsv(csv)).toThrow(/Invalid type "concert"/);
+ });
+
+ it("keeps artist-less rows that have a set name", () => {
+ const csv = [
+ "Artists,Set Name,Type",
+ ",Morning Yoga,workshop",
+ "Carl Cox,,",
+ ].join("\n");
+ const rows = parseScheduleCsv(csv);
+ expect(rows).toHaveLength(2);
+ expect(rows[0].artists).toEqual([]);
+ expect(rows[0].setName).toBe("Morning Yoga");
+ expect(rows[0].setType).toBe("workshop");
+ });
+
+ it("still skips rows with neither artists nor a set name", () => {
+ const csv = ["Artists,Set Name,Stage", "Carl Cox,,Main", ",,Side"].join(
+ "\n",
+ );
+ expect(parseScheduleCsv(csv)).toHaveLength(1);
+ });
+
+ it("throws when an artist-less row's set name has no letters or digits", () => {
+ const csv = ["Artists,Set Name", ",???"].join("\n");
+ expect(() => parseScheduleCsv(csv)).toThrow(/no letters or digits/);
+ });
+
it("throws when an artist name has no letters or digits", () => {
const csv = ["Artists,Stage", "!!!,Main"].join("\n");
expect(() => parseScheduleCsv(csv)).toThrow(/no letters or digits/);
diff --git a/src/services/scheduleImport/parseCsv.ts b/src/services/scheduleImport/parseCsv.ts
index dac84f489..c53363d48 100644
--- a/src/services/scheduleImport/parseCsv.ts
+++ b/src/services/scheduleImport/parseCsv.ts
@@ -1,4 +1,5 @@
import Papa from "papaparse";
+import { asSetType } from "@/api/sets/types";
import { type CsvRow } from "./types";
export function parseScheduleCsv(csvContent: string): CsvRow[] {
@@ -30,7 +31,7 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] {
const endTime = row["end time"]?.trim() || undefined;
const description = row.description?.trim() || undefined;
- const csvRow: CsvRow = { artists };
+ const csvRow: CsvRow = { artists, setType: null };
if (setName !== undefined) csvRow.setName = setName;
if (stage !== undefined) csvRow.stage = stage;
if (date !== undefined) csvRow.date = date;
@@ -38,9 +39,17 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] {
if (endTime !== undefined) csvRow.endTime = endTime;
if (description !== undefined) csvRow.description = description;
- return csvRow;
+ return { csvRow, rawType: row.type };
})
- .filter((row) => row.artists.length > 0);
+ .filter(
+ ({ csvRow }) => csvRow.artists.length > 0 || csvRow.setName !== undefined,
+ )
+ // Validate the type only on rows that survive the filter, so a discarded
+ // row (no artists, no set name) can't abort the import over a bad type.
+ .map(({ csvRow, rawType }) => ({
+ ...csvRow,
+ setType: parseSetType(rawType),
+ }));
for (const row of rows) {
for (const artist of row.artists) {
@@ -50,6 +59,11 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] {
);
}
}
+ if (row.artists.length === 0 && !hasSluggableChars(row.setName ?? "")) {
+ throw new Error(
+ `Set name "${row.setName}" has no letters or digits and can't be imported.`,
+ );
+ }
if (row.stage && !hasSluggableChars(row.stage)) {
throw new Error(
`Stage name "${row.stage}" has no letters or digits and can't be imported.`,
@@ -60,6 +74,18 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] {
return rows;
}
+function parseSetType(raw: string | undefined): CsvRow["setType"] {
+ const value = raw?.trim().toLowerCase();
+ if (!value) return null;
+ const setType = asSetType(value);
+ if (setType === null) {
+ throw new Error(
+ `Invalid type "${raw?.trim()}" — use music, workshop, performance or other, or leave it blank.`,
+ );
+ }
+ return setType;
+}
+
// A name with no [a-z0-9] slugifies to an empty string, which downstream
// breaks slug-based lookups and the slug unique constraints. Reject it here
// with a clear message instead of failing opaquely at commit time.
diff --git a/src/services/scheduleImport/types.ts b/src/services/scheduleImport/types.ts
index 77ac10d05..e6ce8560b 100644
--- a/src/services/scheduleImport/types.ts
+++ b/src/services/scheduleImport/types.ts
@@ -1,7 +1,9 @@
import { z } from "zod";
+import { SET_TYPES, type SetType } from "@/api/sets/types";
export type CsvRow = {
artists: string[];
+ setType: SetType | null;
setName?: string;
stage?: string;
date?: string;
@@ -12,6 +14,7 @@ export type CsvRow = {
export const setPayloadSchema = z.object({
name: z.string(),
+ setType: z.enum(SET_TYPES).nullable(),
description: z.string().nullable(),
stageName: z.string().nullable(),
timeStart: z.string().nullable(),
@@ -33,7 +36,14 @@ export const diffResultSchema = z.object({
artistsToCreate: z.array(z.object({ name: z.string(), slug: z.string() })),
stagesToCreate: z.array(z.object({ name: z.string() })),
setsToCreate: z.array(setPayloadSchema),
- setsToUpdate: z.array(setPayloadSchema.extend({ id: z.string() })),
+ setsToUpdate: z.array(
+ setPayloadSchema.extend({
+ id: z.string(),
+ // The matched set's stored type, so the review can render
+ // stored → incoming chips. Not written on commit.
+ previousSetType: z.enum(SET_TYPES).nullable(),
+ }),
+ ),
}),
conflicts: z.object({
stageNameMismatches: z.array(
diff --git a/supabase/functions/_shared/setTypes.ts b/supabase/functions/_shared/setTypes.ts
new file mode 100644
index 000000000..15b4960c2
--- /dev/null
+++ b/supabase/functions/_shared/setTypes.ts
@@ -0,0 +1,11 @@
+/**
+ * Mirrors SET_TYPES in src/api/sets/types.ts and the sets_set_type_check
+ * constraint. Keep the three in sync when adding a type.
+ */
+export const SET_TYPES = ["music", "workshop", "performance", "other"] as const;
+
+export type SetType = (typeof SET_TYPES)[number];
+
+export function asSetType(value: string | null): SetType | null {
+ return SET_TYPES.includes(value as SetType) ? (value as SetType) : null;
+}
diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts
index dc60ec854..328aef838 100644
--- a/supabase/functions/commit-schedule/commit-schedule.test.ts
+++ b/supabase/functions/commit-schedule/commit-schedule.test.ts
@@ -250,6 +250,125 @@ Deno.test(
},
);
+Deno.test(
+ "commit_schedule: creates an artist-less typed set with an empty roster",
+ async () => {
+ const db = adminClient();
+ const editionId = await getTestEditionId(db);
+ const userId = await getTestUserId(db);
+ const setName = `Morning Yoga ${Date.now()}`;
+
+ const { data, error } = await db.rpc("commit_schedule", {
+ p_festival_edition_id: editionId,
+ p_user_id: userId,
+ p_artists_to_create: [],
+ p_stages_to_create: [],
+ p_sets_to_create: [
+ {
+ name: setName,
+ setType: "workshop",
+ description: "Sun salutations",
+ stageName: null,
+ timeStart: null,
+ timeEnd: null,
+ artistSlugs: [],
+ },
+ ],
+ p_sets_to_update: [],
+ p_set_ids_to_archive: [],
+ });
+
+ assertEquals(error, null);
+ assertEquals(data.setsCreated, 1);
+
+ const { data: sets } = await db
+ .from("sets")
+ .select("id, set_type, set_artists(artist_id)")
+ .eq("festival_edition_id", editionId)
+ .eq("name", setName);
+
+ assertExists(sets?.[0]);
+ assertEquals(sets![0].set_type, "workshop");
+ assertEquals(sets![0].set_artists.length, 0);
+
+ // Cleanup
+ await db.from("sets").delete().eq("id", sets![0].id);
+ },
+);
+
+Deno.test(
+ "commit_schedule: explicit type overwrites, null type preserves",
+ async () => {
+ const db = adminClient();
+ const editionId = await getTestEditionId(db);
+ const userId = await getTestUserId(db);
+ const setName = `Type Roundtrip ${Date.now()}`;
+
+ const { data: set } = await db
+ .from("sets")
+ .insert({
+ festival_edition_id: editionId,
+ name: setName,
+ slug: `type-roundtrip-${Date.now()}`,
+ set_type: "performance",
+ created_by: userId,
+ })
+ .select("id")
+ .single();
+
+ const basePayload = {
+ id: set!.id,
+ name: setName,
+ description: null,
+ stageName: null,
+ timeStart: null,
+ timeEnd: null,
+ artistSlugs: [],
+ };
+
+ // Explicit type overwrites the stored one.
+ const { error: overwriteError } = await db.rpc("commit_schedule", {
+ p_festival_edition_id: editionId,
+ p_user_id: userId,
+ p_artists_to_create: [],
+ p_stages_to_create: [],
+ p_sets_to_create: [],
+ p_sets_to_update: [{ ...basePayload, setType: "workshop" }],
+ p_set_ids_to_archive: [],
+ });
+ assertEquals(overwriteError, null);
+
+ const { data: afterOverwrite } = await db
+ .from("sets")
+ .select("set_type")
+ .eq("id", set!.id)
+ .single();
+ assertEquals(afterOverwrite!.set_type, "workshop");
+
+ // Null type (blank CSV column) preserves the stored one.
+ const { error: preserveError } = await db.rpc("commit_schedule", {
+ p_festival_edition_id: editionId,
+ p_user_id: userId,
+ p_artists_to_create: [],
+ p_stages_to_create: [],
+ p_sets_to_create: [],
+ p_sets_to_update: [{ ...basePayload, setType: null }],
+ p_set_ids_to_archive: [],
+ });
+ assertEquals(preserveError, null);
+
+ const { data: afterPreserve } = await db
+ .from("sets")
+ .select("set_type")
+ .eq("id", set!.id)
+ .single();
+ assertEquals(afterPreserve!.set_type, "workshop");
+
+ // Cleanup
+ await db.from("sets").delete().eq("id", set!.id);
+ },
+);
+
Deno.test(
"commit_schedule: midnight-crossing times stored correctly",
async () => {
diff --git a/supabase/functions/commit-schedule/index.ts b/supabase/functions/commit-schedule/index.ts
index 705484327..5ed2708ff 100644
--- a/supabase/functions/commit-schedule/index.ts
+++ b/supabase/functions/commit-schedule/index.ts
@@ -2,6 +2,7 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
import { requireAdmin } from "../_shared/auth.ts";
import { buildCorsHeaders } from "../_shared/cors.ts";
+import { SET_TYPES } from "../_shared/setTypes.ts";
// timeStart/timeEnd arrive as ISO strings or null. Coerce "" (and undefined)
// to null so the RPC's ::timestamptz cast doesn't choke on an empty string.
@@ -10,13 +11,19 @@ const nullableTimestamp = z
.nullish()
.transform((v) => v || null);
+// An explicit empty artistSlugs array is a valid roster: artist-less sets
+// (workshops, performances) carry no artists by design.
const setPayloadSchema = z.object({
name: z.string().min(1),
+ setType: z
+ .enum(SET_TYPES)
+ .nullish()
+ .transform((v) => v ?? null),
description: z.string().nullish(),
stageName: z.string().nullish(),
timeStart: nullableTimestamp,
timeEnd: nullableTimestamp,
- artistSlugs: z.array(z.string().min(1)).min(1),
+ artistSlugs: z.array(z.string().min(1)),
});
const commitRequestSchema = z.object({
diff --git a/supabase/functions/diff-schedule/computeDiff.artistless.test.ts b/supabase/functions/diff-schedule/computeDiff.artistless.test.ts
new file mode 100644
index 000000000..ca47d83e9
--- /dev/null
+++ b/supabase/functions/diff-schedule/computeDiff.artistless.test.ts
@@ -0,0 +1,298 @@
+import { assertEquals } from "jsr:@std/assert@1";
+import { computeDiff } from "./computeDiff.ts";
+import { makeArtist, makeSet, makeStage } from "./fixtures.ts";
+
+Deno.test("artist-less row creates a set with an empty roster", () => {
+ const result = computeDiff(
+ [{ artists: [], setName: "Morning Yoga", setType: "workshop" }],
+ [],
+ [],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ assertEquals(result.cleanOperations.setsToCreate[0].name, "Morning Yoga");
+ assertEquals(result.cleanOperations.setsToCreate[0].artistSlugs, []);
+ assertEquals(result.cleanOperations.setsToCreate[0].setType, "workshop");
+ assertEquals(result.summary.newArtists, 0);
+});
+
+Deno.test("artist-less row matches an existing 0-artist set by name", () => {
+ const set = makeSet("set-yoga", "Morning Yoga", []);
+ const result = computeDiff(
+ [{ artists: [], setName: "morning yoga" }],
+ [],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-yoga");
+ assertEquals(result.cleanOperations.setsToCreate.length, 0);
+ assertEquals(result.conflicts.orphanedSets.length, 0);
+});
+
+Deno.test(
+ "artist-less row does not match a same-name set that has artists",
+ () => {
+ const artist = makeArtist("Carl Cox");
+ const set = makeSet("set-cox", "Morning Yoga", [artist]);
+ const result = computeDiff(
+ [{ artists: [], setName: "Morning Yoga" }],
+ [],
+ [set],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ assertEquals(result.conflicts.orphanedSets.length, 1);
+ },
+);
+
+Deno.test("roster row does not match a 0-artist set", () => {
+ const artist = makeArtist("Carl Cox");
+ const set = makeSet("set-empty", "Carl Cox", []);
+ const result = computeDiff(
+ [{ artists: ["Carl Cox"] }],
+ [],
+ [set],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ assertEquals(result.conflicts.orphanedSets.length, 1);
+});
+
+Deno.test("same-name artist-less candidates disambiguated by stage", () => {
+ const stage1 = makeStage("s1", "Stage One");
+ const stage2 = makeStage("s2", "Stage Two");
+ const set1 = makeSet("set-a", "Fire Show", [], "s1");
+ const set2 = makeSet("set-b", "Fire Show", [], "s2");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Stage Two" }],
+ [stage1, stage2],
+ [set1, set2],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+ assertEquals(result.conflicts.orphanedSets.length, 1);
+ assertEquals(result.conflicts.orphanedSets[0].id, "set-a");
+});
+
+Deno.test(
+ "same name and stage on different dates disambiguated by date",
+ () => {
+ const stage = makeStage("s1", "Workshop Tent");
+ const set1 = makeSet(
+ "set-a",
+ "Fire Show",
+ [],
+ "s1",
+ "2026-07-11T20:00:00Z",
+ );
+ const set2 = makeSet(
+ "set-b",
+ "Fire Show",
+ [],
+ "s1",
+ "2026-07-12T20:00:00Z",
+ );
+ const result = computeDiff(
+ [
+ {
+ artists: [],
+ setName: "Fire Show",
+ stage: "Workshop Tent",
+ date: "2026-07-12",
+ },
+ ],
+ [stage],
+ [set1, set2],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+ },
+);
+
+Deno.test("same-name artist-less candidates disambiguated by date", () => {
+ const set1 = makeSet("set-a", "Fire Show", [], null, "2026-07-11T20:00:00Z");
+ const set2 = makeSet("set-b", "Fire Show", [], null, "2026-07-12T20:00:00Z");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", date: "2026-07-12" }],
+ [],
+ [set1, set2],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+});
+
+Deno.test(
+ "artist-less row on a different date creates instead of updating",
+ () => {
+ const set = makeSet("set-a", "Fire Show", [], null, "2026-07-11T20:00:00Z");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", date: "2026-07-12" }],
+ [],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ assertEquals(result.conflicts.orphanedSets.length, 1);
+ assertEquals(result.conflicts.orphanedSets[0].id, "set-a");
+ },
+);
+
+Deno.test(
+ "artist-less row on a different stage creates instead of updating",
+ () => {
+ const stage1 = makeStage("s1", "Stage One");
+ const stage2 = makeStage("s2", "Stage Two");
+ const set = makeSet("set-a", "Fire Show", [], "s1");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Stage Two" }],
+ [stage1, stage2],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ assertEquals(result.conflicts.orphanedSets.length, 1);
+ },
+);
+
+Deno.test(
+ "artist-less row with a date still matches a stored set without a time",
+ () => {
+ const set = makeSet("set-a", "Fire Show", []);
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", date: "2026-07-12" }],
+ [],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-a");
+ },
+);
+
+Deno.test(
+ "artist-less row at a new stage creates instead of updating a staged set",
+ () => {
+ const stage = makeStage("s1", "Stage One");
+ const set = makeSet("set-a", "Fire Show", [], "s1");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Secret Forest" }],
+ [stage],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ assertEquals(result.conflicts.orphanedSets.length, 1);
+ },
+);
+
+Deno.test(
+ "artist-less row at a new stage still matches a stage-less stored set",
+ () => {
+ const set = makeSet("set-a", "Fire Show", []);
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Secret Forest" }],
+ [],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-a");
+ },
+);
+
+// Pins the provisional closest-stage behavior; see #447 for its limit.
+Deno.test(
+ "mismatched stage matches artist-less sets via its closest stage",
+ () => {
+ const stage1 = makeStage("s1", "Main Stage");
+ const stage2 = makeStage("s2", "Side");
+ const set1 = makeSet("set-a", "Fire Show", [], "s2");
+ const set2 = makeSet("set-b", "Fire Show", [], "s1");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Mainstage" }],
+ [stage1, stage2],
+ [set1, set2],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+ },
+);
+
+// Executable marker for #447: un-ignore once artist-less matching honors the
+// user's stage-mismatch resolution instead of the closest-match guess pinned
+// above. Encodes the conservative outcome (don't pick between staged
+// candidates while the mismatch is unresolved); adjust it if #447 settles on
+// passing resolutions into the diff instead.
+Deno.test({
+ name: "unresolved stage mismatch defers artist-less set selection (#447)",
+ ignore: true,
+ fn() {
+ const stage1 = makeStage("s1", "Main Stage");
+ const stage2 = makeStage("s2", "Main Stage East");
+ const set1 = makeSet("set-a", "Fire Show", [], "s1");
+ const set2 = makeSet("set-b", "Fire Show", [], "s2");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Mainstage" }],
+ [stage1, stage2],
+ [set1, set2],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ },
+});
+
+Deno.test(
+ "mismatched stage excludes an artist-less set at a different stage",
+ () => {
+ const stage1 = makeStage("s1", "Main Stage");
+ const stage2 = makeStage("s2", "Side");
+ const set = makeSet("set-a", "Fire Show", [], "s2");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", stage: "Mainstage" }],
+ [stage1, stage2],
+ [set],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 0);
+ assertEquals(result.cleanOperations.setsToCreate.length, 1);
+ },
+);
+
+Deno.test("dated artist-less candidate preferred over an undated one", () => {
+ const set1 = makeSet("set-a", "Fire Show", []);
+ const set2 = makeSet("set-b", "Fire Show", [], null, "2026-07-12T20:00:00Z");
+ const result = computeDiff(
+ [{ artists: [], setName: "Fire Show", date: "2026-07-12" }],
+ [],
+ [set1, set2],
+ [],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+});
diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts
index e2a71d154..40cce84e7 100644
--- a/supabase/functions/diff-schedule/computeDiff.test.ts
+++ b/supabase/functions/diff-schedule/computeDiff.test.ts
@@ -1,6 +1,6 @@
import { assertEquals } from "jsr:@std/assert@1";
import { computeDiff } from "./computeDiff.ts";
-import type { DbArtist, DbSet, DbStage } from "./types.ts";
+import { makeArtist, makeSet, makeStage } from "./fixtures.ts";
Deno.test("new artist in CSV creates artist", () => {
const result = computeDiff(
@@ -236,29 +236,146 @@ Deno.test("multiple candidates disambiguated by stage", () => {
assertEquals(result.conflicts.orphanedSets[0].id, "set-a");
});
-function makeArtist(name: string): DbArtist {
- const slug = name.toLowerCase().replace(/\s+/g, "-");
- return { id: `id-${slug}`, name, slug };
-}
+Deno.test(
+ "same-roster candidates disambiguated by set name as last resort",
+ () => {
+ const artist = makeArtist("Carl Cox");
+ const set1 = makeSet("set-a", "Carl Cox Live", [artist]);
+ const set2 = makeSet("set-b", "Carl Cox DJ Set", [artist]);
+ const result = computeDiff(
+ [{ artists: ["Carl Cox"], setName: "Carl Cox DJ Set" }],
+ [],
+ [set1, set2],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+ },
+);
-function makeStage(id: string, name: string): DbStage {
- return { id, name };
-}
+Deno.test("date narrowing beats a set-name match for roster rows", () => {
+ const artist = makeArtist("Carl Cox");
+ const set1 = makeSet(
+ "set-a",
+ "Carl Cox Live",
+ [artist],
+ null,
+ "2026-07-11T20:00:00Z",
+ );
+ const set2 = makeSet(
+ "set-b",
+ "Carl Cox Sunset",
+ [artist],
+ null,
+ "2026-07-12T20:00:00Z",
+ );
+ const result = computeDiff(
+ [
+ {
+ artists: ["Carl Cox"],
+ setName: "Carl Cox Live",
+ date: "2026-07-12",
+ },
+ ],
+ [],
+ [set1, set2],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+});
-function makeSet(
- id: string,
- name: string,
- artists: DbArtist[],
- stageId: string | null = null,
- timeStart: string | null = null,
-): DbSet {
- return {
- id,
- name,
- description: null,
- stage_id: stageId,
- time_start: timeStart,
- time_end: null,
- set_artists: artists.map((a) => ({ artist_id: a.id, artists: a })),
- };
-}
+Deno.test(
+ "same-roster sets on one stage across dates matched by the row's date",
+ () => {
+ const artist = makeArtist("Carl Cox");
+ const stage = makeStage("s1", "Stage One");
+ const set1 = makeSet(
+ "set-a",
+ "Carl Cox",
+ [artist],
+ "s1",
+ "2026-07-11T20:00:00Z",
+ );
+ const set2 = makeSet(
+ "set-b",
+ "Carl Cox",
+ [artist],
+ "s1",
+ "2026-07-12T20:00:00Z",
+ );
+ const result = computeDiff(
+ [{ artists: ["Carl Cox"], stage: "Stage One", date: "2026-07-12" }],
+ [stage],
+ [set1, set2],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+ },
+);
+
+Deno.test(
+ "roster row's stage matching nothing falls back to date narrowing",
+ () => {
+ const artist = makeArtist("Carl Cox");
+ const stage1 = makeStage("s1", "Stage One");
+ const stage2 = makeStage("s2", "Stage Two");
+ const stage3 = makeStage("s3", "Stage Three");
+ const set1 = makeSet(
+ "set-a",
+ "Carl Cox",
+ [artist],
+ "s1",
+ "2026-07-11T20:00:00Z",
+ );
+ const set2 = makeSet(
+ "set-b",
+ "Carl Cox",
+ [artist],
+ "s2",
+ "2026-07-12T20:00:00Z",
+ );
+ const result = computeDiff(
+ [{ artists: ["Carl Cox"], stage: "Stage Three", date: "2026-07-12" }],
+ [stage1, stage2, stage3],
+ [set1, set2],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate.length, 1);
+ assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b");
+ },
+);
+
+Deno.test(
+ "row setType lands in the payload; absent setType becomes null",
+ () => {
+ const result = computeDiff(
+ [{ artists: ["Carl Cox"], setType: "music" }, { artists: ["Peggy Gou"] }],
+ [],
+ [],
+ [makeArtist("Carl Cox"), makeArtist("Peggy Gou")],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToCreate[0].setType, "music");
+ assertEquals(result.cleanOperations.setsToCreate[1].setType, null);
+ },
+);
+
+Deno.test("update payload carries the matched set's stored type", () => {
+ const artist = makeArtist("Carl Cox");
+ const set = { ...makeSet("set-1", "Carl Cox", [artist]), set_type: "music" };
+ const result = computeDiff(
+ [{ artists: ["Carl Cox"], setType: "workshop" }],
+ [],
+ [set],
+ [artist],
+ "UTC",
+ );
+ assertEquals(result.cleanOperations.setsToUpdate[0].previousSetType, "music");
+ assertEquals(result.cleanOperations.setsToUpdate[0].setType, "workshop");
+});
diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts
index c5f2e65db..883d78944 100644
--- a/supabase/functions/diff-schedule/computeDiff.ts
+++ b/supabase/functions/diff-schedule/computeDiff.ts
@@ -1,7 +1,9 @@
+import { asSetType } from "../_shared/setTypes.ts";
import { artistKey } from "./helpers.ts";
import {
buildIndexes,
computeTimes,
+ findMatchingArtistlessSet,
findMatchingSet,
resolveArtists,
resolveStage,
@@ -38,18 +40,25 @@ export function computeDiff(
const { timeStart, timeEnd } = computeTimes(row, timezone);
- const candidates =
- indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? [];
- const matched = findMatchingSet(
- candidates,
- resolvedStage.id,
- row.date,
- timezone,
- state.matchedSetIds,
- );
+ const name = row.setName?.trim() || row.artists.join(" b2b ");
+
+ const matchContext = { stage, date: row.date, timezone, name };
+ const matched =
+ row.artists.length === 0
+ ? findMatchingArtistlessSet(
+ indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? [],
+ matchContext,
+ state.matchedSetIds,
+ )
+ : findMatchingSet(
+ indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? [],
+ matchContext,
+ state.matchedSetIds,
+ );
const payload: SetPayload = {
- name: row.setName?.trim() || row.artists.join(" b2b "),
+ name,
+ setType: row.setType ?? null,
description: row.description ?? null,
stageName: resolvedStage.name,
timeStart,
@@ -59,7 +68,11 @@ export function computeDiff(
if (matched) {
state.matchedSetIds.add(matched.id);
- state.setsToUpdate.push({ id: matched.id, ...payload });
+ state.setsToUpdate.push({
+ id: matched.id,
+ previousSetType: asSetType(matched.set_type),
+ ...payload,
+ });
} else {
state.setsToCreate.push(payload);
}
@@ -103,7 +116,7 @@ type DiffState = {
stagesToCreate: { name: string }[];
stageNameMismatches: DiffResult["conflicts"]["stageNameMismatches"];
setsToCreate: SetPayload[];
- setsToUpdate: ({ id: string } & SetPayload)[];
+ setsToUpdate: DiffResult["cleanOperations"]["setsToUpdate"];
};
function createState(): DiffState {
diff --git a/supabase/functions/diff-schedule/fixtures.ts b/supabase/functions/diff-schedule/fixtures.ts
new file mode 100644
index 000000000..d38e266ed
--- /dev/null
+++ b/supabase/functions/diff-schedule/fixtures.ts
@@ -0,0 +1,29 @@
+import type { DbArtist, DbSet, DbStage } from "./types.ts";
+
+export function makeArtist(name: string): DbArtist {
+ const slug = name.toLowerCase().replace(/\s+/g, "-");
+ return { id: `id-${slug}`, name, slug };
+}
+
+export function makeStage(id: string, name: string): DbStage {
+ return { id, name };
+}
+
+export function makeSet(
+ id: string,
+ name: string,
+ artists: DbArtist[],
+ stageId: string | null = null,
+ timeStart: string | null = null,
+): DbSet {
+ return {
+ id,
+ name,
+ description: null,
+ set_type: null,
+ stage_id: stageId,
+ time_start: timeStart,
+ time_end: null,
+ set_artists: artists.map((a) => ({ artist_id: a.id, artists: a })),
+ };
+}
diff --git a/supabase/functions/diff-schedule/index.ts b/supabase/functions/diff-schedule/index.ts
index ccaf72126..0ff15ff2d 100644
--- a/supabase/functions/diff-schedule/index.ts
+++ b/supabase/functions/diff-schedule/index.ts
@@ -2,6 +2,7 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
import { requireAdmin } from "../_shared/auth.ts";
import { buildCorsHeaders } from "../_shared/cors.ts";
+import { SET_TYPES } from "../_shared/setTypes.ts";
import { computeDiff } from "./computeDiff.ts";
function isValidTimezone(tz: string): boolean {
@@ -26,24 +27,32 @@ function dedupeArtists(names: string[]): string[] {
});
}
-const csvRowSchema = z.object({
- artists: z.array(z.string().trim().min(1)).min(1).transform(dedupeArtists),
- setName: z.string().optional(),
- stage: z.string().optional(),
- date: z
- .string()
- .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
- .optional(),
- startTime: z
- .string()
- .regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM")
- .optional(),
- endTime: z
- .string()
- .regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM")
- .optional(),
- description: z.string().optional(),
-});
+const csvRowSchema = z
+ .object({
+ artists: z.array(z.string().trim().min(1)).transform(dedupeArtists),
+ setType: z
+ .enum(SET_TYPES)
+ .nullish()
+ .transform((v) => v ?? null),
+ setName: z.string().optional(),
+ stage: z.string().optional(),
+ date: z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
+ .optional(),
+ startTime: z
+ .string()
+ .regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM")
+ .optional(),
+ endTime: z
+ .string()
+ .regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM")
+ .optional(),
+ description: z.string().optional(),
+ })
+ .refine((row) => row.artists.length > 0 || row.setName?.trim(), {
+ message: "A row without artists must have a set name",
+ });
const diffRequestSchema = z.object({
festivalEditionId: z.string().uuid(),
@@ -96,7 +105,7 @@ serve(async (req) => {
db
.from("sets")
.select(
- "id, name, description, stage_id, time_start, time_end, set_artists(artist_id, artists(id, name, slug))",
+ "id, name, description, stage_id, time_start, time_end, set_type, set_artists(artist_id, artists(id, name, slug))",
)
.eq("festival_edition_id", festivalEditionId)
.eq("archived", false)
diff --git a/supabase/functions/diff-schedule/resolvers.test.ts b/supabase/functions/diff-schedule/resolvers.test.ts
index a52ed5332..5196021de 100644
--- a/supabase/functions/diff-schedule/resolvers.test.ts
+++ b/supabase/functions/diff-schedule/resolvers.test.ts
@@ -3,6 +3,7 @@ import {
buildIndexes,
computeTimes,
findMatchingSet,
+ type MatchContext,
resolveArtists,
resolveStage,
} from "./resolvers.ts";
@@ -79,24 +80,36 @@ Deno.test("computeTimes returns nulls when date is missing", () => {
});
});
+function makeContext(
+ stageId: string | null = null,
+ date: string | undefined = undefined,
+): MatchContext {
+ return {
+ stage:
+ stageId === null
+ ? { kind: "none" }
+ : { kind: "exact", id: stageId, name: stageId },
+ date,
+ timezone: "UTC",
+ name: "",
+ };
+}
+
Deno.test("findMatchingSet returns the only available candidate", () => {
const set = makeSet("set-1", []);
- assertEquals(findMatchingSet([set], null, undefined, "UTC", new Set()), set);
+ assertEquals(findMatchingSet([set], makeContext(), new Set()), set);
});
Deno.test("findMatchingSet skips already-matched candidates", () => {
const set = makeSet("set-1", []);
- assertEquals(
- findMatchingSet([set], null, undefined, "UTC", new Set(["set-1"])),
- null,
- );
+ assertEquals(findMatchingSet([set], makeContext(), new Set(["set-1"])), null);
});
Deno.test("findMatchingSet disambiguates by stage id", () => {
const a = makeSet("set-a", [], "s1");
const b = makeSet("set-b", [], "s2");
assertEquals(
- findMatchingSet([a, b], "s2", undefined, "UTC", new Set())?.id,
+ findMatchingSet([a, b], makeContext("s2"), new Set())?.id,
"set-b",
);
});
@@ -105,7 +118,7 @@ Deno.test("findMatchingSet disambiguates by date", () => {
const a = makeSet("set-a", [], null, "2026-07-11T20:00:00.000Z");
const b = makeSet("set-b", [], null, "2026-07-12T20:00:00.000Z");
assertEquals(
- findMatchingSet([a, b], null, "2026-07-12", "UTC", new Set())?.id,
+ findMatchingSet([a, b], makeContext(null, "2026-07-12"), new Set())?.id,
"set-b",
);
});
@@ -129,6 +142,7 @@ function makeSet(
id,
name: id,
description: null,
+ set_type: null,
stage_id: stageId,
time_start: timeStart,
time_end: null,
diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts
index 8dfea71de..abbc28e0a 100644
--- a/supabase/functions/diff-schedule/resolvers.ts
+++ b/supabase/functions/diff-schedule/resolvers.ts
@@ -12,6 +12,7 @@ export type DbIndexes = {
stageById: Map;
existingArtistSlugs: Set;
setsByArtistKey: Map;
+ artistlessSetsByNameLower: Map;
};
export function buildIndexes(
@@ -19,8 +20,19 @@ export function buildIndexes(
dbSets: DbSet[],
dbArtists: DbArtist[],
): DbIndexes {
+ // Artist-less sets are matched by name (+ date/stage) instead of by roster,
+ // so they get their own index and stay out of the artist-key one — a roster
+ // row must never match a 0-artist set and vice versa.
const setsByArtistKey = new Map();
+ const artistlessSetsByNameLower = new Map();
for (const set of dbSets) {
+ if (set.set_artists.length === 0) {
+ const key = set.name.trim().toLowerCase();
+ const bucket = artistlessSetsByNameLower.get(key) ?? [];
+ bucket.push(set);
+ artistlessSetsByNameLower.set(key, bucket);
+ continue;
+ }
const slugs = set.set_artists.map((sa) => sa.artists.slug);
const key = artistKey(slugs);
const bucket = setsByArtistKey.get(key) ?? [];
@@ -32,6 +44,7 @@ export function buildIndexes(
stageById: new Map(dbStages.map((s) => [s.id, s])),
existingArtistSlugs: new Set(dbArtists.map((a) => a.slug)),
setsByArtistKey,
+ artistlessSetsByNameLower,
};
}
@@ -107,28 +120,111 @@ export function computeTimes(
return { timeStart, timeEnd };
}
+/** The CSV row's discriminators, as both matching functions consume them. */
+export type MatchContext = {
+ stage: StageResolution;
+ date: string | undefined;
+ timezone: string;
+ name: string;
+};
+
+/**
+ * Picks which existing set a roster row refers to, or null when the row is
+ * new. The roster is the identity, so a stage/date/name difference never
+ * rejects a match (it's just an update) — those fields only break ties
+ * between sets sharing the same roster.
+ */
export function findMatchingSet(
candidates: DbSet[],
- resolvedStageId: string | null,
- date: string | undefined,
- timezone: string,
+ context: MatchContext,
alreadyMatched: Set,
): DbSet | null {
- const available = candidates.filter((s) => !alreadyMatched.has(s.id));
- if (available.length <= 1) return available[0] ?? null;
+ const stageId = context.stage.kind === "exact" ? context.stage.id : null;
+ const pool = candidates.filter((s) => !alreadyMatched.has(s.id));
+ return narrowByDiscriminators(pool, stageId, context);
+}
+
+/**
+ * Picks which existing 0-artist set an artist-less row refers to, or null
+ * when the row is new. The name is the only identity, so a supplied stage
+ * or date must actually hold: a candidate whose stored value contradicts
+ * the row is excluded (the row becomes a create), while candidates with no
+ * stored stage/time still match — otherwise re-importing a time-less row
+ * would duplicate it on every run.
+ */
+export function findMatchingArtistlessSet(
+ candidates: DbSet[],
+ context: MatchContext,
+ alreadyMatched: Set,
+): DbSet | null {
+ const stageSupplied = context.stage.kind !== "none";
+ const stageId = provisionalStageId(context.stage);
+ const pool = candidates.filter((s) => {
+ if (alreadyMatched.has(s.id)) return false;
+ if (stageSupplied && s.stage_id != null && s.stage_id !== stageId)
+ return false;
+ if (
+ context.date &&
+ s.time_start != null &&
+ utcToLocalDate(s.time_start, context.timezone) !== context.date
+ )
+ return false;
+ return true;
+ });
+ return narrowByDiscriminators(pool, stageId, context);
+}
+
+/**
+ * The stage id to compare candidates against before the user has resolved
+ * the row's stage: a mismatch stands in with its closest DB stage, a
+ * new/absent stage pins no stage. Known limitation (#447): if the user
+ * later maps a mismatch to a different stage, the set was already chosen
+ * with this guess and the commit only rewrites stageName.
+ */
+function provisionalStageId(stage: StageResolution): string | null {
+ switch (stage.kind) {
+ case "exact":
+ return stage.id;
+ case "mismatch":
+ return stage.closest.id;
+ default:
+ return null;
+ }
+}
+
+/**
+ * Picks the one candidate the row's discriminators point at, trusting
+ * stage over date over set name (the most volatile column). A discriminator
+ * no candidate satisfies is skipped rather than emptying the pool, so a
+ * partially matching CSV row still falls back to the closest candidate.
+ */
+function narrowByDiscriminators(
+ candidates: DbSet[],
+ resolvedStageId: string | null,
+ { date, timezone, name }: Pick,
+): DbSet | null {
+ let pool = candidates;
+ if (pool.length <= 1) return pool[0] ?? null;
if (resolvedStageId) {
- const byStage = available.find((s) => s.stage_id === resolvedStageId);
- if (byStage) return byStage;
+ const byStage = pool.filter((s) => s.stage_id === resolvedStageId);
+ if (byStage.length > 0) pool = byStage;
}
if (date) {
- const byDate = available.find(
+ const byDate = pool.filter(
(s) =>
s.time_start != null && utcToLocalDate(s.time_start, timezone) === date,
);
- if (byDate) return byDate;
+ if (byDate.length > 0) pool = byDate;
+ }
+ if (name && pool.length > 1) {
+ const nameLower = name.trim().toLowerCase();
+ const byName = pool.filter(
+ (s) => s.name.trim().toLowerCase() === nameLower,
+ );
+ if (byName.length > 0) pool = byName;
}
- return available[0];
+ return pool[0];
}
function strip(s: string): string {
diff --git a/supabase/functions/diff-schedule/types.ts b/supabase/functions/diff-schedule/types.ts
index 9aa657f34..09a32b96e 100644
--- a/supabase/functions/diff-schedule/types.ts
+++ b/supabase/functions/diff-schedule/types.ts
@@ -1,7 +1,10 @@
import type { Database } from "../_shared/database.types.ts";
+import type { SetType } from "../_shared/setTypes.ts";
+
export type CsvRow = {
artists: string[];
+ setType?: SetType | null;
setName?: string;
stage?: string;
date?: string;
@@ -21,13 +24,20 @@ export type DbStage = Pick;
export type DbArtist = Pick;
export type DbSet = Pick<
SetRow,
- "id" | "name" | "description" | "stage_id" | "time_start" | "time_end"
+ | "id"
+ | "name"
+ | "description"
+ | "stage_id"
+ | "time_start"
+ | "time_end"
+ | "set_type"
> & {
set_artists: { artist_id: string; artists: DbArtist }[];
};
export type SetPayload = {
name: string;
+ setType: SetType | null;
description: string | null;
stageName: string | null;
timeStart: string | null;
@@ -48,7 +58,10 @@ export type DiffResult = {
artistsToCreate: { name: string; slug: string }[];
stagesToCreate: { name: string }[];
setsToCreate: SetPayload[];
- setsToUpdate: ({ id: string } & SetPayload)[];
+ setsToUpdate: ({
+ id: string;
+ previousSetType: SetType | null;
+ } & SetPayload)[];
};
conflicts: {
stageNameMismatches: {
diff --git a/supabase/migrations/20260827210000_commit_schedule_set_type.sql b/supabase/migrations/20260827210000_commit_schedule_set_type.sql
new file mode 100644
index 000000000..f7d51d794
--- /dev/null
+++ b/supabase/migrations/20260827210000_commit_schedule_set_type.sql
@@ -0,0 +1,183 @@
+-- Schedule import learns about set types and artist-less sets (#433):
+-- * create/update write sets.set_type from the payload's setType key. On
+-- update an explicit type overwrites while a blank/absent one preserves the
+-- stored type, mirroring how stage/time columns already behave.
+-- * an explicit empty artistSlugs array is a valid roster (workshops and
+-- other artist-less sets); a missing or non-array roster still raises.
+
+CREATE OR REPLACE FUNCTION public.commit_schedule__sync_set_artists(
+ p_set_id UUID,
+ p_festival_edition_id UUID,
+ p_artist_slugs JSONB
+)
+RETURNS VOID
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+DECLARE
+ v_input_count INT;
+ v_resolved_count INT;
+BEGIN
+ -- A NULL or non-array roster means a bad payload (omitted field, manual
+ -- call) — bail before the DELETE below silently strips the set's roster.
+ -- An explicit empty array is intentional: an artist-less set.
+ IF p_artist_slugs IS NULL
+ OR jsonb_typeof(p_artist_slugs) <> 'array' THEN
+ RAISE EXCEPTION 'Missing artist roster in payload for set %', p_set_id;
+ END IF;
+
+ -- Validate that every distinct input slug resolves to an artist before we
+ -- delete the existing links. The diff path is supposed to create missing
+ -- artists in step 1 of commit_schedule, so a mismatch means a bad payload
+ -- (typo, race, manual call) — bail loudly rather than silently producing
+ -- a set with a partial roster.
+ SELECT COUNT(DISTINCT slug_val)
+ INTO v_input_count
+ FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val;
+
+ SELECT COUNT(DISTINCT a.id)
+ INTO v_resolved_count
+ FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val
+ JOIN artists a ON a.slug = slug_val;
+
+ IF v_resolved_count <> v_input_count THEN
+ RAISE EXCEPTION
+ 'Unknown artist slug(s) in payload for set % (got % distinct slugs, resolved %)',
+ p_set_id, v_input_count, v_resolved_count;
+ END IF;
+
+ -- Edition-scoped delete defends against a forged set id even if the caller
+ -- already verified it.
+ DELETE FROM set_artists sa
+ USING sets s
+ WHERE sa.set_id = s.id
+ AND s.id = p_set_id
+ AND s.festival_edition_id = p_festival_edition_id;
+
+ INSERT INTO set_artists (set_id, artist_id)
+ SELECT p_set_id, a.id
+ FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val
+ JOIN artists a ON a.slug = slug_val
+ ON CONFLICT (set_id, artist_id) DO NOTHING;
+END;
+$$;
+
+-- Update existing sets from the payload, re-syncing each set's artist roster.
+-- Raises if a payload id doesn't match a set in the edition. Returns the
+-- number of sets updated.
+--
+-- stage_id/time_start/time_end/set_type are preserved when the payload omits
+-- them (resolves to NULL): a CSV without those columns corrects names and
+-- rosters without wiping metadata already on the matched sets.
+CREATE OR REPLACE FUNCTION public.commit_schedule__update_sets(
+ p_festival_edition_id UUID,
+ p_sets_to_update JSONB
+)
+RETURNS INT
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+DECLARE
+ v_set_elem JSONB;
+ v_set_id UUID;
+ v_row_count INT;
+ v_updated INT := 0;
+BEGIN
+ FOR v_set_elem IN
+ SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_update, '[]'::jsonb))
+ LOOP
+ v_set_id := (v_set_elem->>'id')::UUID;
+
+ UPDATE sets
+ SET
+ name = v_set_elem->>'name',
+ description = NULLIF(v_set_elem->>'description', ''),
+ set_type = COALESCE(NULLIF(v_set_elem->>'setType', ''), sets.set_type),
+ stage_id = COALESCE(
+ commit_schedule__resolve_stage_id(
+ p_festival_edition_id, v_set_elem->>'stageName'
+ ),
+ sets.stage_id
+ ),
+ time_start = COALESCE(
+ commit_schedule__parse_ts(v_set_elem->>'timeStart'), sets.time_start
+ ),
+ time_end = COALESCE(
+ commit_schedule__parse_ts(v_set_elem->>'timeEnd'), sets.time_end
+ ),
+ updated_at = NOW()
+ WHERE id = v_set_id
+ AND festival_edition_id = p_festival_edition_id;
+
+ GET DIAGNOSTICS v_row_count = ROW_COUNT;
+
+ IF v_row_count = 0 THEN
+ RAISE EXCEPTION 'Set % not found in edition %', v_set_id, p_festival_edition_id;
+ END IF;
+
+ v_updated := v_updated + v_row_count;
+
+ PERFORM commit_schedule__sync_set_artists(
+ v_set_id, p_festival_edition_id, v_set_elem->'artistSlugs'
+ );
+ END LOOP;
+
+ RETURN v_updated;
+END;
+$$;
+
+-- Insert new sets from the payload and sync each set's artist roster.
+-- Returns the number of sets created.
+CREATE OR REPLACE FUNCTION public.commit_schedule__create_sets(
+ p_festival_edition_id UUID,
+ p_user_id UUID,
+ p_sets_to_create JSONB
+)
+RETURNS INT
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+DECLARE
+ v_set_elem JSONB;
+ v_new_set_id UUID;
+ v_created INT := 0;
+BEGIN
+ FOR v_set_elem IN
+ SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_create, '[]'::jsonb))
+ LOOP
+ INSERT INTO sets (
+ festival_edition_id, name, slug, description, set_type, stage_id,
+ time_start, time_end, created_by
+ )
+ VALUES (
+ p_festival_edition_id,
+ v_set_elem->>'name',
+ public.slugify(v_set_elem->>'name'),
+ NULLIF(v_set_elem->>'description', ''),
+ NULLIF(v_set_elem->>'setType', ''),
+ commit_schedule__resolve_stage_id(
+ p_festival_edition_id, v_set_elem->>'stageName'
+ ),
+ commit_schedule__parse_ts(v_set_elem->>'timeStart'),
+ commit_schedule__parse_ts(v_set_elem->>'timeEnd'),
+ p_user_id
+ )
+ RETURNING id INTO v_new_set_id;
+
+ -- Always suffix the slug with a short id chunk so two sets with the same
+ -- name (common when an artist plays multiple days) don't collide on the
+ -- (edition, slug) lookup used by the set detail pages.
+ UPDATE sets
+ SET slug = slug || '-' || SUBSTRING(v_new_set_id::text, 1, 8)
+ WHERE id = v_new_set_id;
+
+ v_created := v_created + 1;
+
+ PERFORM commit_schedule__sync_set_artists(
+ v_new_set_id, p_festival_edition_id, v_set_elem->'artistSlugs'
+ );
+ END LOOP;
+
+ RETURN v_created;
+END;
+$$;