From e8b881814bb8abf72dcca4171628dc275691d376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Svr=C4=8Dek?= <24891922+jozef2svrcek@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:51:30 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Multi-source=20Phase=20C2:=20onboarding=20w?= =?UTF-8?q?izard=20=E2=80=94=20populate-vs-empty=20+=20deep-history=20choi?= =?UTF-8?q?ce=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the wizard's inline "download + import TWIC with a progress bar" step with a Sources step that keeps onboarding a simple binary and defers all detail to Maintenance → Sources (C1): - Populate the reference database (recommended) vs Start with an empty database. - Populate path: currency feeds (TWIC + Lichess Broadcasts) auto-selected, plus a deep-history choice — Free (the bulk archive, currently Ajedrez OTB), I own a commercial DB (reuses the existing local PGN import via AddGameDialog), or None. - One acknowledgment list with a per-source credit checkbox (non-commercial licences flagged), reusing the C1 credit_acked gate (sources_set_enabled with credit_acked). Enabling fires sources_sync jobs that run on the daemon in the background, so onboarding finishes immediately instead of blocking on a bar. Client-only: the commercial route and background sync both reuse existing backend job types; no new endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018gaUhu1KVkdxAjSJBLgbbk --- chess-client/src/components/SetupWizard.tsx | 301 ++++++++++++++------ 1 file changed, 208 insertions(+), 93 deletions(-) diff --git a/chess-client/src/components/SetupWizard.tsx b/chess-client/src/components/SetupWizard.tsx index f41812c..ef33bd1 100644 --- a/chess-client/src/components/SetupWizard.tsx +++ b/chess-client/src/components/SetupWizard.tsx @@ -3,9 +3,8 @@ import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { useSidecarProgress } from "../hooks/useSidecarProgress"; import AddGameDialog from "./AddGameDialog"; import { ProfileSetupForm, loadMyPlayer, saveMyPlayer } from "./MyStatsWidget"; -import { TwicCredit, useTwicAck } from "./TwicCredit"; -import { getTwicFrom, setTwicFrom } from "../twicPrefs"; -import { PlayerInfo } from "../types"; +import { getSources, setSourceEnabled, submitJob } from "../api"; +import { PlayerInfo, SourceStatus } from "../types"; interface Props { onClose: () => void; @@ -14,14 +13,14 @@ interface Props { onFinish?: () => void; } -type Step = "welcome" | "players" | "databases" | "twic" | "dedup" | "normalise" | "index" | "profile" | "done"; +type Step = "welcome" | "players" | "databases" | "sources" | "dedup" | "normalise" | "index" | "profile" | "done"; -const STEPS: Step[] = ["welcome", "players", "databases", "twic", "dedup", "normalise", "index", "profile", "done"]; +const STEPS: Step[] = ["welcome", "players", "databases", "sources", "dedup", "normalise", "index", "profile", "done"]; const STEP_LABELS: Record = { welcome: "Welcome", players: "Players", databases: "Databases", - twic: "TWIC", + sources: "Sources", dedup: "Dedup", normalise: "Names", index: "Index", @@ -29,7 +28,7 @@ const STEP_LABELS: Record = { done: "Summary", }; -const OPTIONAL_STEPS: Step[] = ["players", "databases", "twic", "dedup", "normalise", "index", "profile"]; +const OPTIONAL_STEPS: Step[] = ["players", "databases", "sources", "dedup", "normalise", "index", "profile"]; const STORAGE_KEY = "chess-setup-state"; interface PersistedState { @@ -147,7 +146,7 @@ function WelcomeStep({ onExpress, onAdvanced }: { > Express setup - Just the essentials: download and import TWIC, deduplicate, and build the + Just the essentials: populate the reference database, deduplicate, and build the position index. Skips the optional player-reference and existing-database steps. @@ -172,7 +171,7 @@ function WelcomeStep({ onExpress, onAdvanced }: { {[ "Import a player reference file", "Import your existing game collections", - "Download and import TWIC issues", + "Populate from curated reference sources", "Deduplicate the database", "Build the position index", ].map((text, i) => ( @@ -277,106 +276,221 @@ function DatabasesStep({ completed, onComplete, onRunningChange }: { completed: ); } -// ── Step: TWIC (download + import) ──────────────────────────────────────────── +// ── Step: Sources (populate the reference database) ─────────────────────────── +// +// Phase C2 (#98): onboarding is a simple binary — populate the reference +// database vs start empty — with all per-source detail deferred to +// Maintenance → Sources (C1). The "populate" path enables the chosen catalog +// sources (recording the attribution acknowledgment via the C1 `credit_acked` +// gate) and fires their sync jobs, which run on the daemon in the background; +// onboarding finishes immediately rather than blocking on a progress bar. + +type History = "free" | "commercial" | "none"; + +/** A non-commercial / restrictive licence we should visibly flag in the + * acknowledgment (e.g. CC BY-NC-SA sources like Lumbra's). Derived from the + * catalog credit line so new sources are flagged without extra metadata. */ +function isNonCommercial(credit: string): boolean { + return /non-?commercial|CC[\s-]?BY-NC|\bNC[\s-]?SA\b/i.test(credit); +} -/** Download/import progress block, shared by the two TWIC operations. - * `cancellable` is false for the fast (appender) import, which must not be - * interrupted — doing so can corrupt the database. */ -function StepProgress({ progress, label, cancellable = true }: { progress: ReturnType; label: string; cancellable?: boolean }) { +/** One acknowledgment row: tick to accept a source's attribution/licence. */ +function AckRow({ source, checked, onChange }: { source: SourceStatus; checked: boolean; onChange: (v: boolean) => void }) { + const nc = isNonCommercial(source.credit); return ( -
-
- {progress.done ? "Complete" : label} - {Math.round(progress.percent)}% -
- - {progress.done &&

✓ {progress.doneMessage}

} - {progress.running && !progress.done && cancellable && ( -
- -
- )} -
+ ); } -// Download and import TWIC in a single step (mirrors the Maintenance TWIC box). -// The step counts as complete once issues are imported into the database. -function TwicStep({ completed, onComplete, onRunningChange }: { completed: boolean; onComplete: () => void; onRunningChange: (r: boolean) => void }) { - // Shared with the Maintenance panel so the starting issue stays in sync. - const [fromIssue, setFromIssue] = useState(getTwicFrom()); +// Replaces the old inline "download + import TWIC with a progress bar" step. +// The step completes as soon as the chosen sources are enabled and their +// background sync jobs are submitted — the daemon does the work afterwards. +function SourcesStep({ completed, onComplete, onRunningChange }: { completed: boolean; onComplete: () => void; onRunningChange: (r: boolean) => void }) { const [rerunning, setRerunning] = useState(false); - const [twicAck, setTwicAck] = useTwicAck(); - const download = useSidecarProgress(); - const importProgress = useSidecarProgress(); + const [sources, setSources] = useState(null); + const [mode, setMode] = useState<"choice" | "configure">("choice"); + const [history, setHistory] = useState("free"); + const [acked, setAcked] = useState>(new Set()); + const [enabling, setEnabling] = useState(false); - useEffect(() => { if (importProgress.done) onComplete(); }, [importProgress.done]); - useEffect(() => { onRunningChange(download.running || importProgress.running); }, [download.running, importProgress.running]); + useEffect(() => { + getSources() + .then((s) => { setSources(s); setAcked(new Set(s.filter((x) => x.credit_acked).map((x) => x.key))); }) + .catch(() => setSources([])); + }, []); - // No --dir: the server downloads/imports into its own data dir - // (data_root()/twic) — passing a client path is wrong for the system daemon. - function runDownload() { - void download.run(["download", "--from", fromIssue]); + if (completed && !rerunning) { + return { setRerunning(true); setMode("choice"); }} />; } - function runImport() { - // --fast = appender-based bulk inserts (much quicker). It must not be - // interrupted, so the import progress below is rendered non-cancelable. - void importProgress.run(["import", "--fast"]); + + // Currency = the auto-updating feeds; deep history = the bulk archives. + const feeds = sources?.filter((s) => s.kind === "feed") ?? []; + const bulk = sources?.filter((s) => s.kind === "bulk") ?? []; + // Sources enabled by the primary button: the feeds always, plus the free + // historical base when chosen. (Commercial = the user's own local PGN import; + // None = feeds only.) + const selected = [...feeds, ...(history === "free" ? bulk : [])]; + const allAcked = selected.every((s) => acked.has(s.key)); + + function toggleAck(key: string, on: boolean) { + setAcked((prev) => { const next = new Set(prev); if (on) next.add(key); else next.delete(key); return next; }); } - if (completed && !rerunning) { - return setRerunning(true)} />; + function chooseEmpty() { + // Empty database: nothing to enable (sources default to off). Just finish. + onComplete(); + } + + async function enableAndImport() { + setEnabling(true); + onRunningChange(true); + try { + // Enable each chosen source (recording the acknowledgment in the same + // step, per the C1 credit_acked gate) and kick a background sync. The + // daemon runs these serially; we don't block onboarding on them. + for (const s of selected) { + await setSourceEnabled(s.key, true, true); + await submitJob({ type: "sources_sync", params: { source: s.key } }); + } + onComplete(); + } finally { + setEnabling(false); + onRunningChange(false); + } + } + + if (sources === null) { + return

Loading sources…

; + } + + // ── View 1: the binary choice ── + if (mode === "choice") { + return ( +
+

+ Populate your database with curated reference collections, or start empty and import only + your own games. You can change any of this later under Maintenance → Sources. +

+
+ + +
+
+ ); } - const filledBtn = "w-full h-10 rounded-full bg-primary text-on-primary text-label-lg hover:brightness-110 active:brightness-95 transition-all duration-short3 ease-standard"; + // ── View 2: configure the populate path ── + const filledBtn = "w-full h-10 rounded-full bg-primary text-on-primary text-label-lg hover:brightness-110 active:brightness-95 disabled:opacity-40 transition-all duration-short3 ease-standard"; + const historyOptions: { value: History; label: string; hint: string }[] = [ + { value: "free", label: "Free historical base", hint: bulk.map((s) => s.name).join(", ") || "A free public archive of older games." }, + { value: "commercial", label: "I own a commercial database", hint: "e.g. ChessBase Megabase — export to PGN and import it below." }, + { value: "none", label: "None", hint: "Just the live feeds for now." }, + ]; return ( -
-

- Download TWIC (The Week in Chess) issues and import them into your database, in one place. Issues already present or imported are skipped automatically. -

- +
+ - {/* Download */} -
-
Download
-
-
Download from issue
- { setFromIssue(e.target.value); setTwicFrom(e.target.value); }} - disabled={download.running || download.done} - className="w-32 h-10 px-3 rounded-sm bg-transparent text-on-surface text-body-md font-mono border border-outline focus:outline-none focus:border-primary disabled:opacity-50 transition-colors duration-short3 ease-standard" - /> -

Issues are available from 920 onwards.

+ {/* Currency — the auto-updating feeds, always part of populate. */} +
+
Keep current automatically
+

+ Recent tournament games, refreshed in the background. Acknowledge each source to enable it. +

+
+ {feeds.map((s) => ( + toggleAck(s.key, v)} /> + ))} +
+
+ + {/* Deep history — Free / Commercial / None. */} +
+
Deep history
+

+ An optional historical base beneath the live feeds. Overlap is deduplicated automatically. +

+
+ {historyOptions.map((opt) => ( + + ))}
- {!download.running && !download.done && ( - twicAck ? ( - - ) : ( -

Tick “I've read this” above to enable the download.

- ) + {history === "free" && bulk.length > 0 && ( +
+ {bulk.map((s) => ( + toggleAck(s.key, v)} /> + ))} +
)} - {(download.running || download.done) && } -
+ {history === "commercial" && ( +
+

+ Export your database to PGN, then import it here. You can also do this later, and cap the + live feeds to start after its cutoff under Maintenance → Sources. +

+ { /* wizard handles navigation */ }} + onImported={() => { /* optional — the button below still finishes the step */ }} + onRunningChange={onRunningChange} + /> +
+ )} + - {/* Import */} -
-
Import into database
- {!importProgress.running && !importProgress.done && ( - download.done ? ( - - ) : ( -

Download issues first to enable the import.

- ) +
+ + {!allAcked && ( +

Acknowledge each selected source above to continue.

)} - {(importProgress.running || importProgress.done) && } +

+ ⓘ Importing runs on the daemon — you can finish setup and even close LPDO; it keeps going. +

); @@ -611,7 +725,8 @@ function DoneStep() {

Your chess database is ready to use.

- Download newer TWIC issues and run maintenance operations at any time from the Setup panel in the header. + Manage your reference sources and run maintenance operations at any time from + Maintenance → Sources, or re-open this wizard from the header.

); @@ -658,7 +773,7 @@ export default function SetupWizard({ onClose, onFinish }: Props) { function back() { if (stepIndex > 0) setStepIndex((i) => i - 1); } // Welcome choices. Advanced walks every step; Express marks the two optional - // import steps (players, databases) as skipped and jumps straight to TWIC. + // import steps (players, databases) as skipped and jumps straight to Sources. function startAdvanced() { markComplete("welcome"); next(); @@ -666,7 +781,7 @@ export default function SetupWizard({ onClose, onFinish }: Props) { function startExpress() { markComplete("welcome"); setSkippedSteps((prev) => new Set([...prev, "players", "databases"])); - setStepIndex(STEPS.indexOf("twic")); + setStepIndex(STEPS.indexOf("sources")); } const stepProps = (s: Step) => ({ @@ -719,7 +834,7 @@ export default function SetupWizard({ onClose, onFinish }: Props) { {step === "welcome" && } {step === "players" && } {step === "databases" && } - {step === "twic" && } + {step === "sources" && } {step === "dedup" && } {step === "normalise" && } {step === "index" && } From d63f5be4655d25edb2978f64d472907922dd36cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Svr=C4=8Dek?= <24891922+jozef2svrcek@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:07:55 +0200 Subject: [PATCH 2/3] Multi-source C2 review fixes: surface failures + fix commercial-mode guards (#98) Address the high-effort code review of the new SourcesStep: - enableAndImport now catches a failing setSourceEnabled/submitJob, surfaces the error, and keeps the user on the step instead of silently dropping the failure (earlier sources may already be enabled; re-clicking is safe). - getSources failures show a distinct error + Retry rather than collapsing into an empty catalog that looks like "zero sources". - The primary button is gated on a non-empty selection, so an empty selection can no longer "complete" a no-op populate while reporting success. - The step's busy state is reported as (enabling || importing) via one effect, so the brief enable loop no longer clears the close guard while a commercial PGN import is still running. - Commercial mode: the embedded import now completes the step on success, and the primary button is relabelled "Enable live feeds & finish" with copy clarifying the commercial DB is imported separately. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018gaUhu1KVkdxAjSJBLgbbk --- chess-client/src/components/SetupWizard.tsx | 68 ++++++++++++++++++--- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/chess-client/src/components/SetupWizard.tsx b/chess-client/src/components/SetupWizard.tsx index ef33bd1..8763fb3 100644 --- a/chess-client/src/components/SetupWizard.tsx +++ b/chess-client/src/components/SetupWizard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { useSidecarProgress } from "../hooks/useSidecarProgress"; import AddGameDialog from "./AddGameDialog"; @@ -317,16 +317,29 @@ function AckRow({ source, checked, onChange }: { source: SourceStatus; checked: function SourcesStep({ completed, onComplete, onRunningChange }: { completed: boolean; onComplete: () => void; onRunningChange: (r: boolean) => void }) { const [rerunning, setRerunning] = useState(false); const [sources, setSources] = useState(null); + const [loadError, setLoadError] = useState(null); const [mode, setMode] = useState<"choice" | "configure">("choice"); const [history, setHistory] = useState("free"); const [acked, setAcked] = useState>(new Set()); const [enabling, setEnabling] = useState(false); + const [importing, setImporting] = useState(false); + const [submitError, setSubmitError] = useState(null); - useEffect(() => { + const loadSources = useCallback(() => { + setLoadError(null); + setSources(null); getSources() .then((s) => { setSources(s); setAcked(new Set(s.filter((x) => x.credit_acked).map((x) => x.key))); }) - .catch(() => setSources([])); + .catch((e: unknown) => setLoadError(String(e))); }, []); + useEffect(() => { loadSources(); }, [loadSources]); + + // Report this step's combined busy state to the wizard: the brief enable + // loop OR an in-progress commercial PGN import. Reporting the OR (rather than + // letting each writer set the shared flag directly) stops the enable loop's + // completion from clearing the "operation in progress" close guard while a + // commercial import is still running. + useEffect(() => { onRunningChange(enabling || importing); }, [enabling, importing, onRunningChange]); if (completed && !rerunning) { return { setRerunning(true); setMode("choice"); }} />; @@ -351,8 +364,8 @@ function SourcesStep({ completed, onComplete, onRunningChange }: { completed: bo } async function enableAndImport() { + setSubmitError(null); setEnabling(true); - onRunningChange(true); try { // Enable each chosen source (recording the acknowledgment in the same // step, per the C1 credit_acked gate) and kick a background sync. The @@ -362,12 +375,29 @@ function SourcesStep({ completed, onComplete, onRunningChange }: { completed: bo await submitJob({ type: "sources_sync", params: { source: s.key } }); } onComplete(); + } catch (e: unknown) { + // Surface the failure and stay on the step rather than silently dropping + // it. Earlier sources in the loop may already be enabled + sync-submitted; + // re-clicking is safe (enabling is idempotent and overlap is deduplicated). + setSubmitError(`Couldn’t enable the selected sources: ${String(e)} — some may already be enabled; you can retry.`); } finally { setEnabling(false); - onRunningChange(false); } } + if (loadError !== null) { + return ( +
+

Couldn’t load the source catalog: {loadError}

+ +
+ ); + } if (sources === null) { return

Loading sources…

; } @@ -474,19 +504,37 @@ function SourcesStep({ completed, onComplete, onRunningChange }: { completed: bo allowedModes={["file"]} bulk onClose={() => { /* wizard handles navigation */ }} - onImported={() => { /* optional — the button below still finishes the step */ }} - onRunningChange={onRunningChange} + onImported={onComplete} + onRunningChange={setImporting} />
)}
- - {!allAcked && ( + {selected.length === 0 ? ( +

+ No reference sources to enable.{history === "commercial" ? " Import your database above to populate it." : ""} +

+ ) : !allAcked ? (

Acknowledge each selected source above to continue.

+ ) : null} + {submitError &&

{submitError}

} + {history === "commercial" && ( +

+ This enables the live feeds only — import your commercial database separately above (now or later). +

)}

ⓘ Importing runs on the daemon — you can finish setup and even close LPDO; it keeps going. From 64a54ca9c9367751018ca0bb3ae7b293fc257511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jozef=20Svr=C4=8Dek?= <24891922+jozef2svrcek@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:39:19 +0200 Subject: [PATCH 3/3] C2 wizard: drop the explicit sources_sync, rely on C3 auto-sync (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Phase C3 (#99), enabling a source is enough — the daemon's scheduler picks up enabled-but-not-yet-synced sources and imports them in the background. So the onboarding wizard no longer submits a sources_sync per source; it just enables them (with the credit acknowledgment) and finishes. This resolves the C2 review follow-ups #6/#7 (no completion-before-data race from the wizard's side, and no redundant/duplicate sync jobs). Stacked on the C3 branch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018gaUhu1KVkdxAjSJBLgbbk --- chess-client/src/components/SetupWizard.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/chess-client/src/components/SetupWizard.tsx b/chess-client/src/components/SetupWizard.tsx index 8763fb3..06d4175 100644 --- a/chess-client/src/components/SetupWizard.tsx +++ b/chess-client/src/components/SetupWizard.tsx @@ -3,7 +3,7 @@ import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { useSidecarProgress } from "../hooks/useSidecarProgress"; import AddGameDialog from "./AddGameDialog"; import { ProfileSetupForm, loadMyPlayer, saveMyPlayer } from "./MyStatsWidget"; -import { getSources, setSourceEnabled, submitJob } from "../api"; +import { getSources, setSourceEnabled } from "../api"; import { PlayerInfo, SourceStatus } from "../types"; interface Props { @@ -367,18 +367,18 @@ function SourcesStep({ completed, onComplete, onRunningChange }: { completed: bo setSubmitError(null); setEnabling(true); try { - // Enable each chosen source (recording the acknowledgment in the same - // step, per the C1 credit_acked gate) and kick a background sync. The - // daemon runs these serially; we don't block onboarding on them. + // Just enable each chosen source (recording the acknowledgment in the same + // step, per the C1 credit_acked gate). The daemon's scheduler picks up + // enabled-but-not-yet-synced sources and imports them in the background + // (#40 C3), so onboarding doesn't submit or wait on any sync itself. for (const s of selected) { await setSourceEnabled(s.key, true, true); - await submitJob({ type: "sources_sync", params: { source: s.key } }); } onComplete(); } catch (e: unknown) { // Surface the failure and stay on the step rather than silently dropping - // it. Earlier sources in the loop may already be enabled + sync-submitted; - // re-clicking is safe (enabling is idempotent and overlap is deduplicated). + // it. Earlier sources in the loop may already be enabled; re-clicking is + // safe (enabling is idempotent). setSubmitError(`Couldn’t enable the selected sources: ${String(e)} — some may already be enabled; you can retry.`); } finally { setEnabling(false);