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:
@@ -3,7 +3,7 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
|
||||
import { settings, saveSettings, profiles, saveProfiles, templates, createTemplate as createTemplateRow, updateTemplate as updateTemplateRow, deleteTemplate as deleteTemplateRow, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import Toggle from "$lib/components/Toggle.svelte";
|
||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||
@@ -932,21 +932,35 @@
|
||||
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
|
||||
}
|
||||
|
||||
// --- Template management ---
|
||||
function createTemplate() {
|
||||
// --- Template management (B2b: SQLite-backed via createTemplateRow /
|
||||
// updateTemplateRow / deleteTemplateRow). The inline section editor
|
||||
// calls updateTemplateRow directly with the parsed sections array;
|
||||
// the store is the source of truth for the rendered list, so UI
|
||||
// updates flow through the awaited mutators.
|
||||
async function createTemplate() {
|
||||
if (!newTemplateName.trim()) return;
|
||||
templates.push({ name: newTemplateName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
|
||||
saveTemplates();
|
||||
await createTemplateRow(newTemplateName.trim(), ["Section 1", "Section 2", "Section 3"]);
|
||||
newTemplateName = "";
|
||||
showNewTemplate = false;
|
||||
editingTemplate = templates.length - 1;
|
||||
}
|
||||
|
||||
function deleteTemplate(index) {
|
||||
templates.splice(index, 1);
|
||||
saveTemplates();
|
||||
async function deleteTemplate(index) {
|
||||
const template = templates[index];
|
||||
if (!template) return;
|
||||
await deleteTemplateRow(template.id);
|
||||
editingTemplate = -1;
|
||||
}
|
||||
|
||||
function persistTemplateSections(template, value) {
|
||||
const sections = value.split("\n").filter((s) => s.trim());
|
||||
// Optimistic local mutation so the textarea stays in sync with what
|
||||
// the user typed; updateTemplateRow then writes through to SQLite
|
||||
// and rewrites this entry from the returned row (which includes the
|
||||
// bumped updated_at).
|
||||
template.sections = sections;
|
||||
void updateTemplateRow(template.id, { sections });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
||||
@@ -2025,7 +2039,7 @@
|
||||
focus:outline-none focus:border-accent"
|
||||
placeholder="One section per line..."
|
||||
value={template.sections.join("\n")}
|
||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
||||
oninput={(e) => persistTemplateSections(template, e.target.value)}
|
||||
data-no-transition
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,41 @@ export type WhisperModelSize =
|
||||
| "Medium"
|
||||
| "Distil-L";
|
||||
export type AiTier = "off" | "cleanup" | "tasks";
|
||||
/**
|
||||
* B2b microstep granularity (UI hook only — B3.3 owns the LLM-prompt
|
||||
* threading). "default" preserves current behaviour.
|
||||
*/
|
||||
export type MicroStepGranularity = "light" | "default" | "detailed";
|
||||
/**
|
||||
* B2b nudge mode. Splits the legacy `nudgesEnabled: boolean` into a
|
||||
* tri-state so the eventual digest delivery (B3.2) has a place to live.
|
||||
* "off" = no nudges, "immediate" = current behaviour, "digest" = batched
|
||||
* delivery at the times in `nudgeDigestTimes`.
|
||||
*/
|
||||
export type NudgeMode = "off" | "immediate" | "digest";
|
||||
/**
|
||||
* B2b sparkline window. The "N today" badge is unaffected; this only
|
||||
* controls the momentum sparkline range. Default 7 mirrors today's UI.
|
||||
* B3.5 adds the picker UI; this just establishes the field.
|
||||
*/
|
||||
export type SparklineRangeDays = 7 | 28 | 90;
|
||||
|
||||
/**
|
||||
* B2b energy labels. The shape covers the three energy levels (matching
|
||||
* `EnergyLevel` below). Per-profile override lives on the Profile shape;
|
||||
* the resolver in `$lib/utils/energyLabels` falls back to the global
|
||||
* setting when the profile override is null/absent.
|
||||
*/
|
||||
export interface EnergyLabel {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EnergyLabels {
|
||||
high: EnergyLabel;
|
||||
medium: EnergyLabel;
|
||||
brain_dead: EnergyLabel;
|
||||
}
|
||||
export type LlmModelIdStr = "qwen3_1_7b" | "qwen3_4b_instruct_2507" | "qwen3_14b";
|
||||
export type LlmPromptPreset = "default" | "email" | "notes" | "code";
|
||||
export type AiGpuConcurrency = "parallel" | "sequential";
|
||||
@@ -128,16 +163,82 @@ export interface SettingsState {
|
||||
* existing users see the sparkline on upgrade.
|
||||
*/
|
||||
showMomentumSparkline: boolean;
|
||||
/**
|
||||
* B2b — re-entry / fresh-start tracking. B3.1 owns the actual behaviour
|
||||
* (detecting that the user has been away long enough to warrant a fresh
|
||||
* start prompt); this batch just lays the schema. ISO timestamps; null
|
||||
* means "never" / "not yet stamped".
|
||||
*/
|
||||
lastLaunchAt: string | null;
|
||||
reentryFreshStartUntil: string | null;
|
||||
/**
|
||||
* B2b — last active profile id, persisted so the next launch can pick
|
||||
* up where the user left off. Null means "no profile last selected" —
|
||||
* the launcher falls back to the Default profile. This is the
|
||||
* localStorage profile id (the user's per-context switch), not the
|
||||
* SQLite ProfileRow id.
|
||||
*/
|
||||
lastActiveProfileId: string | null;
|
||||
/**
|
||||
* B2b — micro-step generation granularity. Hook only; B3.3 threads
|
||||
* this into the LLM prompt builder. "default" matches today's output.
|
||||
*/
|
||||
microStepGranularity: MicroStepGranularity;
|
||||
/**
|
||||
* B2b — nudge mode tri-state. Replaces the boolean `nudgesEnabled` for
|
||||
* read paths added from B2b onward. The legacy boolean is preserved
|
||||
* (don't remove until the migration window closes) so older callers
|
||||
* still see a sensible value. Default value depends on `nudgesEnabled`
|
||||
* at first load — see the migration in loadSettings.
|
||||
*/
|
||||
nudgeMode: NudgeMode;
|
||||
/**
|
||||
* B2b — when nudgeMode === "digest", the times-of-day (HH:MM, 24h
|
||||
* local) at which to flush the queued nudges. Empty array is the
|
||||
* default and currently produces no digest sends; B3.2 owns the
|
||||
* delivery loop.
|
||||
*/
|
||||
nudgeDigestTimes: string[];
|
||||
/**
|
||||
* B2b — momentum sparkline range. Default 7 mirrors today's UI; B3.5
|
||||
* adds the picker.
|
||||
*/
|
||||
sparklineRangeDays: SparklineRangeDays;
|
||||
/**
|
||||
* B2b — global energy labels. Each profile may override via
|
||||
* `energyLabelsOverride` on its Profile entry. Defaults mirror the
|
||||
* EnergyChip copy so behaviour doesn't change until the user edits.
|
||||
*/
|
||||
energyLabels: EnergyLabels;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
words: string;
|
||||
/**
|
||||
* B2b — optional per-profile override of the global energy labels.
|
||||
* When present, takes precedence over `settings.energyLabels`. Null or
|
||||
* absent means "use the global labels". Wired through
|
||||
* `resolveEnergyLabels` in `$lib/utils/energyLabels`.
|
||||
*
|
||||
* Note: this lives on the localStorage Profile shape (the user's
|
||||
* "switch" between contexts). It is intentionally not on the SQLite
|
||||
* ProfileRow (which stores vocabulary terms). B3.7 unifies the split.
|
||||
*/
|
||||
energyLabelsOverride?: EnergyLabels | null;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
/**
|
||||
* B2b — server-minted uuid for the SQLite-backed templates table. Older
|
||||
* blobs in localStorage may lack this; the import migration in
|
||||
* page.svelte.ts mints a uuid on the way in.
|
||||
*/
|
||||
id: string;
|
||||
name: string;
|
||||
sections: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccessibilityPreferences {
|
||||
|
||||
19
src/lib/utils/energyLabels.ts
Normal file
19
src/lib/utils/energyLabels.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// B2b — per-profile energy-label override resolver.
|
||||
//
|
||||
// The global labels live in `settings.energyLabels`; a Profile may set
|
||||
// `energyLabelsOverride` to take precedence within its context. This
|
||||
// helper centralises the lookup so callers never have to remember the
|
||||
// "profile beats global, missing override is fine" precedence.
|
||||
//
|
||||
// B3.6 will add the editing UI; this batch ships the schema + resolver
|
||||
// only so other features can already read through it without changing
|
||||
// behaviour.
|
||||
|
||||
import type { EnergyLabels, Profile, SettingsState } from "$lib/types/app";
|
||||
|
||||
export function resolveEnergyLabels(
|
||||
profile: Profile | null | undefined,
|
||||
settings: SettingsState,
|
||||
): EnergyLabels {
|
||||
return profile?.energyLabelsOverride ?? settings.energyLabels;
|
||||
}
|
||||
Reference in New Issue
Block a user