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

@@ -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<String>,
pub created_at: String,
pub updated_at: String,
}
impl TryFrom<TemplateRow> for TemplateDto {
type Error = String;
fn try_from(r: TemplateRow) -> Result<Self, String> {
// 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<String> = 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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTemplatePatch {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub sections: Option<Vec<String>>,
}
#[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<String>,
pub name: String,
pub sections: Vec<String>,
/// Accepted but ignored — the DB sets datetime('now') at insert time.
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportSummaryDto {
pub imported: u32,
pub skipped: u32,
}
impl From<ImportSummary> 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<Vec<TemplateDto>, 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<TemplateDto, String> {
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, &sections_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<TemplateDto, String> {
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<TemplateImport>,
) -> Result<ImportSummaryDto, String> {
let mut rows: Vec<TemplateRow> = 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())
}