Simpl-Resultat/src/services/onboardingService.ts
le king fu dcf3b013dc feat(onboarding): share the getting-started path through a provider
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>
2026-10-08 07:33:57 -04:00

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`);
}