feat(storage): B2b — settings/profile schema + templates SQLite persistence

Settings: 8 new fields (lastLaunchAt, reentryFreshStartUntil,
lastActiveProfileId, microStepGranularity, nudgeMode, nudgeDigestTimes,
sparklineRangeDays, energyLabels) with sane defaults; nudgesEnabled
auto-maps to nudgeMode on first load (true→immediate, false→off).

Profile: optional energyLabelsOverride field on the localStorage
Profile shape — when present, overrides the global energyLabels;
absent/null falls through to settings.energyLabels via the new
resolveEnergyLabels helper.

Templates: migration v17 adds the templates table (id PK, name,
sections JSON, created_at, updated_at). 5 storage CRUD functions
plus import_templates with idempotent duplicate-id handling. 5 Tauri
commands (list/create/update/delete/import) wired and registered.
Frontend store rewired to read from SQLite via list_templates_cmd;
one-time migration imports kon_templates localStorage and clears it
on success. Fresh installs only seed the new {Meeting notes, Daily
check-in} defaults. Existing user templates survive the migration
verbatim.

Test coverage: 5 new Rust tests (CRUD, import idempotency, atomic
failure, updated_at bump, missing-id delete is no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:16:31 +01:00
parent 0111fc9e08
commit e4cbf62a20
10 changed files with 943 additions and 37 deletions

View File

@@ -83,6 +83,25 @@ const defaults: SettingsState = {
nudgesMuted: false,
nudgesSpeakAloud: false,
showMomentumSparkline: true,
// B2b — additive schema fields. Fresh users get sane defaults; existing
// users get them spread over their saved blob and then the migration
// step in loadSettings() backfills nudgeMode from nudgesEnabled.
lastLaunchAt: null,
reentryFreshStartUntil: null,
lastActiveProfileId: null,
microStepGranularity: "default",
// For fresh users we default off; existing users with nudgesEnabled
// = true get bumped to "immediate" by the migration in loadSettings.
nudgeMode: "off",
nudgeDigestTimes: [],
sparklineRangeDays: 7,
// Defaults mirror current EnergyChip copy so existing behaviour is
// unchanged until the user edits via the (B3.6) UI.
energyLabels: {
high: { label: "High", description: "Sharp focus, big lifts" },
medium: { label: "Medium", description: "Steady work, moderate lifts" },
brain_dead: { label: "Zero", description: "Rest only, low-touch admin" },
},
};
function canUseStorage(): boolean {
@@ -107,10 +126,22 @@ function loadSettings(): SettingsState {
);
});
}
return {
const merged: SettingsState = {
...defaults,
...(migrated ?? {}),
};
// B2b nudge migration: if the loaded blob carries a boolean
// `nudgesEnabled` but no `nudgeMode`, derive the new tri-state from it.
// We keep `nudgesEnabled` in the schema so callers that haven't been
// migrated yet still see a sane value. The new `nudgeMode` is the
// source of truth for the digest/immediate/off split going forward.
// This is a *one-time* derivation: once the merged blob has nudgeMode
// set explicitly (next save), the boolean stops driving anything.
const blob = migrated as Partial<SettingsState> | undefined;
if (blob && blob.nudgeMode === undefined && typeof blob.nudgesEnabled === "boolean") {
merged.nudgeMode = blob.nudgesEnabled ? "immediate" : "off";
}
return merged;
}
export const settings = $state<SettingsState>(loadSettings());
@@ -895,24 +926,196 @@ function broadcastTaskLists() {
} satisfies TaskListChannelMessage);
}
function defaultTemplates(): Template[] {
// B2b — templates moved from localStorage["kon_templates"] to SQLite
// (migration v17). The store starts empty and is populated asynchronously
// from the backend. The Dictation "Section scaffold" picker reads
// `templates.name` / `templates.sections`, both of which survive the new
// shape — the migration just gains `id`, `createdAt`, `updatedAt`.
//
// Default seeding is gated on a *truly fresh install* (no legacy
// localStorage key AND empty SQLite), so existing users keep their
// custom templates and never get the v3 defaults sprayed on top of
// their data.
interface TemplateDto {
id: string;
name: string;
sections: string[];
createdAt: string;
updatedAt: string;
}
/**
* v3-spec defaults seeded only on a truly fresh install (no legacy
* kon_templates and no rows in SQLite). Existing user templates
* (Meeting Notes / Report / Interview from the pre-B2b defaults) are
* preserved verbatim by the migration — they appear in localStorage
* because the user's first launch wrote them out, so the "fresh
* install" check correctly skips seeding for them.
*/
function freshInstallTemplateSeed() {
return [
{ name: "Meeting Notes", sections: ["Attendees", "Agenda", "Discussion", "Action Items", "Next Steps"] },
{ name: "Report", sections: ["Summary", "Background", "Findings", "Recommendations"] },
{ name: "Interview", sections: ["Candidate", "Role", "Questions & Answers", "Assessment", "Next Steps"] },
{ name: "Meeting notes", sections: ["Action items", "Decisions", "Team members", "Open questions"] },
{ name: "Daily check-in", sections: ["Wins", "Blockers", "Next"] },
];
}
function loadTemplates(): Template[] {
if (!canUseStorage()) return defaultTemplates();
return parseStoredJson<Template[]>(localStorage.getItem(TEMPLATES_KEY)) ?? defaultTemplates();
function mapTemplateRow(row: TemplateDto): Template {
return {
id: row.id,
name: row.name,
sections: Array.isArray(row.sections) ? row.sections : [],
createdAt: row.createdAt ?? "",
updatedAt: row.updatedAt ?? "",
};
}
export const templates = $state<Template[]>(loadTemplates());
export const templates = $state<Template[]>([]);
export function saveTemplates() {
if (!canUseStorage()) return;
async function loadTemplatesFromSqlite(): Promise<TemplateDto[]> {
if (!hasTauriRuntime()) return [];
try {
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
} catch {}
const rows = await invoke<TemplateDto[]>("list_templates_cmd");
const mapped = Array.isArray(rows) ? rows.map(mapTemplateRow) : [];
templates.length = 0;
templates.push(...mapped);
return Array.isArray(rows) ? rows : [];
} catch (err) {
console.error("loadTemplatesFromSqlite failed", err);
toasts.error("Failed to load templates", errorMessage(err));
return [];
}
}
// One-time migration: pull the legacy kon_templates blob into SQLite.
// Idempotent because import_templates_cmd skips ids that already exist;
// we still gate on the localStorage key's presence to avoid a no-op
// round-trip on every launch. The localStorage key is removed only on
// a successful import so a transient backend error doesn't lose data.
//
// Each item gets a server-minted uuid because the legacy shape didn't
// store one — TemplateImport.id is optional on purpose for this path.
async function migrateTemplatesFromLocalStorage(): Promise<boolean> {
if (!canUseStorage()) return false;
const raw = localStorage.getItem(TEMPLATES_KEY);
if (!raw) return false;
const parsed = parseStoredJson<Array<{ name: string; sections: string[] }>>(raw);
if (!parsed || parsed.length === 0) {
// Empty or corrupt — clear and skip so we don't keep retrying.
localStorage.removeItem(TEMPLATES_KEY);
return false;
}
try {
const summary = await invoke<{ imported: number; skipped: number }>(
"import_templates_cmd",
{
items: parsed.map((t) => ({
name: t.name,
sections: Array.isArray(t.sections) ? t.sections : [],
})),
},
);
// Only clear localStorage AFTER the import succeeds.
localStorage.removeItem(TEMPLATES_KEY);
// Re-load from SQLite so the store reflects the imported rows.
await loadTemplatesFromSqlite();
toasts.info(
"Templates migrated",
`${summary.imported} template${summary.imported === 1 ? "" : "s"} moved to local database.`,
);
return true;
} catch (err) {
console.error("migrateTemplatesFromLocalStorage failed", err);
toasts.error(
"Couldn't migrate templates",
"Your data is safe — Kon will retry on next launch.",
);
return false;
}
}
// Fresh-install seeding. Only runs when:
// - SQLite returned zero rows (nothing migrated, nothing pre-existing)
// - localStorage key is absent (no legacy data the migration could
// have moved over — i.e. truly first launch)
// Existing users with localStorage templates take the migration path
// above; existing users who already migrated have non-empty SQLite and
// fall through here without seeding.
async function maybeSeedDefaultTemplates(sqliteRows: TemplateDto[]): Promise<void> {
if (!hasTauriRuntime()) return;
if (sqliteRows.length > 0) return;
if (canUseStorage() && localStorage.getItem(TEMPLATES_KEY) !== null) return;
try {
await invoke<{ imported: number; skipped: number }>("import_templates_cmd", {
items: freshInstallTemplateSeed(),
});
await loadTemplatesFromSqlite();
} catch (err) {
console.error("maybeSeedDefaultTemplates failed", err);
// Non-fatal: the user can still create templates by hand.
}
}
if (typeof window !== "undefined" && hasTauriRuntime()) {
// Sequencing: load → migrate-if-needed → seed-if-fresh-install. The
// migration re-loads on success so the store reflects DB state at
// each step, and the seeding gate sees the post-migration row count.
(async () => {
const initialRows = await loadTemplatesFromSqlite();
const migrated = await migrateTemplatesFromLocalStorage();
// After a migration the row count changed; re-fetch before deciding
// whether to seed defaults.
const rowsForSeed = migrated ? await invoke<TemplateDto[]>("list_templates_cmd").catch(() => []) : initialRows;
await maybeSeedDefaultTemplates(Array.isArray(rowsForSeed) ? rowsForSeed : []);
})().catch(() => {});
}
export async function createTemplate(name: string, sections: string[]): Promise<Template | null> {
if (!hasTauriRuntime()) return null;
const id = crypto.randomUUID();
try {
const row = await invoke<TemplateDto>("create_template_cmd", {
request: { id, name, sections },
});
const mapped = mapTemplateRow(row);
templates.push(mapped);
return mapped;
} catch (err) {
toasts.error("Couldn't create template", errorMessage(err));
return null;
}
}
export async function updateTemplate(
id: string,
patch: { name?: string; sections?: string[] },
): Promise<void> {
if (!hasTauriRuntime()) return;
try {
const row = await invoke<TemplateDto>("update_template_cmd", {
id,
patch: {
name: patch.name ?? null,
sections: patch.sections ?? null,
},
});
const idx = templates.findIndex((t) => t.id === id);
if (idx >= 0) templates[idx] = mapTemplateRow(row);
} catch (err) {
toasts.error("Couldn't update template", errorMessage(err));
}
}
export async function deleteTemplate(id: string): Promise<void> {
if (!hasTauriRuntime()) return;
try {
await invoke("delete_template_cmd", { id });
const idx = templates.findIndex((t) => t.id === id);
if (idx >= 0) templates.splice(idx, 1);
} catch (err) {
toasts.error("Couldn't delete template", errorMessage(err));
}
}