The checklist state moves from DashboardPage to OnboardingProvider, mounted in AppShell around the routed page; pages read it through useOnboarding(). The reducer stays in useOnboardingChecklist: - loads are numbered and a stale answer is ignored; - HIDE, SHOW, SKIP and RESUME win over the snapshot of a load started before them (#384); - a refresh keeps the counters and the ready status, and a failed re-read after a first success keeps the last state; only the start of a failure streak is logged; - re-read at mount, on every route change and 150 ms after a burst of data events. onboardingService: getOnboardingSnapshot replaces getOnboardingCounters and is a pure read (skipped steps included, one preference per step, filtered on the six keys and the value "1"); initializeGuidedPath applies rule D9 once per profile in one transaction (hidden first, then the flag, ROLLBACK on failure), awaited once by the first load through a promise shared by StrictMode's double mount; skipStep / resumeStep are idempotent. computeChecklist counts skipped rows; the new pure onboardingPath module holds nextStep, CARD_PAGES (empty for now), stepsOfPage, the card latch, bannerMode and readOnboardingNavState. The dashboard reads the context and keeps its compact bar for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
7.1 KiB
TypeScript
189 lines
7.1 KiB
TypeScript
import { getDb, withTransaction } from "./db";
|
|
import {
|
|
deletePreference,
|
|
getImportFolder,
|
|
getPreference,
|
|
setPreference,
|
|
} from "./userPreferenceService";
|
|
import { logInfo } from "./logService";
|
|
import {
|
|
CHECKLIST_ITEM_KEYS,
|
|
type ChecklistItemKey,
|
|
type OnboardingCounters,
|
|
} from "../shared/types";
|
|
|
|
/**
|
|
* Getting-started path data (#351, #396).
|
|
*
|
|
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
|
* parallel reads, and writes nothing: the counters, the import folder, the
|
|
* "hidden" preference and the skipped steps. The writes are separate, and the
|
|
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
|
|
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
|
|
* announced on the data channel: the provider updates its own state. No UI
|
|
* lives here; `computeChecklist` turns the snapshot into rows and
|
|
* `useOnboardingChecklist` holds the state.
|
|
*/
|
|
|
|
/** `user_preferences` key set to "1" when the path is hidden (D8: one state for the banner and the list). */
|
|
export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden";
|
|
|
|
/** `user_preferences` key set to "1" once rule D9 ran for the profile. */
|
|
export const ONBOARDING_GUIDED_INITIALIZED_KEY = "onboarding_guided_initialized";
|
|
|
|
/** `user_preferences` key of a skipped step (D3): `onboarding_skipped_<key>`, value "1". */
|
|
export function skippedStepKey(key: ChecklistItemKey): string {
|
|
return `onboarding_skipped_${key}`;
|
|
}
|
|
|
|
export interface OnboardingSnapshot {
|
|
counters: OnboardingCounters;
|
|
/** True when an import folder has been chosen. */
|
|
importFolderSet: boolean;
|
|
/** True when the path is hidden (by the user, or by rule D9). */
|
|
hidden: boolean;
|
|
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
|
|
skipped: ChecklistItemKey[];
|
|
}
|
|
|
|
interface CountersRow {
|
|
sources: number;
|
|
imports: number;
|
|
manual_categorized: number;
|
|
budget_entries: number;
|
|
transactions: number;
|
|
}
|
|
|
|
const COUNTERS_SQL = `
|
|
SELECT
|
|
(SELECT COUNT(*) FROM import_sources) AS sources,
|
|
(SELECT COUNT(*) FROM imported_files WHERE status = 'completed') AS imports,
|
|
(SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized,
|
|
(SELECT COUNT(*) FROM budget_entries) AS budget_entries,
|
|
(SELECT COUNT(*) FROM transactions) AS transactions
|
|
`;
|
|
|
|
// `_` is a LIKE wildcard: the pattern may match more than the prefix, and the
|
|
// exact-key filter of `readSkippedSteps` is what decides.
|
|
const SKIPPED_SQL =
|
|
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
|
|
|
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
|
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
|
|
|
|
async function readCounters(): Promise<OnboardingCounters> {
|
|
const db = await getDb();
|
|
const rows = await db.select<CountersRow[]>(COUNTERS_SQL);
|
|
const row = rows[0];
|
|
return {
|
|
sources: row?.sources ?? 0,
|
|
imports: row?.imports ?? 0,
|
|
manualCategorized: row?.manual_categorized ?? 0,
|
|
budgetEntries: row?.budget_entries ?? 0,
|
|
transactions: row?.transactions ?? 0,
|
|
};
|
|
}
|
|
|
|
/** Skipped steps, read in one query and kept only for a valid key holding "1". */
|
|
async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
|
|
const db = await getDb();
|
|
const rows = await db.select<Array<{ key: string; value: string }>>(SKIPPED_SQL);
|
|
const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
|
|
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
|
|
}
|
|
|
|
/**
|
|
* Reads the counters, the import folder, the hidden flag and the skipped steps
|
|
* in parallel. A pure read: it never writes (rule D9 lives in
|
|
* `initializeGuidedPath`).
|
|
*/
|
|
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
|
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
|
|
readCounters(),
|
|
getImportFolder(),
|
|
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
|
readSkippedSteps(),
|
|
]);
|
|
return {
|
|
counters,
|
|
importFolderSet: importFolder !== null && importFolder !== "",
|
|
hidden: hiddenPref === "1",
|
|
skipped,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rule D9, once per profile: at the first opening under this version, a
|
|
* profile that already has transactions gets the path hidden (same preference
|
|
* as "Hide", so Settings → Users shows it again); an empty or new profile sees
|
|
* it. Runs as one transaction under the DB lock, flag re-read inside it, so two
|
|
* concurrent first loads cannot apply it twice (CWE-362): the hidden preference
|
|
* is written first, then the flag, then COMMIT; any failure rolls both back and
|
|
* leaves the flag unset, so the next opening tries again.
|
|
*
|
|
* Returns `{ hidden: true }` only when this call hid the path, which it also
|
|
* writes to the in-app log. Not announced on the data channel.
|
|
*/
|
|
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
|
const hidden = await withTransaction(async (db) => {
|
|
await db.execute("BEGIN");
|
|
try {
|
|
const flag = await db.select<Array<{ value: string }>>(
|
|
"SELECT value FROM user_preferences WHERE key = $1",
|
|
[ONBOARDING_GUIDED_INITIALIZED_KEY],
|
|
);
|
|
if (flag[0]?.value === "1") {
|
|
await db.execute("COMMIT");
|
|
return false;
|
|
}
|
|
const rows = await db.select<Array<{ transactions: number }>>(
|
|
"SELECT COUNT(*) AS transactions FROM transactions",
|
|
);
|
|
const hasTransactions = (rows[0]?.transactions ?? 0) > 0;
|
|
if (hasTransactions) {
|
|
await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
|
|
}
|
|
await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_GUIDED_INITIALIZED_KEY, "1"]);
|
|
await db.execute("COMMIT");
|
|
return hasTransactions;
|
|
} catch (e) {
|
|
try {
|
|
await db.execute("ROLLBACK");
|
|
} catch {
|
|
// Keep the original error: it is the one that explains the failure.
|
|
}
|
|
throw e;
|
|
}
|
|
});
|
|
if (hidden) {
|
|
logInfo(
|
|
"Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again)",
|
|
);
|
|
}
|
|
return { hidden };
|
|
}
|
|
|
|
/** Hides the path until `showChecklist()` is called. */
|
|
export async function hideChecklist(): Promise<void> {
|
|
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
|
|
logInfo("Onboarding: checklist hidden");
|
|
}
|
|
|
|
/** Shows the path again (removes the hidden preference). */
|
|
export async function showChecklist(): Promise<void> {
|
|
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
|
|
logInfo("Onboarding: checklist shown");
|
|
}
|
|
|
|
/** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */
|
|
export async function skipStep(key: ChecklistItemKey): Promise<void> {
|
|
await setPreference(skippedStepKey(key), "1");
|
|
logInfo(`Onboarding: step ${key} skipped`);
|
|
}
|
|
|
|
/** Cancels a skip. Idempotent: deleting a missing preference changes nothing. */
|
|
export async function resumeStep(key: ChecklistItemKey): Promise<void> {
|
|
await deletePreference(skippedStepKey(key));
|
|
logInfo(`Onboarding: step ${key} resumed`);
|
|
}
|