addToHistory previously swallowed SQLite write failures with a console
warning while still pushing the entry into the in-memory history store
— a silent data-loss bug, since the row vanished on next restart with
no signal to the user. It now returns { persisted, error }, only mutates
in-memory history after the disk write lands, and the dictation flow
awaits the result so it can surface a "Couldn't save" toast and keep
the user's content intact for a retry. The new dictation autosave
writes transcript + segments to localStorage on a 1.5s debounce, on
every segment boundary, and on visibilitychange/pagehide — covering
both live capture and the stopped-but-unsaved walk-away case. On
DictationPage mount any draft <24h old surfaces an inline
Restore/Discard banner that styles as a peer to the existing error
notices. The draft is cleared only after a successful SQLite write or
explicit Discard/Clear; on persistence failure it stays on disk so the
user can recover on relaunch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
773 lines
23 KiB
TypeScript
773 lines
23 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";
|
|
|
|
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,
|
|
};
|
|
|
|
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.",
|
|
);
|
|
});
|
|
}
|
|
return {
|
|
...defaults,
|
|
...(migrated ?? {}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 function saveTasks() {}
|
|
|
|
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);
|
|
}
|
|
|
|
function defaultTaskLists(): TaskList[] {
|
|
return [
|
|
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
|
|
{ id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
|
|
];
|
|
}
|
|
|
|
function loadTaskLists(): TaskList[] {
|
|
if (!canUseStorage()) return defaultTaskLists();
|
|
return parseStoredJson<TaskList[]>(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists();
|
|
}
|
|
|
|
export const taskLists = $state<TaskList[]>(loadTaskLists());
|
|
|
|
export function saveTaskLists() {
|
|
if (canUseStorage()) {
|
|
try {
|
|
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
|
|
} catch {}
|
|
}
|
|
broadcastTaskLists();
|
|
}
|
|
|
|
export function addTaskList(name: string, profileId: string | null = null) {
|
|
taskLists.push({
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
builtIn: false,
|
|
profileId,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
saveTaskLists();
|
|
}
|
|
|
|
export function addProfileTaskList(profileName: string) {
|
|
const existing = taskLists.find((list) => list.profileId === profileName);
|
|
if (existing) return existing.id;
|
|
|
|
const id = crypto.randomUUID();
|
|
taskLists.push({
|
|
id,
|
|
name: profileName,
|
|
builtIn: false,
|
|
profileId: profileName,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
saveTaskLists();
|
|
return id;
|
|
}
|
|
|
|
export function removeProfileTaskList(profileName: string) {
|
|
const idx = taskLists.findIndex((list) => list.profileId === profileName);
|
|
if (idx < 0) return;
|
|
|
|
const listId = taskLists[idx].id;
|
|
for (const task of tasks) {
|
|
if (task.listId === listId) task.listId = null;
|
|
}
|
|
saveTasks();
|
|
broadcastTasks();
|
|
taskLists.splice(idx, 1);
|
|
saveTaskLists();
|
|
}
|
|
|
|
export function moveTaskToList(taskId: string, listId: string) {
|
|
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
|
|
}
|
|
|
|
export function moveTaskListToProfile(listId: string, profileId: string | null) {
|
|
const list = taskLists.find((entry) => entry.id === listId);
|
|
if (list && !list.builtIn) {
|
|
list.profileId = profileId || null;
|
|
if (profileId && !list.name) list.name = profileId;
|
|
saveTaskLists();
|
|
}
|
|
}
|
|
|
|
export function renameTaskList(id: string, name: string) {
|
|
const list = taskLists.find((entry) => entry.id === id);
|
|
if (list && !list.builtIn) {
|
|
list.name = name;
|
|
saveTaskLists();
|
|
}
|
|
}
|
|
|
|
export function deleteTaskList(id: string) {
|
|
const idx = taskLists.findIndex((list) => list.id === id);
|
|
if (idx < 0 || taskLists[idx].builtIn) return;
|
|
|
|
for (const task of tasks) {
|
|
if (task.listId === id) task.listId = null;
|
|
}
|
|
saveTasks();
|
|
broadcastTasks();
|
|
taskLists.splice(idx, 1);
|
|
saveTaskLists();
|
|
}
|
|
|
|
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]];
|
|
saveTaskLists();
|
|
}
|
|
|
|
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);
|
|
saveTaskLists();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function defaultTemplates(): Template[] {
|
|
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"] },
|
|
];
|
|
}
|
|
|
|
function loadTemplates(): Template[] {
|
|
if (!canUseStorage()) return defaultTemplates();
|
|
return parseStoredJson<Template[]>(localStorage.getItem(TEMPLATES_KEY)) ?? defaultTemplates();
|
|
}
|
|
|
|
export const templates = $state<Template[]>(loadTemplates());
|
|
|
|
export function saveTemplates() {
|
|
if (!canUseStorage()) return;
|
|
try {
|
|
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
|
} catch {}
|
|
}
|