Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
434667d
feat(edge-fn): add diff-schedule Edge Function with unit tests
chiptus May 9, 2026
7a95de2
feat(edge-fn): add commit-schedule Edge Function and RPC migration
chiptus May 9, 2026
657e3bb
feat(frontend): add schedule import wizard UI
chiptus May 9, 2026
5d48245
refactor(frontend): split ScheduleImportWizard into focused components
chiptus May 9, 2026
32a626b
fix(test): exclude Deno and Playwright files from Vitest
chiptus May 9, 2026
c8110dd
refactor: remove old client-side CSV import
chiptus May 9, 2026
113d396
fix(rpc): scope set_artists writes to verified update and use ROW_COUNT
claude May 9, 2026
2125cab
fix(rpc): add ON CONFLICT DO NOTHING to create-set artist insert
claude May 9, 2026
1a9c305
fix(import): invalidate queries via key factories
claude May 9, 2026
1cfb1ce
chore(import): remove orphaned CSV import code and import CsvRow type
claude May 9, 2026
1d6e869
refactor(import): extract MismatchRow and use useId for stable HTML ids
claude May 9, 2026
1965236
test(commit-schedule): clean up created sets and select id for delete
claude May 9, 2026
af733df
refactor(import): drive async actions through useMutation
claude May 9, 2026
c977e79
refactor(import): split CsvUploadStep into TimezonePicker and CsvDrop…
claude May 9, 2026
90339e4
refactor(import): extract OrphanedItem row component
claude May 9, 2026
dd2df7a
refactor(import): co-locate FestivalScheduleImport in its route file
claude May 9, 2026
6b2b47f
refactor(import): load edition via route loader instead of useQuery
claude May 9, 2026
a0c36cb
refactor(diff): split computeDiff into per-row helpers
claude May 9, 2026
33f9d67
feat(commit-schedule): validate request body with zod
claude May 9, 2026
9f7fc1c
refactor(rpc): extract commit_schedule helpers for stage lookup, slug…
claude May 9, 2026
85c28b7
fix(test): exclude supabase tests in vitest config and stub supabase env
claude May 10, 2026
8a2d465
fix(lint): convert arrow function and drop unused destructured data
chiptus May 11, 2026
f9637b1
fix(rpc): make commit_schedule migration idempotent and dedup stages
chiptus May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
"Bash(npx oxlint:*)",
"WebSearch",
"WebFetch(domain:tanstack.com)",
"Bash(node:*)"
"Bash(node:*)",
"Bash(deno test *)",
"Bash(git commit -m ' *)",
"Bash(pnpm vitest *)"
],
"deny": []
}
Expand Down
15 changes: 15 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions src/components/Admin/ScheduleImport/CommitResultCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CheckCircle2, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { type CommitResult } from "@/services/scheduleImportService";

type Props = {
result: CommitResult;
onReset: () => void;
};

export function CommitResultCard({ result, onReset }: Props) {
return (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-3 text-green-600">
<CheckCircle2 className="h-6 w-6" />
<span className="font-medium">Schedule imported successfully</span>
</div>
<ul className="text-sm text-muted-foreground space-y-1">
<li>{result.setsCreated} set{result.setsCreated !== 1 ? "s" : ""} created</li>
<li>{result.setsUpdated} set{result.setsUpdated !== 1 ? "s" : ""} updated</li>
{result.setsArchived > 0 && (
<li>{result.setsArchived} set{result.setsArchived !== 1 ? "s" : ""} archived</li>
)}
</ul>
<Button variant="outline" onClick={onReset}>
<RotateCcw className="h-4 w-4 mr-2" />
Import another file
</Button>
</CardContent>
</Card>
);
}
54 changes: 54 additions & 0 deletions src/components/Admin/ScheduleImport/CsvDropZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useRef } from "react";
import { Upload } from "lucide-react";
import { Label } from "@/components/ui/label";

type Props = {
fileName: string | null;
rowCount: number;
onFileSelected: (file: File) => void;
};

export function CsvDropZone({ fileName, rowCount, onFileSelected }: Props) {
const fileRef = useRef<HTMLInputElement>(null);

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) onFileSelected(file);
}

return (
<div className="space-y-2">
<Label>CSV File</Label>
<div
className="border-2 border-dashed border-muted-foreground/30 rounded-lg p-8 text-center cursor-pointer hover:border-muted-foreground/60 transition-colors"
onClick={() => fileRef.current?.click()}
>
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
{fileName ? (
<p className="text-sm font-medium">{fileName}</p>
) : (
<p className="text-sm text-muted-foreground">Click to upload CSV</p>
)}
{rowCount > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{rowCount} rows parsed
</p>
)}
</div>
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleChange}
/>
<p className="text-xs text-muted-foreground">
Required column: <code>Artists</code> (use <code>|</code> for B2B, e.g.{" "}
<code>Carl Cox | Peggy Gou</code>). Optional: <code>Set Name</code>,{" "}
<code>Stage</code>, <code>Date</code> (YYYY-MM-DD),{" "}
<code>Start Time</code> (HH:MM), <code>End Time</code> (HH:MM),{" "}
<code>Description</code>.
</p>
</div>
);
}
83 changes: 83 additions & 0 deletions src/components/Admin/ScheduleImport/CsvUploadStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
parseScheduleCsv,
callDiffSchedule,
type CsvRow,
type DiffResult,
} from "@/services/scheduleImportService";
import { TimezonePicker } from "./TimezonePicker";
import { CsvDropZone } from "./CsvDropZone";

type Props = {
festivalEditionId: string;
onDiffReady: (diff: DiffResult) => void;
};

async function readFile(file: File): Promise<CsvRow[]> {
const content = await file.text();
const parsed = parseScheduleCsv(content);
if (parsed.length === 0) {
throw new Error(
"No valid rows found. Make sure your CSV has an 'Artists' column.",
);
}
return parsed;
}

export function CsvUploadStep({ festivalEditionId, onDiffReady }: Props) {
const [timezone, setTimezone] = useState("Europe/Lisbon");
const [fileName, setFileName] = useState<string | null>(null);

const readFileMutation = useMutation({ mutationFn: readFile });
const analyseMutation = useMutation({
mutationFn: (rows: CsvRow[]) =>
callDiffSchedule(festivalEditionId, timezone, rows),
onSuccess: onDiffReady,
});

const rows = readFileMutation.data ?? [];
const error =
analyseMutation.error?.message ?? readFileMutation.error?.message ?? null;

function handleFileSelected(file: File) {
setFileName(file.name);
analyseMutation.reset();
readFileMutation.mutate(file);
}

function handleAnalyse() {
if (rows.length === 0) return;
analyseMutation.mutate(rows);
}

return (
<div className="space-y-6">
Comment thread
chiptus marked this conversation as resolved.
<TimezonePicker value={timezone} onChange={setTimezone} />
<CsvDropZone
fileName={fileName}
rowCount={rows.length}
onFileSelected={handleFileSelected}
/>

{error && <p className="text-sm text-destructive">{error}</p>}

<Button
onClick={handleAnalyse}
disabled={rows.length === 0 || analyseMutation.isPending}
className="w-full"
>
{analyseMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Analysing…
</>
) : (
"Analyse Schedule"
)}
</Button>
</div>
);
}
93 changes: 93 additions & 0 deletions src/components/Admin/ScheduleImport/DiffReviewStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
type DiffResult,
type StageMismatchResolution,
type OrphanResolution,
} from "@/services/scheduleImportService";
import { DiffSummaryBanner } from "./DiffSummaryBanner";
import { StageMismatchResolver } from "./StageMismatchResolver";
import { OrphanedSetsPanel } from "./OrphanedSetsPanel";

type DbStage = { id: string; name: string };

type Props = {
diff: DiffResult;
dbStages: DbStage[];
stageMismatchResolutions: Record<string, StageMismatchResolution>;
orphanResolutions: Record<string, OrphanResolution>;
onStageMismatchChange: (csvValue: string, resolution: StageMismatchResolution) => void;
onOrphanChange: (setId: string, resolution: OrphanResolution) => void;
onCommit: () => void;
onReset: () => void;
committing: boolean;
commitError: string | null;
canCommit: boolean;
};

export function DiffReviewStep({
diff,
dbStages,
stageMismatchResolutions,
orphanResolutions,
onStageMismatchChange,
onOrphanChange,
onCommit,
onReset,
committing,
commitError,
canCommit,
}: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Review Changes</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<DiffSummaryBanner diff={diff} />

<StageMismatchResolver
mismatches={diff.conflicts.stageNameMismatches}
dbStages={dbStages}
resolutions={stageMismatchResolutions}
onChange={onStageMismatchChange}
/>

<OrphanedSetsPanel
orphanedSets={diff.conflicts.orphanedSets}
resolutions={orphanResolutions}
onChange={onOrphanChange}
/>

{commitError && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">Import failed — no changes were saved.</p>
<p className="mt-0.5">{commitError}</p>
</div>
</div>
)}

<div className="flex gap-3">
<Button variant="outline" onClick={onReset} disabled={committing}>
Start over
</Button>
<Button onClick={onCommit} disabled={!canCommit || committing}>
{committing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Committing…
</>
) : commitError ? (
"Retry"
) : (
"Commit to database"
)}
</Button>
</div>
</CardContent>
</Card>
);
}
39 changes: 39 additions & 0 deletions src/components/Admin/ScheduleImport/DiffSummaryBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Badge } from "@/components/ui/badge";
import { type DiffResult } from "@/services/scheduleImportService";

type Props = { diff: DiffResult };

export function DiffSummaryBanner({ diff }: Props) {
const { summary, newArtistNames } = diff;

const items = [
{ label: "sets to create", value: summary.setsToCreate, variant: "default" as const },
{ label: "sets to update", value: summary.setsMatched, variant: "secondary" as const },
{ label: "new stages", value: summary.newStages, variant: "default" as const },
{ label: "conflicts", value: summary.setsOrphaned + diff.conflicts.stageNameMismatches.length, variant: "destructive" as const },
].filter((item) => item.value > 0);

return (
<div className="rounded-lg border bg-muted/40 p-4 space-y-3">
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<Badge key={item.label} variant={item.variant}>
{item.value} {item.label}
</Badge>
))}
{items.length === 0 && (
<span className="text-sm text-muted-foreground">No changes detected.</span>
)}
</div>

{summary.newArtists > 0 && (
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{summary.newArtists} new artist{summary.newArtists !== 1 ? "s" : ""}</span>
{" "}will be created:{" "}
{newArtistNames.slice(0, 5).join(", ")}
{newArtistNames.length > 5 && ` and ${newArtistNames.length - 5} more`}.
</p>
)}
</div>
);
}
Loading
Loading