From 3b0c35927492a02289e421fb07db1fc47fe693c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:57:37 +0000 Subject: [PATCH 01/18] docs: add link wizard #376 grilling handoff notes --- docs/handoffs/link-wizard-376.md | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/handoffs/link-wizard-376.md diff --git a/docs/handoffs/link-wizard-376.md b/docs/handoffs/link-wizard-376.md new file mode 100644 index 00000000..d2dbe1ab --- /dev/null +++ b/docs/handoffs/link-wizard-376.md @@ -0,0 +1,72 @@ +# Handoff: Link Wizard Enhancements (Issue #376) + +## Focus for next session + +Run the `prototype` skill to settle the one open structural question (Q13 below: desktop layout with artist list moved to the left side), then continue/finish the `grilling` design session and move into `domain-modeling` if needed. This is a **continuation of an in-progress `/grill-with-docs 376` session** — the grilling interview is functionally done (all decisions settled except the prototype), do not restart it from scratch. + +## Source + +- GitHub issue: https://github.com/chiptus/UpLine/issues/376 ("Link Wizard enhancements", label `priority:high`) +- Repo: `chiptus/UpLine`, working dir `/home/user/UpLine` +- Target branch: `claude/link-wizard-enhancements-uhez0n` + +## Issue scope (verbatim asks) + +- Paste a provider link → search and bring metadata +- Validate custom links +- Rename "search again" → "custom search", default to artist name +- Maybe move artist list to the left side on desktop +- Filter by stage +- Locally persist skipped/saved artists across refresh, with view/clear option +- Handle Spotify 429 rate limits + +## Codebase findings (already gathered — don't re-explore) + +Link Wizard lives under `src/pages/admin/festivals/LinkWizard/`: + +- `LinkWizard.tsx` — orchestrator (fetches artists missing links, current artist, pagination) +- `LinkWizardStep.tsx` — per-artist form (Spotify + SoundCloud URL fields via `optionalUrlSchema` Zod check — currently just `.url()`, no provider-shape validation) +- `LinkWizardTable.tsx` — "Remaining Artists" table below the step card (not mobile-adapted) +- `ProviderCandidatesPanel.tsx` — per-provider candidates + "Search Again" toggle (lines ~54-64) that just reveals a custom search input, doesn't itself re-search +- `useProviderCandidates.ts` — custom search query logic +- `CandidateCards.tsx` / `CandidateCard.tsx` — grid `grid-cols-1 md:grid-cols-3` +- `StagedFieldsPreview.tsx` — staged URL/image/description inputs (lines ~42-65 for URL fields) +- `useArtistBatchQuery.ts` — batches initial provider search for all missing-link artists +- Route: `src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/links.tsx` + +Providers: only Spotify + SoundCloud (`Provider = "spotify" | "soundcloud"` in `src/api/artistSearch/types.ts`). Search goes through Supabase edge function `search-artist-links` (`supabase/functions/search-artist-links/index.ts`) → `spotify-adapter.ts` / `soundcloud-adapter.ts`. Spotify auth: client-credentials with in-memory token cache (`supabase/functions/_shared/spotify-api/auth.ts:9-27`). No retry/backoff or 429-specific handling anywhere in the Spotify path today (SoundCloud auth does special-case 429 but only for a friendlier error, no retry). + +Reusable patterns found: + +- Stage filter: `src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx` — multi-select toggle-button group. +- Mobile filter sheet: `ScheduleFilterSheet.tsx` (same tab dir). +- localStorage hook template: `src/hooks/useCookieConsent.ts:25-58` (versioned JSON blob, try/catch parse, setters syncing state + storage). + +Data model: `artists` table has `spotify_url` / `soundcloud_url` columns directly (no join table). Relevant type: `src/integrations/supabase/types.ts:153-170`. + +## Decisions locked in during grilling (do not re-ask) + +1. **Paste-to-fetch is button-triggered, not automatic on paste.** No auto-fetch-on-paste behavior. +2. **Fetch-by-URL applies only to the manual URL input fields** (`StagedFieldsPreview.tsx`), not the custom-search box. +3. **Custom link validation**: enforce provider-specific URL shape (e.g. `open.spotify.com/artist/...`, `soundcloud.com/...`), reject other domains/paths, inline error message. +4. **"Search again" → "Custom search"**: literal rename + default the input to the current artist's name. +5. **"Fetch from URL" button**: one per provider URL field in `StagedFieldsPreview.tsx`, disabled until the field passes the shape validation from decision 3. On click, does an ID-based lookup (not name search) and stages image/description/etc. exactly like picking a candidate card does today. +6. **Fetch failure/404 handling**: inline error near the button/field (e.g. "Artist not found"), no silent fallback to a name search. +7. **Stage filter**: multi-select, mirrors `StageFilterButtons.tsx` pattern exactly, filters the "Remaining Artists" queue. Desktop: inline toggle buttons in the wizard header. Mobile: collapses into a filter sheet like `ScheduleFilterSheet.tsx`. +8. **Skipped/saved local persistence**: key by (edition ID, artist ID) in localStorage. Skipped and saved-this-session artists are excluded from the default wizard queue on reload. Provide a lightweight popover/dropdown in the wizard header (not a separate route) listing skipped+saved artists, with per-item "restore to queue" and a "clear all" action. +9. **Spotify 429 handling**: retry once or twice with backoff honoring the `Retry-After` header (per Spotify's rate-limit docs: https://developer.spotify.com/documentation/web-api/concepts/rate-limits) inside the edge function; if still failing, surface a clear user-facing "rate limited, try again in Ns" message instead of a generic error. + +## Still open (why this handoff exists) + +**Q13 — Desktop layout: artist list on the left.** The issue said "maybe move artist list to be (in desktop) on the left side." Agreed this is a real structural change (current desktop layout: step card on top, "Remaining Artists" table below) and should be prototyped rather than decided blind. User agreed to run the `prototype` skill now, in-session, before finalizing. **This did not happen yet before the handoff was triggered.** + +## Suggested skills for next session + +1. **`prototype`** — build a throwaway prototype of the desktop Link Wizard layout with the artist list moved to the left side, to sanity-check whether it reads better than the current top/bottom stacking. This is the immediate next action. +2. **`grilling`** — resume/close out the design-tree interview once the prototype settles the layout question (confirm the final layout decision with the user; frontier should then be empty). +3. **`domain-modeling`** — the original `/grill-with-docs 376` invocation calls for this after grilling; use it to capture/update any domain vocabulary or ADR-worthy decisions from this feature (e.g. if "skipped/saved" becomes a named concept, or if provider-URL validation rules deserve a documented convention) in `CONTEXT.md` / `docs/adr/`. +4. **`create-pr`** — once implementation is complete, follow this skill exactly for PR title/description/verification format (per repo's CLAUDE.md instruction). + +## Not yet started + +No implementation code has been written. This session was pure requirements-gathering (grilling interview only); domain-modeling has not been invoked yet either. From f9d98faa5250a35e9afcded347202e92bcf72242 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:45:23 +0000 Subject: [PATCH 02/18] feat(import): type column and artist-less rows in schedule import CSV imports accept an optional Type column (blank keeps the stored type, invalid values fail parsing) and keep named artist-less rows, matching them by name + date/stage against 0-artist sets only so workshops survive re-imports. The diff review lists type chips for verification before commit. Closes #433 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../Admin/ScheduleImport/CsvDropZone.tsx | 4 +- .../Admin/ScheduleImport/DiffReviewStep.tsx | 3 + .../Admin/ScheduleImport/TypedSetsPanel.tsx | 84 ++++++++ .../scheduleImport/buildCommitPayload.test.ts | 39 ++++ src/services/scheduleImport/parseCsv.test.ts | 52 +++++ src/services/scheduleImport/parseCsv.ts | 22 ++- src/services/scheduleImport/types.ts | 3 + .../commit-schedule/commit-schedule.test.ts | 119 ++++++++++++ supabase/functions/commit-schedule/index.ts | 8 +- .../diff-schedule/computeDiff.test.ts | 111 +++++++++++ .../functions/diff-schedule/computeDiff.ts | 9 +- supabase/functions/diff-schedule/index.ts | 46 +++-- .../functions/diff-schedule/resolvers.test.ts | 1 + supabase/functions/diff-schedule/resolvers.ts | 13 ++ supabase/functions/diff-schedule/types.ts | 13 +- ...0260827210000_commit_schedule_set_type.sql | 183 ++++++++++++++++++ 16 files changed, 684 insertions(+), 26 deletions(-) create mode 100644 src/components/Admin/ScheduleImport/TypedSetsPanel.tsx create mode 100644 supabase/migrations/20260827210000_commit_schedule_set_type.sql diff --git a/src/components/Admin/ScheduleImport/CsvDropZone.tsx b/src/components/Admin/ScheduleImport/CsvDropZone.tsx index 43734ba3..0e01a576 100644 --- a/src/components/Admin/ScheduleImport/CsvDropZone.tsx +++ b/src/components/Admin/ScheduleImport/CsvDropZone.tsx @@ -50,9 +50,11 @@ 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).

); diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx index e80b44e3..08631fcb 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,8 @@ export function DiffReviewStep({ + + +
+ + {typedSets.length} set{typedSets.length !== 1 ? "s" : ""} with a type + from the CSV +
+ +

+ These rows carry a Type value that will be written on + commit. Rows with a blank type keep whatever type the matched set + already has. +

+ +
+ {typedSets.map((set) => ( +
+

{set.name}

+
+ + + {set.operation === "create" ? "new" : "update"} + +
+
+ ))} +
+ + ); +} + +function SetTypeChip({ setType }: { setType: string }) { + const typeLabel = getSetTypeLabel(setType); + return ( + + + {typeLabel.label} + + ); +} + +function collectTypedSets(diff: DiffResult): TypedSet[] { + function typed( + sets: SetPayload[], + operation: TypedSet["operation"], + ): TypedSet[] { + return sets + .filter((s) => s.setType !== null) + .map((s, i) => ({ + key: `${operation}-${i}-${s.name}`, + name: s.name, + setType: s.setType as string, + operation, + })); + } + return [ + ...typed(diff.cleanOperations.setsToCreate, "create"), + ...typed(diff.cleanOperations.setsToUpdate, "update"), + ]; +} diff --git a/src/services/scheduleImport/buildCommitPayload.test.ts b/src/services/scheduleImport/buildCommitPayload.test.ts index 1290480d..6a4c414e 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,43 @@ 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, + 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 e93d8aa5..39e0ae1f 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,56 @@ 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("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 dac84f48..afe08323 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: parseSetType(row.type) }; if (setName !== undefined) csvRow.setName = setName; if (stage !== undefined) csvRow.stage = stage; if (date !== undefined) csvRow.date = date; @@ -40,7 +41,7 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] { return csvRow; }) - .filter((row) => row.artists.length > 0); + .filter((row) => row.artists.length > 0 || row.setName !== undefined); for (const row of rows) { for (const artist of row.artists) { @@ -50,6 +51,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 +66,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 77ac10d0..6765209c 100644 --- a/src/services/scheduleImport/types.ts +++ b/src/services/scheduleImport/types.ts @@ -1,7 +1,9 @@ import { z } from "zod"; +import 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.string().nullable(), description: z.string().nullable(), stageName: z.string().nullable(), timeStart: z.string().nullable(), diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts index dc60ec85..328aef83 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 70548432..16092740 100644 --- a/supabase/functions/commit-schedule/index.ts +++ b/supabase/functions/commit-schedule/index.ts @@ -10,13 +10,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(["music", "workshop", "performance", "other"]) + .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.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index e2a71d15..74be838e 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -256,9 +256,120 @@ function makeSet( 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 })), }; } + +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("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 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"); +}); diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index c5f2e65d..f5da866b 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -38,8 +38,12 @@ export function computeDiff( const { timeStart, timeEnd } = computeTimes(row, timezone); + const name = row.setName?.trim() || row.artists.join(" b2b "); + const candidates = - indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? []; + row.artists.length === 0 + ? (indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? []) + : (indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? []); const matched = findMatchingSet( candidates, resolvedStage.id, @@ -49,7 +53,8 @@ export function computeDiff( ); const payload: SetPayload = { - name: row.setName?.trim() || row.artists.join(" b2b "), + name, + setType: row.setType ?? null, description: row.description ?? null, stageName: resolvedStage.name, timeStart, diff --git a/supabase/functions/diff-schedule/index.ts b/supabase/functions/diff-schedule/index.ts index ccaf7212..26ed676a 100644 --- a/supabase/functions/diff-schedule/index.ts +++ b/supabase/functions/diff-schedule/index.ts @@ -26,24 +26,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(["music", "workshop", "performance", "other"]) + .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 +104,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 a52ed533..228cb351 100644 --- a/supabase/functions/diff-schedule/resolvers.test.ts +++ b/supabase/functions/diff-schedule/resolvers.test.ts @@ -129,6 +129,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 8dfea71d..475d5ccf 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, }; } diff --git a/supabase/functions/diff-schedule/types.ts b/supabase/functions/diff-schedule/types.ts index 9aa657f3..a8b41409 100644 --- a/supabase/functions/diff-schedule/types.ts +++ b/supabase/functions/diff-schedule/types.ts @@ -1,7 +1,11 @@ import type { Database } from "../_shared/database.types.ts"; +export const SET_TYPES = ["music", "workshop", "performance", "other"] as const; +export type SetType = (typeof SET_TYPES)[number]; + export type CsvRow = { artists: string[]; + setType?: SetType | null; setName?: string; stage?: string; date?: string; @@ -21,13 +25,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: string | null; description: string | null; stageName: string | null; timeStart: string | null; 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 00000000..f7d51d79 --- /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; +$$; From 94f3691d6309da37c8c95d97664fbc4367464758 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:50:14 +0000 Subject: [PATCH 03/18] refactor(import): shared set-type enum and stored-type chips in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: both edge functions validate against a shared _shared/setTypes.ts vocabulary, the client payload schema uses the SET_TYPES enum instead of raw strings, and update rows in the diff review show stored → incoming type chips so type changes are verifiable. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../Admin/ScheduleImport/TypedSetsPanel.tsx | 63 ++++++++++++------- .../scheduleImport/buildCommitPayload.test.ts | 1 + src/services/scheduleImport/types.ts | 13 +++- supabase/functions/_shared/setTypes.ts | 5 ++ supabase/functions/commit-schedule/index.ts | 3 +- .../diff-schedule/computeDiff.test.ts | 14 +++++ .../functions/diff-schedule/computeDiff.ts | 10 ++- supabase/functions/diff-schedule/index.ts | 3 +- supabase/functions/diff-schedule/types.ts | 8 ++- 9 files changed, 87 insertions(+), 33 deletions(-) create mode 100644 supabase/functions/_shared/setTypes.ts diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx index 2420b4de..1181a03f 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -1,15 +1,14 @@ -import { Tags } from "lucide-react"; +import { ArrowRight, Tags } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import type { SetType } from "@/api/sets/types"; import { getSetTypeLabel } from "@/lib/setTypeLabels"; -import { - type DiffResult, - type SetPayload, -} from "@/services/scheduleImport/types"; +import { type DiffResult } from "@/services/scheduleImport/types"; type TypedSet = { key: string; name: string; - setType: string; + setType: SetType; + previousSetType: string | null; operation: "create" | "update"; }; @@ -41,6 +40,12 @@ export function TypedSetsPanel({ diff }: Props) { >

{set.name}

+ {isTypeChange(set) && ( + <> + + + + )} {set.operation === "create" ? "new" : "update"} @@ -53,7 +58,13 @@ export function TypedSetsPanel({ diff }: Props) { ); } -function SetTypeChip({ setType }: { setType: string }) { +// A stored type differing from the incoming one is the change worth +// verifying; a set that was still untyped just gets its first type. +function isTypeChange(set: TypedSet): boolean { + return set.previousSetType !== null && set.previousSetType !== set.setType; +} + +function SetTypeChip({ setType }: { setType: string | null }) { const typeLabel = getSetTypeLabel(setType); return ( @@ -64,21 +75,27 @@ function SetTypeChip({ setType }: { setType: string }) { } function collectTypedSets(diff: DiffResult): TypedSet[] { - function typed( - sets: SetPayload[], - operation: TypedSet["operation"], - ): TypedSet[] { - return sets - .filter((s) => s.setType !== null) - .map((s, i) => ({ - key: `${operation}-${i}-${s.name}`, + const creates = diff.cleanOperations.setsToCreate + .filter((s) => s.setType !== null) + .map( + (s, i): TypedSet => ({ + key: `create-${i}-${s.name}`, + name: s.name, + setType: s.setType as SetType, + previousSetType: null, + operation: "create", + }), + ); + const updates = diff.cleanOperations.setsToUpdate + .filter((s) => s.setType !== null) + .map( + (s): TypedSet => ({ + key: `update-${s.id}`, name: s.name, - setType: s.setType as string, - operation, - })); - } - return [ - ...typed(diff.cleanOperations.setsToCreate, "create"), - ...typed(diff.cleanOperations.setsToUpdate, "update"), - ]; + setType: s.setType as SetType, + previousSetType: s.previousSetType, + operation: "update", + }), + ); + return [...creates, ...updates]; } diff --git a/src/services/scheduleImport/buildCommitPayload.test.ts b/src/services/scheduleImport/buildCommitPayload.test.ts index 6a4c414e..708140f3 100644 --- a/src/services/scheduleImport/buildCommitPayload.test.ts +++ b/src/services/scheduleImport/buildCommitPayload.test.ts @@ -126,6 +126,7 @@ describe("buildCommitPayload", () => { id: "set-1", name: "Fire Show", setType: null, + previousSetType: "performance", description: null, stageName: null, timeStart: null, diff --git a/src/services/scheduleImport/types.ts b/src/services/scheduleImport/types.ts index 6765209c..d4149319 100644 --- a/src/services/scheduleImport/types.ts +++ b/src/services/scheduleImport/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { SetType } from "@/api/sets/types"; +import { SET_TYPES, type SetType } from "@/api/sets/types"; export type CsvRow = { artists: string[]; @@ -14,7 +14,7 @@ export type CsvRow = { export const setPayloadSchema = z.object({ name: z.string(), - setType: z.string().nullable(), + setType: z.enum(SET_TYPES).nullable(), description: z.string().nullable(), stageName: z.string().nullable(), timeStart: z.string().nullable(), @@ -36,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.string().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 00000000..680b2a37 --- /dev/null +++ b/supabase/functions/_shared/setTypes.ts @@ -0,0 +1,5 @@ +// 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]; diff --git a/supabase/functions/commit-schedule/index.ts b/supabase/functions/commit-schedule/index.ts index 16092740..5ed2708f 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. @@ -15,7 +16,7 @@ const nullableTimestamp = z const setPayloadSchema = z.object({ name: z.string().min(1), setType: z - .enum(["music", "workshop", "performance", "other"]) + .enum(SET_TYPES) .nullish() .transform((v) => v ?? null), description: z.string().nullish(), diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index 74be838e..5cafd2ed 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -279,6 +279,20 @@ Deno.test( }, ); +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"); +}); + Deno.test("artist-less row creates a set with an empty roster", () => { const result = computeDiff( [{ artists: [], setName: "Morning Yoga", setType: "workshop" }], diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index f5da866b..30395b88 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -64,7 +64,13 @@ export function computeDiff( if (matched) { state.matchedSetIds.add(matched.id); - state.setsToUpdate.push({ id: matched.id, ...payload }); + // previousSetType lets the diff review render stored → incoming type + // chips; the commit path ignores it. + state.setsToUpdate.push({ + id: matched.id, + previousSetType: matched.set_type, + ...payload, + }); } else { state.setsToCreate.push(payload); } @@ -108,7 +114,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/index.ts b/supabase/functions/diff-schedule/index.ts index 26ed676a..0ff15ff2 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 { @@ -30,7 +31,7 @@ const csvRowSchema = z .object({ artists: z.array(z.string().trim().min(1)).transform(dedupeArtists), setType: z - .enum(["music", "workshop", "performance", "other"]) + .enum(SET_TYPES) .nullish() .transform((v) => v ?? null), setName: z.string().optional(), diff --git a/supabase/functions/diff-schedule/types.ts b/supabase/functions/diff-schedule/types.ts index a8b41409..696e8671 100644 --- a/supabase/functions/diff-schedule/types.ts +++ b/supabase/functions/diff-schedule/types.ts @@ -1,7 +1,6 @@ import type { Database } from "../_shared/database.types.ts"; -export const SET_TYPES = ["music", "workshop", "performance", "other"] as const; -export type SetType = (typeof SET_TYPES)[number]; +import type { SetType } from "../_shared/setTypes.ts"; export type CsvRow = { artists: string[]; @@ -59,7 +58,10 @@ export type DiffResult = { artistsToCreate: { name: string; slug: string }[]; stagesToCreate: { name: string }[]; setsToCreate: SetPayload[]; - setsToUpdate: ({ id: string } & SetPayload)[]; + setsToUpdate: ({ + id: string; + previousSetType: string | null; + } & SetPayload)[]; }; conflicts: { stageNameMismatches: { From 36f61aaed2d618edef1b03b66a7353d594958502 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:53:26 +0000 Subject: [PATCH 04/18] fix(import): address PR #443 review comments Drop the unrelated link-wizard handoff doc, validate the Type column only on rows the import keeps, narrow set-matching by stage and date together so same-name artist-less sets on different dates update the right row, and type setType/previousSetType as SetType | null across the diff contract. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- docs/handoffs/link-wizard-376.md | 72 ------------------- .../Admin/ScheduleImport/TypedSetsPanel.tsx | 11 +-- src/services/scheduleImport/parseCsv.test.ts | 7 ++ src/services/scheduleImport/parseCsv.ts | 14 +++- src/services/scheduleImport/types.ts | 2 +- supabase/functions/_shared/setTypes.ts | 4 ++ .../diff-schedule/computeDiff.test.ts | 37 ++++++++++ .../functions/diff-schedule/computeDiff.ts | 3 +- supabase/functions/diff-schedule/resolvers.ts | 18 +++-- supabase/functions/diff-schedule/types.ts | 4 +- 10 files changed, 81 insertions(+), 91 deletions(-) delete mode 100644 docs/handoffs/link-wizard-376.md diff --git a/docs/handoffs/link-wizard-376.md b/docs/handoffs/link-wizard-376.md deleted file mode 100644 index d2dbe1ab..00000000 --- a/docs/handoffs/link-wizard-376.md +++ /dev/null @@ -1,72 +0,0 @@ -# Handoff: Link Wizard Enhancements (Issue #376) - -## Focus for next session - -Run the `prototype` skill to settle the one open structural question (Q13 below: desktop layout with artist list moved to the left side), then continue/finish the `grilling` design session and move into `domain-modeling` if needed. This is a **continuation of an in-progress `/grill-with-docs 376` session** — the grilling interview is functionally done (all decisions settled except the prototype), do not restart it from scratch. - -## Source - -- GitHub issue: https://github.com/chiptus/UpLine/issues/376 ("Link Wizard enhancements", label `priority:high`) -- Repo: `chiptus/UpLine`, working dir `/home/user/UpLine` -- Target branch: `claude/link-wizard-enhancements-uhez0n` - -## Issue scope (verbatim asks) - -- Paste a provider link → search and bring metadata -- Validate custom links -- Rename "search again" → "custom search", default to artist name -- Maybe move artist list to the left side on desktop -- Filter by stage -- Locally persist skipped/saved artists across refresh, with view/clear option -- Handle Spotify 429 rate limits - -## Codebase findings (already gathered — don't re-explore) - -Link Wizard lives under `src/pages/admin/festivals/LinkWizard/`: - -- `LinkWizard.tsx` — orchestrator (fetches artists missing links, current artist, pagination) -- `LinkWizardStep.tsx` — per-artist form (Spotify + SoundCloud URL fields via `optionalUrlSchema` Zod check — currently just `.url()`, no provider-shape validation) -- `LinkWizardTable.tsx` — "Remaining Artists" table below the step card (not mobile-adapted) -- `ProviderCandidatesPanel.tsx` — per-provider candidates + "Search Again" toggle (lines ~54-64) that just reveals a custom search input, doesn't itself re-search -- `useProviderCandidates.ts` — custom search query logic -- `CandidateCards.tsx` / `CandidateCard.tsx` — grid `grid-cols-1 md:grid-cols-3` -- `StagedFieldsPreview.tsx` — staged URL/image/description inputs (lines ~42-65 for URL fields) -- `useArtistBatchQuery.ts` — batches initial provider search for all missing-link artists -- Route: `src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/links.tsx` - -Providers: only Spotify + SoundCloud (`Provider = "spotify" | "soundcloud"` in `src/api/artistSearch/types.ts`). Search goes through Supabase edge function `search-artist-links` (`supabase/functions/search-artist-links/index.ts`) → `spotify-adapter.ts` / `soundcloud-adapter.ts`. Spotify auth: client-credentials with in-memory token cache (`supabase/functions/_shared/spotify-api/auth.ts:9-27`). No retry/backoff or 429-specific handling anywhere in the Spotify path today (SoundCloud auth does special-case 429 but only for a friendlier error, no retry). - -Reusable patterns found: - -- Stage filter: `src/pages/EditionView/tabs/ScheduleTab/StageFilterButtons.tsx` — multi-select toggle-button group. -- Mobile filter sheet: `ScheduleFilterSheet.tsx` (same tab dir). -- localStorage hook template: `src/hooks/useCookieConsent.ts:25-58` (versioned JSON blob, try/catch parse, setters syncing state + storage). - -Data model: `artists` table has `spotify_url` / `soundcloud_url` columns directly (no join table). Relevant type: `src/integrations/supabase/types.ts:153-170`. - -## Decisions locked in during grilling (do not re-ask) - -1. **Paste-to-fetch is button-triggered, not automatic on paste.** No auto-fetch-on-paste behavior. -2. **Fetch-by-URL applies only to the manual URL input fields** (`StagedFieldsPreview.tsx`), not the custom-search box. -3. **Custom link validation**: enforce provider-specific URL shape (e.g. `open.spotify.com/artist/...`, `soundcloud.com/...`), reject other domains/paths, inline error message. -4. **"Search again" → "Custom search"**: literal rename + default the input to the current artist's name. -5. **"Fetch from URL" button**: one per provider URL field in `StagedFieldsPreview.tsx`, disabled until the field passes the shape validation from decision 3. On click, does an ID-based lookup (not name search) and stages image/description/etc. exactly like picking a candidate card does today. -6. **Fetch failure/404 handling**: inline error near the button/field (e.g. "Artist not found"), no silent fallback to a name search. -7. **Stage filter**: multi-select, mirrors `StageFilterButtons.tsx` pattern exactly, filters the "Remaining Artists" queue. Desktop: inline toggle buttons in the wizard header. Mobile: collapses into a filter sheet like `ScheduleFilterSheet.tsx`. -8. **Skipped/saved local persistence**: key by (edition ID, artist ID) in localStorage. Skipped and saved-this-session artists are excluded from the default wizard queue on reload. Provide a lightweight popover/dropdown in the wizard header (not a separate route) listing skipped+saved artists, with per-item "restore to queue" and a "clear all" action. -9. **Spotify 429 handling**: retry once or twice with backoff honoring the `Retry-After` header (per Spotify's rate-limit docs: https://developer.spotify.com/documentation/web-api/concepts/rate-limits) inside the edge function; if still failing, surface a clear user-facing "rate limited, try again in Ns" message instead of a generic error. - -## Still open (why this handoff exists) - -**Q13 — Desktop layout: artist list on the left.** The issue said "maybe move artist list to be (in desktop) on the left side." Agreed this is a real structural change (current desktop layout: step card on top, "Remaining Artists" table below) and should be prototyped rather than decided blind. User agreed to run the `prototype` skill now, in-session, before finalizing. **This did not happen yet before the handoff was triggered.** - -## Suggested skills for next session - -1. **`prototype`** — build a throwaway prototype of the desktop Link Wizard layout with the artist list moved to the left side, to sanity-check whether it reads better than the current top/bottom stacking. This is the immediate next action. -2. **`grilling`** — resume/close out the design-tree interview once the prototype settles the layout question (confirm the final layout decision with the user; frontier should then be empty). -3. **`domain-modeling`** — the original `/grill-with-docs 376` invocation calls for this after grilling; use it to capture/update any domain vocabulary or ADR-worthy decisions from this feature (e.g. if "skipped/saved" becomes a named concept, or if provider-URL validation rules deserve a documented convention) in `CONTEXT.md` / `docs/adr/`. -4. **`create-pr`** — once implementation is complete, follow this skill exactly for PR title/description/verification format (per repo's CLAUDE.md instruction). - -## Not yet started - -No implementation code has been written. This session was pure requirements-gathering (grilling interview only); domain-modeling has not been invoked yet either. diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx index 1181a03f..81f03912 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -1,6 +1,7 @@ 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"; @@ -8,7 +9,7 @@ type TypedSet = { key: string; name: string; setType: SetType; - previousSetType: string | null; + previousSetType: SetType | null; operation: "create" | "update"; }; @@ -64,12 +65,12 @@ function isTypeChange(set: TypedSet): boolean { return set.previousSetType !== null && set.previousSetType !== set.setType; } -function SetTypeChip({ setType }: { setType: string | null }) { - const typeLabel = getSetTypeLabel(setType); +function SetTypeChip({ setType }: { setType: SetType | null }) { + const { icon: Icon, label, color } = getSetTypeLabel(setType); return ( - - {typeLabel.label} + + {label} ); } diff --git a/src/services/scheduleImport/parseCsv.test.ts b/src/services/scheduleImport/parseCsv.test.ts index 39e0ae1f..7528c124 100644 --- a/src/services/scheduleImport/parseCsv.test.ts +++ b/src/services/scheduleImport/parseCsv.test.ts @@ -88,6 +88,13 @@ describe("parseScheduleCsv", () => { 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"/); diff --git a/src/services/scheduleImport/parseCsv.ts b/src/services/scheduleImport/parseCsv.ts index afe08323..c53363d4 100644 --- a/src/services/scheduleImport/parseCsv.ts +++ b/src/services/scheduleImport/parseCsv.ts @@ -31,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, setType: parseSetType(row.type) }; + const csvRow: CsvRow = { artists, setType: null }; if (setName !== undefined) csvRow.setName = setName; if (stage !== undefined) csvRow.stage = stage; if (date !== undefined) csvRow.date = date; @@ -39,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 || row.setName !== undefined); + .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) { diff --git a/src/services/scheduleImport/types.ts b/src/services/scheduleImport/types.ts index d4149319..e6ce8560 100644 --- a/src/services/scheduleImport/types.ts +++ b/src/services/scheduleImport/types.ts @@ -41,7 +41,7 @@ export const diffResultSchema = z.object({ id: z.string(), // The matched set's stored type, so the review can render // stored → incoming chips. Not written on commit. - previousSetType: z.string().nullable(), + previousSetType: z.enum(SET_TYPES).nullable(), }), ), }), diff --git a/supabase/functions/_shared/setTypes.ts b/supabase/functions/_shared/setTypes.ts index 680b2a37..de0652f8 100644 --- a/supabase/functions/_shared/setTypes.ts +++ b/supabase/functions/_shared/setTypes.ts @@ -3,3 +3,7 @@ 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/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index 5cafd2ed..838ac714 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -374,6 +374,43 @@ Deno.test("same-name artist-less candidates disambiguated by stage", () => { 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"); diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index 30395b88..027ac210 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -1,3 +1,4 @@ +import { asSetType } from "../_shared/setTypes.ts"; import { artistKey } from "./helpers.ts"; import { buildIndexes, @@ -68,7 +69,7 @@ export function computeDiff( // chips; the commit path ignores it. state.setsToUpdate.push({ id: matched.id, - previousSetType: matched.set_type, + previousSetType: asSetType(matched.set_type), ...payload, }); } else { diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 475d5ccf..7f3ef2af 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -120,6 +120,10 @@ export function computeTimes( return { timeStart, timeEnd }; } +// Narrow by every supplied discriminator in turn: a stage match alone must +// not win over a candidate that also matches the date. A discriminator that +// matches nothing is skipped rather than emptying the pool, so a partially +// matching CSV row still falls back to the closest candidate. export function findMatchingSet( candidates: DbSet[], resolvedStageId: string | null, @@ -127,21 +131,21 @@ export function findMatchingSet( timezone: string, alreadyMatched: Set, ): DbSet | null { - const available = candidates.filter((s) => !alreadyMatched.has(s.id)); - if (available.length <= 1) return available[0] ?? null; + let pool = candidates.filter((s) => !alreadyMatched.has(s.id)); + 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; } - 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 696e8671..09a32b96 100644 --- a/supabase/functions/diff-schedule/types.ts +++ b/supabase/functions/diff-schedule/types.ts @@ -37,7 +37,7 @@ export type DbSet = Pick< export type SetPayload = { name: string; - setType: string | null; + setType: SetType | null; description: string | null; stageName: string | null; timeStart: string | null; @@ -60,7 +60,7 @@ export type DiffResult = { setsToCreate: SetPayload[]; setsToUpdate: ({ id: string; - previousSetType: string | null; + previousSetType: SetType | null; } & SetPayload)[]; }; conflicts: { From f08a8d7503319cfeb748a94bbf583ccb62c903eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:35:14 +0000 Subject: [PATCH 05/18] fix(import): enforce date/stage for artist-less set matching An artist-less row's supplied date or stage now excludes stored sets that contradict it, so a same-name set on another day becomes a create instead of overwriting the wrong set. Roster-based matching keeps its fuzzy narrowing; stored sets without a time or stage still match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../diff-schedule/computeDiff.test.ts | 67 +++++++++++++++++++ .../functions/diff-schedule/computeDiff.ts | 26 ++++--- supabase/functions/diff-schedule/resolvers.ts | 46 ++++++++++++- 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index 838ac714..1ddfc3b8 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -424,3 +424,70 @@ Deno.test("same-name artist-less candidates disambiguated by date", () => { 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("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.ts b/supabase/functions/diff-schedule/computeDiff.ts index 027ac210..e4ced862 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -3,6 +3,7 @@ import { artistKey } from "./helpers.ts"; import { buildIndexes, computeTimes, + findMatchingArtistlessSet, findMatchingSet, resolveArtists, resolveStage, @@ -41,17 +42,22 @@ export function computeDiff( const name = row.setName?.trim() || row.artists.join(" b2b "); - const candidates = + const matched = row.artists.length === 0 - ? (indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? []) - : (indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? []); - const matched = findMatchingSet( - candidates, - resolvedStage.id, - row.date, - timezone, - state.matchedSetIds, - ); + ? findMatchingArtistlessSet( + indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? [], + resolvedStage.id, + row.date, + timezone, + state.matchedSetIds, + ) + : findMatchingSet( + indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? [], + resolvedStage.id, + row.date, + timezone, + state.matchedSetIds, + ); const payload: SetPayload = { name, diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 7f3ef2af..23c55bcd 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -120,18 +120,58 @@ export function computeTimes( return { timeStart, timeEnd }; } +// Roster-based matching is fuzzy: the roster already identifies the set, so +// a stage or date difference is just an update, and discriminators only +// disambiguate between several candidate sets. +export function findMatchingSet( + candidates: DbSet[], + resolvedStageId: string | null, + date: string | undefined, + timezone: string, + alreadyMatched: Set, +): DbSet | null { + const pool = candidates.filter((s) => !alreadyMatched.has(s.id)); + return narrowByDiscriminators(pool, resolvedStageId, date, timezone); +} + +// Artist-less sets are identified only by name, so a supplied stage or date +// must actually hold: a candidate whose stored stage or date contradicts the +// CSV row is excluded outright (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[], + resolvedStageId: string | null, + date: string | undefined, + timezone: string, + alreadyMatched: Set, +): DbSet | null { + const pool = candidates.filter((s) => { + if (alreadyMatched.has(s.id)) return false; + if (resolvedStageId && s.stage_id != null && s.stage_id !== resolvedStageId) + return false; + if ( + date && + s.time_start != null && + utcToLocalDate(s.time_start, timezone) !== date + ) + return false; + return true; + }); + return narrowByDiscriminators(pool, resolvedStageId, date, timezone); +} + // Narrow by every supplied discriminator in turn: a stage match alone must // not win over a candidate that also matches the date. A discriminator that // matches nothing is skipped rather than emptying the pool, so a partially // matching CSV row still falls back to the closest candidate. -export function findMatchingSet( +function narrowByDiscriminators( candidates: DbSet[], resolvedStageId: string | null, date: string | undefined, timezone: string, - alreadyMatched: Set, ): DbSet | null { - let pool = candidates.filter((s) => !alreadyMatched.has(s.id)); + let pool = candidates; if (pool.length <= 1) return pool[0] ?? null; if (resolvedStageId) { From 9486d0cdf916dc31f0edc955ee59c790b7eb3c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 11:02:58 +0000 Subject: [PATCH 06/18] fix(import): stage-aware artist-less matching for new stages A new or fuzzy-matched CSV stage no longer skips the stage contradiction check: a new stage excludes all staged candidates and a mismatched stage provisionally uses its closest DB stage (full mismatch-resolution interplay tracked in #447). Also drop the setType casts in collectTypedSets and add TypedSetsPanel tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../ScheduleImport/TypedSetsPanel.test.tsx | 105 ++++++++++++++++++ .../Admin/ScheduleImport/TypedSetsPanel.tsx | 49 ++++---- .../diff-schedule/computeDiff.test.ts | 71 ++++++++++++ .../functions/diff-schedule/computeDiff.ts | 2 +- supabase/functions/diff-schedule/resolvers.ts | 22 +++- 5 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx new file mode 100644 index 00000000..d8f82df5 --- /dev/null +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TypedSetsPanel } from "./TypedSetsPanel"; +import type { DiffResult, SetPayload } from "@/services/scheduleImport/types"; + +describe("TypedSetsPanel", () => { + it("renders nothing when no set carries a type", () => { + const diff = makeDiff({ + setsToCreate: [makePayload("Carl Cox")], + setsToUpdate: [ + { ...makePayload("Peggy Gou"), id: "set-1", previousSetType: null }, + ], + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("lists only sets whose CSV row carries a type", () => { + const diff = makeDiff({ + setsToCreate: [ + { ...makePayload("Morning Yoga"), setType: "workshop" }, + makePayload("Carl Cox"), + ], + setsToUpdate: [ + { + ...makePayload("Fire Show"), + setType: "performance", + id: "set-1", + previousSetType: null, + }, + ], + }); + render(); + 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", () => { + const diff = makeDiff({ + setsToUpdate: [ + { + ...makePayload("Fire Show"), + setType: "performance", + id: "set-1", + previousSetType: "music", + }, + ], + }); + render(); + expect(screen.getByText("Music")).toBeVisible(); + expect(screen.getByText("Performance")).toBeVisible(); + }); + + it("shows a single chip when the stored type is kept", () => { + const diff = makeDiff({ + setsToUpdate: [ + { + ...makePayload("Fire Show"), + setType: "performance", + id: "set-1", + previousSetType: "performance", + }, + ], + }); + render(); + expect(screen.getAllByText("Performance")).toHaveLength(1); + }); +}); + +function makePayload(name: string): SetPayload { + return { + name, + setType: null, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }; +} + +function makeDiff( + operations: Partial, +): DiffResult { + return { + summary: { + newArtists: 0, + newStages: 0, + setsMatched: 0, + setsToCreate: 0, + setsOrphaned: 0, + }, + newArtistNames: [], + cleanOperations: { + artistsToCreate: [], + stagesToCreate: [], + setsToCreate: [], + setsToUpdate: [], + ...operations, + }, + conflicts: { stageNameMismatches: [], orphanedSets: [] }, + }; +} diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx index 81f03912..12d938c4 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -76,27 +76,32 @@ function SetTypeChip({ setType }: { setType: SetType | null }) { } function collectTypedSets(diff: DiffResult): TypedSet[] { - const creates = diff.cleanOperations.setsToCreate - .filter((s) => s.setType !== null) - .map( - (s, i): TypedSet => ({ - key: `create-${i}-${s.name}`, - name: s.name, - setType: s.setType as SetType, - previousSetType: null, - operation: "create", - }), - ); - const updates = diff.cleanOperations.setsToUpdate - .filter((s) => s.setType !== null) - .map( - (s): TypedSet => ({ - key: `update-${s.id}`, - name: s.name, - setType: s.setType as SetType, - previousSetType: s.previousSetType, - operation: "update", - }), - ); + const creates = diff.cleanOperations.setsToCreate.flatMap( + (s, i): TypedSet[] => + s.setType === null + ? [] + : [ + { + key: `create-${i}-${s.name}`, + name: s.name, + setType: s.setType, + previousSetType: null, + operation: "create", + }, + ], + ); + const updates = diff.cleanOperations.setsToUpdate.flatMap((s): TypedSet[] => + s.setType === null + ? [] + : [ + { + key: `update-${s.id}`, + name: s.name, + setType: s.setType, + previousSetType: s.previousSetType, + operation: "update", + }, + ], + ); return [...creates, ...updates]; } diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index 1ddfc3b8..242de1d7 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -478,6 +478,77 @@ Deno.test( }, ); +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"); + }, +); + +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"); + }, +); + +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"); diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index e4ced862..7e1b296a 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -46,7 +46,7 @@ export function computeDiff( row.artists.length === 0 ? findMatchingArtistlessSet( indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? [], - resolvedStage.id, + stage, row.date, timezone, state.matchedSetIds, diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 23c55bcd..593ace1f 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -141,14 +141,16 @@ export function findMatchingSet( // row would duplicate it on every run. export function findMatchingArtistlessSet( candidates: DbSet[], - resolvedStageId: string | null, + stage: StageResolution, date: string | undefined, timezone: string, alreadyMatched: Set, ): DbSet | null { + const stageSupplied = stage.kind !== "none"; + const stageId = provisionalStageId(stage); const pool = candidates.filter((s) => { if (alreadyMatched.has(s.id)) return false; - if (resolvedStageId && s.stage_id != null && s.stage_id !== resolvedStageId) + if (stageSupplied && s.stage_id != null && s.stage_id !== stageId) return false; if ( date && @@ -158,7 +160,21 @@ export function findMatchingArtistlessSet( return false; return true; }); - return narrowByDiscriminators(pool, resolvedStageId, date, timezone); + return narrowByDiscriminators(pool, stageId, date, timezone); +} + +// A mismatched stage hasn't been mapped by the user yet, so its closest DB +// stage stands in provisionally; a new stage matches no stored stage at all, +// so every staged candidate contradicts it. +function provisionalStageId(stage: StageResolution): string | null { + switch (stage.kind) { + case "exact": + return stage.id; + case "mismatch": + return stage.closest.id; + default: + return null; + } } // Narrow by every supplied discriminator in turn: a stage match alone must From 13934b80967394a644d8c70e1d4484a7fa18f5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 11:10:37 +0000 Subject: [PATCH 07/18] refactor(import): bundle match discriminators into MatchContext Pin the cumulative stage-then-date narrowing for roster matching with two tests, and pass the row's stage/date/timezone as one MatchContext instead of threading them through every matching function. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../diff-schedule/computeDiff.test.ts | 64 +++++++++++++++++++ .../functions/diff-schedule/computeDiff.ts | 9 +-- .../functions/diff-schedule/resolvers.test.ts | 26 ++++++-- supabase/functions/diff-schedule/resolvers.ts | 31 +++++---- 4 files changed, 103 insertions(+), 27 deletions(-) diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index 242de1d7..506fde02 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -478,6 +478,70 @@ Deno.test( }, ); +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( "artist-less row at a new stage creates instead of updating a staged set", () => { diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index 7e1b296a..777cf78a 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -42,20 +42,17 @@ export function computeDiff( const name = row.setName?.trim() || row.artists.join(" b2b "); + const matchContext = { stage, date: row.date, timezone }; const matched = row.artists.length === 0 ? findMatchingArtistlessSet( indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? [], - stage, - row.date, - timezone, + matchContext, state.matchedSetIds, ) : findMatchingSet( indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? [], - resolvedStage.id, - row.date, - timezone, + matchContext, state.matchedSetIds, ); diff --git a/supabase/functions/diff-schedule/resolvers.test.ts b/supabase/functions/diff-schedule/resolvers.test.ts index 228cb351..d18d6917 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,35 @@ 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", + }; +} + 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 +117,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", ); }); diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 593ace1f..df76a3fb 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -120,18 +120,24 @@ 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; +}; + // Roster-based matching is fuzzy: the roster already identifies the set, so // a stage or date difference is just an update, and discriminators only // disambiguate between several candidate sets. export function findMatchingSet( candidates: DbSet[], - resolvedStageId: string | null, - date: string | undefined, - timezone: string, + context: MatchContext, alreadyMatched: Set, ): DbSet | null { + const stageId = context.stage.kind === "exact" ? context.stage.id : null; const pool = candidates.filter((s) => !alreadyMatched.has(s.id)); - return narrowByDiscriminators(pool, resolvedStageId, date, timezone); + return narrowByDiscriminators(pool, stageId, context); } // Artist-less sets are identified only by name, so a supplied stage or date @@ -141,26 +147,24 @@ export function findMatchingSet( // row would duplicate it on every run. export function findMatchingArtistlessSet( candidates: DbSet[], - stage: StageResolution, - date: string | undefined, - timezone: string, + context: MatchContext, alreadyMatched: Set, ): DbSet | null { - const stageSupplied = stage.kind !== "none"; - const stageId = provisionalStageId(stage); + 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 ( - date && + context.date && s.time_start != null && - utcToLocalDate(s.time_start, timezone) !== date + utcToLocalDate(s.time_start, context.timezone) !== context.date ) return false; return true; }); - return narrowByDiscriminators(pool, stageId, date, timezone); + return narrowByDiscriminators(pool, stageId, context); } // A mismatched stage hasn't been mapped by the user yet, so its closest DB @@ -184,8 +188,7 @@ function provisionalStageId(stage: StageResolution): string | null { function narrowByDiscriminators( candidates: DbSet[], resolvedStageId: string | null, - date: string | undefined, - timezone: string, + { date, timezone }: MatchContext, ): DbSet | null { let pool = candidates; if (pool.length <= 1) return pool[0] ?? null; From 38a428ebe2cbfee6dc72d6a61f0e2830eb553438 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:44:45 +0000 Subject: [PATCH 08/18] docs(import): mark #447 limitation in code and an ignored test Reference the stage-mismatch-resolution limitation from the provisional closest-stage logic and its pinning test, and add an ignored test that encodes the desired deferral behavior as an executable marker for #447. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../diff-schedule/computeDiff.test.ts | 25 +++++++++++++++++++ supabase/functions/diff-schedule/resolvers.ts | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index 506fde02..a69097ce 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -576,6 +576,7 @@ Deno.test( }, ); +// Pins the provisional closest-stage behavior; see #447 for its limit. Deno.test( "mismatched stage matches artist-less sets via its closest stage", () => { @@ -595,6 +596,30 @@ Deno.test( }, ); +// 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", () => { diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index df76a3fb..99ac4650 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -169,7 +169,9 @@ export function findMatchingArtistlessSet( // A mismatched stage hasn't been mapped by the user yet, so its closest DB // stage stands in provisionally; a new stage matches no stored stage at all, -// so every staged candidate contradicts it. +// so every staged candidate contradicts it. Known limitation (#447): if the +// user later maps the mismatch to a different stage, the set was already +// chosen with the closest-match guess and the commit only rewrites stageName. function provisionalStageId(stage: StageResolution): string | null { switch (stage.kind) { case "exact": From 05f40f51e5faafaec493fae0496cd73b78144b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:55:01 +0000 Subject: [PATCH 09/18] docs(import): pipeline guide, matching ADR, glossary updates Explain the schedule-import pipeline, the fuzzy-vs-strict matching split (ADR-0008), and the add-a-column checklist; extend the set-type null definition to cover untyped imports and name the artist-less set concept. Also two review nits: narrowByDiscriminators only claims the context fields it uses, and a duplicated comment is dropped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- CONTEXT.md | 6 +- docs/adr/0008-artist-less-set-matching.md | 22 ++++ docs/schedule-import.md | 100 ++++++++++++++++++ .../functions/diff-schedule/computeDiff.ts | 2 - supabase/functions/diff-schedule/resolvers.ts | 2 +- 5 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0008-artist-less-set-matching.md create mode 100644 docs/schedule-import.md diff --git a/CONTEXT.md b/CONTEXT.md index ca4ee14d..0c23fceb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,9 +33,13 @@ 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 +**Artist-less set**: +A **set** with zero artists (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 00000000..d72ef24e --- /dev/null +++ b/docs/adr/0008-artist-less-set-matching.md @@ -0,0 +1,22 @@ +# 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. + +## 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.test.ts`). diff --git a/docs/schedule-import.md b/docs/schedule-import.md new file mode 100644 index 00000000..20a295fd --- /dev/null +++ b/docs/schedule-import.md @@ -0,0 +1,100 @@ +# 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; 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. + +## 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`. + +## 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/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index 777cf78a..f5bbd341 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -68,8 +68,6 @@ export function computeDiff( if (matched) { state.matchedSetIds.add(matched.id); - // previousSetType lets the diff review render stored → incoming type - // chips; the commit path ignores it. state.setsToUpdate.push({ id: matched.id, previousSetType: asSetType(matched.set_type), diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 99ac4650..0516dac0 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -190,7 +190,7 @@ function provisionalStageId(stage: StageResolution): string | null { function narrowByDiscriminators( candidates: DbSet[], resolvedStageId: string | null, - { date, timezone }: MatchContext, + { date, timezone }: Pick, ): DbSet | null { let pool = candidates; if (pool.length <= 1) return pool[0] ?? null; From f798c51e6a76b167903271897b413b0f4b07bdf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:56:49 +0000 Subject: [PATCH 10/18] docs(import): record settled matching boundary decisions Capture the design-review outcomes in ADR-0008 and the import guide: roster changes are new identities, artist-less renames happen in-app, the artist-less/roster boundary is hard both ways, and a CSV import is a full snapshot rather than a partial add. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- docs/adr/0008-artist-less-set-matching.md | 9 +++++++++ docs/schedule-import.md | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/adr/0008-artist-less-set-matching.md b/docs/adr/0008-artist-less-set-matching.md index d72ef24e..bcc9fe9e 100644 --- a/docs/adr/0008-artist-less-set-matching.md +++ b/docs/adr/0008-artist-less-set-matching.md @@ -9,6 +9,15 @@ 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, diff --git a/docs/schedule-import.md b/docs/schedule-import.md index 20a295fd..aa5da8ea 100644 --- a/docs/schedule-import.md +++ b/docs/schedule-import.md @@ -65,6 +65,17 @@ 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. From 844957badf1b26642c674ba738dd80c330f70a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:15:04 +0000 Subject: [PATCH 11/18] feat(import): set-name tie-breaker and snapshot copy Roster matching narrows by set name after stage and date, so same-roster sets distinguished only by name no longer mis-match. The upload help now states the CSV is a full-schedule snapshot; type clearing stays in-app per design review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- docs/schedule-import.md | 6 ++- .../Admin/ScheduleImport/CsvDropZone.tsx | 4 +- .../diff-schedule/computeDiff.test.ts | 51 +++++++++++++++++++ .../functions/diff-schedule/computeDiff.ts | 2 +- .../functions/diff-schedule/resolvers.test.ts | 1 + supabase/functions/diff-schedule/resolvers.ts | 12 ++++- 6 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/schedule-import.md b/docs/schedule-import.md index aa5da8ea..1210c455 100644 --- a/docs/schedule-import.md +++ b/docs/schedule-import.md @@ -48,7 +48,8 @@ two modes, chosen by whether the row has artists (see ADR-0008): 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; a tie-breaker matching nothing is skipped rather than +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. @@ -81,7 +82,8 @@ keep, one by one. - `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`. + to `null` — clearing (if ever needed) is an in-app action, deliberately not + a CSV sentinel value. ## Adding a new CSV column: the checklist diff --git a/src/components/Admin/ScheduleImport/CsvDropZone.tsx b/src/components/Admin/ScheduleImport/CsvDropZone.tsx index 0e01a576..3716acc5 100644 --- a/src/components/Admin/ScheduleImport/CsvDropZone.tsx +++ b/src/components/Admin/ScheduleImport/CsvDropZone.tsx @@ -54,7 +54,9 @@ export function CsvDropZone({ fileName, rowCount, onFileSelected }: Props) { Stage, Date (YYYY-MM-DD),{" "} Start Time (HH:MM), End Time (HH:MM),{" "} Description. Rows without artists are kept when they have a{" "} - Set Name (e.g. workshops). + 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/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index a69097ce..3f8beeaf 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -236,6 +236,57 @@ Deno.test("multiple candidates disambiguated by stage", () => { assertEquals(result.conflicts.orphanedSets[0].id, "set-a"); }); +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"); + }, +); + +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 makeArtist(name: string): DbArtist { const slug = name.toLowerCase().replace(/\s+/g, "-"); return { id: `id-${slug}`, name, slug }; diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index f5bbd341..883d7894 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -42,7 +42,7 @@ export function computeDiff( const name = row.setName?.trim() || row.artists.join(" b2b "); - const matchContext = { stage, date: row.date, timezone }; + const matchContext = { stage, date: row.date, timezone, name }; const matched = row.artists.length === 0 ? findMatchingArtistlessSet( diff --git a/supabase/functions/diff-schedule/resolvers.test.ts b/supabase/functions/diff-schedule/resolvers.test.ts index d18d6917..5196021d 100644 --- a/supabase/functions/diff-schedule/resolvers.test.ts +++ b/supabase/functions/diff-schedule/resolvers.test.ts @@ -91,6 +91,7 @@ function makeContext( : { kind: "exact", id: stageId, name: stageId }, date, timezone: "UTC", + name: "", }; } diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 0516dac0..703622b9 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -125,6 +125,7 @@ export type MatchContext = { stage: StageResolution; date: string | undefined; timezone: string; + name: string; }; // Roster-based matching is fuzzy: the roster already identifies the set, so @@ -190,7 +191,7 @@ function provisionalStageId(stage: StageResolution): string | null { function narrowByDiscriminators( candidates: DbSet[], resolvedStageId: string | null, - { date, timezone }: Pick, + { date, timezone, name }: Pick, ): DbSet | null { let pool = candidates; if (pool.length <= 1) return pool[0] ?? null; @@ -206,6 +207,15 @@ function narrowByDiscriminators( ); if (byDate.length > 0) pool = byDate; } + // Set name is the last resort: it only decides between candidates that + // stage and date could not tell apart (names are the most volatile column). + 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 pool[0]; } From 0a2fe58ba6dad5e2030dcb7649494e13d27352f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:25:00 +0000 Subject: [PATCH 12/18] docs: define Roster in the glossary A set's roster (its artists, the set's import identity) is per-set and distinct from the edition-level lineup, which already avoids the word. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- CONTEXT.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 0c23fceb..2c8647fe 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,8 +36,12 @@ _Avoid_: Show, gig, slot, performance (a performance is a _type_ of set, not a s 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 zero artists (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. +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**: From 1aa6f8faf3b0900394a8b83d63e7108d8843c362 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:31:38 +0000 Subject: [PATCH 13/18] style(import): JSDoc for declaration comments, drop a restating one Function- and type-level explanations become JSDoc so they surface on hover; the collectNewArtists comment restated its name and is removed. Mid-function constraint notes stay as line comments. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../Admin/ScheduleImport/TypedSetsPanel.tsx | 6 ++- src/services/scheduleImport/parseCsv.ts | 14 +++--- supabase/functions/_shared/setTypes.ts | 6 ++- .../functions/diff-schedule/computeDiff.ts | 9 ++-- supabase/functions/diff-schedule/resolvers.ts | 44 +++++++++++-------- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx index 12d938c4..93c35438 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -59,8 +59,10 @@ export function TypedSetsPanel({ diff }: Props) { ); } -// A stored type differing from the incoming one is the change worth -// verifying; a set that was still untyped just gets its first type. +/** + * A stored type differing from the incoming one is the change worth + * verifying; a set that was still untyped just gets its first type. + */ function isTypeChange(set: TypedSet): boolean { return set.previousSetType !== null && set.previousSetType !== set.setType; } diff --git a/src/services/scheduleImport/parseCsv.ts b/src/services/scheduleImport/parseCsv.ts index c53363d4..3b5a32c9 100644 --- a/src/services/scheduleImport/parseCsv.ts +++ b/src/services/scheduleImport/parseCsv.ts @@ -86,15 +86,19 @@ function parseSetType(raw: string | undefined): CsvRow["setType"] { 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. +/** + * 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. + */ function hasSluggableChars(value: string): boolean { return /[a-z0-9]/i.test(value); } -// A B2B cell like "Carl Cox | Carl Cox" must not list the same artist twice: -// duplicates change the diff's roster key and send duplicate slugs downstream. +/** + * A B2B cell like "Carl Cox | Carl Cox" must not list the same artist twice: + * duplicates change the diff's roster key and send duplicate slugs downstream. + */ function dedupeArtists(names: string[]): string[] { const seen = new Set(); return names.filter((name) => { diff --git a/supabase/functions/_shared/setTypes.ts b/supabase/functions/_shared/setTypes.ts index de0652f8..15b4960c 100644 --- a/supabase/functions/_shared/setTypes.ts +++ b/supabase/functions/_shared/setTypes.ts @@ -1,5 +1,7 @@ -// 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. +/** + * 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]; diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index 883d7894..6f2fb482 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -106,7 +106,7 @@ export function computeDiff( }; } -// Everything computeDiff accumulates while walking the CSV rows. +/** Everything computeDiff accumulates while walking the CSV rows. */ type DiffState = { matchedSetIds: Set; seenNewArtistSlugs: Set; @@ -133,7 +133,6 @@ function createState(): DiffState { }; } -// Registers any artists not yet seen across the import as new. function collectNewArtists( state: DiffState, newArtists: { name: string; slug: string }[], @@ -146,8 +145,10 @@ function collectNewArtists( } } -// Records a stage resolution into state and returns the id/name to use for -// the row's set payload. +/** + * Records a stage resolution into state and returns the id/name to use for + * the row's set payload. + */ function applyStageResolution( state: DiffState, stage: StageResolution, diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 703622b9..6e5783db 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -120,7 +120,7 @@ export function computeTimes( return { timeStart, timeEnd }; } -// The CSV row's discriminators, as both matching functions consume them. +/** The CSV row's discriminators, as both matching functions consume them. */ export type MatchContext = { stage: StageResolution; date: string | undefined; @@ -128,9 +128,11 @@ export type MatchContext = { name: string; }; -// Roster-based matching is fuzzy: the roster already identifies the set, so -// a stage or date difference is just an update, and discriminators only -// disambiguate between several candidate sets. +/** + * Roster-based matching is fuzzy: the roster already identifies the set, so + * a stage or date difference is just an update, and discriminators only + * disambiguate between several candidate sets. + */ export function findMatchingSet( candidates: DbSet[], context: MatchContext, @@ -141,11 +143,13 @@ export function findMatchingSet( return narrowByDiscriminators(pool, stageId, context); } -// Artist-less sets are identified only by name, so a supplied stage or date -// must actually hold: a candidate whose stored stage or date contradicts the -// CSV row is excluded outright (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. +/** + * Artist-less sets are identified only by name, so a supplied stage or date + * must actually hold: a candidate whose stored stage or date contradicts the + * CSV row is excluded outright (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, @@ -168,11 +172,13 @@ export function findMatchingArtistlessSet( return narrowByDiscriminators(pool, stageId, context); } -// A mismatched stage hasn't been mapped by the user yet, so its closest DB -// stage stands in provisionally; a new stage matches no stored stage at all, -// so every staged candidate contradicts it. Known limitation (#447): if the -// user later maps the mismatch to a different stage, the set was already -// chosen with the closest-match guess and the commit only rewrites stageName. +/** + * A mismatched stage hasn't been mapped by the user yet, so its closest DB + * stage stands in provisionally; a new stage matches no stored stage at all, + * so every staged candidate contradicts it. Known limitation (#447): if the + * user later maps the mismatch to a different stage, the set was already + * chosen with the closest-match guess and the commit only rewrites stageName. + */ function provisionalStageId(stage: StageResolution): string | null { switch (stage.kind) { case "exact": @@ -184,10 +190,12 @@ function provisionalStageId(stage: StageResolution): string | null { } } -// Narrow by every supplied discriminator in turn: a stage match alone must -// not win over a candidate that also matches the date. A discriminator that -// matches nothing is skipped rather than emptying the pool, so a partially -// matching CSV row still falls back to the closest candidate. +/** + * Narrow by every supplied discriminator in turn: a stage match alone must + * not win over a candidate that also matches the date. A discriminator that + * matches nothing 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, From aa7ec28de171559f2fb9d96dbd8566e0d7f8885e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:34:58 +0000 Subject: [PATCH 14/18] refactor(import): narrow TypedSetsPanel props to the set arrays The panel only reads setsToCreate/setsToUpdate, so it takes those instead of the whole diff result. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../Admin/ScheduleImport/DiffReviewStep.tsx | 5 +- .../ScheduleImport/TypedSetsPanel.test.tsx | 119 ++++++++---------- .../Admin/ScheduleImport/TypedSetsPanel.tsx | 41 +++--- 3 files changed, 78 insertions(+), 87 deletions(-) diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx index 08631fcb..bad1893c 100644 --- a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx +++ b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx @@ -61,7 +61,10 @@ export function DiffReviewStep({ - + { it("renders nothing when no set carries a type", () => { - const diff = makeDiff({ - setsToCreate: [makePayload("Carl Cox")], - setsToUpdate: [ - { ...makePayload("Peggy Gou"), id: "set-1", previousSetType: null }, - ], - }); - const { container } = render(); + const { container } = render( + , + ); expect(container).toBeEmptyDOMElement(); }); it("lists only sets whose CSV row carries a type", () => { - const diff = makeDiff({ - setsToCreate: [ - { ...makePayload("Morning Yoga"), setType: "workshop" }, - makePayload("Carl Cox"), - ], - setsToUpdate: [ - { - ...makePayload("Fire Show"), - setType: "performance", - id: "set-1", - previousSetType: null, - }, - ], - }); - render(); + render( + , + ); expect(screen.getByText("2 sets with a type from the CSV")).toBeVisible(); expect(screen.getByText("Morning Yoga")).toBeVisible(); expect(screen.getByText("Fire Show")).toBeVisible(); @@ -38,33 +40,37 @@ describe("TypedSetsPanel", () => { }); it("shows stored and incoming chips when the type changes", () => { - const diff = makeDiff({ - setsToUpdate: [ - { - ...makePayload("Fire Show"), - setType: "performance", - id: "set-1", - previousSetType: "music", - }, - ], - }); - render(); + render( + , + ); expect(screen.getByText("Music")).toBeVisible(); expect(screen.getByText("Performance")).toBeVisible(); }); it("shows a single chip when the stored type is kept", () => { - const diff = makeDiff({ - setsToUpdate: [ - { - ...makePayload("Fire Show"), - setType: "performance", - id: "set-1", - previousSetType: "performance", - }, - ], - }); - render(); + render( + , + ); expect(screen.getAllByText("Performance")).toHaveLength(1); }); }); @@ -80,26 +86,3 @@ function makePayload(name: string): SetPayload { artistSlugs: [], }; } - -function makeDiff( - operations: Partial, -): DiffResult { - return { - summary: { - newArtists: 0, - newStages: 0, - setsMatched: 0, - setsToCreate: 0, - setsOrphaned: 0, - }, - newArtistNames: [], - cleanOperations: { - artistsToCreate: [], - stagesToCreate: [], - setsToCreate: [], - setsToUpdate: [], - ...operations, - }, - conflicts: { stageNameMismatches: [], orphanedSets: [] }, - }; -} diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx index 93c35438..b0c10172 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -13,10 +13,13 @@ type TypedSet = { operation: "create" | "update"; }; -type Props = { diff: DiffResult }; +type Props = { + setsToCreate: DiffResult["cleanOperations"]["setsToCreate"]; + setsToUpdate: DiffResult["cleanOperations"]["setsToUpdate"]; +}; -export function TypedSetsPanel({ diff }: Props) { - const typedSets = collectTypedSets(diff); +export function TypedSetsPanel({ setsToCreate, setsToUpdate }: Props) { + const typedSets = collectTypedSets(setsToCreate, setsToUpdate); if (typedSets.length === 0) return null; return ( @@ -77,22 +80,24 @@ function SetTypeChip({ setType }: { setType: SetType | null }) { ); } -function collectTypedSets(diff: DiffResult): TypedSet[] { - const creates = diff.cleanOperations.setsToCreate.flatMap( - (s, i): TypedSet[] => - s.setType === null - ? [] - : [ - { - key: `create-${i}-${s.name}`, - name: s.name, - setType: s.setType, - previousSetType: null, - operation: "create", - }, - ], +function collectTypedSets( + setsToCreate: Props["setsToCreate"], + setsToUpdate: Props["setsToUpdate"], +): TypedSet[] { + const creates = setsToCreate.flatMap((s, i): TypedSet[] => + s.setType === null + ? [] + : [ + { + key: `create-${i}-${s.name}`, + name: s.name, + setType: s.setType, + previousSetType: null, + operation: "create", + }, + ], ); - const updates = diff.cleanOperations.setsToUpdate.flatMap((s): TypedSet[] => + const updates = setsToUpdate.flatMap((s): TypedSet[] => s.setType === null ? [] : [ From a97475d10cd7cd899a4cfbca229e957a81ee7c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:37:17 +0000 Subject: [PATCH 15/18] style(import): restore pre-existing comments to their original form Comments that predate this branch go back to line comments (and the removed collectNewArtists one returns), so the PR diff only touches comments it introduced. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- src/services/scheduleImport/parseCsv.ts | 14 +++++--------- supabase/functions/diff-schedule/computeDiff.ts | 9 ++++----- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/services/scheduleImport/parseCsv.ts b/src/services/scheduleImport/parseCsv.ts index 3b5a32c9..c53363d4 100644 --- a/src/services/scheduleImport/parseCsv.ts +++ b/src/services/scheduleImport/parseCsv.ts @@ -86,19 +86,15 @@ function parseSetType(raw: string | undefined): CsvRow["setType"] { 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. - */ +// 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. function hasSluggableChars(value: string): boolean { return /[a-z0-9]/i.test(value); } -/** - * A B2B cell like "Carl Cox | Carl Cox" must not list the same artist twice: - * duplicates change the diff's roster key and send duplicate slugs downstream. - */ +// A B2B cell like "Carl Cox | Carl Cox" must not list the same artist twice: +// duplicates change the diff's roster key and send duplicate slugs downstream. function dedupeArtists(names: string[]): string[] { const seen = new Set(); return names.filter((name) => { diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index 6f2fb482..883d7894 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -106,7 +106,7 @@ export function computeDiff( }; } -/** Everything computeDiff accumulates while walking the CSV rows. */ +// Everything computeDiff accumulates while walking the CSV rows. type DiffState = { matchedSetIds: Set; seenNewArtistSlugs: Set; @@ -133,6 +133,7 @@ function createState(): DiffState { }; } +// Registers any artists not yet seen across the import as new. function collectNewArtists( state: DiffState, newArtists: { name: string; slug: string }[], @@ -145,10 +146,8 @@ function collectNewArtists( } } -/** - * Records a stage resolution into state and returns the id/name to use for - * the row's set payload. - */ +// Records a stage resolution into state and returns the id/name to use for +// the row's set payload. function applyStageResolution( state: DiffState, stage: StageResolution, From 0764ecdf5fc626ea2652d26d9a5684db918357e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:44:18 +0000 Subject: [PATCH 16/18] refactor(import): split artist-less diff tests into their own file, purpose-first resolver docs computeDiff.test.ts had grown past 700 lines; the artist-less matching tests now live in computeDiff.artistless.test.ts with shared fixtures in fixtures.ts. Resolver JSDoc rewritten to state what each function is for rather than how it works. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../computeDiff.artistless.test.ts | 298 +++++++++++++++ .../diff-schedule/computeDiff.test.ts | 351 +----------------- supabase/functions/diff-schedule/fixtures.ts | 29 ++ supabase/functions/diff-schedule/resolvers.ts | 38 +- 4 files changed, 360 insertions(+), 356 deletions(-) create mode 100644 supabase/functions/diff-schedule/computeDiff.artistless.test.ts create mode 100644 supabase/functions/diff-schedule/fixtures.ts 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 00000000..ca47d83e --- /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 3f8beeaf..40cce84e 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( @@ -287,248 +287,6 @@ Deno.test("date narrowing beats a set-name match for roster rows", () => { assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); }); -function makeArtist(name: string): DbArtist { - const slug = name.toLowerCase().replace(/\s+/g, "-"); - return { id: `id-${slug}`, name, slug }; -} - -function makeStage(id: string, name: string): DbStage { - return { id, name }; -} - -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 })), - }; -} - -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"); -}); - -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( "same-roster sets on one stage across dates matched by the row's date", () => { @@ -594,111 +352,30 @@ Deno.test( ); 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", + "row setType lands in the payload; absent setType becomes null", () => { - 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], + [{ artists: ["Carl Cox"], setType: "music" }, { artists: ["Peggy Gou"] }], [], - "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], [], + [makeArtist("Carl Cox"), makeArtist("Peggy Gou")], "UTC", ); - assertEquals(result.cleanOperations.setsToUpdate.length, 0); - assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.cleanOperations.setsToCreate[0].setType, "music"); + assertEquals(result.cleanOperations.setsToCreate[1].setType, null); }, ); -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"); +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: [], setName: "Fire Show", date: "2026-07-12" }], - [], - [set1, set2], + [{ artists: ["Carl Cox"], setType: "workshop" }], [], + [set], + [artist], "UTC", ); - assertEquals(result.cleanOperations.setsToUpdate.length, 1); - assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + assertEquals(result.cleanOperations.setsToUpdate[0].previousSetType, "music"); + assertEquals(result.cleanOperations.setsToUpdate[0].setType, "workshop"); }); diff --git a/supabase/functions/diff-schedule/fixtures.ts b/supabase/functions/diff-schedule/fixtures.ts new file mode 100644 index 00000000..d38e266e --- /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/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 6e5783db..abbc28e0 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -129,9 +129,10 @@ export type MatchContext = { }; /** - * Roster-based matching is fuzzy: the roster already identifies the set, so - * a stage or date difference is just an update, and discriminators only - * disambiguate between several candidate sets. + * 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[], @@ -144,11 +145,12 @@ export function findMatchingSet( } /** - * Artist-less sets are identified only by name, so a supplied stage or date - * must actually hold: a candidate whose stored stage or date contradicts the - * CSV row is excluded outright (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. + * 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[], @@ -173,11 +175,11 @@ export function findMatchingArtistlessSet( } /** - * A mismatched stage hasn't been mapped by the user yet, so its closest DB - * stage stands in provisionally; a new stage matches no stored stage at all, - * so every staged candidate contradicts it. Known limitation (#447): if the - * user later maps the mismatch to a different stage, the set was already - * chosen with the closest-match guess and the commit only rewrites stageName. + * 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) { @@ -191,10 +193,10 @@ function provisionalStageId(stage: StageResolution): string | null { } /** - * Narrow by every supplied discriminator in turn: a stage match alone must - * not win over a candidate that also matches the date. A discriminator that - * matches nothing is skipped rather than emptying the pool, so a partially - * matching CSV row still falls back to the closest candidate. + * 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[], @@ -215,8 +217,6 @@ function narrowByDiscriminators( ); if (byDate.length > 0) pool = byDate; } - // Set name is the last resort: it only decides between candidates that - // stage and date could not tell apart (names are the most volatile column). if (name && pool.length > 1) { const nameLower = name.trim().toLowerCase(); const byName = pool.filter( From e7a7151ef77edaa5cbcc66baa36cda68e4758bee Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 28 Aug 2026 21:03:27 +0100 Subject: [PATCH 17/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/adr/0008-artist-less-set-matching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0008-artist-less-set-matching.md b/docs/adr/0008-artist-less-set-matching.md index bcc9fe9e..6b16acd6 100644 --- a/docs/adr/0008-artist-less-set-matching.md +++ b/docs/adr/0008-artist-less-set-matching.md @@ -28,4 +28,4 @@ for all three. - 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.test.ts`). + `computeDiff.artistless.test.ts`). From 7c8ec008cf1e88c7fa98e1957e271d40d83d55b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 15:11:28 +0000 Subject: [PATCH 18/18] refactor(import): list only genuine type changes in the typed-sets review Rows whose type changes nothing (new sets, or updates matching the stored type) collapse into a one-line count, so the review surfaces only the stored-type overwrites worth verifying. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TL2F1n6R7dmSfQiMbornRG --- .../ScheduleImport/TypedSetsPanel.test.tsx | 39 ++++--- .../Admin/ScheduleImport/TypedSetsPanel.tsx | 103 ++++++------------ 2 files changed, 59 insertions(+), 83 deletions(-) diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx index feb85b7a..dd4f3367 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.test.tsx @@ -16,7 +16,7 @@ describe("TypedSetsPanel", () => { expect(container).toBeEmptyDOMElement(); }); - it("lists only sets whose CSV row carries a type", () => { + it("lists only genuine type changes, with stored and incoming chips", () => { render( { ...makePayload("Fire Show"), setType: "performance", id: "set-1", - previousSetType: null, + previousSetType: "music", + }, + { + ...makePayload("Peggy Gou"), + setType: "music", + id: "set-2", + previousSetType: "music", }, ]} />, ); - expect(screen.getByText("2 sets with a type from the CSV")).toBeVisible(); - expect(screen.getByText("Morning Yoga")).toBeVisible(); + expect(screen.getByText("1 set changing type")).toBeVisible(); expect(screen.getByText("Fire Show")).toBeVisible(); - expect(screen.queryByText("Carl Cox")).not.toBeInTheDocument(); + 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("shows stored and incoming chips when the type changes", () => { + it("summarizes typed rows that change nothing without listing them", () => { render( , ); - expect(screen.getByText("Music")).toBeVisible(); - expect(screen.getByText("Performance")).toBeVisible(); + 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 single chip when the stored type is kept", () => { + it("shows a first-time type as changing nothing", () => { render( { ...makePayload("Fire Show"), setType: "performance", id: "set-1", - previousSetType: "performance", + previousSetType: null, }, ]} />, ); - expect(screen.getAllByText("Performance")).toHaveLength(1); + 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(); }); }); diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx index b0c10172..08942302 100644 --- a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -5,13 +5,7 @@ import { cn } from "@/lib/utils"; import { getSetTypeLabel } from "@/lib/setTypeLabels"; import { type DiffResult } from "@/services/scheduleImport/types"; -type TypedSet = { - key: string; - name: string; - setType: SetType; - previousSetType: SetType | null; - operation: "create" | "update"; -}; +type SetToUpdate = DiffResult["cleanOperations"]["setsToUpdate"][number]; type Props = { setsToCreate: DiffResult["cleanOperations"]["setsToCreate"]; @@ -19,54 +13,56 @@ type Props = { }; export function TypedSetsPanel({ setsToCreate, setsToUpdate }: Props) { - const typedSets = collectTypedSets(setsToCreate, setsToUpdate); - if (typedSets.length === 0) return null; + 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 (
- {typedSets.length} set{typedSets.length !== 1 ? "s" : ""} with a type - from the CSV + {typeChanges.length > 0 + ? `${typeChanges.length} set${typeChanges.length !== 1 ? "s" : ""} changing type` + : "Set types from the CSV"}

- These rows carry a Type value that will be written on - commit. Rows with a blank type keep whatever type the matched set - already has. + {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.

-
- {typedSets.map((set) => ( -
-

{set.name}

-
- {isTypeChange(set) && ( - <> - - - - )} - - - {set.operation === "create" ? "new" : "update"} - + {typeChanges.length > 0 && ( +
+ {typeChanges.map((set) => ( +
+

{set.name}

+
+ + + +
-
- ))} -
+ ))} +
+ )}
); } /** * A stored type differing from the incoming one is the change worth - * verifying; a set that was still untyped just gets its first type. + * verifying; new sets and first-time types just take the CSV value. */ -function isTypeChange(set: TypedSet): boolean { +function isTypeChange(set: SetToUpdate): boolean { return set.previousSetType !== null && set.previousSetType !== set.setType; } @@ -79,36 +75,3 @@ function SetTypeChip({ setType }: { setType: SetType | null }) { ); } - -function collectTypedSets( - setsToCreate: Props["setsToCreate"], - setsToUpdate: Props["setsToUpdate"], -): TypedSet[] { - const creates = setsToCreate.flatMap((s, i): TypedSet[] => - s.setType === null - ? [] - : [ - { - key: `create-${i}-${s.name}`, - name: s.name, - setType: s.setType, - previousSetType: null, - operation: "create", - }, - ], - ); - const updates = setsToUpdate.flatMap((s): TypedSet[] => - s.setType === null - ? [] - : [ - { - key: `update-${s.id}`, - name: s.name, - setType: s.setType, - previousSetType: s.previousSetType, - operation: "update", - }, - ], - ); - return [...creates, ...updates]; -}