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

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

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

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

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

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

View File

@@ -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<Vec<TemplateRow>> {
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<TemplateRow> {
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<TemplateRow> {
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<ImportSummary> {
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<String> = 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<i64> = 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<String>` 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");
}
}