Files
Lumotia/src/lib/stores/page.svelte.ts
Jake fc3a56c0dc feat(ux): B3.6 — editable energy labels (global + per-profile) + first-tap prompt
Settings → Energy: edit global energy labels (label + description per
level) with reset-to-defaults. Per-profile override toggle in the
Profiles section ("Customise for this profile" / "Use global").
EnergyChip and Tasks-page energy controls now resolve labels via
resolveEnergyLabels(profile, settings) — global by default, profile
override when present.

First-time prompt: on first energy-chip tap of the day (per profile)
or first-ever tap to Zero, an inline non-modal prompt asks "What does
Zero mean for you today?" with example chips. Per-day gate via
energyPromptLastShownDate; one-shot first-Zero gate via
energyPromptZeroSeen; Settings escape resets both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:30:06 +01:00

1138 lines
37 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import type {
EnergyLevel,
PageState,
Profile,
Segment,
SettingsState,
TaskDraft,
TaskDto,
TaskEntry,
TaskList,
TaskUpdate,
Template,
TranscriptDto,
TranscriptEntry,
TranscriptMetaPatch,
TranscriptWriteEntry,
} from "$lib/types/app";
import { toasts } from "$lib/stores/toasts.svelte.ts";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import {
loadSettingsWithMigration,
saveSettingsWithVersion,
} from "$lib/utils/settingsMigrations.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
export const page = $state<PageState>({
current: "dictation",
status: "Ready",
statusColor: "#7ec89a",
activeProfile: "None",
recording: false,
timerText: "00:00",
handedness: "Right",
taskSidebarOpen: false,
});
const SETTINGS_KEY = "kon_settings";
const PROFILES_KEY = "kon_profiles";
const TASK_LISTS_KEY = "kon_task_lists";
const TEMPLATES_KEY = "kon_templates";
/**
* B3.6 — exported energy-label defaults so the Settings "Reset to
* defaults" button can pull them without reaching into the private
* `defaults` block. Returns a fresh object on each call so callers
* cannot mutate the source-of-truth.
*/
export function defaultEnergyLabels(): SettingsState["energyLabels"] {
return {
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" },
};
}
const defaults: SettingsState = {
engine: "whisper",
modelSize: "Base",
language: "en",
device: "auto",
formatMode: "Smart",
removeFillers: true,
antiHallucination: true,
britishEnglish: true,
autoCopy: true,
autoPaste: false,
transcriptionPreview: false,
meetingAutoCapture: false,
meetingAutoCaptureApps: ["zoom", "teams"],
soundCues: false,
soundCueVolume: 0.15,
includeTimestamps: true,
theme: "Dark",
fontSize: 14,
aiTier: "cleanup",
llmModelId: null,
llmPromptPreset: "default",
aiGpuConcurrency: "parallel",
prewarmModelOnStartup: false,
saveAudio: false,
outputFolder: "",
globalHotkey: "Ctrl+Shift+R",
sidebarCollapsed: false,
microphoneDevice: "",
currentEnergy: null,
matchMyEnergy: false,
ttsVoice: null,
ttsRate: 1.0,
ritualsMorning: false,
ritualsMorningTime: "08:00",
ritualsEvening: false,
launchAtLogin: false,
ritualsPromptSeen: false,
nudgesEnabled: false,
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: defaultEnergyLabels(),
// B3.6 — first-tap energy-prompt gates. Default ON for prompts;
// never-shown date stamp; first-Zero one-shot unfired. The merge in
// loadSettings will fill these on existing installs as well.
energyPromptsEnabled: true,
energyPromptLastShownDate: null,
energyPromptZeroSeen: false,
};
function canUseStorage(): boolean {
return typeof localStorage !== "undefined";
}
function loadSettings(): SettingsState {
if (!canUseStorage()) return { ...defaults };
// Versioned migration: reads the envelope or an unversioned legacy
// blob, runs the migration chain (currently identity: bare object ->
// v1), and returns the current-shape data. Falls back to defaults
// on corruption — the toast below surfaces the reset to the user.
const migrated = loadSettingsWithMigration<Partial<SettingsState>>(SETTINGS_KEY);
if (migrated === null && localStorage.getItem(SETTINGS_KEY) !== null) {
// There was data but it failed to migrate — tell the user we
// reset rather than silently mangling their preferences.
queueMicrotask(() => {
toasts.warn(
"Settings reset",
"Your saved settings couldn't be read, so Kon fell back to defaults. "
+ "Earlier Kon builds can still read your old data.",
);
});
}
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());
export function saveSettings() {
if (!canUseStorage()) return;
// Versioned envelope {version, data}. Older Kon builds that used
// the bare-object layout can still spread the `data` key over
// their defaults — they will just silently drop fields they don't
// know about, which is the same behaviour as today.
saveSettingsWithVersion(SETTINGS_KEY, { ...settings });
}
function loadProfiles(): Profile[] {
if (!canUseStorage()) return [];
return parseStoredJson<Profile[]>(localStorage.getItem(PROFILES_KEY)) ?? [];
}
export const profiles = $state<Profile[]>(loadProfiles());
export function saveProfiles() {
if (!canUseStorage()) return;
try {
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
} catch {}
}
export const history = $state<TranscriptEntry[]>([]);
export function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
const text = row.text ?? "";
const rawTags = row.manualTags ?? "";
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
const rawLlmTags = row.llmTags ?? "";
const llmTags = rawLlmTags ? rawLlmTags.split(",").filter(Boolean) : [];
const rawSegments = row.segmentsJson ?? "";
let segments: Segment[] = [];
if (rawSegments) {
try {
const parsed = JSON.parse(rawSegments);
if (Array.isArray(parsed)) segments = parsed as Segment[];
} catch (err) {
console.warn("mapTranscriptRow: segmentsJson parse failed", err);
}
}
return {
id: row.id,
text,
source: row.source ?? "",
profileId: row.profileId ?? "00000000-0000-0000-0000-000000000001",
title: row.title ?? "",
audioPath: row.audioPath ?? null,
duration: Number(row.duration ?? 0),
engine: row.engine ?? null,
modelId: row.modelId ?? null,
createdAt: row.createdAt ?? null,
date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "",
preview: text.slice(0, 120),
starred: !!row.starred,
manualTags,
template: row.template ?? "",
language: row.language ?? "",
segments,
llmTags,
};
}
function normaliseTranscriptEntry(entry: TranscriptWriteEntry): TranscriptEntry {
const createdAt = entry.createdAt ?? new Date().toISOString();
return {
id: String(entry.id),
text: entry.text ?? "",
source: entry.source ?? "microphone",
profileId: entry.profileId ?? "00000000-0000-0000-0000-000000000001",
title: entry.title ?? "",
audioPath: entry.audioPath ?? null,
duration: Number(entry.duration ?? 0),
engine: entry.engine ?? null,
modelId: entry.modelId ?? null,
createdAt,
date: entry.date ?? new Date(createdAt).toLocaleString("en-GB"),
preview: entry.preview ?? entry.text.slice(0, 120),
starred: !!entry.starred,
manualTags: Array.isArray(entry.manualTags) ? entry.manualTags : [],
template: entry.template ?? "",
language: entry.language ?? "",
segments: Array.isArray(entry.segments) ? entry.segments : [],
llmTags: Array.isArray(entry.llmTags) ? entry.llmTags : [],
};
}
async function loadHistory() {
if (!hasTauriRuntime()) {
history.length = 0;
return;
}
try {
const rows = await invoke<TranscriptDto[]>("list_transcripts", { limit: 500, offset: 0 });
history.splice(0, history.length, ...(Array.isArray(rows) ? rows.map(mapTranscriptRow) : []));
} catch (err) {
console.error("loadHistory failed", err);
history.length = 0;
toasts.error("Failed to load history", errorMessage(err));
}
}
if (typeof window !== "undefined" && hasTauriRuntime()) {
loadHistory().catch(() => {});
}
/**
* Result of an `addToHistory` call. `persisted: true` means the entry is
* safely on disk (Tauri SQLite write succeeded, or we're in the non-Tauri
* shell where there is no disk). `persisted: false` means the in-memory
* history was NOT updated either — the caller is responsible for keeping
* the user's source content (e.g. the dictation draft) alive so a retry
* is possible. Surfacing this is what closes the historical silent-loss
* bug where SQLite failures were swallowed by `console.warn`.
*/
export interface AddToHistoryResult {
persisted: boolean;
error?: unknown;
}
export async function addToHistory(entry: TranscriptWriteEntry): Promise<AddToHistoryResult> {
const normalised = normaliseTranscriptEntry(entry);
// Non-Tauri shell (browser preview, SSR test): no disk to fail. Update
// the in-memory store and report success so callers don't think the
// write was lost — there was no write to lose.
if (!hasTauriRuntime()) {
history.unshift(normalised);
if (history.length > 500) history.length = 500;
return { persisted: true };
}
try {
await invoke("add_transcript", {
transcript: {
id: normalised.id,
text: normalised.text,
source: normalised.source,
profileId: normalised.profileId,
title: normalised.title || null,
audioPath: normalised.audioPath ?? null,
duration: normalised.duration,
engine: normalised.engine ?? null,
modelId: normalised.modelId ?? null,
inferenceMs: entry.inferenceMs ?? null,
sampleRate: entry.sampleRate ?? null,
audioChannels: entry.audioChannels ?? null,
formatMode: entry.formatMode ?? null,
removeFillers: !!entry.removeFillers,
britishEnglish: !!entry.britishEnglish,
antiHallucination: !!entry.antiHallucination,
},
});
// Only mutate the in-memory history after the disk write lands.
// Adding to memory while the disk write fails is exactly the misleading
// state PR 1.4 set out to fix — the row would appear in History, then
// silently vanish on next launch.
history.unshift(normalised);
if (history.length > 500) history.length = 500;
// Fire-and-forget auto-title. Same gate as the existing auto-cleanup
// path (`aiTier !== "off" && formatMode !== "Raw"`) so users opt in
// by the same Settings choice — no new toggle. Skipped silently if
// the LLM isn't loaded; HistoryPage's per-row "Title" button is the
// retry path. Never overwrites a title the caller already set.
void maybeAutoGenerateTitle(normalised.id, normalised.text, normalised.title);
return { persisted: true };
} catch (err) {
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
return { persisted: false, error: err };
}
}
/// Run the auto-title pass when the same gate that drives auto-cleanup
/// is met. Best-effort: any failure (LLM not loaded, model rejected the
/// transcript, sanitisation produced an empty string) is swallowed
/// because the user can still title the row manually via the per-row
/// button in History.
async function maybeAutoGenerateTitle(
id: string,
text: string,
existingTitle: string | undefined | null,
): Promise<void> {
if (!hasTauriRuntime()) return;
if (settings.aiTier === "off" || settings.formatMode === "Raw") return;
if (!text.trim()) return;
if (existingTitle && existingTitle.trim()) return;
try {
const title = await invoke<string>("generate_title_cmd", { transcript: text });
if (title && title.trim()) {
await renameHistoryEntry(id, { title: title.trim() });
}
} catch (err) {
// Auto-title is a nice-to-have, not core flow. The most common
// failure (LLM not loaded yet) is exactly the case where the
// user can re-trigger via the per-row "Title" button.
console.warn("maybeAutoGenerateTitle skipped", err);
}
}
export async function renameHistoryEntry(
id: string,
updates: { title?: string; text?: string },
) {
const idx = history.findIndex((entry) => String(entry.id) === String(id));
if (idx >= 0) {
if (updates.title !== undefined) history[idx].title = updates.title;
if (updates.text !== undefined) history[idx].text = updates.text;
}
if (!hasTauriRuntime()) return;
try {
await invoke("update_transcript", {
id: String(id),
text: updates.text ?? null,
title: updates.title ?? null,
});
} catch (err) {
console.warn("renameHistoryEntry: SQLite update failed", err);
throw err;
}
}
export async function saveTranscriptMeta(id: string, patch: TranscriptMetaPatch) {
if (!hasTauriRuntime()) return;
try {
const payload = {
starred: patch.starred,
manualTags: patch.manualTags == null
? undefined
: (Array.isArray(patch.manualTags)
? patch.manualTags.join(",")
: String(patch.manualTags)),
template: patch.template,
language: patch.language,
segmentsJson: patch.segments === undefined ? undefined : JSON.stringify(patch.segments),
llmTags: patch.llmTags == null
? undefined
: (Array.isArray(patch.llmTags)
? patch.llmTags.join(",")
: String(patch.llmTags)),
};
const row = await invoke<TranscriptDto>("update_transcript_meta_cmd", {
id: String(id),
patch: payload,
});
const idx = history.findIndex((entry) => String(entry.id) === String(id));
if (idx !== -1) history[idx] = mapTranscriptRow(row);
} catch (err) {
toasts.error("Failed to save transcript metadata", errorMessage(err));
}
}
export function deleteFromHistory(index: number) {
const entry = history[index];
history.splice(index, 1);
if (!hasTauriRuntime() || !entry?.id) return;
invoke("delete_transcript", { id: String(entry.id) })
.catch((err) => console.warn("deleteFromHistory: SQLite delete failed", err));
}
export function deleteFromHistoryById(id: string) {
const idx = history.findIndex((entry) => String(entry.id) === String(id));
if (idx === -1) return;
deleteFromHistory(idx);
}
export const tasks = $state<TaskEntry[]>([]);
function mapTaskRow(row: TaskDto): TaskEntry {
return {
id: row.id,
text: row.text ?? "",
bucket: (row.bucket ?? "inbox") as TaskEntry["bucket"],
listId: row.listId ?? null,
effort: row.effort ?? "",
notes: row.notes ?? "",
done: !!row.done,
doneAt: row.doneAt ?? null,
createdAt: row.createdAt ?? new Date().toISOString(),
sourceTranscriptId: row.sourceTranscriptId ?? null,
parentTaskId: row.parentTaskId ?? null,
energy: row.energy ?? null,
archived: !!row.archived,
archivedAt: row.archivedAt ?? null,
};
}
async function loadTasks() {
if (!hasTauriRuntime()) {
tasks.length = 0;
return;
}
try {
const rows = await invoke<TaskDto[]>("list_tasks_cmd");
tasks.splice(0, tasks.length, ...(Array.isArray(rows) ? rows.map(mapTaskRow) : []));
} catch (err) {
console.error("loadTasks failed", err);
tasks.length = 0;
toasts.error("Failed to load tasks", errorMessage(err));
}
}
if (typeof window !== "undefined" && hasTauriRuntime()) {
loadTasks().catch(() => {});
}
export async function addTask(task: TaskDraft) {
if (!hasTauriRuntime()) return;
const id = crypto.randomUUID();
try {
const row = await invoke<TaskDto>("create_task_cmd", {
request: {
id,
text: task.text,
bucket: task.bucket || "inbox",
sourceTranscriptId: task.sourceTranscriptId || null,
listId: task.listId ?? null,
effort: task.effort ?? null,
energy: task.energy ?? null,
},
});
tasks.unshift(mapTaskRow(row));
broadcastTasks();
} catch (err) {
toasts.error("Couldn't add task", errorMessage(err));
}
}
function applyLocalTaskUpdate(id: string, updates: Partial<TaskEntry>) {
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
Object.assign(tasks[idx], updates);
broadcastTasks();
}
}
/**
* PR 1.5: replace an in-store task with a fresh DTO from the backend
* (e.g. the auto-completed parent returned by `complete_subtask_cmd`).
* No-op if the task is not in the store — that's expected when a parent
* has already been pruned client-side. Broadcasts on success so other
* windows pick up the new state.
*/
export function replaceTaskFromDto(row: TaskDto) {
const idx = tasks.findIndex((task) => task.id === row.id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
}
export async function updateTask(id: string, updates: TaskUpdate) {
if (!hasTauriRuntime()) {
applyLocalTaskUpdate(id, updates as Partial<TaskEntry>);
return;
}
try {
const row = await invoke<TaskDto>("update_task_cmd", {
id,
patch: {
text: updates.text ?? null,
bucket: updates.bucket ?? null,
listId: updates.listId ?? null,
effort: updates.effort ?? null,
notes: updates.notes ?? null,
},
});
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't update task", errorMessage(err));
}
}
/**
* Phase 3: explicit tri-state energy setter. Lives outside `updateTask`
* because the backend uses a dedicated command for clearing — see
* `set_task_energy_cmd` in src-tauri/src/commands/tasks.rs. Pass `null`
* to clear the tag entirely; pass an `EnergyLevel` string to set it.
*/
export async function setTaskEnergy(id: string, energy: EnergyLevel | null) {
if (!hasTauriRuntime()) {
applyLocalTaskUpdate(id, { energy });
return;
}
try {
const row = await invoke<TaskDto>("set_task_energy_cmd", { id, energy });
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't change energy", errorMessage(err));
}
}
export async function deleteTask(id: string) {
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_cmd", { id });
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
tasks.splice(idx, 1);
broadcastTasks();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("kon:task-deleted", { detail: { id } }));
}
}
} catch (err) {
toasts.error("Couldn't delete task", errorMessage(err));
}
}
export async function completeTask(id: string) {
if (!hasTauriRuntime()) return;
try {
await invoke("complete_task_cmd", { id });
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
// Phase 6 nudge-bus signal. Fire-and-forget; the bus subscribes
// to this to clear micro-step-idle timers for the completed task
// and, later, to drive Phase 7 "after a task completes" rules.
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("kon:task-completed", { detail: { id } }));
}
} catch (err) {
toasts.error("Couldn't complete task", errorMessage(err));
}
}
export async function uncompleteTask(id: string) {
if (!hasTauriRuntime()) return;
try {
await invoke("uncomplete_task_cmd", { id });
applyLocalTaskUpdate(id, { done: false, doneAt: null });
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("kon:task-uncompleted", { detail: { id } }));
}
} catch (err) {
toasts.error("Couldn't uncomplete task", errorMessage(err));
}
}
interface TaskChannelMessage {
type: "tasks_updated";
tasks: TaskEntry[];
}
const taskChannel = typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("kon_task_sync")
: null;
if (taskChannel) {
taskChannel.onmessage = (event: MessageEvent<TaskChannelMessage>) => {
if (event.data.type === "tasks_updated") {
tasks.length = 0;
tasks.push(...event.data.tasks);
}
};
}
function broadcastTasks() {
if (!taskChannel) return;
taskChannel.postMessage({
type: "tasks_updated",
tasks: $state.snapshot(tasks),
} satisfies TaskChannelMessage);
}
// B2a: task_lists are now persisted in SQLite (migration v1 created
// the table; the actual CRUD wiring happens via the *_task_list_cmd
// handlers). The frontend keeps the two synthetic built-in entries
// (`all`, `inbox`) in-memory so the sidebar always shows them — neither
// has a corresponding DB row.
function defaultTaskLists(): TaskList[] {
return [
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
{ id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
];
}
interface TaskListDto {
id: string;
name: string;
builtIn: boolean;
profileId: string | null;
createdAt: string;
}
function mapTaskListRow(row: TaskListDto): TaskList {
return {
id: row.id,
name: row.name,
builtIn: !!row.builtIn,
profileId: row.profileId ?? null,
createdAt: row.createdAt ?? null,
};
}
export const taskLists = $state<TaskList[]>(defaultTaskLists());
async function loadTaskListsFromSqlite() {
if (!hasTauriRuntime()) return;
try {
const rows = await invoke<TaskListDto[]>("list_task_lists_cmd");
const mapped = Array.isArray(rows) ? rows.map(mapTaskListRow) : [];
taskLists.length = 0;
taskLists.push(...defaultTaskLists(), ...mapped);
} catch (err) {
console.error("loadTaskListsFromSqlite failed", err);
toasts.error("Failed to load task lists", errorMessage(err));
}
}
// One-time migration: pull the legacy `kon_task_lists` blob into SQLite
// via import_task_lists_cmd. Idempotent because the import command
// skips ids that already exist; we still only run it when the
// localStorage key is present so we don't pay 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.
async function migrateTaskListsFromLocalStorage() {
if (!canUseStorage()) return;
const raw = localStorage.getItem(TASK_LISTS_KEY);
if (!raw) return;
const parsed = parseStoredJson<TaskList[]>(raw);
if (!parsed || parsed.length === 0) {
// Empty or corrupt — clear and skip so we don't keep retrying.
localStorage.removeItem(TASK_LISTS_KEY);
return;
}
// Drop the synthetic built-ins (`all`, `inbox`) — they're recreated
// in-memory each launch and have no DB row, so importing them would
// collide with the next launch's defaults.
const importable = parsed.filter((l) => l.id !== "all" && l.id !== "inbox");
if (importable.length === 0) {
localStorage.removeItem(TASK_LISTS_KEY);
return;
}
try {
const summary = await invoke<{ imported: number; skipped: number }>(
"import_task_lists_cmd",
{
items: importable.map((l) => ({
id: l.id,
name: l.name,
builtIn: l.builtIn,
profileId: l.profileId ?? null,
createdAt: l.createdAt ?? null,
})),
},
);
// Only clear localStorage AFTER the import succeeds.
localStorage.removeItem(TASK_LISTS_KEY);
// Re-load from SQLite so the store reflects the imported rows.
await loadTaskListsFromSqlite();
toasts.info(
"Task lists migrated",
`${summary.imported} list${summary.imported === 1 ? "" : "s"} moved to local database.`,
);
} catch (err) {
console.error("migrateTaskListsFromLocalStorage failed", err);
toasts.error(
"Couldn't migrate task lists",
"Your data is safe — Kon will retry on next launch.",
);
}
}
if (typeof window !== "undefined" && hasTauriRuntime()) {
// Sequencing matters: load first so the store reflects existing DB
// state, then run the import which is idempotent vs. that state. The
// import re-loads on success so the toasted summary matches what's
// visible.
(async () => {
await loadTaskListsFromSqlite();
await migrateTaskListsFromLocalStorage();
})().catch(() => {});
}
export async function addTaskList(name: string, profileId: string | null = null) {
if (!hasTauriRuntime()) return;
const id = crypto.randomUUID();
try {
const row = await invoke<TaskListDto>("create_task_list_cmd", {
request: {
id,
name,
builtIn: false,
profileId,
},
});
taskLists.push(mapTaskListRow(row));
broadcastTaskLists();
} catch (err) {
toasts.error("Couldn't add list", errorMessage(err));
}
}
export async function addProfileTaskList(profileName: string) {
const existing = taskLists.find((list) => list.profileId === profileName);
if (existing) return existing.id;
if (!hasTauriRuntime()) return null;
const id = crypto.randomUUID();
try {
const row = await invoke<TaskListDto>("create_task_list_cmd", {
request: {
id,
name: profileName,
builtIn: false,
profileId: profileName,
},
});
taskLists.push(mapTaskListRow(row));
broadcastTaskLists();
return id;
} catch (err) {
toasts.error("Couldn't add profile list", errorMessage(err));
return null;
}
}
export async function removeProfileTaskList(profileName: string) {
const idx = taskLists.findIndex((list) => list.profileId === profileName);
if (idx < 0) return;
const listId = taskLists[idx].id;
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_list_cmd", { id: listId });
// delete_task_list nulls dependent tasks server-side; mirror that
// locally so the UI doesn't show stale list pointers.
for (const task of tasks) {
if (task.listId === listId) task.listId = null;
}
broadcastTasks();
taskLists.splice(idx, 1);
broadcastTaskLists();
} catch (err) {
toasts.error("Couldn't remove profile list", errorMessage(err));
}
}
export function moveTaskToList(taskId: string, listId: string) {
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
}
export async function moveTaskListToProfile(listId: string, profileId: string | null) {
const list = taskLists.find((entry) => entry.id === listId);
if (!list || list.builtIn) return;
if (!hasTauriRuntime()) return;
try {
const row = await invoke<TaskListDto>("update_task_list_cmd", {
id: listId,
patch: {
// Only patch profile_id; leave name alone.
profileId: profileId || null,
},
});
const idx = taskLists.findIndex((l) => l.id === listId);
if (idx >= 0) {
taskLists[idx] = mapTaskListRow(row);
broadcastTaskLists();
}
} catch (err) {
toasts.error("Couldn't move list", errorMessage(err));
}
}
export async function renameTaskList(id: string, name: string) {
const list = taskLists.find((entry) => entry.id === id);
if (!list || list.builtIn) return;
if (!hasTauriRuntime()) return;
try {
const row = await invoke<TaskListDto>("update_task_list_cmd", {
id,
patch: { name },
});
const idx = taskLists.findIndex((l) => l.id === id);
if (idx >= 0) {
taskLists[idx] = mapTaskListRow(row);
broadcastTaskLists();
}
} catch (err) {
toasts.error("Couldn't rename list", errorMessage(err));
}
}
export async function deleteTaskList(id: string) {
const idx = taskLists.findIndex((list) => list.id === id);
if (idx < 0 || taskLists[idx].builtIn) return;
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_list_cmd", { id });
for (const task of tasks) {
if (task.listId === id) task.listId = null;
}
broadcastTasks();
taskLists.splice(idx, 1);
broadcastTaskLists();
} catch (err) {
toasts.error("Couldn't delete list", errorMessage(err));
}
}
// NOTE: `moveTaskList` (manual reorder) and `sortTaskLists` mutate
// the in-memory array only. Persisting display order requires a new
// `display_order` column on `task_lists`; until then, the order survives
// only for the current session. This is a known, scoped limitation
// called out in B2a part 2.
export function moveTaskList(id: string, direction: number) {
const idx = taskLists.findIndex((list) => list.id === id);
const targetIdx = idx + direction;
if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return;
if (taskLists[targetIdx].builtIn) return;
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
broadcastTaskLists();
}
export function sortTaskLists(mode: "alpha" | "date") {
const builtIn = taskLists.filter((list) => list.builtIn);
const custom = taskLists.filter((list) => !list.builtIn);
if (mode === "alpha") {
custom.sort((a, b) => a.name.localeCompare(b.name));
} else {
custom.sort(
(a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime(),
);
}
taskLists.length = 0;
taskLists.push(...builtIn, ...custom);
broadcastTaskLists();
}
interface TaskListChannelMessage {
type: "task_lists_updated";
lists: TaskList[];
}
const taskListChannel = typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("kon_task_lists")
: null;
if (taskListChannel) {
taskListChannel.onmessage = (event: MessageEvent<TaskListChannelMessage>) => {
if (event.data.type === "task_lists_updated") {
taskLists.length = 0;
taskLists.push(...event.data.lists);
}
};
}
function broadcastTaskLists() {
if (!taskListChannel) return;
taskListChannel.postMessage({
type: "task_lists_updated",
lists: $state.snapshot(taskLists),
} satisfies TaskListChannelMessage);
}
// 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: ["Action items", "Decisions", "Team members", "Open questions"] },
{ name: "Daily check-in", sections: ["Wins", "Blockers", "Next"] },
];
}
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[]>([]);
async function loadTemplatesFromSqlite(): Promise<TemplateDto[]> {
if (!hasTauriRuntime()) return [];
try {
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));
}
}