diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index fcdcbe0..85638e2 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -839,6 +839,154 @@ pub async fn import_task_lists( Ok(ImportSummary { imported, skipped }) } +// --- B2b: templates CRUD ------------------------------------------------ +// +// Migration v17 added the templates table. Same pattern as task_lists: +// the storage layer owns the SQL, the Tauri command layer owns the +// JSON-array (de)serialisation for the `sections` column. Sections are +// stored as JSON text rather than a join table — they're small +// (a handful of short labels), order-significant, and never queried by +// section text. + +pub async fn list_templates(pool: &SqlitePool) -> Result> { + let rows = sqlx::query( + "SELECT id, name, sections, created_at, updated_at \ + FROM templates ORDER BY created_at ASC, id ASC", + ) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List templates failed: {e}")))?; + Ok(rows.iter().map(template_row_from).collect()) +} + +pub async fn create_template( + pool: &SqlitePool, + id: &str, + name: &str, + sections_json: &str, +) -> Result { + sqlx::query("INSERT INTO templates (id, name, sections) VALUES (?, ?, ?)") + .bind(id) + .bind(name) + .bind(sections_json) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Create template failed: {e}")))?; + + let row = sqlx::query( + "SELECT id, name, sections, created_at, updated_at FROM templates WHERE id = ?", + ) + .bind(id) + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Refetch template failed: {e}")))?; + Ok(template_row_from(&row)) +} + +/// Patch-style update. `name` and `sections_json` use COALESCE so `None` +/// preserves the existing column. `updated_at` always advances to +/// `datetime('now')` so callers can detect mutations after the fact (the +/// frontend uses this for cache invalidation; the bump test asserts it). +pub async fn update_template( + pool: &SqlitePool, + id: &str, + name: Option<&str>, + sections_json: Option<&str>, +) -> Result { + sqlx::query( + "UPDATE templates \ + SET name = COALESCE(?, name), \ + sections = COALESCE(?, sections), \ + updated_at = datetime('now') \ + WHERE id = ?", + ) + .bind(name) + .bind(sections_json) + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Update template failed: {e}")))?; + + let row = sqlx::query( + "SELECT id, name, sections, created_at, updated_at FROM templates WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("Refetch template failed: {e}")))? + .ok_or_else(|| { + KonError::StorageError(format!("update_template: template {id} not found after update")) + })?; + Ok(template_row_from(&row)) +} + +/// Delete is a no-op when the id is missing — matches the contract for +/// task_lists/transcripts and lets the frontend issue an idempotent +/// "delete then maybe re-create" sequence without special-casing missing +/// rows. +pub async fn delete_template(pool: &SqlitePool, id: &str) -> Result<()> { + sqlx::query("DELETE FROM templates WHERE id = ?") + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Delete template failed: {e}")))?; + Ok(()) +} + +/// Bulk import for the localStorage → SQLite migration. Same idempotency +/// contract as `import_task_lists`: pre-existing IDs skip, duplicate IDs +/// within the input batch hard-fail and roll back. +pub async fn import_templates( + pool: &SqlitePool, + items: &[TemplateRow], +) -> Result { + let mut tx = pool + .begin() + .await + .map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?; + + let mut imported = 0usize; + let mut skipped = 0usize; + let mut seen_in_batch: std::collections::HashSet = std::collections::HashSet::new(); + + for item in items { + if !seen_in_batch.insert(item.id.clone()) { + return Err(KonError::StorageError(format!( + "import_templates: duplicate id '{}' within batch", + item.id + ))); + } + + let exists: Option = sqlx::query_scalar("SELECT 1 FROM templates WHERE id = ?") + .bind(&item.id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| KonError::StorageError(format!("Check template exists failed: {e}")))?; + + if exists.is_some() { + skipped += 1; + continue; + } + + sqlx::query("INSERT INTO templates (id, name, sections) VALUES (?, ?, ?)") + .bind(&item.id) + .bind(&item.name) + .bind(&item.sections_json) + .execute(&mut *tx) + .await + .map_err(|e| { + KonError::StorageError(format!("Insert template during import failed: {e}")) + })?; + imported += 1; + } + + tx.commit() + .await + .map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?; + + Ok(ImportSummary { imported, skipped }) +} + // --- Phase 8: daily completion counts ----------------------------------- // // Drives the Tasks-page badge ("3 today") and the 7-day momentum @@ -1142,6 +1290,19 @@ pub struct TaskListRow { pub created_at: String, } +/// B2b — templates row. Mirrors the `templates` table introduced in +/// migration v17. `sections_json` is a JSON-encoded array of strings; +/// the storage layer keeps it opaque and the Tauri command layer +/// (de)serialises into the frontend-facing `Vec` representation. +#[derive(Debug, Clone)] +pub struct TemplateRow { + pub id: String, + pub name: String, + pub sections_json: String, + pub created_at: String, + pub updated_at: String, +} + /// Result of `import_task_lists` — how many rows landed and how many were /// skipped because their `id` already exists. The whole batch runs in one /// transaction so a duplicate inside the batch (rather than against the @@ -1241,6 +1402,16 @@ fn task_list_row_from(r: &sqlx::sqlite::SqliteRow) -> TaskListRow { } } +fn template_row_from(r: &sqlx::sqlite::SqliteRow) -> TemplateRow { + TemplateRow { + id: r.get("id"), + name: r.get("name"), + sections_json: r.get("sections"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + } +} + fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow { ImplementationRuleRow { id: r.get("id"), @@ -3145,4 +3316,173 @@ mod tests { "WHERE archived = 0 must skip the already-archived row" ); } + + // --- B2b: templates CRUD tests ------------------------------------- + + #[tokio::test] + async fn template_crud_roundtrip() { + let pool = test_pool().await; + + // Baseline: fresh DB has no templates. + let baseline = list_templates(&pool).await.unwrap(); + assert!(baseline.is_empty(), "fresh DB has no templates"); + + let created = create_template( + &pool, + "tpl-1", + "Meeting notes", + r#"["Action items","Decisions"]"#, + ) + .await + .unwrap(); + assert_eq!(created.id, "tpl-1"); + assert_eq!(created.name, "Meeting notes"); + assert_eq!(created.sections_json, r#"["Action items","Decisions"]"#); + assert!(!created.created_at.is_empty(), "DB default fills created_at"); + assert!(!created.updated_at.is_empty(), "DB default fills updated_at"); + + let listed = list_templates(&pool).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "tpl-1"); + + // Update name only — sections preserved via COALESCE. + let renamed = update_template(&pool, "tpl-1", Some("Daily check-in"), None) + .await + .unwrap(); + assert_eq!(renamed.name, "Daily check-in"); + assert_eq!( + renamed.sections_json, r#"["Action items","Decisions"]"#, + "sections None means leave alone" + ); + + // Update sections only — name preserved. + let resectioned = update_template( + &pool, + "tpl-1", + None, + Some(r#"["Wins","Blockers","Next"]"#), + ) + .await + .unwrap(); + assert_eq!(resectioned.name, "Daily check-in", "name None means leave alone"); + assert_eq!(resectioned.sections_json, r#"["Wins","Blockers","Next"]"#); + + delete_template(&pool, "tpl-1").await.unwrap(); + let after_delete = list_templates(&pool).await.unwrap(); + assert!(after_delete.is_empty()); + } + + #[tokio::test] + async fn import_templates_is_idempotent() { + let pool = test_pool().await; + let items = vec![ + TemplateRow { + id: "i1".into(), + name: "Meeting notes".into(), + sections_json: r#"["A","B"]"#.into(), + created_at: String::new(), + updated_at: String::new(), + }, + TemplateRow { + id: "i2".into(), + name: "Daily check-in".into(), + sections_json: r#"["Wins"]"#.into(), + created_at: String::new(), + updated_at: String::new(), + }, + ]; + + let summary = import_templates(&pool, &items).await.unwrap(); + assert_eq!(summary.imported, 2); + assert_eq!(summary.skipped, 0); + + let summary2 = import_templates(&pool, &items).await.unwrap(); + assert_eq!(summary2.imported, 0); + assert_eq!(summary2.skipped, 2); + } + + #[tokio::test] + async fn import_templates_atomic_on_failure() { + let pool = test_pool().await; + let baseline = list_templates(&pool).await.unwrap(); + + // Two items share the same id — must abort the whole batch. + let bad = vec![ + TemplateRow { + id: "dup".into(), + name: "First".into(), + sections_json: r#"["A"]"#.into(), + created_at: String::new(), + updated_at: String::new(), + }, + TemplateRow { + id: "dup".into(), + name: "Second".into(), + sections_json: r#"["B"]"#.into(), + created_at: String::new(), + updated_at: String::new(), + }, + ]; + + let res = import_templates(&pool, &bad).await; + assert!(res.is_err(), "duplicate id within batch must error"); + + let after = list_templates(&pool).await.unwrap(); + assert_eq!( + after.len(), + baseline.len(), + "rollback: no rows from the failed batch should remain" + ); + assert!(after.iter().all(|t| t.id != "dup")); + } + + #[tokio::test] + async fn update_template_bumps_updated_at() { + // SQLite datetime('now') has 1-second resolution; we sleep a hair + // over a second to guarantee a tick. This test guards the + // documented contract that any update advances updated_at, which + // the frontend relies on for invalidating cached template state. + let pool = test_pool().await; + + let created = create_template( + &pool, + "bump", + "Before", + r#"["S1"]"#, + ) + .await + .unwrap(); + let initial_updated = created.updated_at.clone(); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + + let updated = update_template(&pool, "bump", Some("After"), None) + .await + .unwrap(); + assert_ne!( + updated.updated_at, initial_updated, + "updated_at must advance on a name change", + ); + // Ordering check rather than just inequality — if the trigger + // ever stamps an earlier value we'd want to know. + assert!( + updated.updated_at > initial_updated, + "updated_at must move forward, got {} -> {}", + initial_updated, + updated.updated_at, + ); + } + + #[tokio::test] + async fn delete_template_is_noop_when_missing() { + let pool = test_pool().await; + + // No row with this id exists. Idempotent contract: deleting a + // missing id returns Ok without raising. + let res = delete_template(&pool, "does-not-exist").await; + assert!(res.is_ok(), "delete of missing template must be Ok, got: {res:?}"); + + let listed = list_templates(&pool).await.unwrap(); + assert!(listed.is_empty(), "no rows touched by missing-id delete"); + } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 053931b..1013946 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -9,19 +9,21 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; pub use database::{ add_profile_term, archive_inbox_older_than, archive_task, complete_subtask_and_check_parent, complete_task, count_transcripts, - create_profile, create_task_list, delete_implementation_rule, delete_profile, - delete_profile_term, delete_task, delete_task_list, delete_transcript, - get_implementation_rule, get_profile, get_setting, get_task_by_id, get_transcript, - import_task_lists, init, init_readonly, insert_implementation_rule, insert_subtask, - insert_task, insert_transcript, list_archived_tasks, list_feedback_examples, + create_profile, create_task_list, create_template, delete_implementation_rule, + delete_profile, delete_profile_term, delete_task, delete_task_list, + delete_template, delete_transcript, get_implementation_rule, get_profile, + get_setting, get_task_by_id, get_transcript, import_task_lists, import_templates, + init, init_readonly, insert_implementation_rule, insert_subtask, insert_task, + insert_transcript, list_archived_tasks, list_feedback_examples, list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions, - list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_transcripts, - list_transcripts_paged, log_error, mark_implementation_rule_fired, prune_error_log, - record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting, - set_task_energy, unarchive_task, uncomplete_task, update_profile, update_task, - update_task_list, update_transcript, update_transcript_meta, DailyCompletionCount, - ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, ImportSummary, - InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow, - TaskRow, TranscriptRow, + list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_templates, + list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, + prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled, + set_setting, set_task_energy, unarchive_task, uncomplete_task, update_profile, + update_task, update_task_list, update_template, update_transcript, + update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, + FeedbackTargetType, ImplementationRuleRow, ImportSummary, InsertTranscriptParams, + ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow, TaskRow, TemplateRow, + TranscriptRow, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 28f2b2c..5c73670 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -495,6 +495,33 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived); "#, ), + ( + 17, + "templates: SQLite-backed section scaffold templates (B2b)", + r#" + -- B2b moves dictation section templates from + -- localStorage["kon_templates"] into SQLite so they survive across + -- machines and behave consistently with the rest of the userland + -- data (task_lists in B2a, transcripts since v1). + -- + -- sections is a JSON array of strings. We keep it as TEXT rather + -- than a join table because templates are small (a handful of + -- short labels) and the order matters but is fully ordered by + -- the array. Index on name is for the future "search templates" + -- affordance and to keep alphabetised lookups cheap. + -- + -- Comment style note: avoid semicolons inside SQL comments. The + -- statement splitter is naive and will treat them as boundaries. + CREATE TABLE IF NOT EXISTS templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + sections TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name); + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -644,7 +671,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 16); + assert_eq!(count, 17); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -663,7 +690,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 16); + assert_eq!(count, 17); } #[tokio::test] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 153d34e..62eb028 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ pub mod rituals; pub mod security; pub mod task_lists; pub mod tasks; +pub mod templates; pub mod transcription; pub mod transcripts; pub mod tts; diff --git a/src-tauri/src/commands/templates.rs b/src-tauri/src/commands/templates.rs new file mode 100644 index 0000000..f65772c --- /dev/null +++ b/src-tauri/src/commands/templates.rs @@ -0,0 +1,193 @@ +// B2b — Tauri commands wrapping kon_storage templates CRUD. +// +// The frontend stores templates as `{ name, sections }` objects. This +// command layer is responsible for the JSON (de)serialisation of the +// `sections` column (the storage layer keeps it as opaque TEXT) and for +// minting fresh ids during the localStorage → SQLite import. + +use serde::{Deserialize, Serialize}; + +use kon_storage::{ + create_template as db_create_template, delete_template as db_delete_template, + import_templates as db_import_templates, list_templates as db_list_templates, + update_template as db_update_template, ImportSummary, TemplateRow, +}; + +use crate::AppState; + +/// Frontend-facing template shape. Sections come over the wire as a real +/// array — the JSON encoding is an implementation detail of the storage +/// row that callers shouldn't have to think about. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateDto { + pub id: String, + pub name: String, + pub sections: Vec, + pub created_at: String, + pub updated_at: String, +} + +impl TryFrom for TemplateDto { + type Error = String; + + fn try_from(r: TemplateRow) -> Result { + // Malformed sections_json is a soft failure: log + return empty + // sections rather than failing the whole list. A user will see + // an empty template they can edit, instead of a broken page. + let sections: Vec = match serde_json::from_str(&r.sections_json) { + Ok(v) => v, + Err(e) => { + eprintln!( + "[templates] sections_json parse failed for template {} ({}): {}", + r.id, r.name, e + ); + Vec::new() + } + }; + Ok(Self { + id: r.id, + name: r.name, + sections, + created_at: r.created_at, + updated_at: r.updated_at, + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTemplateRequest { + pub id: String, + pub name: String, + pub sections: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTemplatePatch { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub sections: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateImport { + /// Optional — if absent, the server mints a uuid v4. The localStorage + /// shape pre-B2b had no id, so the import path needs to backfill it. + #[serde(default)] + pub id: Option, + pub name: String, + pub sections: Vec, + /// Accepted but ignored — the DB sets datetime('now') at insert time. + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportSummaryDto { + pub imported: u32, + pub skipped: u32, +} + +impl From for ImportSummaryDto { + fn from(s: ImportSummary) -> Self { + Self { + imported: s.imported as u32, + skipped: s.skipped as u32, + } + } +} + +#[tauri::command] +pub async fn list_templates_cmd( + state: tauri::State<'_, AppState>, +) -> Result, String> { + let rows = db_list_templates(&state.db).await.map_err(|e| e.to_string())?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + // TryFrom returns Ok with an empty sections vec on parse failure — + // there's no error path that bubbles up here today, but keep the + // ?-style plumbing in case the contract tightens. + out.push(TemplateDto::try_from(r)?); + } + Ok(out) +} + +#[tauri::command] +pub async fn create_template_cmd( + state: tauri::State<'_, AppState>, + request: CreateTemplateRequest, +) -> Result { + let sections_json = serde_json::to_string(&request.sections) + .map_err(|e| format!("create_template: serialise sections failed: {e}"))?; + let row = db_create_template(&state.db, &request.id, &request.name, §ions_json) + .await + .map_err(|e| e.to_string())?; + TemplateDto::try_from(row) +} + +#[tauri::command] +pub async fn update_template_cmd( + state: tauri::State<'_, AppState>, + id: String, + patch: UpdateTemplatePatch, +) -> Result { + let sections_json = match patch.sections { + Some(s) => Some( + serde_json::to_string(&s) + .map_err(|e| format!("update_template: serialise sections failed: {e}"))?, + ), + None => None, + }; + let row = db_update_template( + &state.db, + &id, + patch.name.as_deref(), + sections_json.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + TemplateDto::try_from(row) +} + +#[tauri::command] +pub async fn delete_template_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + db_delete_template(&state.db, &id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn import_templates_cmd( + state: tauri::State<'_, AppState>, + items: Vec, +) -> Result { + let mut rows: Vec = Vec::with_capacity(items.len()); + for item in items { + let sections_json = serde_json::to_string(&item.sections) + .map_err(|e| format!("import_templates: serialise sections failed: {e}"))?; + let id = item.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + rows.push(TemplateRow { + id, + name: item.name, + sections_json, + // The DB column has DEFAULT datetime('now'); these placeholder + // strings are overwritten by the INSERT for the live row and + // are only used to build the in-memory TemplateRow shape + // passed to import_templates. + created_at: item.created_at.unwrap_or_default(), + updated_at: String::new(), + }); + } + db_import_templates(&state.db, &rows) + .await + .map(ImportSummaryDto::from) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49c69da..dda1e63 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -373,6 +373,12 @@ pub fn run() { commands::task_lists::update_task_list_cmd, commands::task_lists::delete_task_list_cmd, commands::task_lists::import_task_lists_cmd, + // B2b — templates CRUD (replaces kon_templates localStorage) + commands::templates::list_templates_cmd, + commands::templates::create_template_cmd, + commands::templates::update_template_cmd, + commands::templates::delete_template_cmd, + commands::templates::import_templates_cmd, // HITL feedback (Phase 2 roadmap) commands::feedback::record_feedback, commands::feedback::list_feedback_examples_cmd, diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 90de505..b94df0c 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -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 }); + }
@@ -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 >
diff --git a/src/lib/stores/page.svelte.ts b/src/lib/stores/page.svelte.ts index 8f066a2..bb6a9b2 100644 --- a/src/lib/stores/page.svelte.ts +++ b/src/lib/stores/page.svelte.ts @@ -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 | undefined; + if (blob && blob.nudgeMode === undefined && typeof blob.nudgesEnabled === "boolean") { + merged.nudgeMode = blob.nudgesEnabled ? "immediate" : "off"; + } + return merged; } export const settings = $state(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(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(loadTemplates()); +export const templates = $state([]); -export function saveTemplates() { - if (!canUseStorage()) return; +async function loadTemplatesFromSqlite(): Promise { + if (!hasTauriRuntime()) return []; try { - localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates)); - } catch {} + const rows = await invoke("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 { + if (!canUseStorage()) return false; + const raw = localStorage.getItem(TEMPLATES_KEY); + if (!raw) return false; + + const parsed = parseStoredJson>(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 { + 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("list_templates_cmd").catch(() => []) : initialRows; + await maybeSeedDefaultTemplates(Array.isArray(rowsForSeed) ? rowsForSeed : []); + })().catch(() => {}); +} + +export async function createTemplate(name: string, sections: string[]): Promise