From f3e5ebdb0ad3e8f40aa5d399d6f43cd7b9c192cf Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:09:11 +0800
Subject: [PATCH 001/117] chore: clean Tauri package metadata
---
src-tauri/Cargo.toml | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index eeb0a99..181ca3e 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,16 +1,13 @@
[package]
name = "timeprism"
version = "0.1.0"
-description = "A Tauri App"
-authors = ["you"]
+description = "A Windows-first, local-first desktop time and attention tracker."
+authors = ["Air000000"]
edition = "2021"
-
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+license = "MIT"
+repository = "https://github.com/Air000000/timeprism"
[lib]
-# The `_lib` suffix may seem redundant but it is necessary
-# to make the lib name unique and wouldn't conflict with the bin name.
-# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "timeprism_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@@ -32,4 +29,3 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Threading"
] }
tauri-plugin-single-instance = "2"
-
From b04ca82d1ebfb611952e72593609fb9bfe954551 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:12:03 +0800
Subject: [PATCH 002/117] test: define database lifecycle regression coverage
---
src-tauri/src/db/tests.rs | 269 ++++++++++++++++++++++++++++++++++++++
1 file changed, 269 insertions(+)
create mode 100644 src-tauri/src/db/tests.rs
diff --git a/src-tauri/src/db/tests.rs b/src-tauri/src/db/tests.rs
new file mode 100644
index 0000000..30f1c18
--- /dev/null
+++ b/src-tauri/src/db/tests.rs
@@ -0,0 +1,269 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rusqlite::Connection;
+
+fn temp_db_path(label: &str) -> PathBuf {
+ let unique = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system time after unix epoch")
+ .as_nanos();
+ std::env::temp_dir().join(format!(
+ "timeprism-{label}-{}-{unique}.db",
+ std::process::id()
+ ))
+}
+
+fn remove_sqlite_files(path: &Path) {
+ let _ = fs::remove_file(path);
+ for suffix in ["-wal", "-shm"] {
+ let _ = fs::remove_file(format!("{}{}", path.display(), suffix));
+ }
+}
+
+fn table_exists(conn: &Connection, table: &str) -> bool {
+ conn.query_row(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
+ [table],
+ |row| row.get::<_, i64>(0),
+ )
+ .expect("query sqlite_master")
+ == 1
+}
+
+fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
+ let mut stmt = conn
+ .prepare(&format!("PRAGMA table_info({table})"))
+ .expect("prepare table info");
+ stmt.query_map([], |row| row.get::<_, String>(1))
+ .expect("query table info")
+ .map(|row| row.expect("read column name"))
+ .any(|name| name == column)
+}
+
+#[test]
+fn fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens() {
+ let path = temp_db_path("fresh");
+ remove_sqlite_files(&path);
+
+ {
+ let conn = Connection::open(&path).expect("open fresh sqlite file");
+ super::migrations::initialize_connection(&conn).expect("initialize database");
+
+ let foreign_keys: i64 = conn
+ .pragma_query_value(None, "foreign_keys", |row| row.get(0))
+ .expect("read foreign_keys pragma");
+ assert_eq!(foreign_keys, 1);
+
+ for table in [
+ "categories",
+ "task_sessions",
+ "app_usage_logs",
+ "app_rules",
+ "app_config",
+ "app_whitelist",
+ "reminders",
+ "daily_heatmap_snapshot",
+ ] {
+ assert!(table_exists(&conn, table), "missing table {table}");
+ }
+
+ let browser_mode: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'browser_title_mode'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read default browser mode");
+ assert_eq!(browser_mode, "BLUR");
+
+ let code_rule: String = conn
+ .query_row(
+ "SELECT mapped_type FROM app_rules WHERE process_name = 'code.exe'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read default code rule");
+ assert_eq!(code_rule, "LEARN");
+ }
+
+ {
+ let reopened = Connection::open(&path).expect("reopen initialized sqlite file");
+ assert!(table_exists(&reopened, "app_rules"));
+ let rule_count: i64 = reopened
+ .query_row("SELECT COUNT(*) FROM app_rules", [], |row| row.get(0))
+ .expect("count persisted rules");
+ assert!(rule_count >= 10);
+ }
+
+ remove_sqlite_files(&path);
+}
+
+#[test]
+fn database_initialization_is_idempotent_and_preserves_user_values() {
+ let path = temp_db_path("idempotent");
+ remove_sqlite_files(&path);
+ let conn = Connection::open(&path).expect("open sqlite file");
+
+ super::migrations::initialize_connection(&conn).expect("first initialization");
+ conn.execute(
+ "UPDATE app_config SET value = 'NONE' WHERE key = 'browser_title_mode'",
+ [],
+ )
+ .expect("customize browser title mode");
+ conn.execute(
+ "UPDATE app_rules SET mapped_type = 'REST' WHERE process_name = 'code.exe'",
+ [],
+ )
+ .expect("customize code rule");
+
+ super::migrations::initialize_connection(&conn).expect("second initialization");
+
+ let browser_mode: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'browser_title_mode'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read customized browser mode");
+ assert_eq!(browser_mode, "NONE");
+
+ let code_rule: String = conn
+ .query_row(
+ "SELECT mapped_type FROM app_rules WHERE process_name = 'code.exe'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read customized code rule");
+ assert_eq!(code_rule, "REST");
+
+ let root_count: i64 = conn
+ .query_row(
+ "SELECT COUNT(*) FROM categories WHERE id IN (1, 2)",
+ [],
+ |row| row.get(0),
+ )
+ .expect("count root categories");
+ assert_eq!(root_count, 2);
+
+ drop(conn);
+ remove_sqlite_files(&path);
+}
+
+#[test]
+fn legacy_schema_is_upgraded_without_losing_existing_rows() {
+ let path = temp_db_path("legacy-schema");
+ remove_sqlite_files(&path);
+ let conn = Connection::open(&path).expect("open legacy sqlite file");
+
+ conn.execute_batch(
+ r#"
+ CREATE TABLE app_rules (
+ process_name TEXT PRIMARY KEY,
+ mapped_type TEXT NOT NULL,
+ privacy_level TEXT NOT NULL
+ );
+ INSERT INTO app_rules (process_name, mapped_type, privacy_level)
+ VALUES ('legacy.exe', 'LEARN', 'NORMAL');
+
+ CREATE TABLE reminders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT NOT NULL,
+ repeat_rule TEXT NOT NULL,
+ remind_at INTEGER NULL,
+ daily_time_minutes INTEGER NULL,
+ is_completed INTEGER NOT NULL DEFAULT 0,
+ completed_day_key TEXT NULL,
+ completed_at INTEGER NULL,
+ snooze_until INTEGER NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ INSERT INTO reminders (
+ content, repeat_rule, remind_at, daily_time_minutes, is_completed,
+ completed_day_key, completed_at, snooze_until, created_at, updated_at
+ ) VALUES ('legacy reminder', 'DAILY', NULL, 540, 0, NULL, NULL, NULL, 100, 100);
+ "#,
+ )
+ .expect("create legacy schema");
+
+ super::migrations::initialize_connection(&conn).expect("migrate legacy schema");
+
+ assert!(column_exists(&conn, "app_rules", "created_at"));
+ assert!(column_exists(&conn, "app_rules", "updated_at"));
+ assert!(column_exists(&conn, "reminders", "weekly_days"));
+ assert!(column_exists(&conn, "reminders", "sort_order"));
+
+ let mapped_type: String = conn
+ .query_row(
+ "SELECT mapped_type FROM app_rules WHERE process_name = 'legacy.exe'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("legacy app rule survived migration");
+ assert_eq!(mapped_type, "LEARN");
+
+ let reminder: (String, i64) = conn
+ .query_row(
+ "SELECT content, daily_time_minutes FROM reminders WHERE content = 'legacy reminder'",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("legacy reminder survived migration");
+ assert_eq!(reminder, ("legacy reminder".to_string(), 540));
+
+ drop(conn);
+ remove_sqlite_files(&path);
+}
+
+#[test]
+fn legacy_database_file_is_copied_once_to_preferred_location() {
+ let legacy_path = temp_db_path("legacy-file");
+ let preferred_path = temp_db_path("preferred-file");
+ remove_sqlite_files(&legacy_path);
+ remove_sqlite_files(&preferred_path);
+
+ {
+ let legacy = Connection::open(&legacy_path).expect("create legacy sqlite file");
+ legacy
+ .execute_batch(
+ "CREATE TABLE marker (value TEXT NOT NULL); INSERT INTO marker VALUES ('legacy');",
+ )
+ .expect("seed legacy database");
+ }
+
+ super::connection::migrate_legacy_db_files(&legacy_path, &preferred_path)
+ .expect("copy legacy database");
+
+ {
+ let preferred = Connection::open(&preferred_path).expect("open copied preferred db");
+ let marker: String = preferred
+ .query_row("SELECT value FROM marker", [], |row| row.get(0))
+ .expect("read copied marker");
+ assert_eq!(marker, "legacy");
+ preferred
+ .execute("UPDATE marker SET value = 'preferred'", [])
+ .expect("customize preferred database");
+ }
+
+ {
+ let legacy = Connection::open(&legacy_path).expect("reopen legacy db");
+ legacy
+ .execute("UPDATE marker SET value = 'changed-legacy'", [])
+ .expect("change legacy database");
+ }
+
+ super::connection::migrate_legacy_db_files(&legacy_path, &preferred_path)
+ .expect("second legacy migration is a no-op");
+
+ let preferred = Connection::open(&preferred_path).expect("reopen preferred db");
+ let marker: String = preferred
+ .query_row("SELECT value FROM marker", [], |row| row.get(0))
+ .expect("read preserved preferred marker");
+ assert_eq!(marker, "preferred");
+
+ drop(preferred);
+ remove_sqlite_files(&legacy_path);
+ remove_sqlite_files(&preferred_path);
+}
From 970267cd42cf91c3e8581fd48eef04a763bf277a Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:12:09 +0800
Subject: [PATCH 003/117] test: enable database lifecycle regression suite
---
src-tauri/src/db/mod.rs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs
index 221c82c..45fc809 100644
--- a/src-tauri/src/db/mod.rs
+++ b/src-tauri/src/db/mod.rs
@@ -1,2 +1,5 @@
pub mod connection;
pub mod migrations;
+
+#[cfg(test)]
+mod tests;
From cd6797a35644cf30bc28c30948e91342c764820e Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:12:26 +0800
Subject: [PATCH 004/117] test: define database initialization integration
coverage
---
src-tauri/src/db/database_tests.rs | 152 +++++++++++++++++++++++++++++
1 file changed, 152 insertions(+)
create mode 100644 src-tauri/src/db/database_tests.rs
diff --git a/src-tauri/src/db/database_tests.rs b/src-tauri/src/db/database_tests.rs
new file mode 100644
index 0000000..f7a8916
--- /dev/null
+++ b/src-tauri/src/db/database_tests.rs
@@ -0,0 +1,152 @@
+use rusqlite::Connection;
+
+use super::migrations::init_connection;
+
+fn table_exists(conn: &Connection, table: &str) -> bool {
+ conn.query_row(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
+ [table],
+ |row| row.get::<_, i64>(0),
+ )
+ .expect("query table existence")
+ == 1
+}
+
+fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
+ let mut stmt = conn
+ .prepare(&format!("PRAGMA table_info({table})"))
+ .expect("prepare table info");
+ stmt.query_map([], |row| row.get::<_, String>(1))
+ .expect("query table info")
+ .any(|name| name.expect("parse column name") == column)
+}
+
+#[test]
+fn fresh_database_initialization_creates_schema_and_defaults() {
+ let conn = Connection::open_in_memory().expect("open in-memory db");
+
+ init_connection(&conn).expect("initialize fresh database");
+
+ for table in [
+ "categories",
+ "task_sessions",
+ "app_usage_logs",
+ "app_rules",
+ "app_config",
+ "app_whitelist",
+ "reminders",
+ "daily_heatmap_snapshot",
+ ] {
+ assert!(table_exists(&conn, table), "missing table: {table}");
+ }
+
+ let learn_name: String = conn
+ .query_row("SELECT name FROM categories WHERE id = 1", [], |row| row.get(0))
+ .expect("load LEARN root name");
+ let rest_name: String = conn
+ .query_row("SELECT name FROM categories WHERE id = 2", [], |row| row.get(0))
+ .expect("load REST root name");
+ assert_eq!(learn_name, "学习");
+ assert_eq!(rest_name, "休息");
+
+ let browser_mode: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'browser_title_mode'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("load browser_title_mode");
+ assert_eq!(browser_mode, "BLUR");
+
+ let code_rule: (String, String) = conn
+ .query_row(
+ "SELECT mapped_type, privacy_level FROM app_rules WHERE process_name = 'code.exe'",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("load code.exe rule");
+ assert_eq!(code_rule, ("LEARN".into(), "NORMAL".into()));
+
+ let foreign_keys: i64 = conn
+ .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
+ .expect("read foreign_keys pragma");
+ assert_eq!(foreign_keys, 1);
+}
+
+#[test]
+fn database_initialization_is_idempotent_and_preserves_user_values() {
+ let conn = Connection::open_in_memory().expect("open in-memory db");
+ init_connection(&conn).expect("initialize database first time");
+
+ conn.execute(
+ "UPDATE app_config SET value = '3600' WHERE key = 'heatmap_goal_seconds'",
+ [],
+ )
+ .expect("customize heatmap goal");
+ conn.execute(
+ "UPDATE app_rules SET mapped_type = 'REST' WHERE process_name = 'code.exe'",
+ [],
+ )
+ .expect("customize code.exe rule");
+
+ init_connection(&conn).expect("initialize database second time");
+
+ let goal: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'heatmap_goal_seconds'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("load customized goal");
+ let code_mapping: String = conn
+ .query_row(
+ "SELECT mapped_type FROM app_rules WHERE process_name = 'code.exe'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("load customized code mapping");
+
+ assert_eq!(goal, "3600");
+ assert_eq!(code_mapping, "REST");
+}
+
+#[test]
+fn legacy_reminders_schema_is_upgraded_without_losing_rows() {
+ let conn = Connection::open_in_memory().expect("open in-memory db");
+ conn.execute_batch(
+ r#"
+ CREATE TABLE reminders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT NOT NULL,
+ repeat_rule TEXT NOT NULL CHECK(repeat_rule IN ('NONE', 'DAILY')),
+ remind_at INTEGER NULL,
+ daily_time_minutes INTEGER NULL,
+ is_completed INTEGER NOT NULL DEFAULT 0,
+ completed_day_key TEXT NULL,
+ completed_at INTEGER NULL,
+ snooze_until INTEGER NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ INSERT INTO reminders (
+ content, repeat_rule, remind_at, daily_time_minutes,
+ is_completed, created_at, updated_at
+ ) VALUES ('legacy reminder', 'DAILY', NULL, 540, 0, 1, 1);
+ "#,
+ )
+ .expect("create legacy reminders schema");
+
+ init_connection(&conn).expect("upgrade legacy database");
+
+ assert!(column_exists(&conn, "reminders", "weekly_days"));
+ assert!(column_exists(&conn, "reminders", "sort_order"));
+
+ let reminder: (String, String, Option, i64) = conn
+ .query_row(
+ "SELECT content, repeat_rule, weekly_days, sort_order FROM reminders WHERE id = 1",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
+ )
+ .expect("load migrated reminder");
+ assert_eq!(reminder, ("legacy reminder".into(), "DAILY".into(), None, 0));
+}
From 80ed2ba8d5850dd587e5c9d9b8d6e146872cb4c1 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:16:31 +0800
Subject: [PATCH 005/117] ci: add reproducible Windows release workflow
---
.github/workflows/release.yml | 100 ++++++++++++++++++++++++++++++++++
1 file changed, 100 insertions(+)
create mode 100644 .github/workflows/release.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ecd5d12
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,100 @@
+name: Windows Release
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ windows:
+ name: Build Windows installers
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Enable pnpm
+ run: |
+ corepack enable
+ corepack prepare pnpm@9.15.9 --activate
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust build output
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: "./src-tauri -> target"
+
+ - name: Install frontend dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Validate synchronized versions
+ shell: pwsh
+ run: |
+ $tauriVersion = (Get-Content src-tauri/tauri.conf.json | ConvertFrom-Json).version
+ $packageVersion = (Get-Content package.json | ConvertFrom-Json).version
+ $cargoMetadata = cargo metadata --manifest-path src-tauri/Cargo.toml --no-deps --format-version 1 | ConvertFrom-Json
+ $cargoVersion = ($cargoMetadata.packages | Where-Object { $_.name -eq 'timeprism' } | Select-Object -First 1).version
+
+ if ($tauriVersion -ne $packageVersion -or $tauriVersion -ne $cargoVersion) {
+ throw "Version mismatch: tauri=$tauriVersion package=$packageVersion cargo=$cargoVersion"
+ }
+
+ if ($env:GITHUB_REF_TYPE -eq 'tag') {
+ $expectedTag = "v$tauriVersion"
+ if ($env:GITHUB_REF_NAME -ne $expectedTag) {
+ throw "Tag/version mismatch: pushed=$env:GITHUB_REF_NAME expected=$expectedTag"
+ }
+ }
+
+ "TIMEPRISM_VERSION=$tauriVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
+
+ - name: Typecheck frontend
+ run: pnpm run typecheck
+
+ - name: Test Rust backend
+ working-directory: src-tauri
+ run: cargo test
+
+ - name: Build installers for manual validation
+ if: github.event_name == 'workflow_dispatch'
+ run: pnpm tauri build -- --bundles nsis,msi
+
+ - name: Upload manual-build installers
+ if: github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: TimePrism-${{ env.TIMEPRISM_VERSION }}-windows-installers
+ if-no-files-found: error
+ path: |
+ src-tauri/target/release/bundle/nsis/*-setup.exe
+ src-tauri/target/release/bundle/msi/*.msi
+
+ - name: Build and create draft GitHub Release
+ if: github.event_name == 'push' && github.ref_type == 'tag'
+ uses: tauri-apps/tauri-action@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tagName: v__VERSION__
+ releaseName: TimePrism v__VERSION__
+ releaseBody: |
+ Windows-first, local-first TimePrism desktop release.
+
+ Download either the NSIS setup executable or MSI installer from the release assets.
+ This project is currently unsigned, so Windows SmartScreen may warn about an unknown publisher.
+ releaseDraft: true
+ prerelease: false
+ generateReleaseNotes: true
+ args: --bundles nsis,msi
From 212543e70e68c4e60e9f93ab8d2d385876287836 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:16:44 +0800
Subject: [PATCH 006/117] docs: document Windows release gate
---
docs/RELEASE_CHECKLIST.md | 72 +++++++++++++++++++++++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 docs/RELEASE_CHECKLIST.md
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..80b3a4e
--- /dev/null
+++ b/docs/RELEASE_CHECKLIST.md
@@ -0,0 +1,72 @@
+# Release Checklist
+
+TimePrism publishes Windows installers from GitHub Actions. Releases are created as **drafts** first so the generated installers can be smoke-tested before they become public.
+
+## Release artifacts
+
+The Windows release workflow builds both Tauri-supported installer formats:
+
+- NSIS setup executable: `*-setup.exe`
+- WiX/MSI installer: `*.msi`
+
+The current project is unsigned. Windows SmartScreen can therefore show an unknown-publisher warning until code signing is added.
+
+## Before tagging
+
+1. Confirm `main` CI is green.
+2. Update the same semantic version in:
+ - `package.json`
+ - `src-tauri/Cargo.toml`
+ - `src-tauri/tauri.conf.json`
+3. Run or verify:
+
+ ```text
+ pnpm run typecheck
+ pnpm run build:check
+ cargo test --manifest-path src-tauri/Cargo.toml
+ ```
+
+4. Smoke-test the desktop flows listed in `docs/SMOKE_TESTS.md`.
+5. Commit the synchronized version bump to `main`.
+
+The release workflow rejects mismatched Node/Cargo/Tauri versions. Tag-triggered runs also reject a tag that does not equal `v`.
+
+## Dry-run installer build
+
+Run **Windows Release** manually from GitHub Actions before creating a tag.
+
+The `workflow_dispatch` path:
+
+1. validates version synchronization;
+2. runs frontend typecheck and Rust tests;
+3. builds both NSIS and MSI installers on `windows-latest`;
+4. uploads the installers as workflow artifacts;
+5. does **not** create a GitHub Release.
+
+Install and smoke-test at least one generated installer on Windows before publishing a version tag.
+
+## Publish a release draft
+
+Create and push a tag that exactly matches the app version, for example:
+
+```text
+git tag v0.2.0
+git push origin v0.2.0
+```
+
+The tag-triggered workflow validates the tag/version match and uses `tauri-apps/tauri-action` to create a draft GitHub Release with the NSIS and MSI assets attached.
+
+## Verify the draft
+
+Before publishing the draft Release:
+
+- install from the NSIS `-setup.exe` asset;
+- install or inspect the `.msi` asset;
+- launch TimePrism and confirm the main window renders normally;
+- verify Home, Insights, Focus Guard, reminders, privacy settings, and desktop pet flows;
+- verify full-app quit behavior;
+- confirm local data survives app restart;
+- confirm Release notes describe any known limitations;
+- explicitly retain the unsigned/SmartScreen note until Windows code signing exists.
+
+Only after this verification should the GitHub Release be published.
From 8d820af3b9a7ab4a4638d9a2687d96a9ec738871 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:17:35 +0800
Subject: [PATCH 007/117] docs: define real product media capture gate
---
docs/media/README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100644 docs/media/README.md
diff --git a/docs/media/README.md b/docs/media/README.md
new file mode 100644
index 0000000..0f6f7c7
--- /dev/null
+++ b/docs/media/README.md
@@ -0,0 +1,56 @@
+# Product Media Capture Guide
+
+This directory is reserved for **real TimePrism application captures** used by the repository README and Releases. Do not use mockups or generated UI images as evidence of implemented behavior.
+
+## Required captures
+
+Capture these from a current Windows build using synthetic or sanitized data:
+
+| File | Surface | What must be visible |
+| --- | --- | --- |
+| `timeprism-home.png` | Home | Today totals, rhythm/progress, reminders, navigation |
+| `timeprism-insights.png` | Insights | Top apps plus at least one historical/heatmap view |
+| `timeprism-focus-guard.png` | Focus Guard | Rule classification or idle-resolution workflow without private titles |
+| `timeprism-pet.png` | Desktop pet | Pet together with its compact panel or prompt |
+| `timeprism-demo.gif` | Short product flow | Home -> Insights -> Focus Guard -> desktop pet |
+
+## Privacy gate
+
+Foreground-window data can contain private information. Before committing any capture:
+
+- use synthetic application/process names where practical;
+- remove real window titles, document names, URLs, account names, and notification text;
+- do not expose the real SQLite database path or local username;
+- verify Focus Guard diagnostics contain no raw private titles;
+- inspect every GIF frame, not only its first frame.
+
+If sanitization would make a capture ambiguous, create a clean demo database instead of editing pixels after capture.
+
+## Presentation rules
+
+- Capture the actual application, not Figma/mockups/generated UI.
+- Keep the main window at its normal aspect ratio; do not stretch screenshots.
+- Prefer one coherent theme across all captures.
+- Crop surrounding desktop chrome when it adds no information.
+- Keep screenshots readable on a GitHub README at roughly 900-1200 px display width.
+- Keep the GIF focused on interaction; avoid cursor wandering and long idle pauses.
+
+## README layout after capture
+
+Place the GIF directly below the repository badges, then use a compact two-column screenshot table:
+
+```markdown
+
+
+
+
+| Home | Insights |
+| --- | --- |
+|  |  |
+
+| Focus Guard | Desktop pet |
+| --- | --- |
+|  |  |
+```
+
+Do not add these references to the root README until the corresponding files exist, because broken image placeholders are worse than a text-only README.
From 76fcdc9b624fe76083b32779995324d7643b67c5 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:18:14 +0800
Subject: [PATCH 008/117] test: fix database regression helper lifetime
---
src-tauri/src/db/tests.rs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src-tauri/src/db/tests.rs b/src-tauri/src/db/tests.rs
index 30f1c18..1eb7bb2 100644
--- a/src-tauri/src/db/tests.rs
+++ b/src-tauri/src/db/tests.rs
@@ -36,10 +36,12 @@ fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.expect("prepare table info");
- stmt.query_map([], |row| row.get::<_, String>(1))
+ let has_column = stmt
+ .query_map([], |row| row.get::<_, String>(1))
.expect("query table info")
.map(|row| row.expect("read column name"))
- .any(|name| name == column)
+ .any(|name| name == column);
+ has_column
}
#[test]
From c8b981079bd211474aff072655ca25682a9b03d2 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:18:22 +0800
Subject: [PATCH 009/117] test: remove duplicate database regression file
---
src-tauri/src/db/database_tests.rs | 152 -----------------------------
1 file changed, 152 deletions(-)
delete mode 100644 src-tauri/src/db/database_tests.rs
diff --git a/src-tauri/src/db/database_tests.rs b/src-tauri/src/db/database_tests.rs
deleted file mode 100644
index f7a8916..0000000
--- a/src-tauri/src/db/database_tests.rs
+++ /dev/null
@@ -1,152 +0,0 @@
-use rusqlite::Connection;
-
-use super::migrations::init_connection;
-
-fn table_exists(conn: &Connection, table: &str) -> bool {
- conn.query_row(
- "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
- [table],
- |row| row.get::<_, i64>(0),
- )
- .expect("query table existence")
- == 1
-}
-
-fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
- let mut stmt = conn
- .prepare(&format!("PRAGMA table_info({table})"))
- .expect("prepare table info");
- stmt.query_map([], |row| row.get::<_, String>(1))
- .expect("query table info")
- .any(|name| name.expect("parse column name") == column)
-}
-
-#[test]
-fn fresh_database_initialization_creates_schema_and_defaults() {
- let conn = Connection::open_in_memory().expect("open in-memory db");
-
- init_connection(&conn).expect("initialize fresh database");
-
- for table in [
- "categories",
- "task_sessions",
- "app_usage_logs",
- "app_rules",
- "app_config",
- "app_whitelist",
- "reminders",
- "daily_heatmap_snapshot",
- ] {
- assert!(table_exists(&conn, table), "missing table: {table}");
- }
-
- let learn_name: String = conn
- .query_row("SELECT name FROM categories WHERE id = 1", [], |row| row.get(0))
- .expect("load LEARN root name");
- let rest_name: String = conn
- .query_row("SELECT name FROM categories WHERE id = 2", [], |row| row.get(0))
- .expect("load REST root name");
- assert_eq!(learn_name, "学习");
- assert_eq!(rest_name, "休息");
-
- let browser_mode: String = conn
- .query_row(
- "SELECT value FROM app_config WHERE key = 'browser_title_mode'",
- [],
- |row| row.get(0),
- )
- .expect("load browser_title_mode");
- assert_eq!(browser_mode, "BLUR");
-
- let code_rule: (String, String) = conn
- .query_row(
- "SELECT mapped_type, privacy_level FROM app_rules WHERE process_name = 'code.exe'",
- [],
- |row| Ok((row.get(0)?, row.get(1)?)),
- )
- .expect("load code.exe rule");
- assert_eq!(code_rule, ("LEARN".into(), "NORMAL".into()));
-
- let foreign_keys: i64 = conn
- .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
- .expect("read foreign_keys pragma");
- assert_eq!(foreign_keys, 1);
-}
-
-#[test]
-fn database_initialization_is_idempotent_and_preserves_user_values() {
- let conn = Connection::open_in_memory().expect("open in-memory db");
- init_connection(&conn).expect("initialize database first time");
-
- conn.execute(
- "UPDATE app_config SET value = '3600' WHERE key = 'heatmap_goal_seconds'",
- [],
- )
- .expect("customize heatmap goal");
- conn.execute(
- "UPDATE app_rules SET mapped_type = 'REST' WHERE process_name = 'code.exe'",
- [],
- )
- .expect("customize code.exe rule");
-
- init_connection(&conn).expect("initialize database second time");
-
- let goal: String = conn
- .query_row(
- "SELECT value FROM app_config WHERE key = 'heatmap_goal_seconds'",
- [],
- |row| row.get(0),
- )
- .expect("load customized goal");
- let code_mapping: String = conn
- .query_row(
- "SELECT mapped_type FROM app_rules WHERE process_name = 'code.exe'",
- [],
- |row| row.get(0),
- )
- .expect("load customized code mapping");
-
- assert_eq!(goal, "3600");
- assert_eq!(code_mapping, "REST");
-}
-
-#[test]
-fn legacy_reminders_schema_is_upgraded_without_losing_rows() {
- let conn = Connection::open_in_memory().expect("open in-memory db");
- conn.execute_batch(
- r#"
- CREATE TABLE reminders (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- content TEXT NOT NULL,
- repeat_rule TEXT NOT NULL CHECK(repeat_rule IN ('NONE', 'DAILY')),
- remind_at INTEGER NULL,
- daily_time_minutes INTEGER NULL,
- is_completed INTEGER NOT NULL DEFAULT 0,
- completed_day_key TEXT NULL,
- completed_at INTEGER NULL,
- snooze_until INTEGER NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- );
- INSERT INTO reminders (
- content, repeat_rule, remind_at, daily_time_minutes,
- is_completed, created_at, updated_at
- ) VALUES ('legacy reminder', 'DAILY', NULL, 540, 0, 1, 1);
- "#,
- )
- .expect("create legacy reminders schema");
-
- init_connection(&conn).expect("upgrade legacy database");
-
- assert!(column_exists(&conn, "reminders", "weekly_days"));
- assert!(column_exists(&conn, "reminders", "sort_order"));
-
- let reminder: (String, String, Option, i64) = conn
- .query_row(
- "SELECT content, repeat_rule, weekly_days, sort_order FROM reminders WHERE id = 1",
- [],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
- )
- .expect("load migrated reminder");
- assert_eq!(reminder, ("legacy reminder".into(), "DAILY".into(), None, 0));
-}
From 5c28cb855bbdcc11e4c6e3454da20ee0df9515d9 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:19:44 +0800
Subject: [PATCH 010/117] ci: modernize Windows checks and cache Rust builds
---
.github/workflows/ci.yml | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e0bf3e9..b431e2f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,10 +14,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v7
with:
node-version: 20
@@ -26,6 +26,15 @@ jobs:
corepack enable
corepack prepare pnpm@9.15.9 --activate
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust build output
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: "./src-tauri -> target"
+ cache-on-failure: true
+
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
@@ -35,9 +44,9 @@ jobs:
- name: Build frontend to temporary output
run: pnpm run build:check
- - name: Check Rust backend
+ - name: Check all Rust targets
working-directory: src-tauri
- run: cargo check
+ run: cargo check --all-targets
- name: Test Rust backend
working-directory: src-tauri
From db427f76219f3b69ab529d4ee5410d40efafae80 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:20:05 +0800
Subject: [PATCH 011/117] ci: use current GitHub action runtimes for releases
---
.github/workflows/release.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ecd5d12..09388a7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v7
with:
node-version: 20
@@ -73,7 +73,7 @@ jobs:
- name: Upload manual-build installers
if: github.event_name == 'workflow_dispatch'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: TimePrism-${{ env.TIMEPRISM_VERSION }}-windows-installers
if-no-files-found: error
From 3c79357982b201dcd823731724817ee808002c4d Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:21:49 +0800
Subject: [PATCH 012/117] docs: expose distribution and product evidence gates
---
README.md | 24 ++++++++++++++++++++----
1 file changed, 20 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index cc31bc4..89f7af5 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
+ Windows Releases |
What It Demonstrates |
Architecture |
Engineering Highlights |
@@ -31,6 +32,8 @@ Rather than relying on manual start/stop timers, it samples the foreground appli
The project is intentionally scoped as a Windows-first, local-first desktop application. It does not depend on cloud accounts, remote sync, or external services for its core workflow.
+> **Release status:** the currently published `v0.1.0-preview` is an early portable build and predates the latest architecture/reliability work. The repository now includes a gated Windows release pipeline for NSIS `-setup.exe` and MSI installers; releases are created as drafts and smoke-tested before publication.
+
## What It Demonstrates
TimePrism is primarily a desktop full-stack engineering project. The repository demonstrates:
@@ -144,7 +147,7 @@ SQLite persistence
| Backend | Rust |
| Storage | SQLite / rusqlite |
| Platform integration | Windows + Tauri window APIs |
-| Validation | GitHub Actions, `vue-tsc`, Vite build, `cargo check`, `cargo test` |
+| Validation | GitHub Actions, `vue-tsc`, Vite build, `cargo check --all-targets`, `cargo test` |
## Project Structure
@@ -178,12 +181,12 @@ timeprism/
## Validation
-Every push to `main` and `refactor/**`, plus pull requests, runs the repository CI workflow on Windows:
+Every push to `main` and `refactor/**`, plus pull requests, runs the repository CI workflow on Windows. Rust build output is cached between runs and all test targets are compiled before the test phase:
```text
pnpm run typecheck
pnpm run build:check
-cargo check
+cargo check --all-targets
cargo test
```
@@ -191,6 +194,15 @@ The current desktop flow has also been manually smoke-tested on Windows for star
CI and smoke tests are used as project-level regression evidence; they are not a claim of production-grade hardening.
+## Distribution
+
+`.github/workflows/release.yml` provides two release paths on `windows-latest`:
+
+- **Manual dry run:** builds NSIS and MSI installers and uploads them as workflow artifacts without creating a Release.
+- **Version tag:** requires the tag to exactly match the synchronized Cargo/Node/Tauri app version, then creates a **draft** GitHub Release with both installer formats.
+
+The project is currently unsigned, so Windows SmartScreen may warn about an unknown publisher. Release publication is deliberately separated from installer generation so the generated build can be smoke-tested first. See [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md).
+
## Privacy
TimePrism is designed around local storage and explicit privacy boundaries.
@@ -242,7 +254,7 @@ pnpm dev
pnpm run typecheck
pnpm run build:check
cd src-tauri
-cargo check
+cargo check --all-targets
cargo test
```
@@ -277,8 +289,12 @@ Engineering notes and design rationale live under `docs/`, including:
- database and privacy notes
- smoke-test catalog
- testing strategy
+- release checklist
+- product-media capture guide
- ADRs and refactor traceability
+Real product screenshots/GIF are only added from sanitized current Windows builds; the capture/privacy requirements are documented in [`docs/media/README.md`](docs/media/README.md) so generated or mock UI is not presented as implementation evidence.
+
## License
TimePrism is licensed under the MIT License.
From 38bc4a0b7d0a5fc78782476d9df9c459764fdac5 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:23:55 +0800
Subject: [PATCH 013/117] ci: pass installer bundle flags directly to Tauri CLI
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 09388a7..b1f3645 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,7 +69,7 @@ jobs:
- name: Build installers for manual validation
if: github.event_name == 'workflow_dispatch'
- run: pnpm tauri build -- --bundles nsis,msi
+ run: pnpm tauri build --bundles nsis,msi
- name: Upload manual-build installers
if: github.event_name == 'workflow_dispatch'
From 44ad6bc478c50bf277c0d03214731ec3db59448c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:24:25 +0800
Subject: [PATCH 014/117] refactor: expose legacy database file migration seam
---
src-tauri/src/db/connection.rs | 36 +++++++++++++++++-----------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs
index e93e680..4e9d23d 100644
--- a/src-tauri/src/db/connection.rs
+++ b/src-tauri/src/db/connection.rs
@@ -27,17 +27,15 @@ fn db_path(app: &AppHandle) -> Result {
Ok(data_dir.join("timeprism.db"))
}
-fn try_migrate_legacy_db(app: &AppHandle, target_path: &Path) -> Result<(), String> {
- if target_path.exists() {
- return Ok(());
- }
-
- let legacy_path = legacy_db_path(app)?;
- if !legacy_path.exists() {
+pub(super) fn migrate_legacy_db_files(
+ legacy_path: &Path,
+ target_path: &Path,
+) -> Result<(), String> {
+ if target_path.exists() || !legacy_path.exists() {
return Ok(());
}
- fs::copy(&legacy_path, target_path).map_err(|e| {
+ fs::copy(legacy_path, target_path).map_err(|e| {
format!(
"failed to migrate legacy sqlite db from {} to {}: {e}",
legacy_path.display(),
@@ -62,6 +60,11 @@ fn try_migrate_legacy_db(app: &AppHandle, target_path: &Path) -> Result<(), Stri
Ok(())
}
+fn try_migrate_legacy_db(app: &AppHandle, target_path: &Path) -> Result<(), String> {
+ let legacy_path = legacy_db_path(app)?;
+ migrate_legacy_db_files(&legacy_path, target_path)
+}
+
pub fn open_connection(app: &AppHandle) -> Result {
let preferred_path = db_path(app)?;
let migration_error = try_migrate_legacy_db(app, &preferred_path).err();
@@ -78,15 +81,12 @@ pub fn open_connection(app: &AppHandle) -> Result {
match Connection::open(&preferred_path) {
Ok(conn) => Ok(conn),
- Err(primary_err) => {
- Connection::open(&legacy_path).map_err(|fallback_err| {
- format!(
- "failed to open preferred sqlite db {}: {primary_err}; fallback legacy db {} also failed: {fallback_err}",
- preferred_path.display(),
- legacy_path.display()
- )
- })
- }
+ Err(primary_err) => Connection::open(&legacy_path).map_err(|fallback_err| {
+ format!(
+ "failed to open preferred sqlite db {}: {primary_err}; fallback legacy db {} also failed: {fallback_err}",
+ preferred_path.display(),
+ legacy_path.display()
+ )
+ }),
}
}
-
From 8b4f1b434472f302105a386720ce27ad2a076d4f Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:24:56 +0800
Subject: [PATCH 015/117] refactor: extract connection-based database
initializer
---
src-tauri/src/db/migrations.rs | 68 ++++++++++++++++++----------------
1 file changed, 36 insertions(+), 32 deletions(-)
diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs
index 7605a12..caac340 100644
--- a/src-tauri/src/db/migrations.rs
+++ b/src-tauri/src/db/migrations.rs
@@ -174,8 +174,7 @@ pub fn ensure_heatmap_snapshot_table(conn: &Connection) -> Result<(), String> {
Ok(())
}
-pub fn init_database(app: &AppHandle) -> Result<(), String> {
- let conn = open_connection(app)?;
+pub(super) fn initialize_connection(conn: &Connection) -> Result<(), String> {
conn.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| format!("failed to enable foreign keys: {e}"))?;
@@ -211,44 +210,44 @@ pub fn init_database(app: &AppHandle) -> Result<(), String> {
privacy_level TEXT NOT NULL CHECK(privacy_level IN ('NORMAL', 'BLUR_TITLE', 'WHITELIST_ONLY'))
);
- CREATE TABLE IF NOT EXISTS app_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS app_whitelist (
- process_name TEXT PRIMARY KEY
- );
-
- CREATE TABLE IF NOT EXISTS reminders (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- content TEXT NOT NULL,
- repeat_rule TEXT NOT NULL CHECK(repeat_rule IN ('NONE', 'DAILY', 'WEEKLY')),
- sort_order INTEGER NOT NULL DEFAULT 0,
- remind_at INTEGER NULL,
- daily_time_minutes INTEGER NULL,
- weekly_days TEXT NULL,
- is_completed INTEGER NOT NULL DEFAULT 0,
- completed_day_key TEXT NULL,
- completed_at INTEGER NULL,
- snooze_until INTEGER NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- );
+ CREATE TABLE IF NOT EXISTS app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS app_whitelist (
+ process_name TEXT PRIMARY KEY
+ );
+
+ CREATE TABLE IF NOT EXISTS reminders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content TEXT NOT NULL,
+ repeat_rule TEXT NOT NULL CHECK(repeat_rule IN ('NONE', 'DAILY', 'WEEKLY')),
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ remind_at INTEGER NULL,
+ daily_time_minutes INTEGER NULL,
+ weekly_days TEXT NULL,
+ is_completed INTEGER NOT NULL DEFAULT 0,
+ completed_day_key TEXT NULL,
+ completed_at INTEGER NULL,
+ snooze_until INTEGER NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
CREATE INDEX IF NOT EXISTS idx_task_sessions_start_time ON task_sessions(start_time);
CREATE INDEX IF NOT EXISTS idx_app_usage_logs_start ON app_usage_logs(start_timestamp);
CREATE INDEX IF NOT EXISTS idx_app_usage_logs_process ON app_usage_logs(process_name);
- CREATE INDEX IF NOT EXISTS idx_reminders_due_none ON reminders(repeat_rule, remind_at, is_completed);
- CREATE INDEX IF NOT EXISTS idx_reminders_due_daily ON reminders(repeat_rule, daily_time_minutes, completed_day_key);
+ CREATE INDEX IF NOT EXISTS idx_reminders_due_none ON reminders(repeat_rule, remind_at, is_completed);
+ CREATE INDEX IF NOT EXISTS idx_reminders_due_daily ON reminders(repeat_rule, daily_time_minutes, completed_day_key);
"#,
)
.map_err(|e| format!("failed to run schema init: {e}"))?;
- ensure_app_rules_time_columns(&conn)?;
- ensure_heatmap_snapshot_table(&conn)?;
- ensure_reminders_weekly_columns(&conn)?;
- ensure_reminders_sort_order(&conn)?;
+ ensure_app_rules_time_columns(conn)?;
+ ensure_heatmap_snapshot_table(conn)?;
+ ensure_reminders_weekly_columns(conn)?;
+ ensure_reminders_sort_order(conn)?;
conn.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS idx_reminders_due_none ON reminders(repeat_rule, remind_at, is_completed);
@@ -325,3 +324,8 @@ pub fn init_database(app: &AppHandle) -> Result<(), String> {
Ok(())
}
+
+pub fn init_database(app: &AppHandle) -> Result<(), String> {
+ let conn = open_connection(app)?;
+ initialize_connection(&conn)
+}
From 1f48690f28267ae608de54f406d34fec8f3fb50b Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:25:59 +0800
Subject: [PATCH 016/117] refactor: preserve legacy migration short-circuit
semantics
---
src-tauri/src/db/connection.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs
index 4e9d23d..f2f2019 100644
--- a/src-tauri/src/db/connection.rs
+++ b/src-tauri/src/db/connection.rs
@@ -61,6 +61,10 @@ pub(super) fn migrate_legacy_db_files(
}
fn try_migrate_legacy_db(app: &AppHandle, target_path: &Path) -> Result<(), String> {
+ if target_path.exists() {
+ return Ok(());
+ }
+
let legacy_path = legacy_db_path(app)?;
migrate_legacy_db_files(&legacy_path, target_path)
}
From 48de5c260eda3845534a35b5aeb5474e8efe5f6a Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:26:41 +0800
Subject: [PATCH 017/117] ci: cancel superseded pull-request runs
---
.github/workflows/ci.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b431e2f..816d49b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,10 @@ on:
- main
- "refactor/**"
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
jobs:
checks:
name: Typecheck, Vite build, Rust check/test
From 1f14230570f33c152c18bf1f48c1e1a7861d118f Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:29:51 +0800
Subject: [PATCH 018/117] ci: move validation to Node 24 LTS
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 816d49b..707bc58 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v7
with:
- node-version: 20
+ node-version: 24
- name: Enable pnpm
run: |
From cee67e6236b1bc800e55920f3e008e7204c1e0b6 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:30:06 +0800
Subject: [PATCH 019/117] ci: use Node 24 LTS for Windows releases
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b1f3645..8689b53 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,7 +21,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v7
with:
- node-version: 20
+ node-version: 24
- name: Enable pnpm
run: |
From 78986c5916420c572bd22bfef02ce77495109591 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:30:38 +0800
Subject: [PATCH 020/117] chore: declare Node 24 development baseline
---
package.json | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index aa045b9..2857ea8 100644
--- a/package.json
+++ b/package.json
@@ -3,14 +3,17 @@
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@9.15.9",
+ "engines": {
+ "node": "24.x"
+ },
"type": "module",
"scripts": {
- "dev": "vite",
- "typecheck": "vue-tsc --noEmit",
- "build:check": "vue-tsc --noEmit && vite build --outDir dist-codex-check --emptyOutDir",
- "build": "vue-tsc --noEmit && vite build",
- "preview": "vite preview",
- "tauri": "tauri"
+ "dev": "vite",
+ "typecheck": "vue-tsc --noEmit",
+ "build:check": "vue-tsc --noEmit && vite build --outDir dist-codex-check --emptyOutDir",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "tauri": "tauri"
},
"dependencies": {
"vue": "^3.5.13",
From 0f0d591f995250ebf816511c81e7429adb2a8e65 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:31:05 +0800
Subject: [PATCH 021/117] docs: align prerequisites with Node 24 LTS
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 89f7af5..1045e11 100644
--- a/README.md
+++ b/README.md
@@ -222,7 +222,7 @@ Because active-window data can be sensitive, privacy is treated as part of the d
Recommended local development environment:
-- Node.js 20
+- Node.js 24 LTS
- pnpm 9
- Rust toolchain
- Tauri v2 system prerequisites
From 89a72fe6a7dce48d0197a1fff44efdb3792905b1 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:31:25 +0800
Subject: [PATCH 022/117] docs: align testing strategy with current coverage
---
docs/TESTING_STRATEGY.md | 118 +++++++++++++++++++--------------------
1 file changed, 56 insertions(+), 62 deletions(-)
diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md
index 02c3337..e94874b 100644
--- a/docs/TESTING_STRATEGY.md
+++ b/docs/TESTING_STRATEGY.md
@@ -1,12 +1,12 @@
# Testing Strategy
-The goal is not perfect coverage before refactor. The goal is enough feedback to make change safe.
+The goal is not nominal coverage. The goal is enough deterministic feedback to make refactors and desktop-runtime changes safe.
## Test Categories
### 1. Static Checks
-Use for every frontend or backend batch.
+Run for every frontend or backend batch.
Frontend:
@@ -18,67 +18,70 @@ pnpm.cmd run build:check
Backend:
```powershell
-cargo check
+cargo check --all-targets
+cargo test
```
-### 2. Characterization Tests
+CI runs these checks on Windows so the platform-specific backend and the Rust test target are compiled in the same environment used by the application.
-Purpose: lock current behavior before moving risky code.
+### 2. Characterization Tests
-Use for:
+Purpose: lock important existing behavior before moving or rewriting risky code.
-- Business-day window calculation.
-- Interval merging and overlap.
-- Reminder recurrence.
-- Privacy title transformation.
-- App rule normalization.
-- Heatmap level calculation.
-- Usage-stack aggregation.
+Covered areas include:
-Important: characterization tests describe current behavior. They do not prove the behavior is ideal.
+- business-day window calculation;
+- interval merging and overlap;
+- reminder recurrence and wall-clock semantics;
+- privacy title transformation and diagnostic redaction;
+- app-rule normalization;
+- analytics aggregation;
+- idle-interval replacement.
-### 3. Contract Tests
+Characterization tests describe current behavior. They are regression evidence, not proof that every existing behavior is ideal.
-Purpose: ensure command payloads and return shapes remain compatible.
+### 3. Contract Checks
-Target:
+Purpose: ensure frontend callers and Tauri command boundaries remain compatible.
-- `src/api.ts` types.
-- Tauri command names in `src-tauri/src/lib.rs`.
-- Pet and pet-panel command usage.
+Current safeguards:
-Early practical approach:
+- TypeScript typecheck for command payload/return usage;
+- Tauri command inventory in `docs/API_CONTRACTS.md`;
+- Rust compilation of all targets;
+- manual smoke coverage for multi-window behavior that is not practical to prove with pure unit tests.
-- TypeScript typecheck.
-- Manual command/caller table in `docs/API_CONTRACTS.md`.
-- Add automated command smoke tests later when a stable test harness exists.
+Automated end-to-end Tauri command tests remain a later hardening option if a stable desktop test harness is introduced.
### 4. Repository / Database Tests
-Purpose: protect schema, migration, and query semantics.
+Purpose: protect schema, migration, seed, and file-lifecycle semantics.
-Target:
+`src-tauri/src/db/tests.rs` uses synthetic temporary SQLite files and covers:
-- Fresh DB initialization.
-- Legacy DB path fallback.
-- Default app rules/config insertion.
-- Reminder table migrations.
-- Heatmap snapshot table creation.
+- fresh database initialization;
+- required schema/table creation;
+- default app rules and config insertion;
+- close/reopen persistence;
+- repeated initialization without overwriting user-modified values;
+- legacy `app_rules` / `reminders` schema migration without losing rows;
+- one-time migration from the legacy database location to the preferred location.
-Use temporary SQLite files. Do not test against the user's real app data.
+Service-level tests also use in-memory SQLite connections for rules, privacy, reminders, usage, and analytics behavior.
-### 5. Manual Smoke Tests
+Never run automated tests against the user's real TimePrism database.
-Use `docs/SMOKE_TESTS.md`.
+### 5. Manual Smoke Tests
-Manual smoke tests are required when changing:
+Use `docs/SMOKE_TESTS.md` when changing:
-- Window behavior.
-- Pet behavior.
-- Privacy settings.
-- Capture loop.
-- Reminder workflows.
-- Navigation and page structure.
+- window behavior;
+- pet behavior;
+- privacy settings;
+- capture loop;
+- reminder workflows;
+- navigation and page structure;
+- packaged Windows installers.
## Risk-Based Validation Matrix
@@ -88,30 +91,21 @@ Manual smoke tests are required when changing:
| Type-only frontend change | `vue-tsc` |
| Frontend component change | `vue-tsc`, Vite temp build, relevant smoke test |
| API wrapper change | `vue-tsc`, API contracts reviewed |
-| Rust pure helper extraction | `cargo check`, unit/characterization test if available |
-| Tauri command wrapper change | `cargo check`, frontend typecheck if caller touched |
-| SQLite query change | `cargo check`, DB/repository test or manual DB smoke |
-| Privacy/capture change | `cargo check`, privacy checklist, manual capture smoke |
-| Pet/window change | Vite temp build, `cargo check`, pet smoke test |
-| Full packaging change | full Tauri build |
-
-## Initial Tests To Add First
-
-Recommended first automated tests:
-
-1. Business day starts at 04:00 local time.
-2. `merge_intervals_total` handles overlapping and adjacent intervals.
-3. Reminder daily/weekly next due calculation.
-4. Browser/private title mode transformation.
-5. Rule process-name normalization.
+| Rust pure helper extraction | `cargo check --all-targets`, relevant unit/characterization test |
+| Tauri command wrapper change | `cargo check --all-targets`, frontend typecheck if caller touched |
+| SQLite query/schema change | `cargo check --all-targets`, `cargo test`, DB lifecycle/repository test |
+| Privacy/capture change | Rust checks/tests, privacy checklist, manual capture smoke |
+| Pet/window change | Vite temp build, Rust checks/tests, pet smoke test |
+| Full packaging change | release-workflow dry run, installer smoke test |
## Test Data Rules
-- Use synthetic process names such as `code.exe`, `chrome.exe`, `game.exe`.
-- Use synthetic titles that include private/incognito keywords.
-- Use deterministic timestamps.
+- Use synthetic process names such as `code.exe`, `chrome.exe`, and `game.exe`.
+- Use synthetic titles that include private/incognito keywords where privacy behavior is under test.
+- Use deterministic timestamps unless a test explicitly targets current-time behavior.
+- Use in-memory or temporary-file SQLite databases.
- Never rely on the user's real SQLite database.
-- Never assert raw private titles should be stored.
+- Never assert that raw private titles should be persisted.
## Temporary Build Output
@@ -123,4 +117,4 @@ For source validation, use:
pnpm.cmd run build:check
```
-Then delete `dist-codex-check`.
+Then delete `dist-codex-check` if it is not needed.
From 56657a54b47220804f124ad25ed9d953de863ea9 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:35:33 +0800
Subject: [PATCH 023/117] test: expose corrupted root category seed names
---
src-tauri/src/db/tests.rs | 43 +++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/src-tauri/src/db/tests.rs b/src-tauri/src/db/tests.rs
index 1eb7bb2..bde3cf0 100644
--- a/src-tauri/src/db/tests.rs
+++ b/src-tauri/src/db/tests.rs
@@ -88,6 +88,15 @@ fn fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens() {
)
.expect("read default code rule");
assert_eq!(code_rule, "LEARN");
+
+ let learn_name: String = conn
+ .query_row("SELECT name FROM categories WHERE id = 1", [], |row| row.get(0))
+ .expect("read LEARN root name");
+ let rest_name: String = conn
+ .query_row("SELECT name FROM categories WHERE id = 2", [], |row| row.get(0))
+ .expect("read REST root name");
+ assert_eq!(learn_name, "学习");
+ assert_eq!(rest_name, "休息");
}
{
@@ -153,6 +162,40 @@ fn database_initialization_is_idempotent_and_preserves_user_values() {
remove_sqlite_files(&path);
}
+#[test]
+fn database_initialization_repairs_only_known_mojibake_root_names() {
+ let path = temp_db_path("root-name-repair");
+ remove_sqlite_files(&path);
+ let conn = Connection::open(&path).expect("open sqlite file");
+
+ super::migrations::initialize_connection(&conn).expect("first initialization");
+ conn.execute(
+ "UPDATE categories SET name = '瀛︿範' WHERE id = 1 AND root_type = 'LEARN'",
+ [],
+ )
+ .expect("seed known LEARN mojibake");
+ conn.execute(
+ "UPDATE categories SET name = '我的休息' WHERE id = 2 AND root_type = 'REST'",
+ [],
+ )
+ .expect("customize REST root name");
+
+ super::migrations::initialize_connection(&conn).expect("repair known root names");
+
+ let learn_name: String = conn
+ .query_row("SELECT name FROM categories WHERE id = 1", [], |row| row.get(0))
+ .expect("read repaired LEARN root name");
+ let rest_name: String = conn
+ .query_row("SELECT name FROM categories WHERE id = 2", [], |row| row.get(0))
+ .expect("read customized REST root name");
+
+ assert_eq!(learn_name, "学习");
+ assert_eq!(rest_name, "我的休息");
+
+ drop(conn);
+ remove_sqlite_files(&path);
+}
+
#[test]
fn legacy_schema_is_upgraded_without_losing_existing_rows() {
let path = temp_db_path("legacy-schema");
From 4a785ba1f88ecfa5d2e4c3aa5db9e785029b6978 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:40:43 +0800
Subject: [PATCH 024/117] fix: repair corrupted root category seed names
---
src-tauri/src/db/migrations.rs | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs
index caac340..277b0b4 100644
--- a/src-tauri/src/db/migrations.rs
+++ b/src-tauri/src/db/migrations.rs
@@ -258,17 +258,29 @@ pub(super) fn initialize_connection(conn: &Connection) -> Result<(), String> {
.map_err(|e| format!("failed to ensure reminder indexes: {e}"))?;
conn.execute(
- "INSERT OR IGNORE INTO categories (id, parent_id, name, color_hex, root_type) VALUES (1, NULL, '瀛︿範', '#22c55e', 'LEARN')",
+ "INSERT OR IGNORE INTO categories (id, parent_id, name, color_hex, root_type) VALUES (1, NULL, '学习', '#22c55e', 'LEARN')",
[],
)
.map_err(|e| format!("failed to seed LEARN root: {e}"))?;
conn.execute(
- "INSERT OR IGNORE INTO categories (id, parent_id, name, color_hex, root_type) VALUES (2, NULL, '浼戞伅', '#f97316', 'REST')",
+ "INSERT OR IGNORE INTO categories (id, parent_id, name, color_hex, root_type) VALUES (2, NULL, '休息', '#f97316', 'REST')",
[],
)
.map_err(|e| format!("failed to seed REST root: {e}"))?;
+ conn.execute(
+ "UPDATE categories SET name = '学习' WHERE id = 1 AND root_type = 'LEARN' AND name = '瀛︿範'",
+ [],
+ )
+ .map_err(|e| format!("failed to repair LEARN root name: {e}"))?;
+
+ conn.execute(
+ "UPDATE categories SET name = '休息' WHERE id = 2 AND root_type = 'REST' AND name = '浼戞伅'",
+ [],
+ )
+ .map_err(|e| format!("failed to repair REST root name: {e}"))?;
+
let default_rules = [
("code.exe", "LEARN", "NORMAL"),
("pycharm64.exe", "LEARN", "NORMAL"),
From da7dcc4f620b68bbf1def4ab7b10dcb1ac36f71d Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:59:20 +0800
Subject: [PATCH 025/117] ci: validate Windows installers on packaging PRs
---
.github/workflows/release.yml | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8689b53..80377d4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,6 +1,16 @@
name: Windows Release
on:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/release.yml"
+ - "src-tauri/tauri.conf.json"
+ - "src-tauri/Cargo.toml"
+ - "src-tauri/Cargo.lock"
+ - "package.json"
+ - "pnpm-lock.yaml"
workflow_dispatch:
push:
tags:
@@ -67,15 +77,15 @@ jobs:
working-directory: src-tauri
run: cargo test
- - name: Build installers for manual validation
- if: github.event_name == 'workflow_dispatch'
+ - name: Build installers for validation
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
run: pnpm tauri build --bundles nsis,msi
- - name: Upload manual-build installers
- if: github.event_name == 'workflow_dispatch'
+ - name: Upload validation installers
+ if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
- name: TimePrism-${{ env.TIMEPRISM_VERSION }}-windows-installers
+ name: TimePrism-${{ env.TIMEPRISM_VERSION }}-windows-installers-${{ github.event_name }}
if-no-files-found: error
path: |
src-tauri/target/release/bundle/nsis/*-setup.exe
From e54b9fd04770e64589525926af2f96bc57dc6389 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 17:00:20 +0800
Subject: [PATCH 026/117] chore: retrigger packaging workflow discovery
---
.github/workflows/release.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 80377d4..3649ae4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,5 +1,6 @@
name: Windows Release
+# Packaging PRs build real Windows installers without publishing a Release.
on:
pull_request:
branches:
From b84d1c31405c0f47d00bd19b1f6f01bfdd1b120c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 17:05:29 +0800
Subject: [PATCH 027/117] fix: pass Windows bundle targets as separate
arguments
---
.github/workflows/release.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3649ae4..4bb9d76 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -80,7 +80,7 @@ jobs:
- name: Build installers for validation
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
- run: pnpm tauri build --bundles nsis,msi
+ run: pnpm tauri build --bundles nsis msi
- name: Upload validation installers
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'
@@ -108,4 +108,4 @@ jobs:
releaseDraft: true
prerelease: false
generateReleaseNotes: true
- args: --bundles nsis,msi
+ args: --bundles nsis msi
From 9f06e8e45e81baa26fb3d9f1da1b71c66907daae Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 17:18:00 +0800
Subject: [PATCH 028/117] docs: document automated installer packaging gate
---
README.md | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 1045e11..8f0324d 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Rather than relying on manual start/stop timers, it samples the foreground appli
The project is intentionally scoped as a Windows-first, local-first desktop application. It does not depend on cloud accounts, remote sync, or external services for its core workflow.
-> **Release status:** the currently published `v0.1.0-preview` is an early portable build and predates the latest architecture/reliability work. The repository now includes a gated Windows release pipeline for NSIS `-setup.exe` and MSI installers; releases are created as drafts and smoke-tested before publication.
+> **Release status:** the currently published `v0.1.0-preview` is an early portable build and predates the latest architecture/reliability work. The repository now includes a gated Windows release pipeline for NSIS `-setup.exe` and MSI installers; packaging changes are validated on Windows before merge, while public releases remain draft-first and require an interactive installer smoke test before publication.
## What It Demonstrates
@@ -190,18 +190,23 @@ cargo check --all-targets
cargo test
```
+Release-related pull requests also run an independent Windows packaging gate that builds both NSIS and MSI installers and uploads them as workflow artifacts. This verifies that the current tree can reach real Tauri installer artifacts without publishing a Release.
+
The current desktop flow has also been manually smoke-tested on Windows for startup behavior, main views, reminder workflows, pet interactions, and full-app quit behavior.
-CI and smoke tests are used as project-level regression evidence; they are not a claim of production-grade hardening.
+CI, packaging validation, and smoke tests are used as project-level regression evidence; they are not a claim of production-grade hardening.
## Distribution
-`.github/workflows/release.yml` provides two release paths on `windows-latest`:
+`.github/workflows/release.yml` separates installer generation from publication on `windows-latest`:
-- **Manual dry run:** builds NSIS and MSI installers and uploads them as workflow artifacts without creating a Release.
+- **Packaging PR:** release-related pull requests validate synchronized versions, run frontend/Rust checks, build both NSIS and MSI installers, and upload the real installer artifacts without publishing anything.
+- **Manual dry run:** `workflow_dispatch` performs the same installer build on demand after merge and uploads the installers as workflow artifacts without creating a Release.
- **Version tag:** requires the tag to exactly match the synchronized Cargo/Node/Tauri app version, then creates a **draft** GitHub Release with both installer formats.
-The project is currently unsigned, so Windows SmartScreen may warn about an unknown publisher. Release publication is deliberately separated from installer generation so the generated build can be smoke-tested first. See [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md).
+Automated packaging proves that the installers can be generated; it does not replace the interactive Windows gate. At least one generated installer must still be downloaded, installed, launched, and smoke-tested before the draft Release is made public.
+
+The project is currently unsigned, so Windows SmartScreen may warn about an unknown publisher. See [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md) for the full publication gate.
## Privacy
From 5d8aa3432693504d301ecf1807537c6099577996 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 17:18:16 +0800
Subject: [PATCH 029/117] docs: separate packaging and installer smoke gates
---
docs/RELEASE_CHECKLIST.md | 60 ++++++++++++++++++++++++++++-----------
1 file changed, 44 insertions(+), 16 deletions(-)
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index 80b3a4e..4465f7c 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -11,14 +11,24 @@ The Windows release workflow builds both Tauri-supported installer formats:
The current project is unsigned. Windows SmartScreen can therefore show an unknown-publisher warning until code signing is added.
+## Two-layer installer gate
+
+Installer validation is deliberately split into two layers:
+
+1. **Automated packaging gate:** release-related pull requests build both NSIS and MSI installers on `windows-latest` and upload them as workflow artifacts. This proves the current tree can reach real installer artifacts before merge.
+2. **Interactive Windows smoke:** a generated installer is downloaded to a real Windows desktop, installed, launched, exercised, and checked for restart/data/lifecycle behavior. CI cannot replace this interaction-level gate.
+
+A green packaging job is therefore necessary evidence, but it is not sufficient by itself to publish a Release.
+
## Before tagging
1. Confirm `main` CI is green.
-2. Update the same semantic version in:
+2. Confirm the latest relevant packaging PR produced both installer formats successfully.
+3. Update the same semantic version in:
- `package.json`
- `src-tauri/Cargo.toml`
- `src-tauri/tauri.conf.json`
-3. Run or verify:
+4. Run or verify:
```text
pnpm run typecheck
@@ -26,24 +36,46 @@ The current project is unsigned. Windows SmartScreen can therefore show an unkno
cargo test --manifest-path src-tauri/Cargo.toml
```
-4. Smoke-test the desktop flows listed in `docs/SMOKE_TESTS.md`.
-5. Commit the synchronized version bump to `main`.
+5. Smoke-test the desktop flows listed in `docs/SMOKE_TESTS.md`.
+6. Commit the synchronized version bump to `main`.
The release workflow rejects mismatched Node/Cargo/Tauri versions. Tag-triggered runs also reject a tag that does not equal `v`.
-## Dry-run installer build
+## Pull-request packaging validation
-Run **Windows Release** manually from GitHub Actions before creating a tag.
+When a pull request changes release/package inputs, **Windows Release** runs automatically.
-The `workflow_dispatch` path:
+The pull-request path:
1. validates version synchronization;
2. runs frontend typecheck and Rust tests;
3. builds both NSIS and MSI installers on `windows-latest`;
-4. uploads the installers as workflow artifacts;
+4. uploads both installers as one workflow artifact;
5. does **not** create a GitHub Release.
-Install and smoke-test at least one generated installer on Windows before publishing a version tag.
+The packaging job uses `if-no-files-found: error`, so a green upload step also verifies that both configured installer paths resolved to real files.
+
+## Manual dry-run installer build
+
+After the workflow is on `main`, **Windows Release** can also be started manually from GitHub Actions before creating a tag.
+
+The `workflow_dispatch` path performs the same version checks, tests, installer build, and artifact upload without creating a GitHub Release. Use this when you want a fresh installer candidate from the default branch even if no packaging-related pull request is open.
+
+Download the generated artifact and perform the interactive Windows smoke before publishing a version tag.
+
+## Interactive installer smoke
+
+At minimum, verify one generated installer end to end on Windows:
+
+- install from the NSIS `-setup.exe` or MSI artifact;
+- launch TimePrism and confirm the main window renders normally;
+- verify Home, Insights, Focus Guard, reminders, privacy settings, and desktop pet flows;
+- verify main-window close/background behavior and full-app quit behavior;
+- restart the app and confirm local data survives;
+- uninstall the tested installer and confirm the uninstall flow completes normally;
+- retain the unsigned/SmartScreen note until Windows code signing exists.
+
+If the installer or installed application fails this gate, fix the issue and produce a fresh artifact before tagging.
## Publish a release draft
@@ -60,13 +92,9 @@ The tag-triggered workflow validates the tag/version match and uses `tauri-apps/
Before publishing the draft Release:
-- install from the NSIS `-setup.exe` asset;
-- install or inspect the `.msi` asset;
-- launch TimePrism and confirm the main window renders normally;
-- verify Home, Insights, Focus Guard, reminders, privacy settings, and desktop pet flows;
-- verify full-app quit behavior;
-- confirm local data survives app restart;
+- confirm the expected NSIS and MSI assets are attached;
+- install at least one draft asset if it differs from the previously smoke-tested artifact;
- confirm Release notes describe any known limitations;
- explicitly retain the unsigned/SmartScreen note until Windows code signing exists.
-Only after this verification should the GitHub Release be published.
+Only after these checks should the GitHub Release be published.
From f6f5c1ab785541724ee6eb1f83450c87682a620a Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 21:38:26 +0800
Subject: [PATCH 030/117] docs: specify first-run tracking activation
---
...-08-timeprism-first-run-tracking-design.md | 440 ++++++++++++++++++
1 file changed, 440 insertions(+)
create mode 100644 docs/superpowers/specs/2026-09-08-timeprism-first-run-tracking-design.md
diff --git a/docs/superpowers/specs/2026-09-08-timeprism-first-run-tracking-design.md b/docs/superpowers/specs/2026-09-08-timeprism-first-run-tracking-design.md
new file mode 100644
index 0000000..5a15b6f
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-08-timeprism-first-run-tracking-design.md
@@ -0,0 +1,440 @@
+# TimePrism First-Run Tracking Design
+
+Date: 2026-09-08
+Status: Proposed / user-approved direction, pending written-spec review
+Scope: First-run startup safety, tracking consent/activation, persistent tracking state, and first-run explanation
+
+## 1. Problem statement
+
+A real Windows installer smoke test exposed three related problems:
+
+1. A fresh install can briefly render `failed to prepare summary query: no such table: app_usage_logs` even though the database becomes usable moments later.
+2. Raw application usage can appear in Insights while the desktop pet remains at `0`, which is technically valid but unexplained: Insights includes observed application time while the pet only shows time classified as `LEARN` or `REST`.
+3. A first-time user is dropped directly into the product with foreground capture already enabled, before the product explains what is collected, where it is stored, or how learning/rest classification works.
+
+The current behavior also stores the auto-capture toggle only in frontend memory, so an explicit pause is lost after restart.
+
+## 2. Goals
+
+This change must:
+
+- eliminate the fresh-install database/WebView startup race;
+- ensure no foreground capture begins before the first-run explanation is acknowledged;
+- match the common automatic-tracker model: one-time activation, then automatic tracking on future launches unless the user pauses it;
+- persist the user's tracking pause/resume choice across restarts;
+- keep the current approximately 5-second capture and UI refresh cadence;
+- explain the difference between observed application activity and classified `LEARN` / `REST` time;
+- preserve existing-user behavior during migration;
+- fail closed if the database cannot be initialized rather than launching a partially initialized product shell.
+
+## 3. Non-goals
+
+This work will not:
+
+- change the 5-second foreground sampling interval;
+- change the 5-second Home/pet refresh cadence;
+- redesign the analytics model;
+- add cloud accounts, telemetry, or remote consent storage;
+- add a multi-step tutorial carousel;
+- add screenshots, keyboard logging, or any new capture source;
+- re-architect the existing `services / domain / db` backend structure;
+- change application classification thresholds except where needed to explain them in UI copy.
+
+## 4. Product behavior
+
+### 4.1 Fresh installation
+
+A genuinely new TimePrism database starts with:
+
+- `onboarding_completed = false`
+- `auto_capture_enabled = false`
+
+The first visible product surface is a one-time welcome / tracking-disclosure screen in the main window.
+
+Before the user accepts:
+
+- foreground capture is not started;
+- no foreground application metadata is read by TimePrism's sampler;
+- Home/Insights/Guard polling is not started;
+- the desktop pet is not shown as an active tracking surface;
+- normal product data reads that are unrelated to rendering the onboarding screen are not started.
+
+The primary action is:
+
+`开始使用 TimePrism`
+
+The user may close/quit the application instead of accepting. No separate persistent "browse without tracking" mode is introduced.
+
+### 4.2 First-run disclosure
+
+The welcome screen must state, in plain language:
+
+- TimePrism identifies the current foreground application and measures how long it remains active;
+- application/window metadata can be stored locally for time analysis;
+- browser titles are privacy-processed by default;
+- private/incognito browser windows are not recorded by the current privacy rules;
+- TimePrism does not capture screenshots or keystroke contents;
+- core data is stored locally in SQLite and is not uploaded by the current product;
+- Insights can show observed application activity, while the pet's `学 / 休` counters only include applications classified as `LEARN` or `REST`.
+
+The disclosure is informational and activation-oriented rather than a long legal flow.
+
+### 4.3 Activation
+
+When the user selects `开始使用 TimePrism`, the backend performs one atomic product-state transition:
+
+- `onboarding_completed = true`
+- `auto_capture_enabled = true`
+
+Only after that transition succeeds may the frontend start the capture sampler and normal product polling.
+
+The first capture still behaves as a baseline-only sample; this is retained to avoid synthetic +5-second overcounting.
+
+### 4.4 Subsequent launches
+
+On later launches:
+
+- if `onboarding_completed = true` and `auto_capture_enabled = true`, TimePrism automatically resumes normal capture;
+- if the user previously paused capture, `auto_capture_enabled = false` remains false after restart;
+- onboarding is not shown again after successful first activation.
+
+This matches the intended automatic-tracker model: activate once, then run automatically unless explicitly paused.
+
+### 4.5 Pause / resume semantics
+
+The existing auto-capture control becomes persistent.
+
+Changing the toggle updates the SQLite-backed tracking preference, not only a Vue `ref`.
+
+Rules:
+
+- pause: persist `auto_capture_enabled = false`, then stop the sampler;
+- resume: persist `auto_capture_enabled = true`, then start the sampler;
+- a failed persistence write must not silently change the runtime state;
+- enabling capture before onboarding is complete is rejected by the backend invariant rather than trusted to the UI.
+
+## 5. Startup architecture
+
+### 5.1 Root cause to remove
+
+The current configuration allows WebViews to be created before `.setup()` completes, while database initialization currently runs inside `.setup()`. Main and pet frontends can therefore invoke database-backed commands before `app_usage_logs` and the rest of the schema exist.
+
+### 5.2 Required startup ordering
+
+The startup order becomes:
+
+```text
+Tauri process starts
+ -> resolve/open database path
+ -> run schema initialization and migrations
+ -> load tracking state
+ -> create desktop WebViews
+ -> render main window
+ -> if onboarding incomplete: show onboarding only
+ -> if onboarding complete: start normal product runtime according to persisted auto_capture_enabled
+```
+
+The key invariant is:
+
+> No database-backed product WebView may issue product queries before database initialization has completed successfully.
+
+### 5.3 Window creation
+
+Configured windows should no longer auto-create before database initialization.
+
+The Tauri window configs remain the source of truth for window shape/URL/behavior, but startup code creates the required WebViews only after successful database initialization.
+
+Main-window reveal still occurs after Vue mount so the prior blank-startup regression remains fixed.
+
+Desktop-pet behavior:
+
+- on first run before activation: keep the pet hidden;
+- after first activation: show/settle the pet using the existing window service;
+- on subsequent launches: pet visibility follows the normal application behavior, independent of whether tracking is paused;
+- when tracking is paused, the pet displays a paused/not-recording state rather than implying active capture.
+
+### 5.4 Database initialization failure
+
+Database initialization is no longer treated as a warning that allows the app to continue.
+
+If initialization/migration fails:
+
+- do not start normal product WebViews and polling;
+- do not start foreground capture;
+- fail startup with an explicit error path/log rather than showing a partially functional UI that later emits table-missing errors.
+
+A richer recovery UI is out of scope for this change.
+
+## 6. Persistent state model
+
+Use the existing `app_config` table rather than introducing another settings database.
+
+Required keys:
+
+- `onboarding_completed`
+- `auto_capture_enabled`
+
+A backend tracking-state model should expose both values together, e.g. conceptually:
+
+```text
+TrackingState {
+ onboarding_completed: bool,
+ auto_capture_enabled: bool
+}
+```
+
+Backend operations should be explicit rather than exposing arbitrary `app_config` writes to the frontend:
+
+- read current tracking state;
+- complete onboarding and enable tracking atomically;
+- set auto-capture enabled/disabled after onboarding.
+
+This preserves invariants in Rust rather than relying on frontend sequencing.
+
+## 7. Migration semantics
+
+Existing installations must not unexpectedly stop tracking after upgrading.
+
+Initialization must distinguish a genuinely fresh database from a pre-existing TimePrism database before inserting the new tracking-state keys.
+
+### Fresh database
+
+```text
+onboarding_completed = false
+auto_capture_enabled = false
+```
+
+### Existing database without the new keys
+
+```text
+onboarding_completed = true
+auto_capture_enabled = true
+```
+
+Rationale: existing users have already been using automatic capture; forcing them through a new consent screen or silently disabling capture would be a backward-incompatible behavior change.
+
+### Existing database with the new keys
+
+Never overwrite user choices.
+
+In particular:
+
+- a persisted pause remains paused;
+- completed onboarding remains completed.
+
+An empty/corrupt file with no recognizable TimePrism schema can be treated as a fresh database; database corruption recovery beyond that is out of scope.
+
+## 8. Frontend runtime gate
+
+The current main-window lifecycle starts Home refresh, polling, settings warmup, and the auto-capture sampler on mount. This must be split into two phases.
+
+### Phase A: shell initialization
+
+Allowed before onboarding completion:
+
+- locale initialization;
+- theme initialization;
+- load tracking state;
+- render/reveal the main window;
+- render onboarding when needed.
+
+### Phase B: product runtime
+
+Started only after onboarding is complete:
+
+- Home core data refresh;
+- Home analytics refresh scheduling;
+- settings warmup;
+- main refresh polling;
+- auto-capture sampler only if `auto_capture_enabled = true`;
+- Insights/Guard listeners and normal product orchestration.
+
+The transition from onboarding to normal product runtime occurs without requiring an app restart.
+
+Runtime startup must be idempotent so the same polling/sampler is not started twice.
+
+## 9. Tracking status and pet semantics
+
+The 5-second cadence remains unchanged.
+
+Current timing model remains:
+
+```text
+capture sampler: about every 5 seconds
+main data polling: about every 5 seconds
+pet summary refresh: about every 5 seconds
+```
+
+Because the first foreground sample is baseline-only, a newly classified LEARN/REST interval can take several seconds before it becomes visible. This is acceptable; the UI should communicate the tracking state rather than simulate second-by-second updates.
+
+The pet should distinguish at least:
+
+- active tracking: e.g. `自动记录中`;
+- tracking paused: e.g. `记录已暂停`;
+- pre-onboarding: pet hidden, so no ambiguous state is needed.
+
+The pet continues to show only `LEARN` and `REST` totals. It does not become a raw observed-app timer.
+
+## 10. Explain observed activity vs. classified time
+
+The product should add concise first-run copy explaining:
+
+- Insights may immediately show observed applications because it reports captured application activity;
+- `学 / 休` totals remain zero until those applications map to `LEARN` or `REST`;
+- unknown applications can later be classified in Focus Guard.
+
+This explanation belongs in onboarding and may be repeated as lightweight helper text where the classification workflow is introduced. It does not require changing the analytics computation.
+
+Obvious system/self processes that should never require meaningful user classification can be evaluated separately as a small cleanup, but changing default rule coverage is not required to complete this first-run design.
+
+## 11. Error handling
+
+### Startup/database
+
+- initialization failure prevents normal runtime startup;
+- no schema-missing message should be rendered as a persistent red footer during a normal fresh launch.
+
+### Frontend data errors
+
+The global error surface should no longer preserve a stale transient startup error forever after later refreshes succeed.
+
+Minimum behavior:
+
+- successful refresh after a transient data-read error clears the relevant global error;
+- onboarding/runtime errors remain visible when they block the requested transition.
+
+This is secondary to fixing the database race at the source; frontend error clearing is not a substitute for startup ordering.
+
+### Tracking state writes
+
+- failed activation leaves onboarding incomplete and capture off;
+- failed pause/resume keeps the prior persisted/runtime state and reports an error;
+- no optimistic state that contradicts SQLite should survive a failed write.
+
+## 12. Backend boundaries
+
+Keep the current architecture:
+
+```text
+Tauri command wrappers
+ -> service layer
+ -> db / app_config
+```
+
+Add a focused tracking-preferences boundary rather than placing SQL in command wrappers.
+
+Expected responsibility split:
+
+- `db/migrations`: fresh-vs-existing defaults and schema/config migration;
+- tracking service: read state, complete onboarding, persist pause/resume invariant;
+- `lib.rs`: thin commands plus startup/window ordering;
+- existing foreground service: capture mechanics remain unchanged;
+- existing window service: reuse current reveal/show/settle semantics.
+
+Do not perform another broad backend directory refactor.
+
+## 13. Frontend boundaries
+
+Prefer a focused composable for persisted tracking/onboarding state rather than adding more startup booleans directly to `App.vue`.
+
+Conceptual responsibilities:
+
+- load backend tracking state;
+- expose onboarding-complete and auto-capture state;
+- perform activation;
+- perform pause/resume with persistence;
+- trigger product-runtime start only after state permits it.
+
+The existing auto-capture sampler remains responsible only for periodic sampling; it should not own persistence or onboarding policy.
+
+## 14. Tests
+
+Implementation must be test-driven where practical.
+
+### Rust/database tests
+
+Add tests covering:
+
+1. fresh DB receives `onboarding_completed=false` and `auto_capture_enabled=false`;
+2. recognizable legacy/existing DB with missing keys migrates to `true / true`;
+3. rerunning initialization never overwrites persisted `auto_capture_enabled=false`;
+4. completing onboarding atomically produces `true / true`;
+5. pause persists `false` while keeping onboarding complete;
+6. resume persists `true` after onboarding;
+7. backend rejects enabling capture when onboarding is incomplete;
+8. existing database lifecycle/migration tests continue to pass.
+
+### Frontend tests / pure-logic characterization
+
+Where existing test infrastructure permits, cover runtime-state decisions as pure logic rather than requiring full WebView automation:
+
+- incomplete onboarding -> no product runtime/capture start;
+- completed + enabled -> product runtime + capture start;
+- completed + paused -> product runtime starts but sampler stays stopped;
+- activation transitions to normal runtime once;
+- pause/resume does not create duplicate sampling intervals.
+
+If the repository still lacks a Vue unit-test runner, keep the state-transition logic small/pure and cover the Rust invariant plus Windows smoke path; do not add a large new test framework solely for this change.
+
+### CI
+
+Final gates remain:
+
+```text
+pnpm run typecheck
+pnpm run build:check
+cargo check --all-targets
+cargo test
+```
+
+Windows installer packaging must still produce both NSIS and MSI artifacts.
+
+## 15. Manual Windows acceptance test
+
+Use a clean/sandboxed TimePrism data directory.
+
+A fresh install passes only if:
+
+1. no `no such table: app_usage_logs` error appears;
+2. no foreground activity is captured before first-run activation;
+3. onboarding clearly explains foreground-app metadata, local storage, browser privacy, no screenshots/keystrokes, and classified `学 / 休` semantics;
+4. clicking `开始使用 TimePrism` starts tracking without restart;
+5. Insights begins to show observed application activity after sampling;
+6. pet totals remain classification-based and update after LEARN/REST activity with the existing several-second cadence;
+7. pausing capture stops new samples;
+8. closing/reopening TimePrism preserves the pause;
+9. resuming persists across a later restart;
+10. onboarding does not reappear after successful activation;
+11. pet shows an appropriate paused state when tracking is paused;
+12. existing close-to-background / full-quit behavior still works.
+
+An upgrade test with an existing pre-change database passes only if tracking remains enabled and onboarding is treated as already complete.
+
+## 16. Rollout / release impact
+
+The current `0.1.0` installer candidate exposed this first-run issue, so it should not be published as the polished recruiting/demo release.
+
+After implementation:
+
+- rerun Windows CI;
+- rerun NSIS + MSI packaging smoke;
+- perform the fresh-install and upgrade manual tests above;
+- capture final sanitized Home / Insights / Focus Guard / pet screenshots only from the corrected build;
+- then move the PR out of Draft, merge, and create the intended public release.
+
+## 17. Acceptance summary
+
+This design is complete when the product has the following observable behavior:
+
+```text
+Fresh install
+ -> DB fully initialized before product WebViews query it
+ -> one-time tracking disclosure
+ -> no capture before activation
+ -> Start TimePrism
+ -> tracking ON and persisted
+ -> future launches auto-track
+ -> user Pause persists across restart
+```
+
+And the existing 5-second capture/refresh model remains intact.
From 5a878a079c3a7237e034cd699539c6988aa7e996 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 21:49:56 +0800
Subject: [PATCH 031/117] docs: plan first-run tracking implementation
---
...2026-09-08-timeprism-first-run-tracking.md | 1196 +++++++++++++++++
1 file changed, 1196 insertions(+)
create mode 100644 docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
diff --git a/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md b/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
new file mode 100644
index 0000000..d8e5638
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
@@ -0,0 +1,1196 @@
+# TimePrism First-Run Tracking Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make a fresh TimePrism install initialize SQLite before any product WebView can query it, show a one-time tracking disclosure before foreground capture starts, and persist tracking pause/resume state across restarts.
+
+**Architecture:** Keep the existing `commands -> services -> domain/db` structure. Add a small SQLite-backed tracking-state service, manually create configured Tauri windows only after database initialization, and gate the existing Vue runtime behind a persisted `TrackingState`. The foreground sampler keeps its current approximately 5-second cadence; policy/persistence stays outside the sampler.
+
+**Tech Stack:** Tauri 2, Rust, rusqlite/SQLite, Vue 3, TypeScript, Vite, GitHub Actions on Windows.
+
+**Spec:** `docs/superpowers/specs/2026-09-08-timeprism-first-run-tracking-design.md`
+
+## Global Constraints
+
+- Fresh databases start with `onboarding_completed=false` and `auto_capture_enabled=false`.
+- Existing pre-change TimePrism databases migrate to `onboarding_completed=true` and `auto_capture_enabled=true` unless either key already exists.
+- No foreground capture may occur before onboarding activation succeeds.
+- A user-initiated pause persists across restart; restart must never silently turn capture back on.
+- Keep the foreground sampling interval at approximately 5 seconds.
+- Keep Home and pet refresh cadence at approximately 5 seconds.
+- The first foreground sample remains baseline-only to avoid synthetic overcounting.
+- No new frontend test framework is added solely for this feature.
+- Database initialization failure must prevent normal product runtime startup.
+- Do not perform another broad backend or frontend directory refactor.
+- Product copy must state that TimePrism reads foreground application/window metadata, stores core data locally, privacy-processes browser titles, does not capture screenshots/keystroke contents, and that Insights activity differs from classified `LEARN` / `REST` totals.
+
+---
+
+## File Structure
+
+### Create
+
+- `src-tauri/src/domain/tracking.rs` — serialized backend `TrackingState` contract.
+- `src-tauri/src/services/tracking.rs` — SQLite-backed tracking-state invariants and transitions.
+- `src/api/commands/tracking.ts` — typed frontend wrappers for tracking-state Tauri commands.
+- `src/api/commands/window.ts` — typed main-window wrapper for showing the pet after first activation.
+- `src/composables/useTrackingState.ts` — frontend persistence-facing tracking state only; no timer ownership.
+- `src/components/OnboardingView.vue` — one-screen first-run disclosure and activation UI.
+
+### Modify
+
+- `src-tauri/src/db/migrations.rs` — classify fresh vs recognizable existing DB before schema creation; seed tracking keys with migration-safe defaults.
+- `src-tauri/src/db/tests.rs` — file-backed regression coverage for fresh/existing tracking defaults and persisted pause preservation.
+- `src-tauri/src/domain/mod.rs` — expose tracking domain model.
+- `src-tauri/src/services/mod.rs` — expose tracking service.
+- `src-tauri/src/lib.rs` — thin tracking commands; fail-closed DB startup; create windows only after DB init; gate startup pet creation/shortcut by onboarding state.
+- `src-tauri/src/services/window.rs` — create main/pet windows from `tauri.conf.json` config after DB readiness instead of duplicating startup config.
+- `src-tauri/tauri.conf.json` — set configured WebViews to `create: false` so Tauri cannot auto-create them before `.setup()` completes.
+- `src/api/types.ts` and `src/api.ts` — expose `TrackingState` to TypeScript callers.
+- `src/api/commands/index.ts` — export tracking/window command modules.
+- `src/composables/useAutoCaptureSampler.ts` — timer only; remove policy ownership from the sampler.
+- `src/composables/useGuardData.ts` — consume persisted auto-capture state rather than creating `ref(true)`.
+- `src/composables/useMainWindowLifecycle.ts` — split shell initialization from idempotent product-runtime start.
+- `src/composables/useErrorMessage.ts` — add explicit clearing for a recovered transient read error.
+- `src/composables/useHomeData.ts` — clear its own transient global read error after a successful core refresh.
+- `src/App.vue` — composition-root wiring for tracking state, onboarding, activation, pause/resume, runtime start, and pet reveal.
+- `src/pet.ts` — render active vs paused tracking state while retaining 5-second summary polling.
+- `src/styles/app.css` — onboarding layout/styles using existing visual tokens.
+- `docs/SMOKE_TESTS.md` and `docs/RELEASE_CHECKLIST.md` — record fresh-install and upgrade acceptance gates.
+
+---
+
+### Task 1: Add migration-safe tracking defaults
+
+**Files:**
+- Modify: `src-tauri/src/db/migrations.rs` around `initialize_connection`
+- Modify/Test: `src-tauri/src/db/tests.rs` around the fresh/idempotent/legacy database tests
+
+**Interfaces:**
+- Consumes: existing `app_config(key TEXT PRIMARY KEY, value TEXT NOT NULL)` table.
+- Produces: guaranteed `onboarding_completed` and `auto_capture_enabled` config keys after every successful `initialize_connection(&Connection)` call.
+
+- [ ] **Step 1: Add failing database tests for fresh tracking defaults**
+
+Extend `fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens` with exact assertions:
+
+```rust
+let onboarding: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'onboarding_completed'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read onboarding default");
+let auto_capture: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'auto_capture_enabled'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read auto capture default");
+assert_eq!(onboarding, "false");
+assert_eq!(auto_capture, "false");
+```
+
+- [ ] **Step 2: Add failing migration tests for an existing recognizable database**
+
+In `legacy_schema_is_upgraded_without_losing_existing_rows`, after creating the legacy `app_rules` / `reminders` schema and calling `initialize_connection`, assert:
+
+```rust
+let onboarding: String = conn.query_row(
+ "SELECT value FROM app_config WHERE key = 'onboarding_completed'",
+ [],
+ |row| row.get(0),
+).expect("read migrated onboarding state");
+let auto_capture: String = conn.query_row(
+ "SELECT value FROM app_config WHERE key = 'auto_capture_enabled'",
+ [],
+ |row| row.get(0),
+).expect("read migrated capture state");
+assert_eq!(onboarding, "true");
+assert_eq!(auto_capture, "true");
+```
+
+- [ ] **Step 3: Add a failing regression test proving initialization preserves a user pause**
+
+Add:
+
+```rust
+#[test]
+fn database_initialization_preserves_persisted_tracking_pause() {
+ let path = temp_db_path("tracking-pause");
+ remove_sqlite_files(&path);
+ let conn = Connection::open(&path).expect("open sqlite file");
+
+ super::migrations::initialize_connection(&conn).expect("initialize fresh database");
+ conn.execute(
+ "UPDATE app_config SET value = 'true' WHERE key = 'onboarding_completed'",
+ [],
+ ).expect("mark onboarding complete");
+ conn.execute(
+ "UPDATE app_config SET value = 'false' WHERE key = 'auto_capture_enabled'",
+ [],
+ ).expect("persist capture pause");
+
+ super::migrations::initialize_connection(&conn).expect("reinitialize database");
+
+ let state: (String, String) = conn.query_row(
+ "SELECT
+ (SELECT value FROM app_config WHERE key = 'onboarding_completed'),
+ (SELECT value FROM app_config WHERE key = 'auto_capture_enabled')",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ ).expect("read persisted tracking state");
+ assert_eq!(state, ("true".to_string(), "false".to_string()));
+
+ drop(conn);
+ remove_sqlite_files(&path);
+}
+```
+
+- [ ] **Step 4: Run the focused tests and confirm RED**
+
+Run:
+
+```bash
+cd src-tauri
+cargo test db::tests::fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens
+cargo test db::tests::legacy_schema_is_upgraded_without_losing_existing_rows
+cargo test db::tests::database_initialization_preserves_persisted_tracking_pause
+```
+
+Expected: the first two fail because the new keys do not exist; the third cannot observe the requested keys yet.
+
+- [ ] **Step 5: Detect whether the DB was recognizable before schema creation**
+
+Add a helper before `initialize_connection`:
+
+```rust
+fn has_recognizable_timeprism_schema(conn: &Connection) -> Result {
+ let count: i64 = conn
+ .query_row(
+ "SELECT COUNT(*)
+ FROM sqlite_master
+ WHERE type = 'table'
+ AND name IN ('categories', 'task_sessions', 'app_usage_logs', 'app_rules', 'app_config', 'reminders')",
+ [],
+ |row| row.get(0),
+ )
+ .map_err(|e| format!("failed to inspect existing TimePrism schema: {e}"))?;
+ Ok(count > 0)
+}
+```
+
+At the start of `initialize_connection`, before the existing `CREATE TABLE IF NOT EXISTS ...` batch:
+
+```rust
+let was_existing_timeprism_db = has_recognizable_timeprism_schema(conn)?;
+```
+
+- [ ] **Step 6: Seed tracking keys with fresh-vs-upgrade defaults using `INSERT OR IGNORE`**
+
+After the existing default config loop, add:
+
+```rust
+let tracking_defaults = if was_existing_timeprism_db {
+ [("onboarding_completed", "true"), ("auto_capture_enabled", "true")]
+} else {
+ [("onboarding_completed", "false"), ("auto_capture_enabled", "false")]
+};
+
+for (key, value) in tracking_defaults {
+ conn.execute(
+ "INSERT OR IGNORE INTO app_config (key, value) VALUES (?1, ?2)",
+ params![key, value],
+ )
+ .map_err(|e| format!("failed to seed tracking config {key}: {e}"))?;
+}
+```
+
+Do not update existing rows; `INSERT OR IGNORE` is the preservation mechanism.
+
+- [ ] **Step 7: Run focused tests and the full Rust suite**
+
+Run:
+
+```bash
+cd src-tauri
+cargo test db::tests::fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens
+cargo test db::tests::legacy_schema_is_upgraded_without_losing_existing_rows
+cargo test db::tests::database_initialization_preserves_persisted_tracking_pause
+cargo test
+```
+
+Expected: all pass; existing DB lifecycle and mojibake tests stay green.
+
+- [ ] **Step 8: Commit Task 1**
+
+```bash
+git add src-tauri/src/db/migrations.rs src-tauri/src/db/tests.rs
+git commit -m "feat(db): persist first-run tracking defaults"
+```
+
+---
+
+### Task 2: Add the backend tracking-state invariant
+
+**Files:**
+- Create: `src-tauri/src/domain/tracking.rs`
+- Create: `src-tauri/src/services/tracking.rs`
+- Modify: `src-tauri/src/domain/mod.rs`
+- Modify: `src-tauri/src/services/mod.rs`
+- Modify: `src-tauri/src/lib.rs` command imports/handlers
+
+**Interfaces:**
+- Produces: `TrackingState { onboarding_completed: bool, auto_capture_enabled: bool }`.
+- Produces: `get_tracking_state_entry(&Connection) -> Result`.
+- Produces: `complete_onboarding_entry(&Connection) -> Result`.
+- Produces: `set_auto_capture_enabled_entry(&Connection, bool) -> Result`.
+- Produces Tauri commands: `get_tracking_state`, `complete_tracking_onboarding`, `set_auto_capture_enabled`.
+
+- [ ] **Step 1: Create the domain contract**
+
+`src-tauri/src/domain/tracking.rs`:
+
+```rust
+use serde::Serialize;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+pub struct TrackingState {
+ pub onboarding_completed: bool,
+ pub auto_capture_enabled: bool,
+}
+```
+
+Expose it from `domain/mod.rs`:
+
+```rust
+pub mod tracking;
+```
+
+- [ ] **Step 2: Write failing service tests before the service implementation**
+
+Create `src-tauri/src/services/tracking.rs` with a `#[cfg(test)]` module first. Use a minimal in-memory table:
+
+```rust
+#[cfg(test)]
+mod tests {
+ use rusqlite::{params, Connection};
+
+ use super::{
+ complete_onboarding_entry, get_tracking_state_entry, set_auto_capture_enabled_entry,
+ };
+
+ fn tracking_conn(onboarding: bool, enabled: bool) -> Connection {
+ let conn = Connection::open_in_memory().expect("open tracking test db");
+ conn.execute_batch(
+ "CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
+ ).expect("create app_config");
+ for (key, value) in [
+ ("onboarding_completed", onboarding.to_string()),
+ ("auto_capture_enabled", enabled.to_string()),
+ ] {
+ conn.execute(
+ "INSERT INTO app_config (key, value) VALUES (?1, ?2)",
+ params![key, value],
+ ).expect("seed tracking state");
+ }
+ conn
+ }
+
+ #[test]
+ fn completing_onboarding_atomically_enables_capture() {
+ let conn = tracking_conn(false, false);
+ let state = complete_onboarding_entry(&conn).expect("complete onboarding");
+ assert!(state.onboarding_completed);
+ assert!(state.auto_capture_enabled);
+ assert_eq!(get_tracking_state_entry(&conn).unwrap(), state);
+ }
+
+ #[test]
+ fn pause_and_resume_persist_after_onboarding() {
+ let conn = tracking_conn(true, true);
+ assert!(!set_auto_capture_enabled_entry(&conn, false).unwrap().auto_capture_enabled);
+ assert!(set_auto_capture_enabled_entry(&conn, true).unwrap().auto_capture_enabled);
+ }
+
+ #[test]
+ fn enabling_capture_before_onboarding_is_rejected() {
+ let conn = tracking_conn(false, false);
+ let err = set_auto_capture_enabled_entry(&conn, true).unwrap_err();
+ assert!(err.contains("onboarding"));
+ assert!(!get_tracking_state_entry(&conn).unwrap().auto_capture_enabled);
+ }
+}
+```
+
+- [ ] **Step 3: Run the tracking tests and confirm RED**
+
+Run:
+
+```bash
+cd src-tauri
+cargo test services::tracking::tests
+```
+
+Expected: compile/test failure because the service functions do not exist yet.
+
+- [ ] **Step 4: Implement strict config reads and the three state transitions**
+
+Use exact boolean parsing rather than a default that could hide a missing/corrupt key:
+
+```rust
+use rusqlite::{params, Connection};
+use crate::domain::tracking::TrackingState;
+
+fn read_bool_config(conn: &Connection, key: &str) -> Result {
+ let value: String = conn
+ .query_row("SELECT value FROM app_config WHERE key = ?1", [key], |row| row.get(0))
+ .map_err(|e| format!("failed to read tracking config {key}: {e}"))?;
+ match value.trim().to_lowercase().as_str() {
+ "true" => Ok(true),
+ "false" => Ok(false),
+ other => Err(format!("invalid boolean tracking config {key}: {other}")),
+ }
+}
+
+pub(crate) fn get_tracking_state_entry(conn: &Connection) -> Result {
+ Ok(TrackingState {
+ onboarding_completed: read_bool_config(conn, "onboarding_completed")?,
+ auto_capture_enabled: read_bool_config(conn, "auto_capture_enabled")?,
+ })
+}
+```
+
+`complete_onboarding_entry` must use one SQLite transaction and update both rows before commit:
+
+```rust
+pub(crate) fn complete_onboarding_entry(conn: &Connection) -> Result {
+ let tx = conn.unchecked_transaction()
+ .map_err(|e| format!("failed to begin onboarding transaction: {e}"))?;
+ tx.execute(
+ "UPDATE app_config SET value = 'true' WHERE key IN ('onboarding_completed', 'auto_capture_enabled')",
+ [],
+ ).map_err(|e| format!("failed to complete onboarding: {e}"))?;
+ tx.commit().map_err(|e| format!("failed to commit onboarding: {e}"))?;
+ get_tracking_state_entry(conn)
+}
+```
+
+`set_auto_capture_enabled_entry` must read current state first, reject `true` before onboarding, write one key, then re-read and return persisted state.
+
+- [ ] **Step 5: Expose the service module and run tests GREEN**
+
+Add to `services/mod.rs`:
+
+```rust
+pub mod tracking;
+```
+
+Run:
+
+```bash
+cd src-tauri
+cargo test services::tracking::tests
+cargo test
+```
+
+Expected: all pass.
+
+- [ ] **Step 6: Add thin Tauri command wrappers**
+
+In `lib.rs`, import the model/service and add:
+
+```rust
+#[tauri::command(async)]
+fn get_tracking_state(app: AppHandle) -> Result {
+ let conn = open_connection(&app)?;
+ get_tracking_state_entry(&conn)
+}
+
+#[tauri::command]
+fn complete_tracking_onboarding(app: AppHandle) -> Result {
+ let conn = open_connection(&app)?;
+ complete_onboarding_entry(&conn)
+}
+
+#[tauri::command]
+fn set_auto_capture_enabled(app: AppHandle, enabled: bool) -> Result {
+ let conn = open_connection(&app)?;
+ set_auto_capture_enabled_entry(&conn, enabled)
+}
+```
+
+Register all three in `tauri::generate_handler!`.
+
+- [ ] **Step 7: Compile all Rust targets**
+
+Run:
+
+```bash
+cd src-tauri
+cargo check --all-targets
+cargo test
+```
+
+Expected: pass.
+
+- [ ] **Step 8: Commit Task 2**
+
+```bash
+git add src-tauri/src/domain/tracking.rs src-tauri/src/domain/mod.rs src-tauri/src/services/tracking.rs src-tauri/src/services/mod.rs src-tauri/src/lib.rs
+git commit -m "feat(tracking): add persisted tracking state invariant"
+```
+
+---
+
+### Task 3: Eliminate the DB/WebView startup race
+
+**Files:**
+- Modify: `src-tauri/tauri.conf.json` window definitions
+- Modify: `src-tauri/src/services/window.rs` creation/reveal helpers and tests
+- Modify: `src-tauri/src/lib.rs` `.setup()` and global shortcut handler
+
+**Interfaces:**
+- Consumes: `get_tracking_state_entry(&Connection)` from Task 2.
+- Produces: `create_configured_window(app: &AppHandle, label: &str) -> Result`.
+- Invariant: no configured product WebView exists before `init_database` succeeds.
+
+- [ ] **Step 1: Add a failing config characterization test**
+
+In `services/window.rs` tests, add:
+
+```rust
+#[test]
+fn configured_product_windows_require_manual_creation() {
+ let config: serde_json::Value = serde_json::from_str(include_str!("../../tauri.conf.json"))
+ .expect("parse tauri config");
+ let windows = config["app"]["windows"].as_array().expect("window config array");
+ for label in ["main", "pet", "pet-panel"] {
+ let item = windows.iter()
+ .find(|item| item["label"] == label)
+ .unwrap_or_else(|| panic!("missing window config {label}"));
+ assert_eq!(item["create"], false, "{label} must not auto-create before DB init");
+ }
+}
+```
+
+- [ ] **Step 2: Run the test and confirm RED**
+
+Run:
+
+```bash
+cd src-tauri
+cargo test services::window::tests::configured_product_windows_require_manual_creation
+```
+
+Expected: failure because `create` is absent/default-true.
+
+- [ ] **Step 3: Set every configured WebView to manual creation**
+
+In each of the `main`, `pet`, and `pet-panel` objects in `tauri.conf.json`, add:
+
+```json
+"create": false
+```
+
+Keep all existing URL, size, decoration, transparency, visibility, and icon settings unchanged.
+
+- [ ] **Step 4: Add one config-driven window creation helper**
+
+In `services/window.rs`:
+
+```rust
+pub(crate) fn create_configured_window(
+ app: &AppHandle,
+ label: &str,
+) -> Result {
+ if let Some(existing) = app.get_webview_window(label) {
+ return Ok(existing);
+ }
+ let config = app.config().app.windows.iter()
+ .find(|item| item.label == label)
+ .ok_or_else(|| format!("window config not found: {label}"))?;
+ tauri::WebviewWindowBuilder::from_config(app, config)
+ .map_err(|e| format!("failed to build window config {label}: {e}"))?
+ .build()
+ .map_err(|e| format!("failed to create configured window {label}: {e}"))
+}
+```
+
+Use this helper for the startup creation path. Do not rewrite unrelated panel/docking behavior in this task.
+
+- [ ] **Step 5: Make `.setup()` fail closed and order initialization before window creation**
+
+Replace the warning-only DB startup with this order:
+
+```rust
+.setup(|app| {
+ init_database(app.handle()).map_err(std::io::Error::other)?;
+
+ let conn = open_connection(app.handle()).map_err(std::io::Error::other)?;
+ let tracking = get_tracking_state_entry(&conn).map_err(std::io::Error::other)?;
+ drop(conn);
+
+ let main_window = window_service::create_configured_window(app.handle(), "main")
+ .map_err(std::io::Error::other)?;
+ window_service::set_main_close_behavior(&main_window);
+
+ if tracking.onboarding_completed {
+ window_service::create_configured_window(app.handle(), "pet")
+ .map_err(std::io::Error::other)?;
+ window_service::ensure_pet_window_position(app.handle(), true)
+ .map_err(std::io::Error::other)?;
+ }
+
+ // existing global shortcut registration remains after the DB/window readiness boundary
+ Ok(())
+})
+```
+
+Do not create `pet-panel` at startup; existing lazy panel creation remains.
+
+- [ ] **Step 6: Prevent the global shortcut from revealing a pre-onboarding pet**
+
+In the shortcut handler, read tracking state first. Only call `window_service::summon_pet_window` when `onboarding_completed` is true. If DB/state read fails, log the error and do not create the pet.
+
+The policy belongs in the composition root (`lib.rs`); keep `window_service` free of DB reads.
+
+- [ ] **Step 7: Run Rust verification**
+
+Run:
+
+```bash
+cd src-tauri
+cargo test services::window::tests::configured_product_windows_require_manual_creation
+cargo check --all-targets
+cargo test
+```
+
+Expected: pass.
+
+- [ ] **Step 8: Commit Task 3**
+
+```bash
+git add src-tauri/tauri.conf.json src-tauri/src/services/window.rs src-tauri/src/lib.rs
+git commit -m "fix(startup): initialize database before webviews"
+```
+
+---
+
+### Task 4: Add typed frontend tracking state and separate sampler mechanism from policy
+
+**Files:**
+- Modify: `src/api/types.ts`
+- Modify: `src/api.ts`
+- Create: `src/api/commands/tracking.ts`
+- Create: `src/api/commands/window.ts`
+- Modify: `src/api/commands/index.ts`
+- Create: `src/composables/useTrackingState.ts`
+- Modify: `src/composables/useAutoCaptureSampler.ts`
+
+**Interfaces:**
+- Produces TS type `TrackingState`.
+- Produces `getTrackingState()`, `completeTrackingOnboarding()`, `setAutoCaptureEnabled(enabled)`, `summonPetWindow()`.
+- Produces composable refs `trackingState`, `trackingStateLoading`, `trackingActionLoading`, `onboardingCompleted`, `autoCaptureEnabled` plus persistence methods.
+- Sampler `startAutoCaptureSampler()` / `stopAutoCaptureSampler()` remains idempotent and is started only by the runtime gate.
+
+- [ ] **Step 1: Add the shared TypeScript contract and command wrappers**
+
+In `src/api/types.ts`:
+
+```ts
+export type TrackingState = {
+ onboarding_completed: boolean;
+ auto_capture_enabled: boolean;
+};
+```
+
+Export it from `src/api.ts`.
+
+Create `src/api/commands/tracking.ts`:
+
+```ts
+import { invokeCommand } from "../client";
+import type { TrackingState } from "../types";
+
+export async function getTrackingState(): Promise {
+ return invokeCommand("get_tracking_state");
+}
+
+export async function completeTrackingOnboarding(): Promise {
+ return invokeCommand("complete_tracking_onboarding");
+}
+
+export async function setAutoCaptureEnabled(enabled: boolean): Promise {
+ return invokeCommand("set_auto_capture_enabled", { enabled });
+}
+```
+
+Create `src/api/commands/window.ts`:
+
+```ts
+import { invokeCommand } from "../client";
+
+export async function summonPetWindow(): Promise {
+ await invokeCommand("summon_pet_window");
+}
+```
+
+Export both modules from `src/api/commands/index.ts`.
+
+- [ ] **Step 2: Create `useTrackingState` with persistence-only responsibility**
+
+Implement:
+
+```ts
+import { computed, ref } from "vue";
+import {
+ completeTrackingOnboarding,
+ getTrackingState,
+ setAutoCaptureEnabled,
+ type TrackingState,
+} from "../api";
+
+export function useTrackingState(setErrorMessage: (error: unknown) => void) {
+ const trackingState = ref(null);
+ const trackingStateLoading = ref(true);
+ const trackingActionLoading = ref(false);
+ const onboardingCompleted = computed(() => trackingState.value?.onboarding_completed === true);
+ const autoCaptureEnabled = computed(() => trackingState.value?.auto_capture_enabled === true);
+
+ async function loadTrackingState() {
+ trackingStateLoading.value = true;
+ try {
+ trackingState.value = await getTrackingState();
+ return trackingState.value;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
+ } finally {
+ trackingStateLoading.value = false;
+ }
+ }
+
+ async function activateTracking() {
+ trackingActionLoading.value = true;
+ try {
+ trackingState.value = await completeTrackingOnboarding();
+ return trackingState.value;
+ } finally {
+ trackingActionLoading.value = false;
+ }
+ }
+
+ async function persistAutoCaptureEnabled(enabled: boolean) {
+ trackingActionLoading.value = true;
+ try {
+ trackingState.value = await setAutoCaptureEnabled(enabled);
+ return trackingState.value;
+ } finally {
+ trackingActionLoading.value = false;
+ }
+ }
+
+ return {
+ trackingState,
+ trackingStateLoading,
+ trackingActionLoading,
+ onboardingCompleted,
+ autoCaptureEnabled,
+ loadTrackingState,
+ activateTracking,
+ persistAutoCaptureEnabled,
+ };
+}
+```
+
+Route action errors through `setErrorMessage` in the final implementation; never mutate the ref optimistically before the backend returns.
+
+- [ ] **Step 3: Remove policy from `useAutoCaptureSampler`**
+
+Change `AutoCaptureSamplerOptions` so it no longer accepts `autoCaptureEnabled`. `sampleAutoCapture()` should always perform one `captureForegroundOnce(intervalMs)` call when the sampler is running. `startAutoCaptureSampler()` remains responsible for `stop -> immediate baseline sample -> setInterval(5000)` and `stopAutoCaptureSampler()` clears the timer.
+
+This makes the runtime gate the sole authority for whether the sampler is running.
+
+- [ ] **Step 4: Run frontend static validation**
+
+There is no existing Vue unit-test runner, so do not add one for this task.
+
+Run:
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+```
+
+Expected: pass.
+
+- [ ] **Step 5: Commit Task 4**
+
+```bash
+git add src/api/types.ts src/api.ts src/api/commands/tracking.ts src/api/commands/window.ts src/api/commands/index.ts src/composables/useTrackingState.ts src/composables/useAutoCaptureSampler.ts
+git commit -m "feat(ui): add persisted tracking state client"
+```
+
+---
+
+### Task 5: Gate the Vue product runtime and persist Guard pause/resume
+
+**Files:**
+- Modify: `src/composables/useMainWindowLifecycle.ts`
+- Modify: `src/composables/useGuardData.ts`
+- Modify: `src/lib/guardFeedback.ts`
+- Modify: `src/App.vue` script wiring
+
+**Interfaces:**
+- Consumes: `loadTrackingState`, `onboardingCompleted`, `autoCaptureEnabled`, `persistAutoCaptureEnabled` from Task 4.
+- Produces from `useMainWindowLifecycle`: `startProductRuntime(captureEnabled: boolean)`, `syncCaptureRuntime(captureEnabled: boolean)`, `stopProductRuntime()`.
+- Guard consumes one authoritative `autoCaptureEnabled` ref/computed and async `setAutoCaptureEnabled(next)` callback.
+
+- [ ] **Step 1: Make product runtime start idempotent**
+
+Refactor `useMainWindowLifecycle` so `onMounted` performs only locale/theme initialization plus `await loadTrackingState()`. Add internal `let productRuntimeStarted = false` and a returned function:
+
+```ts
+async function startProductRuntime(captureEnabled: boolean) {
+ if (!productRuntimeStarted) {
+ productRuntimeStarted = true;
+ await loadHeatmapGoalSecondsSetting();
+ resetGuardFeedback();
+ resetPrivacyFeedback();
+ void refreshHomeData();
+ startSettingsWarmup();
+ startMainRefreshPolling();
+ await startInsightsSectionNavigationListener();
+ }
+ syncCaptureRuntime(captureEnabled);
+}
+
+function syncCaptureRuntime(captureEnabled: boolean) {
+ if (!productRuntimeStarted) return;
+ if (captureEnabled) startAutoCaptureSampler();
+ else stopAutoCaptureSampler();
+}
+```
+
+If the loaded state is already onboarded, `onMounted` calls `startProductRuntime(state.auto_capture_enabled)`. If incomplete, it returns without starting Home polling/settings warmup/sampler/listeners.
+
+`onUnmounted` must stop polling, sampler, warmups/listeners, and reset `productRuntimeStarted=false`.
+
+- [ ] **Step 2: Make Guard consume persisted state instead of `ref(true)`**
+
+Change `UseGuardDataOptions` to accept:
+
+```ts
+autoCaptureEnabled: Readonly[>;
+setAutoCaptureEnabled: (enabled: boolean) => Promise];
+```
+
+Delete:
+
+```ts
+const autoCaptureEnabled = ref(true);
+```
+
+Change `onAutoCaptureToggle` to parse the checkbox state and call the injected async setter. Do not flip the shared state locally; the backend response updates it through `useTrackingState`.
+
+Add a feedback helper in `guardFeedback.ts`:
+
+```ts
+export function guardAutoCaptureStateFeedback(enabled: boolean, tx: TranslateFn): string {
+ return enabled
+ ? tx("自动采样已开启", "Auto capture enabled")
+ : tx("记录已暂停;重启后仍保持暂停", "Tracking paused; pause persists after restart");
+}
+```
+
+Update feedback only after successful persistence.
+
+- [ ] **Step 3: Wire persistence to sampler start/stop in `App.vue`**
+
+At the composition root, create one async bridge:
+
+```ts
+async function handleSetAutoCaptureEnabled(enabled: boolean) {
+ const state = await persistAutoCaptureEnabled(enabled);
+ syncCaptureRuntime(state.auto_capture_enabled);
+}
+```
+
+Pass `autoCaptureEnabled` and this callback into `useGuardData`.
+
+Do not let `useGuardData`, `useTrackingState`, or `useAutoCaptureSampler` own all three responsibilities; `App.vue` remains the orchestration boundary.
+
+- [ ] **Step 4: Run frontend static gates**
+
+Run:
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+```
+
+Expected: pass and there is no remaining `ref(true)` tracking authority in `useGuardData`.
+
+- [ ] **Step 5: Commit Task 5**
+
+```bash
+git add src/composables/useMainWindowLifecycle.ts src/composables/useGuardData.ts src/lib/guardFeedback.ts src/App.vue
+git commit -m "feat(tracking): gate runtime on persisted state"
+```
+
+---
+
+### Task 6: Add the one-time onboarding surface and activation transition
+
+**Files:**
+- Create: `src/components/OnboardingView.vue`
+- Modify: `src/App.vue`
+- Modify: `src/styles/app.css`
+
+**Interfaces:**
+- Consumes: `trackingStateLoading`, `onboardingCompleted`, `trackingActionLoading`, `activateTracking`.
+- Emits from `OnboardingView`: `activate` only; there is no persistent browse-without-tracking branch.
+- Activation order: backend state commit -> product runtime start -> pet reveal.
+
+- [ ] **Step 1: Create a single-screen disclosure component**
+
+Use the existing `tx(zh, en)` translation pattern. Component API:
+
+```ts
+const props = defineProps<{
+ tx: (zh: string, en: string) => string;
+ loading: boolean;
+}>();
+const emit = defineEmits<{ activate: [] }>();
+```
+
+Required visible content, expressed concisely rather than as a carousel:
+
+```text
+欢迎使用 TimePrism
+
+自动记录你的电脑时间流向,数据保存在本机。
+
+开始后,TimePrism 会:
+• 读取当前前台应用及窗口信息,用于计算使用时长
+• 默认对浏览器标题做隐私处理,并跳过无痕/隐私窗口
+• 将核心数据保存在本机 SQLite;当前版本不会上传到云端
+
+TimePrism 不会:
+• 截屏
+• 记录键盘输入内容
+
+“数据看板”展示观察到的应用活动;桌宠的“学 / 休”只统计已分类为学习或休息的应用。
+
+[开始使用 TimePrism]
+```
+
+English copy must communicate the same claims. The primary button is disabled while `loading` is true.
+
+- [ ] **Step 2: Style onboarding as the first product surface**
+
+In `app.css`, add focused classes such as `.onboarding-shell`, `.onboarding-card`, `.onboarding-points`, `.onboarding-primary`. Reuse existing background, card, text, hint, button radii, and spacing tokens; do not introduce a new visual system.
+
+The content must fit the existing 888x900 main-window minimum without scrolling on the default Windows scale used by the app.
+
+- [ ] **Step 3: Conditionally render onboarding before the normal product shell**
+
+In `App.vue` template, structure the top-level condition as:
+
+```vue
+
+
+
+
+
+```
+
+Do not start Home/Insights/Guard data flows merely because the normal DOM exists; Task 5's runtime gate remains authoritative.
+
+- [ ] **Step 4: Implement the activation order without optimistic tracking**
+
+In `App.vue`:
+
+```ts
+async function handleActivateTracking() {
+ try {
+ const state = await activateTracking();
+ await startProductRuntime(state.auto_capture_enabled);
+ await summonPetWindow();
+ } catch (error) {
+ setErrorMessage(error);
+ }
+}
+```
+
+If the backend state write fails, onboarding remains visible and the sampler remains stopped. If the later pet reveal fails, tracking state remains valid; report the pet error without rolling the persisted activation back.
+
+- [ ] **Step 5: Run frontend and Rust static gates**
+
+Run:
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+cd src-tauri
+cargo check --all-targets
+cargo test
+```
+
+Expected: pass.
+
+- [ ] **Step 6: Commit Task 6**
+
+```bash
+git add src/components/OnboardingView.vue src/App.vue src/styles/app.css
+git commit -m "feat(onboarding): require first-run tracking activation"
+```
+
+---
+
+### Task 7: Make the pet communicate persisted tracking state
+
+**Files:**
+- Modify: `src/pet.ts`
+
+**Interfaces:**
+- Consumes: `getTrackingState()` and existing `getTodaySummary()`.
+- Behavior: pet remains hidden pre-onboarding by Rust startup policy; when present, mood says active vs paused while the counters remain `LEARN` / `REST` totals.
+
+- [ ] **Step 1: Read summary and tracking state in the same existing 5-second refresh**
+
+Change `refreshSummary()` to use:
+
+```ts
+const [summary, tracking] = await Promise.all([
+ getTodaySummary(),
+ getTrackingState(),
+]);
+```
+
+Keep `renderPetSummary` totals exactly based on `summary.learn_seconds` and `summary.rest_seconds`.
+
+Set mood text from persisted state:
+
+```ts
+const trackingMood = tracking.auto_capture_enabled
+ ? tx("自动记录中", "Auto tracking")
+ : tx("记录已暂停", "Tracking paused");
+```
+
+Pass `trackingMood` into `renderPetSummary`.
+
+- [ ] **Step 2: Do not change timer frequency**
+
+Keep the existing:
+
+```ts
+window.setInterval(..., 5000)
+```
+
+No 1-second animation or synthetic counter increment is introduced.
+
+- [ ] **Step 3: Run frontend validation**
+
+Run:
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+```
+
+Expected: pass.
+
+- [ ] **Step 4: Commit Task 7**
+
+```bash
+git add src/pet.ts
+git commit -m "feat(pet): show persisted tracking status"
+```
+
+---
+
+### Task 8: Clear recovered Home read errors without masking write failures
+
+**Files:**
+- Modify: `src/composables/useErrorMessage.ts`
+- Modify: `src/composables/useHomeData.ts`
+- Modify: `src/App.vue` wiring
+
+**Interfaces:**
+- Produces: `clearErrorMessage()`.
+- Home core refresh clears the global read error only after its own successful `Promise.all` completes.
+- Tracking/onboarding action failures stay visible until another explicit user action or relevant successful operation replaces/clears them.
+
+- [ ] **Step 1: Add an explicit clear function**
+
+In `useErrorMessage.ts`:
+
+```ts
+function clearErrorMessage() {
+ error.value = "";
+}
+
+return {
+ error,
+ setErrorMessage,
+ clearErrorMessage,
+};
+```
+
+- [ ] **Step 2: Clear only after successful Home core data assignment**
+
+Add `clearErrorMessage: () => void` to `UseHomeDataOptions`. In `refreshHomeCoreData()`, call it only after all five reads succeed and the refs are assigned:
+
+```ts
+todaySummary.value = summary;
+recentLogs.value = logs;
+pendingRuleProcesses.value = pendingRules;
+idlePrompts.value = pendingIdle;
+reminders.value = reminderRows;
+clearErrorMessage();
+```
+
+Do not clear in `finally`, and do not clear on a failed refresh.
+
+- [ ] **Step 3: Wire `clearErrorMessage` from `App.vue` and run gates**
+
+Run:
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+```
+
+Expected: pass.
+
+- [ ] **Step 4: Commit Task 8**
+
+```bash
+git add src/composables/useErrorMessage.ts src/composables/useHomeData.ts src/App.vue
+git commit -m "fix(ui): clear recovered home read errors"
+```
+
+---
+
+### Task 9: Update acceptance docs and perform final automated verification
+
+**Files:**
+- Modify: `docs/SMOKE_TESTS.md`
+- Modify: `docs/RELEASE_CHECKLIST.md`
+- Review only: `.github/workflows/ci.yml`, `.github/workflows/release.yml`
+
+**Interfaces:**
+- Produces: explicit fresh-install and upgrade smoke checklist tied to the corrected installer.
+- Does not alter the release workflow unless verification exposes a separate workflow defect.
+
+- [ ] **Step 1: Add a first-run smoke section**
+
+Document these exact manual assertions in `docs/SMOKE_TESTS.md`:
+
+```text
+Fresh install / clean app-data directory
+1. Launch TimePrism.
+2. Confirm no `no such table: app_usage_logs` message appears.
+3. Before activation, switch foreground apps for >10 seconds; confirm no usage rows are captured.
+4. Confirm the onboarding disclosure states foreground app/window metadata, local SQLite storage, browser privacy, no screenshots/keystroke contents, and Insights vs 学/休 semantics.
+5. Click 开始使用 TimePrism.
+6. Confirm capture begins without restart.
+7. Confirm Insights shows observed usage after sampling.
+8. Confirm pet 学/休 remains classification-based and updates on the existing several-second cadence.
+9. Pause capture; wait >10 seconds while switching apps; confirm no new capture is appended.
+10. Fully quit and reopen; confirm capture remains paused and onboarding does not reappear.
+11. Resume capture; quit/reopen again; confirm capture automatically resumes.
+12. Confirm close-to-background, pet behavior, and Quit TimePrism remain correct.
+```
+
+Add a separate upgrade assertion: a pre-change DB without the two new keys starts as onboarded + capture enabled, without showing onboarding.
+
+- [ ] **Step 2: Add the first-run gate to `RELEASE_CHECKLIST.md`**
+
+Before public release, require both a clean-data fresh install and an upgrade-from-old-DB smoke. Keep the unsigned/SmartScreen note.
+
+- [ ] **Step 3: Run the full repository gates from a clean dependency/build state as practical**
+
+Run:
+
+```bash
+pnpm install --frozen-lockfile
+pnpm run typecheck
+pnpm run build:check
+cd src-tauri
+cargo check --all-targets
+cargo test
+```
+
+Expected: all pass. Record the Rust test count from actual output; do not reuse the previous `35 passed` claim if the suite count changes.
+
+- [ ] **Step 4: Push and require both Windows workflows to pass on the final HEAD**
+
+Verify GitHub Actions:
+
+```text
+CI
+- typecheck
+- Vite build
+- cargo check --all-targets
+- cargo test
+
+Windows Release PR packaging
+- synchronized version check
+- frontend typecheck
+- cargo test
+- Tauri NSIS build
+- Tauri MSI build
+- installer artifact upload
+```
+
+The PR packaging run must generate both `*-setup.exe` and `*.msi` on the final code HEAD.
+
+- [ ] **Step 5: Commit docs if they were not included with an earlier task**
+
+```bash
+git add docs/SMOKE_TESTS.md docs/RELEASE_CHECKLIST.md
+git commit -m "docs: add first-run tracking release gates"
+```
+
+- [ ] **Step 6: Stop before merge and perform the real Windows acceptance test**
+
+Use a clean/sandboxed app-data directory for the fresh path and a backed-up pre-change database for the upgrade path. Do not mark PR #2 ready or publish a release until both pass.
+
+After the corrected build passes, capture sanitized current-build screenshots for Home, Insights, Focus Guard, and the desktop pet, then wire those real files into README as the separate product-media gate already documented in `docs/media/README.md`.
+
+---
+
+## Final Review Checklist
+
+Before claiming implementation complete:
+
+- [ ] Fresh database defaults are `false / false`.
+- [ ] Existing recognizable pre-change database defaults are `true / true`.
+- [ ] Re-initialization preserves an explicit persisted pause.
+- [ ] Backend rejects enabling capture before onboarding completion.
+- [ ] Onboarding activation writes both state keys atomically.
+- [ ] `tauri.conf.json` has `create: false` for `main`, `pet`, and `pet-panel`.
+- [ ] `.setup()` initializes/migrates DB before creating the main WebView.
+- [ ] DB initialization failure prevents normal runtime startup.
+- [ ] Pre-onboarding main UI does not start foreground sampling or normal product polling.
+- [ ] Pre-onboarding pet cannot be summoned via startup or global shortcut.
+- [ ] Successful activation starts normal runtime without restart and shows the pet.
+- [ ] Guard pause/resume is persisted before sampler state changes.
+- [ ] Restart preserves pause; restart after resume automatically tracks.
+- [ ] Sampler and UI refresh intervals remain 5 seconds.
+- [ ] First capture remains baseline-only.
+- [ ] Pet counters remain classified `LEARN / REST` totals and mood shows active vs paused.
+- [ ] A recovered transient Home read error does not remain as a stale red footer.
+- [ ] `pnpm run typecheck`, `pnpm run build:check`, `cargo check --all-targets`, and `cargo test` pass on final HEAD.
+- [ ] Final Windows PR packaging produces both NSIS and MSI installers.
+- [ ] Fresh-install and upgrade manual acceptance tests pass before PR/release promotion.
From c2e1b8d4fbe884a8343cafb4623a42b7b392d7e2 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 21:52:30 +0800
Subject: [PATCH 032/117] docs: tighten first-run tracking implementation plan
---
...2026-09-08-timeprism-first-run-tracking.md | 815 ++++++++----------
1 file changed, 382 insertions(+), 433 deletions(-)
diff --git a/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md b/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
index d8e5638..006762c 100644
--- a/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
+++ b/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
@@ -4,7 +4,7 @@
**Goal:** Make a fresh TimePrism install initialize SQLite before any product WebView can query it, show a one-time tracking disclosure before foreground capture starts, and persist tracking pause/resume state across restarts.
-**Architecture:** Keep the existing `commands -> services -> domain/db` structure. Add a small SQLite-backed tracking-state service, manually create configured Tauri windows only after database initialization, and gate the existing Vue runtime behind a persisted `TrackingState`. The foreground sampler keeps its current approximately 5-second cadence; policy/persistence stays outside the sampler.
+**Architecture:** Keep the existing `commands -> services -> domain/db` structure. Add a small SQLite-backed tracking-state service, manually create configured Tauri windows only after database initialization, and gate the Vue product runtime behind a persisted `TrackingState`. The foreground sampler keeps its current approximately 5-second cadence; persistence and onboarding policy stay outside the sampler.
**Tech Stack:** Tauri 2, Rust, rusqlite/SQLite, Vue 3, TypeScript, Vite, GitHub Actions on Windows.
@@ -13,16 +13,16 @@
## Global Constraints
- Fresh databases start with `onboarding_completed=false` and `auto_capture_enabled=false`.
-- Existing pre-change TimePrism databases migrate to `onboarding_completed=true` and `auto_capture_enabled=true` unless either key already exists.
+- Existing pre-change TimePrism databases migrate to `onboarding_completed=true` and `auto_capture_enabled=true` unless a key already exists.
+- Existing values are never overwritten by migration defaults.
- No foreground capture may occur before onboarding activation succeeds.
- A user-initiated pause persists across restart; restart must never silently turn capture back on.
-- Keep the foreground sampling interval at approximately 5 seconds.
-- Keep Home and pet refresh cadence at approximately 5 seconds.
-- The first foreground sample remains baseline-only to avoid synthetic overcounting.
+- Keep foreground sampling, Home refresh, and pet refresh at approximately 5 seconds.
+- The first foreground sample remains baseline-only.
- No new frontend test framework is added solely for this feature.
-- Database initialization failure must prevent normal product runtime startup.
-- Do not perform another broad backend or frontend directory refactor.
-- Product copy must state that TimePrism reads foreground application/window metadata, stores core data locally, privacy-processes browser titles, does not capture screenshots/keystroke contents, and that Insights activity differs from classified `LEARN` / `REST` totals.
+- Database initialization failure prevents normal product runtime startup.
+- Do not perform another broad backend/frontend directory refactor.
+- Product copy must state foreground app/window metadata, local storage, browser privacy processing, no screenshots/keystroke contents, and the difference between observed activity and classified `LEARN` / `REST` time.
---
@@ -30,72 +30,59 @@
### Create
-- `src-tauri/src/domain/tracking.rs` — serialized backend `TrackingState` contract.
-- `src-tauri/src/services/tracking.rs` — SQLite-backed tracking-state invariants and transitions.
-- `src/api/commands/tracking.ts` — typed frontend wrappers for tracking-state Tauri commands.
-- `src/api/commands/window.ts` — typed main-window wrapper for showing the pet after first activation.
-- `src/composables/useTrackingState.ts` — frontend persistence-facing tracking state only; no timer ownership.
-- `src/components/OnboardingView.vue` — one-screen first-run disclosure and activation UI.
+- `src-tauri/src/domain/tracking.rs` — serialized backend `TrackingState`.
+- `src-tauri/src/services/tracking.rs` — SQLite-backed tracking invariants/transitions.
+- `src/api/commands/tracking.ts` — typed tracking-state Tauri wrappers.
+- `src/api/commands/window.ts` — typed `showMainWindow` / `summonPetWindow` wrappers.
+- `src/composables/useTrackingState.ts` — persisted tracking state only; no timer ownership.
+- `src/components/OnboardingView.vue` — one-screen first-run disclosure.
### Modify
-- `src-tauri/src/db/migrations.rs` — classify fresh vs recognizable existing DB before schema creation; seed tracking keys with migration-safe defaults.
-- `src-tauri/src/db/tests.rs` — file-backed regression coverage for fresh/existing tracking defaults and persisted pause preservation.
-- `src-tauri/src/domain/mod.rs` — expose tracking domain model.
-- `src-tauri/src/services/mod.rs` — expose tracking service.
-- `src-tauri/src/lib.rs` — thin tracking commands; fail-closed DB startup; create windows only after DB init; gate startup pet creation/shortcut by onboarding state.
-- `src-tauri/src/services/window.rs` — create main/pet windows from `tauri.conf.json` config after DB readiness instead of duplicating startup config.
-- `src-tauri/tauri.conf.json` — set configured WebViews to `create: false` so Tauri cannot auto-create them before `.setup()` completes.
-- `src/api/types.ts` and `src/api.ts` — expose `TrackingState` to TypeScript callers.
-- `src/api/commands/index.ts` — export tracking/window command modules.
-- `src/composables/useAutoCaptureSampler.ts` — timer only; remove policy ownership from the sampler.
-- `src/composables/useGuardData.ts` — consume persisted auto-capture state rather than creating `ref(true)`.
-- `src/composables/useMainWindowLifecycle.ts` — split shell initialization from idempotent product-runtime start.
-- `src/composables/useErrorMessage.ts` — add explicit clearing for a recovered transient read error.
-- `src/composables/useHomeData.ts` — clear its own transient global read error after a successful core refresh.
-- `src/App.vue` — composition-root wiring for tracking state, onboarding, activation, pause/resume, runtime start, and pet reveal.
-- `src/pet.ts` — render active vs paused tracking state while retaining 5-second summary polling.
-- `src/styles/app.css` — onboarding layout/styles using existing visual tokens.
-- `docs/SMOKE_TESTS.md` and `docs/RELEASE_CHECKLIST.md` — record fresh-install and upgrade acceptance gates.
+- `src-tauri/src/db/migrations.rs`, `src-tauri/src/db/tests.rs`
+- `src-tauri/src/domain/mod.rs`, `src-tauri/src/services/mod.rs`
+- `src-tauri/src/lib.rs`, `src-tauri/src/services/window.rs`, `src-tauri/tauri.conf.json`
+- `src/api/types.ts`, `src/api.ts`, `src/api/commands/index.ts`
+- `src/composables/useAutoCaptureSampler.ts`, `src/composables/useGuardData.ts`, `src/composables/useMainWindowLifecycle.ts`
+- `src/composables/useErrorMessage.ts`, `src/composables/useHomeData.ts`
+- `src/lib/guardFeedback.ts`
+- `src/App.vue`, `src/main.ts`, `src/pet.ts`, `src/styles/app.css`
+- `docs/SMOKE_TESTS.md`, `docs/RELEASE_CHECKLIST.md`
---
### Task 1: Add migration-safe tracking defaults
**Files:**
-- Modify: `src-tauri/src/db/migrations.rs` around `initialize_connection`
-- Modify/Test: `src-tauri/src/db/tests.rs` around the fresh/idempotent/legacy database tests
+- Modify: `src-tauri/src/db/migrations.rs` (`initialize_connection` and helpers)
+- Modify/Test: `src-tauri/src/db/tests.rs` (fresh/idempotent/legacy tests)
**Interfaces:**
-- Consumes: existing `app_config(key TEXT PRIMARY KEY, value TEXT NOT NULL)` table.
-- Produces: guaranteed `onboarding_completed` and `auto_capture_enabled` config keys after every successful `initialize_connection(&Connection)` call.
+- Consumes: existing `app_config(key TEXT PRIMARY KEY, value TEXT NOT NULL)`.
+- Produces: guaranteed `onboarding_completed` and `auto_capture_enabled` keys after successful DB initialization.
-- [ ] **Step 1: Add failing database tests for fresh tracking defaults**
+- [ ] **Step 1: Add RED assertions for a fresh DB**
-Extend `fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens` with exact assertions:
+Extend `fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens`:
```rust
-let onboarding: String = conn
- .query_row(
- "SELECT value FROM app_config WHERE key = 'onboarding_completed'",
- [],
- |row| row.get(0),
- )
- .expect("read onboarding default");
-let auto_capture: String = conn
- .query_row(
- "SELECT value FROM app_config WHERE key = 'auto_capture_enabled'",
- [],
- |row| row.get(0),
- )
- .expect("read auto capture default");
+let onboarding: String = conn.query_row(
+ "SELECT value FROM app_config WHERE key = 'onboarding_completed'",
+ [],
+ |row| row.get(0),
+).expect("read onboarding default");
+let auto_capture: String = conn.query_row(
+ "SELECT value FROM app_config WHERE key = 'auto_capture_enabled'",
+ [],
+ |row| row.get(0),
+).expect("read auto capture default");
assert_eq!(onboarding, "false");
assert_eq!(auto_capture, "false");
```
-- [ ] **Step 2: Add failing migration tests for an existing recognizable database**
+- [ ] **Step 2: Add RED assertions for a recognizable legacy DB**
-In `legacy_schema_is_upgraded_without_losing_existing_rows`, after creating the legacy `app_rules` / `reminders` schema and calling `initialize_connection`, assert:
+Extend `legacy_schema_is_upgraded_without_losing_existing_rows` after `initialize_connection`:
```rust
let onboarding: String = conn.query_row(
@@ -112,9 +99,7 @@ assert_eq!(onboarding, "true");
assert_eq!(auto_capture, "true");
```
-- [ ] **Step 3: Add a failing regression test proving initialization preserves a user pause**
-
-Add:
+- [ ] **Step 3: Add RED regression for a persisted pause**
```rust
#[test]
@@ -149,9 +134,7 @@ fn database_initialization_preserves_persisted_tracking_pause() {
}
```
-- [ ] **Step 4: Run the focused tests and confirm RED**
-
-Run:
+- [ ] **Step 4: Run focused tests and verify RED**
```bash
cd src-tauri
@@ -160,37 +143,33 @@ cargo test db::tests::legacy_schema_is_upgraded_without_losing_existing_rows
cargo test db::tests::database_initialization_preserves_persisted_tracking_pause
```
-Expected: the first two fail because the new keys do not exist; the third cannot observe the requested keys yet.
+Expected: missing tracking keys cause failure.
-- [ ] **Step 5: Detect whether the DB was recognizable before schema creation**
+- [ ] **Step 5: Detect recognizable TimePrism schema before creating tables**
-Add a helper before `initialize_connection`:
+Add before `initialize_connection`:
```rust
fn has_recognizable_timeprism_schema(conn: &Connection) -> Result {
- let count: i64 = conn
- .query_row(
- "SELECT COUNT(*)
- FROM sqlite_master
- WHERE type = 'table'
- AND name IN ('categories', 'task_sessions', 'app_usage_logs', 'app_rules', 'app_config', 'reminders')",
- [],
- |row| row.get(0),
- )
- .map_err(|e| format!("failed to inspect existing TimePrism schema: {e}"))?;
+ let count: i64 = conn.query_row(
+ "SELECT COUNT(*)
+ FROM sqlite_master
+ WHERE type = 'table'
+ AND name IN ('categories', 'task_sessions', 'app_usage_logs', 'app_rules', 'app_config', 'reminders')",
+ [],
+ |row| row.get(0),
+ ).map_err(|e| format!("failed to inspect existing TimePrism schema: {e}"))?;
Ok(count > 0)
}
```
-At the start of `initialize_connection`, before the existing `CREATE TABLE IF NOT EXISTS ...` batch:
+At the very start of `initialize_connection`, before the existing schema `CREATE TABLE` batch:
```rust
let was_existing_timeprism_db = has_recognizable_timeprism_schema(conn)?;
```
-- [ ] **Step 6: Seed tracking keys with fresh-vs-upgrade defaults using `INSERT OR IGNORE`**
-
-After the existing default config loop, add:
+- [ ] **Step 6: Seed migration-safe defaults after `app_config` exists**
```rust
let tracking_defaults = if was_existing_timeprism_db {
@@ -203,16 +182,13 @@ for (key, value) in tracking_defaults {
conn.execute(
"INSERT OR IGNORE INTO app_config (key, value) VALUES (?1, ?2)",
params![key, value],
- )
- .map_err(|e| format!("failed to seed tracking config {key}: {e}"))?;
+ ).map_err(|e| format!("failed to seed tracking config {key}: {e}"))?;
}
```
-Do not update existing rows; `INSERT OR IGNORE` is the preservation mechanism.
-
-- [ ] **Step 7: Run focused tests and the full Rust suite**
+`INSERT OR IGNORE` is required so existing explicit values survive re-initialization.
-Run:
+- [ ] **Step 7: Verify GREEN and full regression**
```bash
cd src-tauri
@@ -222,9 +198,7 @@ cargo test db::tests::database_initialization_preserves_persisted_tracking_pause
cargo test
```
-Expected: all pass; existing DB lifecycle and mojibake tests stay green.
-
-- [ ] **Step 8: Commit Task 1**
+- [ ] **Step 8: Commit**
```bash
git add src-tauri/src/db/migrations.rs src-tauri/src/db/tests.rs
@@ -237,9 +211,8 @@ git commit -m "feat(db): persist first-run tracking defaults"
**Files:**
- Create: `src-tauri/src/domain/tracking.rs`
-- Create: `src-tauri/src/services/tracking.rs`
-- Modify: `src-tauri/src/domain/mod.rs`
-- Modify: `src-tauri/src/services/mod.rs`
+- Create/Test: `src-tauri/src/services/tracking.rs`
+- Modify: `src-tauri/src/domain/mod.rs`, `src-tauri/src/services/mod.rs`
- Modify: `src-tauri/src/lib.rs` command imports/handlers
**Interfaces:**
@@ -249,7 +222,7 @@ git commit -m "feat(db): persist first-run tracking defaults"
- Produces: `set_auto_capture_enabled_entry(&Connection, bool) -> Result`.
- Produces Tauri commands: `get_tracking_state`, `complete_tracking_onboarding`, `set_auto_capture_enabled`.
-- [ ] **Step 1: Create the domain contract**
+- [ ] **Step 1: Create and expose the domain contract**
`src-tauri/src/domain/tracking.rs`:
@@ -263,21 +236,16 @@ pub struct TrackingState {
}
```
-Expose it from `domain/mod.rs`:
-
-```rust
-pub mod tracking;
-```
+Add `pub mod tracking;` to `domain/mod.rs`.
-- [ ] **Step 2: Write failing service tests before the service implementation**
+- [ ] **Step 2: Create the tracking module, expose it, and add RED tests before functions**
-Create `src-tauri/src/services/tracking.rs` with a `#[cfg(test)]` module first. Use a minimal in-memory table:
+Create `src-tauri/src/services/tracking.rs` with the tests below and add `pub mod tracking;` to `services/mod.rs` immediately so `cargo test services::tracking::tests` actually compiles the new module.
```rust
#[cfg(test)]
mod tests {
use rusqlite::{params, Connection};
-
use super::{
complete_onboarding_entry, get_tracking_state_entry, set_auto_capture_enabled_entry,
};
@@ -303,16 +271,16 @@ mod tests {
fn completing_onboarding_atomically_enables_capture() {
let conn = tracking_conn(false, false);
let state = complete_onboarding_entry(&conn).expect("complete onboarding");
- assert!(state.onboarding_completed);
- assert!(state.auto_capture_enabled);
+ assert_eq!(state.onboarding_completed, true);
+ assert_eq!(state.auto_capture_enabled, true);
assert_eq!(get_tracking_state_entry(&conn).unwrap(), state);
}
#[test]
fn pause_and_resume_persist_after_onboarding() {
let conn = tracking_conn(true, true);
- assert!(!set_auto_capture_enabled_entry(&conn, false).unwrap().auto_capture_enabled);
- assert!(set_auto_capture_enabled_entry(&conn, true).unwrap().auto_capture_enabled);
+ assert_eq!(set_auto_capture_enabled_entry(&conn, false).unwrap().auto_capture_enabled, false);
+ assert_eq!(set_auto_capture_enabled_entry(&conn, true).unwrap().auto_capture_enabled, true);
}
#[test]
@@ -320,34 +288,33 @@ mod tests {
let conn = tracking_conn(false, false);
let err = set_auto_capture_enabled_entry(&conn, true).unwrap_err();
assert!(err.contains("onboarding"));
- assert!(!get_tracking_state_entry(&conn).unwrap().auto_capture_enabled);
+ assert_eq!(get_tracking_state_entry(&conn).unwrap().auto_capture_enabled, false);
}
}
```
-- [ ] **Step 3: Run the tracking tests and confirm RED**
-
-Run:
+- [ ] **Step 3: Run and verify RED**
```bash
cd src-tauri
cargo test services::tracking::tests
```
-Expected: compile/test failure because the service functions do not exist yet.
+Expected: compile failure because the referenced tracking functions do not exist.
-- [ ] **Step 4: Implement strict config reads and the three state transitions**
-
-Use exact boolean parsing rather than a default that could hide a missing/corrupt key:
+- [ ] **Step 4: Implement strict reads and state transitions**
```rust
use rusqlite::{params, Connection};
use crate::domain::tracking::TrackingState;
fn read_bool_config(conn: &Connection, key: &str) -> Result {
- let value: String = conn
- .query_row("SELECT value FROM app_config WHERE key = ?1", [key], |row| row.get(0))
- .map_err(|e| format!("failed to read tracking config {key}: {e}"))?;
+ let value: String = conn.query_row(
+ "SELECT value FROM app_config WHERE key = ?1",
+ [key],
+ |row| row.get(0),
+ ).map_err(|e| format!("failed to read tracking config {key}: {e}"))?;
+
match value.trim().to_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
@@ -363,32 +330,45 @@ pub(crate) fn get_tracking_state_entry(conn: &Connection) -> Result Result {
let tx = conn.unchecked_transaction()
.map_err(|e| format!("failed to begin onboarding transaction: {e}"))?;
- tx.execute(
- "UPDATE app_config SET value = 'true' WHERE key IN ('onboarding_completed', 'auto_capture_enabled')",
- [],
- ).map_err(|e| format!("failed to complete onboarding: {e}"))?;
+ for key in ["onboarding_completed", "auto_capture_enabled"] {
+ tx.execute(
+ "INSERT INTO app_config (key, value) VALUES (?1, 'true')
+ ON CONFLICT(key) DO UPDATE SET value = 'true'",
+ [key],
+ ).map_err(|e| format!("failed to persist onboarding config {key}: {e}"))?;
+ }
tx.commit().map_err(|e| format!("failed to commit onboarding: {e}"))?;
get_tracking_state_entry(conn)
}
```
-`set_auto_capture_enabled_entry` must read current state first, reject `true` before onboarding, write one key, then re-read and return persisted state.
-
-- [ ] **Step 5: Expose the service module and run tests GREEN**
-
-Add to `services/mod.rs`:
+Implement `set_auto_capture_enabled_entry` as:
```rust
-pub mod tracking;
+pub(crate) fn set_auto_capture_enabled_entry(
+ conn: &Connection,
+ enabled: bool,
+) -> Result {
+ let current = get_tracking_state_entry(conn)?;
+ if enabled && !current.onboarding_completed {
+ return Err("cannot enable auto capture before onboarding is complete".to_string());
+ }
+ conn.execute(
+ "INSERT INTO app_config (key, value) VALUES ('auto_capture_enabled', ?1)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ [if enabled { "true" } else { "false" }],
+ ).map_err(|e| format!("failed to persist auto capture state: {e}"))?;
+ get_tracking_state_entry(conn)
+}
```
-Run:
+- [ ] **Step 5: Verify GREEN**
```bash
cd src-tauri
@@ -396,11 +376,9 @@ cargo test services::tracking::tests
cargo test
```
-Expected: all pass.
+- [ ] **Step 6: Add thin Tauri commands and register them**
-- [ ] **Step 6: Add thin Tauri command wrappers**
-
-In `lib.rs`, import the model/service and add:
+In `lib.rs`:
```rust
#[tauri::command(async)]
@@ -422,23 +400,15 @@ fn set_auto_capture_enabled(app: AppHandle, enabled: bool) -> Result Result`.
-- Invariant: no configured product WebView exists before `init_database` succeeds.
+- Consumes: `get_tracking_state_entry` from Task 2.
+- Produces: `create_configured_window(app, label) -> Result`.
+- Invariant: no product WebView exists before `init_database` succeeds.
-- [ ] **Step 1: Add a failing config characterization test**
+- [ ] **Step 1: Add RED config test**
-In `services/window.rs` tests, add:
+In `services/window.rs` tests:
```rust
#[test]
@@ -476,30 +446,24 @@ fn configured_product_windows_require_manual_creation() {
}
```
-- [ ] **Step 2: Run the test and confirm RED**
-
-Run:
+- [ ] **Step 2: Verify RED**
```bash
cd src-tauri
cargo test services::window::tests::configured_product_windows_require_manual_creation
```
-Expected: failure because `create` is absent/default-true.
-
-- [ ] **Step 3: Set every configured WebView to manual creation**
+- [ ] **Step 3: Set all configured product windows to manual creation**
-In each of the `main`, `pet`, and `pet-panel` objects in `tauri.conf.json`, add:
+Add to each `main`, `pet`, `pet-panel` object in `tauri.conf.json`:
```json
"create": false
```
-Keep all existing URL, size, decoration, transparency, visibility, and icon settings unchanged.
-
-- [ ] **Step 4: Add one config-driven window creation helper**
+Do not change their other config values.
-In `services/window.rs`:
+- [ ] **Step 4: Add config-driven creation helper**
```rust
pub(crate) fn create_configured_window(
@@ -519,11 +483,9 @@ pub(crate) fn create_configured_window(
}
```
-Use this helper for the startup creation path. Do not rewrite unrelated panel/docking behavior in this task.
+- [ ] **Step 5: Make `.setup()` fail closed and order DB before windows**
-- [ ] **Step 5: Make `.setup()` fail closed and order initialization before window creation**
-
-Replace the warning-only DB startup with this order:
+Required order in `lib.rs`:
```rust
.setup(|app| {
@@ -544,22 +506,35 @@ Replace the warning-only DB startup with this order:
.map_err(std::io::Error::other)?;
}
- // existing global shortcut registration remains after the DB/window readiness boundary
+ // register existing shortcut only after DB/window readiness
Ok(())
})
```
-Do not create `pet-panel` at startup; existing lazy panel creation remains.
+Delete the current `eprintln!("database init warning...")` continue-on-error behavior.
-- [ ] **Step 6: Prevent the global shortcut from revealing a pre-onboarding pet**
+- [ ] **Step 6: Gate both shortcut and Tauri pet command before onboarding**
-In the shortcut handler, read tracking state first. Only call `window_service::summon_pet_window` when `onboarding_completed` is true. If DB/state read fails, log the error and do not create the pet.
+For the global shortcut handler: open DB, read `TrackingState`, and summon only when `onboarding_completed=true`. On read failure, log and do nothing.
-The policy belongs in the composition root (`lib.rs`); keep `window_service` free of DB reads.
+For the `summon_pet_window` Tauri command wrapper, enforce the same backend policy:
-- [ ] **Step 7: Run Rust verification**
+```rust
+#[tauri::command]
+fn summon_pet_window(app: AppHandle) -> Result<(), String> {
+ let conn = open_connection(&app)?;
+ let state = get_tracking_state_entry(&conn)?;
+ if !state.onboarding_completed {
+ return Err("pet is unavailable before onboarding is complete".to_string());
+ }
+ drop(conn);
+ window_service::summon_pet_window(&app)
+}
+```
-Run:
+The window service itself remains DB-agnostic.
+
+- [ ] **Step 7: Verify Rust**
```bash
cd src-tauri
@@ -568,9 +543,7 @@ cargo check --all-targets
cargo test
```
-Expected: pass.
-
-- [ ] **Step 8: Commit Task 3**
+- [ ] **Step 8: Commit**
```bash
git add src-tauri/tauri.conf.json src-tauri/src/services/window.rs src-tauri/src/lib.rs
@@ -579,26 +552,24 @@ git commit -m "fix(startup): initialize database before webviews"
---
-### Task 4: Add typed frontend tracking state and separate sampler mechanism from policy
+### Task 4: Add typed frontend tracking/window APIs and make the sampler mechanism-only
**Files:**
-- Modify: `src/api/types.ts`
-- Modify: `src/api.ts`
-- Create: `src/api/commands/tracking.ts`
-- Create: `src/api/commands/window.ts`
-- Modify: `src/api/commands/index.ts`
+- Modify: `src/api/types.ts`, `src/api.ts`, `src/api/commands/index.ts`
+- Create: `src/api/commands/tracking.ts`, `src/api/commands/window.ts`
- Create: `src/composables/useTrackingState.ts`
- Modify: `src/composables/useAutoCaptureSampler.ts`
**Interfaces:**
-- Produces TS type `TrackingState`.
-- Produces `getTrackingState()`, `completeTrackingOnboarding()`, `setAutoCaptureEnabled(enabled)`, `summonPetWindow()`.
-- Produces composable refs `trackingState`, `trackingStateLoading`, `trackingActionLoading`, `onboardingCompleted`, `autoCaptureEnabled` plus persistence methods.
-- Sampler `startAutoCaptureSampler()` / `stopAutoCaptureSampler()` remains idempotent and is started only by the runtime gate.
+- Produces TS `TrackingState`.
+- Produces `getTrackingState`, `completeTrackingOnboarding`, `setAutoCaptureEnabled`.
+- Produces `showMainWindow`, `summonPetWindow`.
+- Produces `useTrackingState` refs/methods.
+- Sampler owns only immediate sample + 5-second interval + stop.
-- [ ] **Step 1: Add the shared TypeScript contract and command wrappers**
+- [ ] **Step 1: Add typed contracts/wrappers**
-In `src/api/types.ts`:
+`src/api/types.ts`:
```ts
export type TrackingState = {
@@ -607,42 +578,41 @@ export type TrackingState = {
};
```
-Export it from `src/api.ts`.
+Export `TrackingState` from `src/api.ts`.
-Create `src/api/commands/tracking.ts`:
+`src/api/commands/tracking.ts`:
```ts
import { invokeCommand } from "../client";
import type { TrackingState } from "../types";
-export async function getTrackingState(): Promise {
- return invokeCommand("get_tracking_state");
-}
+export const getTrackingState = (): Promise =>
+ invokeCommand("get_tracking_state");
-export async function completeTrackingOnboarding(): Promise {
- return invokeCommand("complete_tracking_onboarding");
-}
+export const completeTrackingOnboarding = (): Promise =>
+ invokeCommand("complete_tracking_onboarding");
-export async function setAutoCaptureEnabled(enabled: boolean): Promise {
- return invokeCommand("set_auto_capture_enabled", { enabled });
-}
+export const setAutoCaptureEnabled = (enabled: boolean): Promise =>
+ invokeCommand("set_auto_capture_enabled", { enabled });
```
-Create `src/api/commands/window.ts`:
+`src/api/commands/window.ts`:
```ts
import { invokeCommand } from "../client";
+export async function showMainWindow(): Promise {
+ await invokeCommand("show_main_window");
+}
+
export async function summonPetWindow(): Promise {
await invokeCommand("summon_pet_window");
}
```
-Export both modules from `src/api/commands/index.ts`.
-
-- [ ] **Step 2: Create `useTrackingState` with persistence-only responsibility**
+Export both new modules from `src/api/commands/index.ts`.
-Implement:
+- [ ] **Step 2: Create persistence-only `useTrackingState`**
```ts
import { computed, ref } from "vue";
@@ -660,11 +630,12 @@ export function useTrackingState(setErrorMessage: (error: unknown) => void) {
const onboardingCompleted = computed(() => trackingState.value?.onboarding_completed === true);
const autoCaptureEnabled = computed(() => trackingState.value?.auto_capture_enabled === true);
- async function loadTrackingState() {
+ async function loadTrackingState(): Promise {
trackingStateLoading.value = true;
try {
- trackingState.value = await getTrackingState();
- return trackingState.value;
+ const next = await getTrackingState();
+ trackingState.value = next;
+ return next;
} catch (error) {
setErrorMessage(error);
throw error;
@@ -673,21 +644,29 @@ export function useTrackingState(setErrorMessage: (error: unknown) => void) {
}
}
- async function activateTracking() {
+ async function activateTracking(): Promise {
trackingActionLoading.value = true;
try {
- trackingState.value = await completeTrackingOnboarding();
- return trackingState.value;
+ const next = await completeTrackingOnboarding();
+ trackingState.value = next;
+ return next;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
} finally {
trackingActionLoading.value = false;
}
}
- async function persistAutoCaptureEnabled(enabled: boolean) {
+ async function persistAutoCaptureEnabled(enabled: boolean): Promise {
trackingActionLoading.value = true;
try {
- trackingState.value = await setAutoCaptureEnabled(enabled);
- return trackingState.value;
+ const next = await setAutoCaptureEnabled(enabled);
+ trackingState.value = next;
+ return next;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
} finally {
trackingActionLoading.value = false;
}
@@ -706,54 +685,84 @@ export function useTrackingState(setErrorMessage: (error: unknown) => void) {
}
```
-Route action errors through `setErrorMessage` in the final implementation; never mutate the ref optimistically before the backend returns.
-
-- [ ] **Step 3: Remove policy from `useAutoCaptureSampler`**
+No optimistic ref mutation is allowed.
-Change `AutoCaptureSamplerOptions` so it no longer accepts `autoCaptureEnabled`. `sampleAutoCapture()` should always perform one `captureForegroundOnce(intervalMs)` call when the sampler is running. `startAutoCaptureSampler()` remains responsible for `stop -> immediate baseline sample -> setInterval(5000)` and `stopAutoCaptureSampler()` clears the timer.
+- [ ] **Step 3: Remove policy ownership from `useAutoCaptureSampler`**
-This makes the runtime gate the sole authority for whether the sampler is running.
+Delete `autoCaptureEnabled` from `AutoCaptureSamplerOptions` and delete the early return in `sampleAutoCapture()`. The sampler runs only when `startAutoCaptureSampler()` has been called. Preserve:
-- [ ] **Step 4: Run frontend static validation**
+```ts
+function startAutoCaptureSampler() {
+ stopAutoCaptureSampler();
+ sampleAutoCapture();
+ captureTimer = window.setInterval(sampleAutoCapture, intervalMs);
+}
+```
-There is no existing Vue unit-test runner, so do not add one for this task.
+Keep default `intervalMs = 5000`.
-Run:
+- [ ] **Step 4: Static verification and commit**
```bash
pnpm run typecheck
pnpm run build:check
-```
-
-Expected: pass.
-
-- [ ] **Step 5: Commit Task 4**
-
-```bash
git add src/api/types.ts src/api.ts src/api/commands/tracking.ts src/api/commands/window.ts src/api/commands/index.ts src/composables/useTrackingState.ts src/composables/useAutoCaptureSampler.ts
git commit -m "feat(ui): add persisted tracking state client"
```
---
-### Task 5: Gate the Vue product runtime and persist Guard pause/resume
+### Task 5: Gate Vue runtime, delay main reveal until state is loaded, and persist Guard pause/resume
**Files:**
- Modify: `src/composables/useMainWindowLifecycle.ts`
- Modify: `src/composables/useGuardData.ts`
- Modify: `src/lib/guardFeedback.ts`
-- Modify: `src/App.vue` script wiring
+- Modify: `src/App.vue`
+- Modify: `src/main.ts`
**Interfaces:**
-- Consumes: `loadTrackingState`, `onboardingCompleted`, `autoCaptureEnabled`, `persistAutoCaptureEnabled` from Task 4.
-- Produces from `useMainWindowLifecycle`: `startProductRuntime(captureEnabled: boolean)`, `syncCaptureRuntime(captureEnabled: boolean)`, `stopProductRuntime()`.
-- Guard consumes one authoritative `autoCaptureEnabled` ref/computed and async `setAutoCaptureEnabled(next)` callback.
+- Consumes: Task 4 tracking state/APIs.
+- Produces: `startProductRuntime(captureEnabled)`, `syncCaptureRuntime(captureEnabled)`, `stopProductRuntime`.
+- Main WebView remains hidden until tracking state has loaded; first visible UI is onboarding or normal product shell, not an empty shell.
+
+- [ ] **Step 1: Remove unconditional main reveal from `src/main.ts`**
+
+Replace current mount-plus-`invoke("show_main_window")` code with:
+
+```ts
+import { createApp } from "vue";
+import App from "./App.vue";
+import "./styles/app.css";
+
+createApp(App).mount("#app");
+```
+
+Main window is already created hidden by Rust after DB init. It will be revealed only after tracking state loads.
-- [ ] **Step 1: Make product runtime start idempotent**
+- [ ] **Step 2: Refactor lifecycle into shell-init + idempotent product runtime**
-Refactor `useMainWindowLifecycle` so `onMounted` performs only locale/theme initialization plus `await loadTrackingState()`. Add internal `let productRuntimeStarted = false` and a returned function:
+Add `loadTrackingState` and `showMainWindow` dependencies. `onMounted` order:
```ts
+onMounted(async () => {
+ initLocale();
+ initThemeModeSafely();
+
+ const tracking = await loadTrackingState();
+ await showMainWindow();
+
+ if (tracking.onboarding_completed) {
+ await startProductRuntime(tracking.auto_capture_enabled);
+ }
+});
+```
+
+Use:
+
+```ts
+let productRuntimeStarted = false;
+
async function startProductRuntime(captureEnabled: boolean) {
if (!productRuntimeStarted) {
productRuntimeStarted = true;
@@ -773,30 +782,45 @@ function syncCaptureRuntime(captureEnabled: boolean) {
if (captureEnabled) startAutoCaptureSampler();
else stopAutoCaptureSampler();
}
-```
-If the loaded state is already onboarded, `onMounted` calls `startProductRuntime(state.auto_capture_enabled)`. If incomplete, it returns without starting Home polling/settings warmup/sampler/listeners.
+function stopProductRuntime() {
+ stopMainRefreshPolling();
+ stopAutoCaptureSampler();
+ cleanupSettingsWarmup();
+ cleanupInsightsSectionNavigation();
+ cleanupHeatmapGoalSetting();
+ productRuntimeStarted = false;
+}
+```
-`onUnmounted` must stop polling, sampler, warmups/listeners, and reset `productRuntimeStarted=false`.
+Return `startProductRuntime`, `syncCaptureRuntime`, `stopProductRuntime`; call `stopProductRuntime()` on unmount.
-- [ ] **Step 2: Make Guard consume persisted state instead of `ref(true)`**
+- [ ] **Step 3: Make Guard consume the authoritative persisted state**
-Change `UseGuardDataOptions` to accept:
+Change `UseGuardDataOptions` to include:
```ts
autoCaptureEnabled: Readonly[>;
setAutoCaptureEnabled: (enabled: boolean) => Promise];
```
-Delete:
+Import `type Ref` from Vue and delete local `const autoCaptureEnabled = ref(true)`.
+
+`onAutoCaptureToggle` becomes async:
```ts
-const autoCaptureEnabled = ref(true);
+async function onAutoCaptureToggle(event: Event) {
+ const enabled = guardCheckedFromEvent(event);
+ try {
+ await setAutoCaptureEnabled(enabled);
+ autoCaptureFeedback.value = guardAutoCaptureStateFeedback(enabled, tx);
+ } catch (error) {
+ setErrorMessage(error);
+ }
+}
```
-Change `onAutoCaptureToggle` to parse the checkbox state and call the injected async setter. Do not flip the shared state locally; the backend response updates it through `useTrackingState`.
-
-Add a feedback helper in `guardFeedback.ts`:
+Add to `guardFeedback.ts`:
```ts
export function guardAutoCaptureStateFeedback(enabled: boolean, tx: TranslateFn): string {
@@ -806,11 +830,9 @@ export function guardAutoCaptureStateFeedback(enabled: boolean, tx: TranslateFn)
}
```
-Update feedback only after successful persistence.
-
-- [ ] **Step 3: Wire persistence to sampler start/stop in `App.vue`**
+- [ ] **Step 4: Wire persistence to sampler only after backend success**
-At the composition root, create one async bridge:
+In `App.vue`:
```ts
async function handleSetAutoCaptureEnabled(enabled: boolean) {
@@ -819,45 +841,30 @@ async function handleSetAutoCaptureEnabled(enabled: boolean) {
}
```
-Pass `autoCaptureEnabled` and this callback into `useGuardData`.
-
-Do not let `useGuardData`, `useTrackingState`, or `useAutoCaptureSampler` own all three responsibilities; `App.vue` remains the orchestration boundary.
+Pass `autoCaptureEnabled` and `handleSetAutoCaptureEnabled` into `useGuardData`.
-- [ ] **Step 4: Run frontend static gates**
-
-Run:
+- [ ] **Step 5: Verify and commit**
```bash
pnpm run typecheck
pnpm run build:check
-```
-
-Expected: pass and there is no remaining `ref(true)` tracking authority in `useGuardData`.
-
-- [ ] **Step 5: Commit Task 5**
-
-```bash
-git add src/composables/useMainWindowLifecycle.ts src/composables/useGuardData.ts src/lib/guardFeedback.ts src/App.vue
+git add src/main.ts src/composables/useMainWindowLifecycle.ts src/composables/useGuardData.ts src/lib/guardFeedback.ts src/App.vue
git commit -m "feat(tracking): gate runtime on persisted state"
```
---
-### Task 6: Add the one-time onboarding surface and activation transition
+### Task 6: Add one-time onboarding and activation transition
**Files:**
- Create: `src/components/OnboardingView.vue`
-- Modify: `src/App.vue`
-- Modify: `src/styles/app.css`
+- Modify: `src/App.vue`, `src/styles/app.css`
**Interfaces:**
-- Consumes: `trackingStateLoading`, `onboardingCompleted`, `trackingActionLoading`, `activateTracking`.
-- Emits from `OnboardingView`: `activate` only; there is no persistent browse-without-tracking branch.
-- Activation order: backend state commit -> product runtime start -> pet reveal.
-
-- [ ] **Step 1: Create a single-screen disclosure component**
+- Emits `activate` only; no persistent browse-without-tracking branch.
+- Activation order: backend commit -> start runtime -> reveal/create pet.
-Use the existing `tx(zh, en)` translation pattern. Component API:
+- [ ] **Step 1: Create disclosure component**
```ts
const props = defineProps<{
@@ -867,11 +874,10 @@ const props = defineProps<{
const emit = defineEmits<{ activate: [] }>();
```
-Required visible content, expressed concisely rather than as a carousel:
+Required Chinese meaning (English copy must match):
```text
欢迎使用 TimePrism
-
自动记录你的电脑时间流向,数据保存在本机。
开始后,TimePrism 会:
@@ -888,17 +894,13 @@ TimePrism 不会:
[开始使用 TimePrism]
```
-English copy must communicate the same claims. The primary button is disabled while `loading` is true.
-
-- [ ] **Step 2: Style onboarding as the first product surface**
-
-In `app.css`, add focused classes such as `.onboarding-shell`, `.onboarding-card`, `.onboarding-points`, `.onboarding-primary`. Reuse existing background, card, text, hint, button radii, and spacing tokens; do not introduce a new visual system.
+Disable the primary button while `loading`.
-The content must fit the existing 888x900 main-window minimum without scrolling on the default Windows scale used by the app.
+- [ ] **Step 2: Add focused onboarding styles**
-- [ ] **Step 3: Conditionally render onboarding before the normal product shell**
+Use `.onboarding-shell`, `.onboarding-card`, `.onboarding-points`, `.onboarding-primary`; reuse existing colors/radii/spacing. Fit within 888x900 without default-scale scrolling.
-In `App.vue` template, structure the top-level condition as:
+- [ ] **Step 3: Render onboarding before normal product shell**
```vue
-
+
```
-Do not start Home/Insights/Guard data flows merely because the normal DOM exists; Task 5's runtime gate remains authoritative.
+The hidden main window is revealed by Task 5 only after tracking state resolves, so users do not see an empty shell during this conditional decision.
-- [ ] **Step 4: Implement the activation order without optimistic tracking**
-
-In `App.vue`:
+- [ ] **Step 4: Implement activation without optimistic tracking**
```ts
async function handleActivateTracking() {
try {
const state = await activateTracking();
await startProductRuntime(state.auto_capture_enabled);
- await summonPetWindow();
+ try {
+ await summonPetWindow();
+ } catch (petError) {
+ setErrorMessage(petError);
+ }
} catch (error) {
setErrorMessage(error);
}
}
```
-If the backend state write fails, onboarding remains visible and the sampler remains stopped. If the later pet reveal fails, tracking state remains valid; report the pet error without rolling the persisted activation back.
-
-- [ ] **Step 5: Run frontend and Rust static gates**
+A backend activation failure leaves onboarding visible and sampler stopped. A later pet-show failure does not roll back successfully persisted activation.
-Run:
+- [ ] **Step 5: Verify and commit**
```bash
pnpm run typecheck
@@ -943,112 +945,83 @@ pnpm run build:check
cd src-tauri
cargo check --all-targets
cargo test
-```
-
-Expected: pass.
-
-- [ ] **Step 6: Commit Task 6**
-
-```bash
+cd ..
git add src/components/OnboardingView.vue src/App.vue src/styles/app.css
git commit -m "feat(onboarding): require first-run tracking activation"
```
---
-### Task 7: Make the pet communicate persisted tracking state
+### Task 7: Make pet show active vs paused tracking state
**Files:**
- Modify: `src/pet.ts`
**Interfaces:**
-- Consumes: `getTrackingState()` and existing `getTodaySummary()`.
-- Behavior: pet remains hidden pre-onboarding by Rust startup policy; when present, mood says active vs paused while the counters remain `LEARN` / `REST` totals.
+- Consumes `getTrackingState` + `getTodaySummary`.
+- Counters remain classified `LEARN` / `REST`; only mood reflects tracking state.
-- [ ] **Step 1: Read summary and tracking state in the same existing 5-second refresh**
-
-Change `refreshSummary()` to use:
+- [ ] **Step 1: Read state in existing summary refresh**
```ts
const [summary, tracking] = await Promise.all([
getTodaySummary(),
getTrackingState(),
]);
-```
-
-Keep `renderPetSummary` totals exactly based on `summary.learn_seconds` and `summary.rest_seconds`.
-
-Set mood text from persisted state:
-
-```ts
const trackingMood = tracking.auto_capture_enabled
? tx("自动记录中", "Auto tracking")
: tx("记录已暂停", "Tracking paused");
```
-Pass `trackingMood` into `renderPetSummary`.
-
-- [ ] **Step 2: Do not change timer frequency**
-
-Keep the existing:
-
-```ts
-window.setInterval(..., 5000)
-```
+Pass `formatSeconds(summary.learn_seconds)`, `formatSeconds(summary.rest_seconds)`, and `trackingMood` to `renderPetSummary`.
-No 1-second animation or synthetic counter increment is introduced.
+- [ ] **Step 2: Preserve 5-second cadence**
-- [ ] **Step 3: Run frontend validation**
+Keep the existing `window.setInterval(..., 5000)` and baseline-only capture behavior. Do not synthesize per-second increments.
-Run:
+- [ ] **Step 3: Verify and commit**
```bash
pnpm run typecheck
pnpm run build:check
-```
-
-Expected: pass.
-
-- [ ] **Step 4: Commit Task 7**
-
-```bash
git add src/pet.ts
git commit -m "feat(pet): show persisted tracking status"
```
---
-### Task 8: Clear recovered Home read errors without masking write failures
+### Task 8: Clear only the recovered Home error, never a newer tracking error
**Files:**
- Modify: `src/composables/useErrorMessage.ts`
- Modify: `src/composables/useHomeData.ts`
-- Modify: `src/App.vue` wiring
+- Modify: `src/App.vue`
**Interfaces:**
-- Produces: `clearErrorMessage()`.
-- Home core refresh clears the global read error only after its own successful `Promise.all` completes.
-- Tracking/onboarding action failures stay visible until another explicit user action or relevant successful operation replaces/clears them.
-
-- [ ] **Step 1: Add an explicit clear function**
+- Produces compare-and-clear API: `clearErrorMessage(expected?: unknown)`.
+- Home remembers the exact error string it last published and clears only that same string after recovery.
-In `useErrorMessage.ts`:
+- [ ] **Step 1: Add compare-and-clear semantics**
```ts
-function clearErrorMessage() {
- error.value = "";
+function clearErrorMessage(expected?: unknown) {
+ if (expected === undefined || error.value === `${expected}`) {
+ error.value = "";
+ }
}
-
-return {
- error,
- setErrorMessage,
- clearErrorMessage,
-};
```
-- [ ] **Step 2: Clear only after successful Home core data assignment**
+Return it with `error` and `setErrorMessage`.
-Add `clearErrorMessage: () => void` to `UseHomeDataOptions`. In `refreshHomeCoreData()`, call it only after all five reads succeed and the refs are assigned:
+- [ ] **Step 2: Track the last Home-owned read error**
+
+In `useHomeData`:
+
+```ts
+let lastHomeCoreError = "";
+```
+
+Add `clearErrorMessage: (expected?: unknown) => void` to options. On successful core assignment:
```ts
todaySummary.value = summary;
@@ -1056,71 +1029,64 @@ recentLogs.value = logs;
pendingRuleProcesses.value = pendingRules;
idlePrompts.value = pendingIdle;
reminders.value = reminderRows;
-clearErrorMessage();
+if (lastHomeCoreError) {
+ clearErrorMessage(lastHomeCoreError);
+ lastHomeCoreError = "";
+}
```
-Do not clear in `finally`, and do not clear on a failed refresh.
-
-- [ ] **Step 3: Wire `clearErrorMessage` from `App.vue` and run gates**
-
-Run:
+On failure:
-```bash
-pnpm run typecheck
-pnpm run build:check
+```ts
+lastHomeCoreError = `${e}`;
+setErrorMessage(e);
```
-Expected: pass.
+If a newer tracking write error has replaced the global message, `clearErrorMessage(lastHomeCoreError)` does nothing, so polling cannot erase the newer failure.
-- [ ] **Step 4: Commit Task 8**
+- [ ] **Step 3: Wire and verify**
```bash
+pnpm run typecheck
+pnpm run build:check
git add src/composables/useErrorMessage.ts src/composables/useHomeData.ts src/App.vue
-git commit -m "fix(ui): clear recovered home read errors"
+git commit -m "fix(ui): clear only recovered home read errors"
```
---
-### Task 9: Update acceptance docs and perform final automated verification
+### Task 9: Update acceptance docs and run final automated gates
**Files:**
-- Modify: `docs/SMOKE_TESTS.md`
-- Modify: `docs/RELEASE_CHECKLIST.md`
-- Review only: `.github/workflows/ci.yml`, `.github/workflows/release.yml`
-
-**Interfaces:**
-- Produces: explicit fresh-install and upgrade smoke checklist tied to the corrected installer.
-- Does not alter the release workflow unless verification exposes a separate workflow defect.
+- Modify: `docs/SMOKE_TESTS.md`, `docs/RELEASE_CHECKLIST.md`
+- Review only unless a new defect appears: `.github/workflows/ci.yml`, `.github/workflows/release.yml`
-- [ ] **Step 1: Add a first-run smoke section**
+- [ ] **Step 1: Add exact fresh-install smoke assertions**
-Document these exact manual assertions in `docs/SMOKE_TESTS.md`:
+Document:
```text
-Fresh install / clean app-data directory
-1. Launch TimePrism.
-2. Confirm no `no such table: app_usage_logs` message appears.
-3. Before activation, switch foreground apps for >10 seconds; confirm no usage rows are captured.
-4. Confirm the onboarding disclosure states foreground app/window metadata, local SQLite storage, browser privacy, no screenshots/keystroke contents, and Insights vs 学/休 semantics.
-5. Click 开始使用 TimePrism.
-6. Confirm capture begins without restart.
-7. Confirm Insights shows observed usage after sampling.
-8. Confirm pet 学/休 remains classification-based and updates on the existing several-second cadence.
-9. Pause capture; wait >10 seconds while switching apps; confirm no new capture is appended.
-10. Fully quit and reopen; confirm capture remains paused and onboarding does not reappear.
-11. Resume capture; quit/reopen again; confirm capture automatically resumes.
-12. Confirm close-to-background, pet behavior, and Quit TimePrism remain correct.
+1. Clean/sandbox the TimePrism app-data directory and launch.
+2. Confirm no `no such table: app_usage_logs` error appears.
+3. Before activation, switch foreground apps for >10 seconds and confirm no usage rows are captured.
+4. Confirm onboarding explains foreground app/window metadata, local SQLite, browser privacy, no screenshots/keystroke contents, and Insights vs 学/休 semantics.
+5. Click 开始使用 TimePrism; confirm capture begins without restart.
+6. Confirm Insights shows observed activity after sampling.
+7. Confirm pet 学/休 remains classification-based and updates on the existing several-second cadence.
+8. Pause capture; switch apps for >10 seconds; confirm no new samples are appended.
+9. Fully quit/reopen; confirm pause persists and onboarding does not reappear.
+10. Resume; quit/reopen; confirm automatic tracking resumes.
+11. Confirm pet says paused while paused.
+12. Confirm close-to-background, pet, and Quit TimePrism behavior still works.
```
-Add a separate upgrade assertion: a pre-change DB without the two new keys starts as onboarded + capture enabled, without showing onboarding.
-
-- [ ] **Step 2: Add the first-run gate to `RELEASE_CHECKLIST.md`**
+Add upgrade test: pre-change DB without the new keys starts onboarded + capture enabled without showing onboarding.
-Before public release, require both a clean-data fresh install and an upgrade-from-old-DB smoke. Keep the unsigned/SmartScreen note.
+- [ ] **Step 2: Add fresh + upgrade gates to release checklist**
-- [ ] **Step 3: Run the full repository gates from a clean dependency/build state as practical**
+Require both before public release; keep unsigned/SmartScreen note.
-Run:
+- [ ] **Step 3: Run repository gates**
```bash
pnpm install --frozen-lockfile
@@ -1131,66 +1097,49 @@ cargo check --all-targets
cargo test
```
-Expected: all pass. Record the Rust test count from actual output; do not reuse the previous `35 passed` claim if the suite count changes.
-
-- [ ] **Step 4: Push and require both Windows workflows to pass on the final HEAD**
+Record actual test count from output; do not reuse `35 passed` if it changes.
-Verify GitHub Actions:
+- [ ] **Step 4: Push final HEAD and require both Windows workflows GREEN**
-```text
-CI
-- typecheck
-- Vite build
-- cargo check --all-targets
-- cargo test
-
-Windows Release PR packaging
-- synchronized version check
-- frontend typecheck
-- cargo test
-- Tauri NSIS build
-- Tauri MSI build
-- installer artifact upload
-```
+CI must pass typecheck, Vite build, `cargo check --all-targets`, `cargo test`.
-The PR packaging run must generate both `*-setup.exe` and `*.msi` on the final code HEAD.
+Windows Release PR packaging must pass synchronized-version check, typecheck, Rust tests, `tauri build --bundles nsis msi`, and artifact upload. Verify both `*-setup.exe` and `*.msi` exist on the final code HEAD.
-- [ ] **Step 5: Commit docs if they were not included with an earlier task**
+- [ ] **Step 5: Commit docs**
```bash
git add docs/SMOKE_TESTS.md docs/RELEASE_CHECKLIST.md
git commit -m "docs: add first-run tracking release gates"
```
-- [ ] **Step 6: Stop before merge and perform the real Windows acceptance test**
+- [ ] **Step 6: Stop before merge for real Windows acceptance**
-Use a clean/sandboxed app-data directory for the fresh path and a backed-up pre-change database for the upgrade path. Do not mark PR #2 ready or publish a release until both pass.
+Use a clean app-data directory for fresh install and a backed-up pre-change DB for upgrade. Do not mark PR #2 ready or publish a release until both pass.
-After the corrected build passes, capture sanitized current-build screenshots for Home, Insights, Focus Guard, and the desktop pet, then wire those real files into README as the separate product-media gate already documented in `docs/media/README.md`.
+After acceptance, capture sanitized Home / Insights / Focus Guard / pet screenshots from this corrected build and wire only those real product images into README.
---
## Final Review Checklist
-Before claiming implementation complete:
-
-- [ ] Fresh database defaults are `false / false`.
-- [ ] Existing recognizable pre-change database defaults are `true / true`.
-- [ ] Re-initialization preserves an explicit persisted pause.
-- [ ] Backend rejects enabling capture before onboarding completion.
-- [ ] Onboarding activation writes both state keys atomically.
-- [ ] `tauri.conf.json` has `create: false` for `main`, `pet`, and `pet-panel`.
-- [ ] `.setup()` initializes/migrates DB before creating the main WebView.
-- [ ] DB initialization failure prevents normal runtime startup.
-- [ ] Pre-onboarding main UI does not start foreground sampling or normal product polling.
-- [ ] Pre-onboarding pet cannot be summoned via startup or global shortcut.
-- [ ] Successful activation starts normal runtime without restart and shows the pet.
-- [ ] Guard pause/resume is persisted before sampler state changes.
-- [ ] Restart preserves pause; restart after resume automatically tracks.
-- [ ] Sampler and UI refresh intervals remain 5 seconds.
-- [ ] First capture remains baseline-only.
-- [ ] Pet counters remain classified `LEARN / REST` totals and mood shows active vs paused.
-- [ ] A recovered transient Home read error does not remain as a stale red footer.
-- [ ] `pnpm run typecheck`, `pnpm run build:check`, `cargo check --all-targets`, and `cargo test` pass on final HEAD.
-- [ ] Final Windows PR packaging produces both NSIS and MSI installers.
-- [ ] Fresh-install and upgrade manual acceptance tests pass before PR/release promotion.
+- [ ] Fresh DB defaults are `false / false`.
+- [ ] Existing recognizable pre-change DB defaults are `true / true`.
+- [ ] Re-initialization preserves explicit pause.
+- [ ] Backend rejects enabling capture before onboarding.
+- [ ] Onboarding activation atomically persists `true / true`.
+- [ ] `main`, `pet`, `pet-panel` all have `create:false`.
+- [ ] DB initializes before main WebView creation.
+- [ ] DB init failure prevents normal startup.
+- [ ] Main stays hidden until tracking state loads.
+- [ ] Pre-onboarding runtime starts no sampler/polling.
+- [ ] Pre-onboarding pet cannot be created through startup, shortcut, or Tauri command.
+- [ ] Activation starts runtime without restart and then reveals pet.
+- [ ] Guard pause/resume persists before sampler changes.
+- [ ] Restart preserves pause; restart after resume auto-tracks.
+- [ ] Sampling/Home/pet intervals remain 5 seconds.
+- [ ] First sample remains baseline-only.
+- [ ] Pet counters remain `LEARN / REST`; mood shows active vs paused.
+- [ ] Recovered Home error clears only if still the active Home-owned error.
+- [ ] Final typecheck/build/Rust gates pass.
+- [ ] Final Windows packaging produces NSIS + MSI.
+- [ ] Fresh-install and upgrade manual acceptance pass before PR/release promotion.
From 97406b116efda2dbd55206ce612a44e6bcd26951 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 21:56:02 +0800
Subject: [PATCH 033/117] test(db): specify first-run tracking defaults
---
src-tauri/src/db/tests.rs | 69 +++++++++++++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
diff --git a/src-tauri/src/db/tests.rs b/src-tauri/src/db/tests.rs
index bde3cf0..8b30853 100644
--- a/src-tauri/src/db/tests.rs
+++ b/src-tauri/src/db/tests.rs
@@ -80,6 +80,23 @@ fn fresh_file_database_initialization_seeds_schema_and_defaults_and_reopens() {
.expect("read default browser mode");
assert_eq!(browser_mode, "BLUR");
+ let onboarding: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'onboarding_completed'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read onboarding default");
+ let auto_capture: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'auto_capture_enabled'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read auto capture default");
+ assert_eq!(onboarding, "false");
+ assert_eq!(auto_capture, "false");
+
let code_rule: String = conn
.query_row(
"SELECT mapped_type FROM app_rules WHERE process_name = 'code.exe'",
@@ -162,6 +179,41 @@ fn database_initialization_is_idempotent_and_preserves_user_values() {
remove_sqlite_files(&path);
}
+#[test]
+fn database_initialization_preserves_persisted_tracking_pause() {
+ let path = temp_db_path("tracking-pause");
+ remove_sqlite_files(&path);
+ let conn = Connection::open(&path).expect("open sqlite file");
+
+ super::migrations::initialize_connection(&conn).expect("initialize fresh database");
+ conn.execute(
+ "UPDATE app_config SET value = 'true' WHERE key = 'onboarding_completed'",
+ [],
+ )
+ .expect("mark onboarding complete");
+ conn.execute(
+ "UPDATE app_config SET value = 'false' WHERE key = 'auto_capture_enabled'",
+ [],
+ )
+ .expect("persist capture pause");
+
+ super::migrations::initialize_connection(&conn).expect("reinitialize database");
+
+ let state: (String, String) = conn
+ .query_row(
+ "SELECT
+ (SELECT value FROM app_config WHERE key = 'onboarding_completed'),
+ (SELECT value FROM app_config WHERE key = 'auto_capture_enabled')",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("read persisted tracking state");
+ assert_eq!(state, ("true".to_string(), "false".to_string()));
+
+ drop(conn);
+ remove_sqlite_files(&path);
+}
+
#[test]
fn database_initialization_repairs_only_known_mojibake_root_names() {
let path = temp_db_path("root-name-repair");
@@ -240,6 +292,23 @@ fn legacy_schema_is_upgraded_without_losing_existing_rows() {
assert!(column_exists(&conn, "reminders", "weekly_days"));
assert!(column_exists(&conn, "reminders", "sort_order"));
+ let onboarding: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'onboarding_completed'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read migrated onboarding state");
+ let auto_capture: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = 'auto_capture_enabled'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read migrated capture state");
+ assert_eq!(onboarding, "true");
+ assert_eq!(auto_capture, "true");
+
let mapped_type: String = conn
.query_row(
"SELECT mapped_type FROM app_rules WHERE process_name = 'legacy.exe'",
From 9920dd48d252872fb4ea36d1e1c23739868d518c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 21:59:32 +0800
Subject: [PATCH 034/117] feat(db): persist first-run tracking defaults
---
src-tauri/src/db/migrations.rs | 35 ++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs
index 277b0b4..1d14263 100644
--- a/src-tauri/src/db/migrations.rs
+++ b/src-tauri/src/db/migrations.rs
@@ -174,7 +174,23 @@ pub fn ensure_heatmap_snapshot_table(conn: &Connection) -> Result<(), String> {
Ok(())
}
+fn has_recognizable_timeprism_schema(conn: &Connection) -> Result {
+ let count: i64 = conn
+ .query_row(
+ "SELECT COUNT(*)
+ FROM sqlite_master
+ WHERE type = 'table'
+ AND name IN ('categories', 'task_sessions', 'app_usage_logs', 'app_rules', 'app_config', 'reminders')",
+ [],
+ |row| row.get(0),
+ )
+ .map_err(|e| format!("failed to inspect existing TimePrism schema: {e}"))?;
+ Ok(count > 0)
+}
+
pub(super) fn initialize_connection(conn: &Connection) -> Result<(), String> {
+ let was_existing_timeprism_db = has_recognizable_timeprism_schema(conn)?;
+
conn.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| format!("failed to enable foreign keys: {e}"))?;
@@ -319,6 +335,25 @@ pub(super) fn initialize_connection(conn: &Connection) -> Result<(), String> {
.map_err(|e| format!("failed to seed app config {key}: {e}"))?;
}
+ let tracking_defaults = if was_existing_timeprism_db {
+ [
+ ("onboarding_completed", "true"),
+ ("auto_capture_enabled", "true"),
+ ]
+ } else {
+ [
+ ("onboarding_completed", "false"),
+ ("auto_capture_enabled", "false"),
+ ]
+ };
+ for (key, value) in tracking_defaults {
+ conn.execute(
+ "INSERT OR IGNORE INTO app_config (key, value) VALUES (?1, ?2)",
+ params![key, value],
+ )
+ .map_err(|e| format!("failed to seed tracking config {key}: {e}"))?;
+ }
+
let default_whitelist = [
"code.exe",
"pycharm64.exe",
From 8feb50df1f2a1b38a91d8ffbce655086c7f65901 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:02:07 +0800
Subject: [PATCH 035/117] feat(tracking): add tracking state contract
---
src-tauri/src/domain/tracking.rs | 7 +++++++
1 file changed, 7 insertions(+)
create mode 100644 src-tauri/src/domain/tracking.rs
diff --git a/src-tauri/src/domain/tracking.rs b/src-tauri/src/domain/tracking.rs
new file mode 100644
index 0000000..2f12fea
--- /dev/null
+++ b/src-tauri/src/domain/tracking.rs
@@ -0,0 +1,7 @@
+use serde::Serialize;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+pub struct TrackingState {
+ pub onboarding_completed: bool,
+ pub auto_capture_enabled: bool,
+}
From 38001ce7297df5b22d745070c61d892f1932aea6 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:02:17 +0800
Subject: [PATCH 036/117] test(tracking): specify persisted state invariants
---
src-tauri/src/services/tracking.rs | 57 ++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 src-tauri/src/services/tracking.rs
diff --git a/src-tauri/src/services/tracking.rs b/src-tauri/src/services/tracking.rs
new file mode 100644
index 0000000..052343f
--- /dev/null
+++ b/src-tauri/src/services/tracking.rs
@@ -0,0 +1,57 @@
+#[cfg(test)]
+mod tests {
+ use rusqlite::{params, Connection};
+
+ use super::{
+ complete_onboarding_entry, get_tracking_state_entry, set_auto_capture_enabled_entry,
+ };
+
+ fn tracking_conn(onboarding: bool, enabled: bool) -> Connection {
+ let conn = Connection::open_in_memory().expect("open tracking test db");
+ conn.execute_batch(
+ "CREATE TABLE app_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
+ )
+ .expect("create app_config");
+ for (key, value) in [
+ ("onboarding_completed", onboarding.to_string()),
+ ("auto_capture_enabled", enabled.to_string()),
+ ] {
+ conn.execute(
+ "INSERT INTO app_config (key, value) VALUES (?1, ?2)",
+ params![key, value],
+ )
+ .expect("seed tracking state");
+ }
+ conn
+ }
+
+ #[test]
+ fn completing_onboarding_atomically_enables_capture() {
+ let conn = tracking_conn(false, false);
+ let state = complete_onboarding_entry(&conn).expect("complete onboarding");
+ assert!(state.onboarding_completed);
+ assert!(state.auto_capture_enabled);
+ assert_eq!(get_tracking_state_entry(&conn).unwrap(), state);
+ }
+
+ #[test]
+ fn pause_and_resume_persist_after_onboarding() {
+ let conn = tracking_conn(true, true);
+ assert!(!set_auto_capture_enabled_entry(&conn, false)
+ .unwrap()
+ .auto_capture_enabled);
+ assert!(set_auto_capture_enabled_entry(&conn, true)
+ .unwrap()
+ .auto_capture_enabled);
+ }
+
+ #[test]
+ fn enabling_capture_before_onboarding_is_rejected() {
+ let conn = tracking_conn(false, false);
+ let err = set_auto_capture_enabled_entry(&conn, true).unwrap_err();
+ assert!(err.contains("onboarding"));
+ assert!(!get_tracking_state_entry(&conn)
+ .unwrap()
+ .auto_capture_enabled);
+ }
+}
From 8d8e34648f7514afa565cf8fa7bfd17cf91e0e1e Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:02:27 +0800
Subject: [PATCH 037/117] feat(tracking): expose tracking domain
---
src-tauri/src/domain/mod.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs
index 34e65ea..08edc15 100644
--- a/src-tauri/src/domain/mod.rs
+++ b/src-tauri/src/domain/mod.rs
@@ -3,4 +3,5 @@ pub mod idle;
pub mod privacy;
pub mod reminders;
pub mod rules;
+pub mod tracking;
pub mod window;
From 11ee4a934b65880ef8478f77f296229840077ca6 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:02:37 +0800
Subject: [PATCH 038/117] test(tracking): compile tracking invariant tests
---
src-tauri/src/services/mod.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs
index 6b2c937..be13c28 100644
--- a/src-tauri/src/services/mod.rs
+++ b/src-tauri/src/services/mod.rs
@@ -10,5 +10,6 @@ pub mod rules;
pub mod sessions;
pub mod startup;
pub mod time;
+pub mod tracking;
pub mod usage;
pub mod window;
From 6b64c27124c3c6aeb56698f10f547c00c3678957 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:06:03 +0800
Subject: [PATCH 039/117] feat(tracking): add persisted tracking state
invariant
---
src-tauri/src/services/tracking.rs | 89 ++++++++++++++++++++++++++++++
1 file changed, 89 insertions(+)
diff --git a/src-tauri/src/services/tracking.rs b/src-tauri/src/services/tracking.rs
index 052343f..1b0a96b 100644
--- a/src-tauri/src/services/tracking.rs
+++ b/src-tauri/src/services/tracking.rs
@@ -1,3 +1,79 @@
+use rusqlite::{params, Connection};
+
+use crate::domain::tracking::TrackingState;
+
+fn read_bool_config(conn: &Connection, key: &str) -> Result {
+ let value: String = conn
+ .query_row(
+ "SELECT value FROM app_config WHERE key = ?1",
+ [key],
+ |row| row.get(0),
+ )
+ .map_err(|e| format!("failed to read tracking config {key}: {e}"))?;
+
+ match value.trim().to_lowercase().as_str() {
+ "true" => Ok(true),
+ "false" => Ok(false),
+ other => Err(format!("invalid boolean tracking config {key}: {other}")),
+ }
+}
+
+pub(crate) fn get_tracking_state_entry(conn: &Connection) -> Result {
+ Ok(TrackingState {
+ onboarding_completed: read_bool_config(conn, "onboarding_completed")?,
+ auto_capture_enabled: read_bool_config(conn, "auto_capture_enabled")?,
+ })
+}
+
+pub(crate) fn complete_onboarding_entry(conn: &Connection) -> Result {
+ let tx = conn
+ .unchecked_transaction()
+ .map_err(|e| format!("failed to begin onboarding transaction: {e}"))?;
+
+ let changed = tx
+ .execute(
+ "UPDATE app_config
+ SET value = 'true'
+ WHERE key IN ('onboarding_completed', 'auto_capture_enabled')",
+ [],
+ )
+ .map_err(|e| format!("failed to complete onboarding: {e}"))?;
+ if changed != 2 {
+ return Err(format!(
+ "failed to complete onboarding: expected 2 tracking config rows, updated {changed}"
+ ));
+ }
+
+ tx.commit()
+ .map_err(|e| format!("failed to commit onboarding: {e}"))?;
+
+ get_tracking_state_entry(conn)
+}
+
+pub(crate) fn set_auto_capture_enabled_entry(
+ conn: &Connection,
+ enabled: bool,
+) -> Result {
+ let current = get_tracking_state_entry(conn)?;
+ if enabled && !current.onboarding_completed {
+ return Err("cannot enable auto capture before onboarding is complete".to_string());
+ }
+
+ let changed = conn
+ .execute(
+ "UPDATE app_config SET value = ?1 WHERE key = 'auto_capture_enabled'",
+ params![enabled.to_string()],
+ )
+ .map_err(|e| format!("failed to update auto capture state: {e}"))?;
+ if changed != 1 {
+ return Err(format!(
+ "failed to update auto capture state: expected 1 config row, updated {changed}"
+ ));
+ }
+
+ get_tracking_state_entry(conn)
+}
+
#[cfg(test)]
mod tests {
use rusqlite::{params, Connection};
@@ -54,4 +130,17 @@ mod tests {
.unwrap()
.auto_capture_enabled);
}
+
+ #[test]
+ fn completing_onboarding_rejects_missing_tracking_key() {
+ let conn = tracking_conn(false, false);
+ conn.execute(
+ "DELETE FROM app_config WHERE key = 'auto_capture_enabled'",
+ [],
+ )
+ .expect("remove tracking key");
+
+ let err = complete_onboarding_entry(&conn).unwrap_err();
+ assert!(err.contains("expected 2 tracking config rows"));
+ }
}
From 7676da03f69b4a464e6890d69011c68ff3dc0776 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:07:43 +0800
Subject: [PATCH 040/117] test(startup): require manual product window creation
---
src-tauri/src/services/window_config_tests.rs | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
create mode 100644 src-tauri/src/services/window_config_tests.rs
diff --git a/src-tauri/src/services/window_config_tests.rs b/src-tauri/src/services/window_config_tests.rs
new file mode 100644
index 0000000..98b489e
--- /dev/null
+++ b/src-tauri/src/services/window_config_tests.rs
@@ -0,0 +1,19 @@
+#[test]
+fn configured_product_windows_require_manual_creation() {
+ let config: serde_json::Value = serde_json::from_str(include_str!("../../tauri.conf.json"))
+ .expect("parse tauri config");
+ let windows = config["app"]["windows"]
+ .as_array()
+ .expect("window config array");
+
+ for label in ["main", "pet", "pet-panel"] {
+ let item = windows
+ .iter()
+ .find(|item| item["label"] == label)
+ .unwrap_or_else(|| panic!("missing window config {label}"));
+ assert_eq!(
+ item["create"], false,
+ "{label} must not auto-create before DB init"
+ );
+ }
+}
From 9ba30c5805e74dc603603e06bf9c0b09928b4ff6 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:07:55 +0800
Subject: [PATCH 041/117] test(startup): compile window creation gate
---
src-tauri/src/services/mod.rs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs
index be13c28..ac86113 100644
--- a/src-tauri/src/services/mod.rs
+++ b/src-tauri/src/services/mod.rs
@@ -13,3 +13,6 @@ pub mod time;
pub mod tracking;
pub mod usage;
pub mod window;
+
+#[cfg(test)]
+mod window_config_tests;
From 705a997042b01c484038e415136ba8807eb504f9 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:10:39 +0800
Subject: [PATCH 042/117] fix(startup): require manual webview creation
---
src-tauri/tauri.conf.json | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 93b7bd6..d67ad28 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -4,10 +4,10 @@
"version": "0.1.0",
"identifier": "com.air.timeprism",
"build": {
- "beforeDevCommand": "pnpm vite",
- "devUrl": "http://localhost:1420",
- "beforeBuildCommand": "pnpm build",
- "frontendDist": "../dist"
+ "beforeDevCommand": "pnpm vite",
+ "devUrl": "http://localhost:1420",
+ "beforeBuildCommand": "pnpm build",
+ "frontendDist": "../dist"
},
"app": {
"windows": [
@@ -18,7 +18,8 @@
"height": 900,
"minWidth": 888,
"minHeight": 900,
- "visible": false
+ "visible": false,
+ "create": false
},
{
"label": "pet",
@@ -31,7 +32,8 @@
"resizable": false,
"alwaysOnTop": true,
"skipTaskbar": true,
- "shadow": false
+ "shadow": false,
+ "create": false
},
{
"label": "pet-panel",
@@ -45,7 +47,8 @@
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": false,
- "visible": false
+ "visible": false,
+ "create": false
}
],
"security": {
From 895300a9432190833a45898b93919862b4f078af Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:10:46 +0800
Subject: [PATCH 043/117] feat(startup): add configured window factory
---
src-tauri/src/services/window_factory.rs | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
create mode 100644 src-tauri/src/services/window_factory.rs
diff --git a/src-tauri/src/services/window_factory.rs b/src-tauri/src/services/window_factory.rs
new file mode 100644
index 0000000..2ed74f9
--- /dev/null
+++ b/src-tauri/src/services/window_factory.rs
@@ -0,0 +1,23 @@
+use tauri::{AppHandle, Manager, WebviewWindow};
+
+pub(crate) fn create_configured_window(
+ app: &AppHandle,
+ label: &str,
+) -> Result {
+ if let Some(existing) = app.get_webview_window(label) {
+ return Ok(existing);
+ }
+
+ let config = app
+ .config()
+ .app
+ .windows
+ .iter()
+ .find(|item| item.label == label)
+ .ok_or_else(|| format!("window config not found: {label}"))?;
+
+ tauri::WebviewWindowBuilder::from_config(app, config)
+ .map_err(|e| format!("failed to build window config {label}: {e}"))?
+ .build()
+ .map_err(|e| format!("failed to create configured window {label}: {e}"))
+}
From d6fe7c36bc46d7b7e8af165e2446d21572d15a12 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:10:56 +0800
Subject: [PATCH 044/117] feat(startup): expose configured window factory
---
src-tauri/src/services/mod.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs
index ac86113..278effe 100644
--- a/src-tauri/src/services/mod.rs
+++ b/src-tauri/src/services/mod.rs
@@ -13,6 +13,7 @@ pub mod time;
pub mod tracking;
pub mod usage;
pub mod window;
+pub mod window_factory;
#[cfg(test)]
mod window_config_tests;
From e905a1d8c2c193d0c786f40ac39e92b02b292821 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:11:53 +0800
Subject: [PATCH 045/117] fix(startup): initialize database before webviews
---
src-tauri/src/lib.rs | 103 ++++++++++++++++++++++++++++++++++---------
1 file changed, 82 insertions(+), 21 deletions(-)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index c61906d..fdbc156 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -17,6 +17,7 @@ use domain::reminders::{
ReminderEntry, SaveReminderInput, SetReminderDoneInput, SetReminderOrderInput,
};
use domain::rules::{AppRuleEntry, DeviationCheck, PendingRuleProcess, SaveAppRuleInput};
+use domain::tracking::TrackingState;
use domain::window::{ForegroundCaptureDiagnostic, PetWindowSettleResult, SettlePetWindowInput};
use services::analytics::{
heatmap_goal_seconds_setting, learn_heatmap, list_recent_log_entries,
@@ -25,6 +26,7 @@ use services::analytics::{
usage_stack,
};
use services::categories::{create_category_entry, list_category_entries};
+use services::focus::{check_focus_deviation_state, snooze_focus_guard_state};
use services::foreground as foreground_service;
use services::privacy::{
get_privacy_settings_entry, list_whitelist_entries, set_whitelist_item_entry,
@@ -35,13 +37,14 @@ use services::reminders::{
set_reminder_done_entry, set_reminder_order_entries, snooze_reminder_entry,
};
use services::rules::{list_app_rule_entries, list_pending_rule_process_entries, save_app_rule_entry};
-use services::focus::{
- check_focus_deviation_state, snooze_focus_guard_state,
-};
use services::sessions::{start_session_entry, stop_active_session_entry};
use services::startup::{get_auto_start_enabled_state, set_auto_start_enabled_state};
+use services::tracking::{
+ complete_onboarding_entry, get_tracking_state_entry, set_auto_capture_enabled_entry,
+};
use services::usage::append_usage_log_record;
use services::window as window_service;
+use services::window_factory::create_configured_window;
#[tauri::command(async)]
fn list_reminders(
@@ -86,7 +89,7 @@ fn set_reminder_order(app: AppHandle, input: SetReminderOrderInput) -> Result) -> Result {
let conn = open_connection(&app)?;
- snooze_reminder_entry(&conn, id, snooze_seconds)
+ snooze_reminder_entry(&conn, id)
}
#[tauri::command]
@@ -122,7 +125,13 @@ fn append_app_usage_log(
duration_ms: i64,
) -> Result {
let conn = open_connection(&app)?;
- let (stored, _) = append_usage_log_record(&conn, &process_name, &window_title, start_timestamp, duration_ms)?;
+ let (stored, _) = append_usage_log_record(
+ &conn,
+ &process_name,
+ &window_title,
+ start_timestamp,
+ duration_ms,
+ )?;
Ok(stored)
}
@@ -173,7 +182,10 @@ fn list_app_rules(app: AppHandle, limit: Option) -> Result) -> Result, String> {
+fn list_pending_rule_processes(
+ app: AppHandle,
+ limit: Option,
+) -> Result, String> {
let conn = open_connection(&app)?;
let cap = limit.unwrap_or(10).clamp(1, 200);
list_pending_rule_process_entries(&conn, cap)
@@ -277,6 +289,24 @@ fn set_auto_start_enabled(enabled: bool) -> Result {
set_auto_start_enabled_state(enabled)
}
+#[tauri::command(async)]
+fn get_tracking_state(app: AppHandle) -> Result {
+ let conn = open_connection(&app)?;
+ get_tracking_state_entry(&conn)
+}
+
+#[tauri::command]
+fn complete_tracking_onboarding(app: AppHandle) -> Result {
+ let conn = open_connection(&app)?;
+ complete_onboarding_entry(&conn)
+}
+
+#[tauri::command]
+fn set_auto_capture_enabled(app: AppHandle, enabled: bool) -> Result {
+ let conn = open_connection(&app)?;
+ set_auto_capture_enabled_entry(&conn, enabled)
+}
+
#[tauri::command]
fn list_whitelist(app: AppHandle) -> Result, String> {
let conn = open_connection(&app)?;
@@ -289,8 +319,16 @@ fn set_whitelist_item(app: AppHandle, input: SetWhitelistItemInput) -> Result<()
set_whitelist_item_entry(&conn, input)
}
-#[tauri::command]
+#[tauri::command(async)]
fn summon_pet_window(app: AppHandle) -> Result<(), String> {
+ let conn = open_connection(&app)?;
+ let tracking = get_tracking_state_entry(&conn)?;
+ drop(conn);
+ if !tracking.onboarding_completed {
+ return Err("cannot show pet before tracking onboarding is complete".to_string());
+ }
+
+ create_configured_window(&app, "pet")?;
window_service::summon_pet_window(&app)
}
@@ -367,32 +405,52 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
- if let Some(main_window) = app.get_webview_window("main") {
- let _ = main_window.show();
- let _ = main_window.unminimize();
- let _ = main_window.set_focus();
- }
window_service::reveal_main_window(app);
}))
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, _shortcut, event| {
- if event.state == ShortcutState::Pressed {
- let _ = window_service::summon_pet_window(app);
+ if event.state != ShortcutState::Pressed {
+ return;
}
+
+ let handle = app.clone();
+ std::thread::spawn(move || {
+ let tracking = open_connection(&handle)
+ .and_then(|conn| get_tracking_state_entry(&conn));
+ match tracking {
+ Ok(state) if state.onboarding_completed => {
+ if let Err(err) = create_configured_window(&handle, "pet")
+ .and_then(|_| window_service::summon_pet_window(&handle))
+ {
+ eprintln!("global shortcut warning: failed to summon pet: {err}");
+ }
+ }
+ Ok(_) => {}
+ Err(err) => {
+ eprintln!("global shortcut warning: failed to read tracking state: {err}");
+ }
+ }
+ });
})
.build(),
)
.setup(|app| {
- if let Err(err) = init_database(app.handle()) {
- eprintln!("database init warning: {err}");
- }
+ init_database(app.handle()).map_err(std::io::Error::other)?;
- if let Some(main_window) = app.get_webview_window("main") {
- window_service::set_main_close_behavior(&main_window);
- }
+ let conn = open_connection(app.handle()).map_err(std::io::Error::other)?;
+ let tracking = get_tracking_state_entry(&conn).map_err(std::io::Error::other)?;
+ drop(conn);
+
+ let main_window = create_configured_window(app.handle(), "main")
+ .map_err(std::io::Error::other)?;
+ window_service::set_main_close_behavior(&main_window);
- let _ = window_service::ensure_pet_window_position(app.handle(), true);
+ if tracking.onboarding_completed {
+ create_configured_window(app.handle(), "pet").map_err(std::io::Error::other)?;
+ window_service::ensure_pet_window_position(app.handle(), true)
+ .map_err(std::io::Error::other)?;
+ }
let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyT);
if let Err(err) = app.global_shortcut().register(shortcut) {
@@ -437,6 +495,9 @@ pub fn run() {
update_privacy_settings,
get_auto_start_enabled,
set_auto_start_enabled,
+ get_tracking_state,
+ complete_tracking_onboarding,
+ set_auto_capture_enabled,
list_whitelist,
set_whitelist_item,
show_main_window,
From 4490e50353a74ec7d64b9468d0957d1a2e08e160 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:14:46 +0800
Subject: [PATCH 046/117] fix(reminders): preserve snooze duration argument
---
src-tauri/src/lib.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index fdbc156..5c94ca6 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -89,7 +89,7 @@ fn set_reminder_order(app: AppHandle, input: SetReminderOrderInput) -> Result) -> Result {
let conn = open_connection(&app)?;
- snooze_reminder_entry(&conn, id)
+ snooze_reminder_entry(&conn, id, snooze_seconds)
}
#[tauri::command]
From 30999d90d57f8aa41aa5fd2bc1e0aeacc34c4091 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:27:23 +0800
Subject: [PATCH 047/117] test(frontend): add tracking API type contract
---
src/api/tracking.contract.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
create mode 100644 src/api/tracking.contract.ts
diff --git a/src/api/tracking.contract.ts b/src/api/tracking.contract.ts
new file mode 100644
index 0000000..41a66ab
--- /dev/null
+++ b/src/api/tracking.contract.ts
@@ -0,0 +1,16 @@
+import {
+ completeTrackingOnboarding,
+ getTrackingState,
+ setAutoCaptureEnabled,
+ type TrackingState,
+} from "../api";
+
+type ReadTrackingState = () => Promise;
+type CompleteTrackingOnboarding = () => Promise;
+type SetAutoCaptureEnabled = (enabled: boolean) => Promise;
+
+export const trackingApiContract = {
+ get: getTrackingState satisfies ReadTrackingState,
+ completeOnboarding: completeTrackingOnboarding satisfies CompleteTrackingOnboarding,
+ setAutoCaptureEnabled: setAutoCaptureEnabled satisfies SetAutoCaptureEnabled,
+};
From 24e53f81cbc8ce9b624a19fd0d2b3b792c2c8ccf Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:27:35 +0800
Subject: [PATCH 048/117] test(frontend): move capture policy out of sampler
contract
---
src/composables/useAutoCaptureSampler.contract.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 src/composables/useAutoCaptureSampler.contract.ts
diff --git a/src/composables/useAutoCaptureSampler.contract.ts b/src/composables/useAutoCaptureSampler.contract.ts
new file mode 100644
index 0000000..49deabc
--- /dev/null
+++ b/src/composables/useAutoCaptureSampler.contract.ts
@@ -0,0 +1,13 @@
+import { ref } from "vue";
+import { useAutoCaptureSampler } from "./useAutoCaptureSampler";
+
+const sampler = useAutoCaptureSampler({
+ autoCaptureFeedback: ref(""),
+ tx: (_zh, en) => en,
+ intervalMs: 5000,
+});
+
+export const autoCaptureSamplerContract = sampler satisfies {
+ startAutoCaptureSampler: () => void;
+ stopAutoCaptureSampler: () => void;
+};
From 75085a33caba537aeaa6ed1ce595875ae5cd9fb3 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:29:22 +0800
Subject: [PATCH 049/117] feat(frontend): add persisted tracking state type
---
src/api/types.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/api/types.ts b/src/api/types.ts
index d162f94..4619393 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -64,6 +64,11 @@ export type PrivacySettings = {
whitelist_only_enabled: boolean;
};
+export type TrackingState = {
+ onboarding_completed: boolean;
+ auto_capture_enabled: boolean;
+};
+
export type ForegroundCaptureDiagnostic = {
id: number;
captured_at_ms: number;
From 89bfaccefcc7801f145c08c025a7ff24907182bd Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:29:32 +0800
Subject: [PATCH 050/117] feat(frontend): add tracking command client
---
src/api/commands/tracking.ts | 14 ++++++++++++++
1 file changed, 14 insertions(+)
create mode 100644 src/api/commands/tracking.ts
diff --git a/src/api/commands/tracking.ts b/src/api/commands/tracking.ts
new file mode 100644
index 0000000..026bc1c
--- /dev/null
+++ b/src/api/commands/tracking.ts
@@ -0,0 +1,14 @@
+import { invokeCommand } from "../client";
+import type { TrackingState } from "../types";
+
+export function getTrackingState(): Promise {
+ return invokeCommand("get_tracking_state");
+}
+
+export function completeTrackingOnboarding(): Promise {
+ return invokeCommand("complete_tracking_onboarding");
+}
+
+export function setAutoCaptureEnabled(enabled: boolean): Promise {
+ return invokeCommand("set_auto_capture_enabled", { enabled });
+}
From 934c2e900f3f95f8851c9215261dc40c947d0d79 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:29:45 +0800
Subject: [PATCH 051/117] feat(frontend): export tracking commands
---
src/api/commands/index.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/api/commands/index.ts b/src/api/commands/index.ts
index 8c8a3d2..611d3c3 100644
--- a/src/api/commands/index.ts
+++ b/src/api/commands/index.ts
@@ -6,4 +6,5 @@ export * from "./reminders";
export * from "./rules";
export * from "./sessions";
export * from "./startup";
+export * from "./tracking";
export * from "./usage";
From 7a3dc9b081717226e6d4a51fbe4b7d9ba25ce549 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:29:58 +0800
Subject: [PATCH 052/117] feat(frontend): export tracking state
---
src/api.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/api.ts b/src/api.ts
index b7a3d90..abe2f46 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -14,6 +14,7 @@ export type {
Reminder,
TodaySummary,
TopApp,
+ TrackingState,
UsageRootFilter,
UsageStackDay,
UsageStackSegment,
From cf3071fdbe5219d770a02b499c7541cbacdeaee4 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:30:11 +0800
Subject: [PATCH 053/117] refactor(frontend): make capture sampler policy-free
---
src/composables/useAutoCaptureSampler.ts | 6 ------
1 file changed, 6 deletions(-)
diff --git a/src/composables/useAutoCaptureSampler.ts b/src/composables/useAutoCaptureSampler.ts
index 9b1e992..d28132e 100644
--- a/src/composables/useAutoCaptureSampler.ts
+++ b/src/composables/useAutoCaptureSampler.ts
@@ -4,14 +4,12 @@ import { captureForegroundOnce } from "../api";
type TranslateFn = (zh: string, en: string) => string;
type AutoCaptureSamplerOptions = {
- autoCaptureEnabled: Ref;
autoCaptureFeedback: Ref;
tx: TranslateFn;
intervalMs?: number;
};
export function useAutoCaptureSampler({
- autoCaptureEnabled,
autoCaptureFeedback,
tx,
intervalMs = 5000,
@@ -19,10 +17,6 @@ export function useAutoCaptureSampler({
let captureTimer: number | null = null;
function sampleAutoCapture() {
- if (!autoCaptureEnabled.value) {
- return;
- }
-
void captureForegroundOnce(intervalMs)
.then((stored) => {
autoCaptureFeedback.value = stored
From 75b484fad73cb3213a9451568030414e4d8f3dae Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:32:46 +0800
Subject: [PATCH 054/117] refactor(frontend): keep legacy sampler option inert
---
src/composables/useAutoCaptureSampler.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/composables/useAutoCaptureSampler.ts b/src/composables/useAutoCaptureSampler.ts
index d28132e..709c472 100644
--- a/src/composables/useAutoCaptureSampler.ts
+++ b/src/composables/useAutoCaptureSampler.ts
@@ -7,6 +7,7 @@ type AutoCaptureSamplerOptions = {
autoCaptureFeedback: Ref;
tx: TranslateFn;
intervalMs?: number;
+ autoCaptureEnabled?: Ref;
};
export function useAutoCaptureSampler({
From 3ff8317e88dea22ff988a37c9c74a797d5863ea5 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:35:49 +0800
Subject: [PATCH 055/117] test(frontend): define tracking runtime contract
---
src/composables/useTrackingState.contract.ts | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 src/composables/useTrackingState.contract.ts
diff --git a/src/composables/useTrackingState.contract.ts b/src/composables/useTrackingState.contract.ts
new file mode 100644
index 0000000..ab58af0
--- /dev/null
+++ b/src/composables/useTrackingState.contract.ts
@@ -0,0 +1,17 @@
+import type { Ref } from "vue";
+import { useTrackingState } from "./useTrackingState";
+
+const tracking = useTrackingState({
+ setErrorMessage: (_error: unknown) => {},
+});
+
+export const trackingStateContract = tracking satisfies {
+ trackingReady: Readonly[>;
+ onboardingCompleted: Readonly][>;
+ autoCaptureEnabled: Readonly][>;
+ captureShouldRun: Readonly][>;
+ trackingActionLoading: Readonly][>;
+ loadTrackingState: () => Promise];
+ completeOnboarding: () => Promise;
+ persistAutoCaptureEnabled: (enabled: boolean) => Promise;
+};
From df250fd6786909c35df8829535b91d3b927eac01 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:36:02 +0800
Subject: [PATCH 056/117] test(frontend): require persisted guard capture
toggle
---
src/composables/useGuardData.contract.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 src/composables/useGuardData.contract.ts
diff --git a/src/composables/useGuardData.contract.ts b/src/composables/useGuardData.contract.ts
new file mode 100644
index 0000000..7dc48c0
--- /dev/null
+++ b/src/composables/useGuardData.contract.ts
@@ -0,0 +1,18 @@
+import { ref } from "vue";
+import { useGuardData } from "./useGuardData";
+
+const autoCaptureEnabled = ref(false);
+
+const guard = useGuardData({
+ tx: (_zh, en) => en,
+ mappedTypeText: (mappedType) => mappedType,
+ refreshData: async () => {},
+ setErrorMessage: (_error: unknown) => {},
+ autoCaptureEnabled,
+ persistAutoCaptureEnabled: async (_enabled: boolean) => {},
+});
+
+export const guardTrackingContract = guard satisfies {
+ autoCaptureEnabled: Readonly;
+ onAutoCaptureToggle: (event: Event) => Promise;
+};
From b6428b4cae11a536d2c80527b84d2869c67b30fc Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:36:14 +0800
Subject: [PATCH 057/117] test(frontend): require tracking gate before main
runtime
---
.../useMainWindowLifecycle.contract.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
create mode 100644 src/composables/useMainWindowLifecycle.contract.ts
diff --git a/src/composables/useMainWindowLifecycle.contract.ts b/src/composables/useMainWindowLifecycle.contract.ts
new file mode 100644
index 0000000..09a2b50
--- /dev/null
+++ b/src/composables/useMainWindowLifecycle.contract.ts
@@ -0,0 +1,19 @@
+import { useMainWindowLifecycle } from "./useMainWindowLifecycle";
+
+export const mainWindowLifecycleContract: Parameters[0] = {
+ initLocale: () => {},
+ initThemeModeSafely: () => {},
+ loadTrackingState: async () => {},
+ loadHeatmapGoalSecondsSetting: async () => {},
+ resetGuardFeedback: () => {},
+ resetPrivacyFeedback: () => {},
+ refreshHomeData: async () => {},
+ startSettingsWarmup: () => {},
+ startMainRefreshPolling: () => {},
+ startInsightsSectionNavigationListener: async () => {},
+ stopMainRefreshPolling: () => {},
+ stopAutoCaptureSampler: () => {},
+ cleanupSettingsWarmup: () => {},
+ cleanupInsightsSectionNavigation: () => {},
+ cleanupHeatmapGoalSetting: () => {},
+};
From 3fae87e30812af1aa3f9d4d0280b3e82d22f6ca4 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:40:07 +0800
Subject: [PATCH 058/117] feat(frontend): centralize persisted tracking state
---
src/composables/useTrackingState.ts | 84 +++++++++++++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 src/composables/useTrackingState.ts
diff --git a/src/composables/useTrackingState.ts b/src/composables/useTrackingState.ts
new file mode 100644
index 0000000..01d64c1
--- /dev/null
+++ b/src/composables/useTrackingState.ts
@@ -0,0 +1,84 @@
+import { computed, ref } from "vue";
+import {
+ completeTrackingOnboarding,
+ getTrackingState,
+ setAutoCaptureEnabled,
+ type TrackingState,
+} from "../api";
+
+type UseTrackingStateOptions = {
+ setErrorMessage: (error: unknown) => void;
+};
+
+export function useTrackingState({ setErrorMessage }: UseTrackingStateOptions) {
+ const trackingState = ref(null);
+ const trackingReady = ref(false);
+ const trackingActionLoading = ref(false);
+
+ const onboardingCompleted = computed(
+ () => trackingState.value?.onboarding_completed === true,
+ );
+ const autoCaptureEnabled = computed(
+ () => trackingState.value?.auto_capture_enabled === true,
+ );
+ const captureShouldRun = computed(
+ () =>
+ trackingReady.value &&
+ onboardingCompleted.value &&
+ autoCaptureEnabled.value,
+ );
+
+ async function loadTrackingState(): Promise {
+ try {
+ trackingState.value = await getTrackingState();
+ trackingReady.value = true;
+ } catch (error) {
+ trackingReady.value = false;
+ setErrorMessage(error);
+ }
+ }
+
+ async function completeOnboarding(): Promise {
+ if (trackingActionLoading.value) {
+ return;
+ }
+
+ trackingActionLoading.value = true;
+ try {
+ trackingState.value = await completeTrackingOnboarding();
+ trackingReady.value = true;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
+ } finally {
+ trackingActionLoading.value = false;
+ }
+ }
+
+ async function persistAutoCaptureEnabled(enabled: boolean): Promise {
+ if (trackingActionLoading.value) {
+ return;
+ }
+
+ trackingActionLoading.value = true;
+ try {
+ trackingState.value = await setAutoCaptureEnabled(enabled);
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
+ } finally {
+ trackingActionLoading.value = false;
+ }
+ }
+
+ return {
+ trackingReady,
+ onboardingCompleted,
+ autoCaptureEnabled,
+ captureShouldRun,
+ trackingActionLoading,
+ loadTrackingState,
+ completeOnboarding,
+ persistAutoCaptureEnabled,
+ };
+}
From f456736883672af399e53e84a16454b1c7e512cc Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:40:41 +0800
Subject: [PATCH 059/117] feat(frontend): persist focus guard capture toggle
---
src/composables/useGuardData.ts | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/src/composables/useGuardData.ts b/src/composables/useGuardData.ts
index c37d432..c0da770 100644
--- a/src/composables/useGuardData.ts
+++ b/src/composables/useGuardData.ts
@@ -1,4 +1,4 @@
-import { computed, ref } from "vue";
+import { computed, ref, type Ref } from "vue";
import {
getIdleMemoryState,
listAppRules,
@@ -56,6 +56,8 @@ type UseGuardDataOptions = {
mappedTypeText: (mappedType: RuleMappedType) => string;
refreshData: () => Promise;
setErrorMessage: (error: unknown) => void;
+ autoCaptureEnabled: Readonly[>;
+ persistAutoCaptureEnabled: (enabled: boolean) => Promise];
};
export function useGuardData({
@@ -63,9 +65,10 @@ export function useGuardData({
mappedTypeText,
refreshData,
setErrorMessage,
+ autoCaptureEnabled,
+ persistAutoCaptureEnabled,
}: UseGuardDataOptions) {
const loadingGuard = ref(false);
- const autoCaptureEnabled = ref(true);
const autoCaptureFeedback = ref(defaultGuardAutoCaptureFeedback(tx));
const guardFeedback = ref(defaultGuardCheckFeedback(tx));
const guardFeedbackType = ref("info");
@@ -215,8 +218,20 @@ export function useGuardData({
);
}
- function onAutoCaptureToggle(event: Event) {
- autoCaptureEnabled.value = guardCheckedFromEvent(event);
+ async function onAutoCaptureToggle(event: Event): Promise {
+ const enabled = guardCheckedFromEvent(event);
+ try {
+ await persistAutoCaptureEnabled(enabled);
+ autoCaptureFeedback.value = enabled
+ ? tx("自动采样已恢复。", "Auto capture resumed.")
+ : tx("自动采样已暂停。", "Auto capture paused.");
+ } catch (e) {
+ setErrorMessage(e);
+ autoCaptureFeedback.value = tx(
+ `切换自动采样失败:${e}`,
+ `Failed to change auto capture: ${e}`,
+ );
+ }
}
function onIdleRememberChoiceChange(event: Event) {
From 7a21d918324329d7116585a3bc1de0b72acbdb3c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:41:00 +0800
Subject: [PATCH 060/117] feat(frontend): load persisted tracking before
runtime startup
---
src/composables/useMainWindowLifecycle.ts | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/composables/useMainWindowLifecycle.ts b/src/composables/useMainWindowLifecycle.ts
index 3354ccc..30f92ae 100644
--- a/src/composables/useMainWindowLifecycle.ts
+++ b/src/composables/useMainWindowLifecycle.ts
@@ -3,13 +3,13 @@ import { onMounted, onUnmounted } from "vue";
type MainWindowLifecycleOptions = {
initLocale: () => void;
initThemeModeSafely: () => void;
+ loadTrackingState: () => Promise;
loadHeatmapGoalSecondsSetting: () => Promise;
resetGuardFeedback: () => void;
resetPrivacyFeedback: () => void;
refreshHomeData: () => Promise | void;
startSettingsWarmup: () => void;
startMainRefreshPolling: () => void;
- startAutoCaptureSampler: () => void;
startInsightsSectionNavigationListener: () => Promise;
stopMainRefreshPolling: () => void;
stopAutoCaptureSampler: () => void;
@@ -21,13 +21,13 @@ type MainWindowLifecycleOptions = {
export function useMainWindowLifecycle({
initLocale,
initThemeModeSafely,
+ loadTrackingState,
loadHeatmapGoalSecondsSetting,
resetGuardFeedback,
resetPrivacyFeedback,
refreshHomeData,
startSettingsWarmup,
startMainRefreshPolling,
- startAutoCaptureSampler,
startInsightsSectionNavigationListener,
stopMainRefreshPolling,
stopAutoCaptureSampler,
@@ -39,6 +39,7 @@ export function useMainWindowLifecycle({
initLocale();
initThemeModeSafely();
+ await loadTrackingState();
await loadHeatmapGoalSecondsSetting();
resetGuardFeedback();
@@ -47,8 +48,6 @@ export function useMainWindowLifecycle({
startSettingsWarmup();
startMainRefreshPolling();
- startAutoCaptureSampler();
-
await startInsightsSectionNavigationListener();
});
From eb3cf2aa5dc27269dbcb8b8d345850b27622d43b Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:41:39 +0800
Subject: [PATCH 061/117] feat(frontend): gate capture runtime on persisted
tracking state
---
src/App.vue | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index d27393e..b4b7e38 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,4 +1,5 @@
+
+
+
+
+
+
+
+
+ 01
+
+
{{ props.tx("数据保存在本地", "Stored locally") }}
+
+ {{
+ props.tx(
+ "当前版本的记录写入本机 SQLite 数据库,用于统计、时间线与专注分析。",
+ "The current version writes tracking records to a local SQLite database for summaries, timelines, and focus analysis.",
+ )
+ }}
+
+
+
+
+
+ 02
+
+
{{ props.tx("先过隐私规则,再决定是否保存", "Privacy rules run before storage") }}
+
+ {{
+ props.tx(
+ "浏览器标题可模糊处理,应用也可被忽略或限制;被拦截的样本不会写入使用记录。",
+ "Browser titles can be blurred and apps can be ignored or restricted; blocked samples are not written to usage history.",
+ )
+ }}
+
+
+
+
+
+ 03
+
+
{{ props.tx("随时暂停,状态会保留", "Pause anytime, persistently") }}
+
+ {{
+ props.tx(
+ "开始后可在专注守护中暂停或恢复自动采样;选择会持久化,下次启动继续沿用。",
+ "After activation, pause or resume auto capture from Focus Guard. The choice persists across restarts.",
+ )
+ }}
+
+
+
+
+
+
+
{{ props.tx("点击开始后", "After you continue") }}
+
+ {{
+ props.tx(
+ "TimePrism 会启用约每 5 秒一次的前台应用采样,并显示桌面宠物。无需重启。",
+ "TimePrism enables foreground-app sampling at roughly five-second intervals and shows the desktop pet. No restart is required.",
+ )
+ }}
+
+
+
+ {{ props.error }}
+
+
+
+ {{
+ props.tx(
+ "继续即表示你已了解上述本地记录方式。",
+ "Continuing confirms that you understand the local tracking behavior above.",
+ )
+ }}
+
+
+ {{
+ props.loading
+ ? props.tx("正在启用…", "Enabling…")
+ : props.tx("开始使用 TimePrism", "Start using TimePrism")
+ }}
+
+
+
+
+
+
+
From 4be13900f6737bab9a705787f2ca8c14f33b01c4 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:53:01 +0800
Subject: [PATCH 069/117] feat(frontend): gate first-run UI and activate
tracking without restart
---
src/App.vue | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 116 insertions(+), 2 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index b4b7e38..b1cbe3c 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,11 +1,13 @@
-
+
+
+ TP
+
+
{{ tx("正在准备 TimePrism", "Preparing TimePrism") }}
+
+ {{
+ tx(
+ "正在读取本地记录设置。自动采样会保持关闭,直到状态确认完成。",
+ "Reading local tracking settings. Auto capture remains off until the state is confirmed.",
+ )
+ }}
+
+
+ {{ error }}
+
+
+
+
+
+
+
+
From 2183bded77b3d8cf7ad8b78cb4f8f4688010fa9c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 22:55:32 +0800
Subject: [PATCH 070/117] fix(frontend): expose runtime tracking UI overload
---
src/lib/trackingUi.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/lib/trackingUi.ts b/src/lib/trackingUi.ts
index e4ef937..7babab2 100644
--- a/src/lib/trackingUi.ts
+++ b/src/lib/trackingUi.ts
@@ -12,6 +12,10 @@ export function trackingUiMode(
trackingReady: true,
onboardingCompleted: true,
): "app";
+export function trackingUiMode(
+ trackingReady: boolean,
+ onboardingCompleted: boolean,
+): TrackingUiMode;
export function trackingUiMode(
trackingReady: boolean,
onboardingCompleted: boolean,
From 8850a9724f36cce0b7c122581c1e47ac01dab9e5 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:02:06 +0800
Subject: [PATCH 071/117] test(frontend): forbid capture policy in sampler API
---
src/composables/useAutoCaptureSampler.contract.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/composables/useAutoCaptureSampler.contract.ts b/src/composables/useAutoCaptureSampler.contract.ts
index 49deabc..54d047f 100644
--- a/src/composables/useAutoCaptureSampler.contract.ts
+++ b/src/composables/useAutoCaptureSampler.contract.ts
@@ -7,6 +7,14 @@ const sampler = useAutoCaptureSampler({
intervalMs: 5000,
});
+// Runtime policy belongs to useTrackingState/App, not to the sampler API.
+// @ts-expect-error autoCaptureEnabled must not be accepted by the sampler.
+useAutoCaptureSampler({
+ autoCaptureEnabled: ref(true),
+ autoCaptureFeedback: ref(""),
+ tx: (_zh, en) => en,
+});
+
export const autoCaptureSamplerContract = sampler satisfies {
startAutoCaptureSampler: () => void;
stopAutoCaptureSampler: () => void;
From 7ca48a4c7b124889535915417ac3cb04fbb66264 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:05:03 +0800
Subject: [PATCH 072/117] refactor(frontend): remove capture policy from
sampler API
---
src/composables/useAutoCaptureSampler.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/composables/useAutoCaptureSampler.ts b/src/composables/useAutoCaptureSampler.ts
index 709c472..d28132e 100644
--- a/src/composables/useAutoCaptureSampler.ts
+++ b/src/composables/useAutoCaptureSampler.ts
@@ -7,7 +7,6 @@ type AutoCaptureSamplerOptions = {
autoCaptureFeedback: Ref;
tx: TranslateFn;
intervalMs?: number;
- autoCaptureEnabled?: Ref;
};
export function useAutoCaptureSampler({
From cce1dcff9916babaa2e2e96a33f33381767264d3 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:07:00 +0800
Subject: [PATCH 073/117] test(frontend): pin sampler policy rejection to
property
---
src/composables/useAutoCaptureSampler.contract.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/composables/useAutoCaptureSampler.contract.ts b/src/composables/useAutoCaptureSampler.contract.ts
index 54d047f..c5d8156 100644
--- a/src/composables/useAutoCaptureSampler.contract.ts
+++ b/src/composables/useAutoCaptureSampler.contract.ts
@@ -8,8 +8,8 @@ const sampler = useAutoCaptureSampler({
});
// Runtime policy belongs to useTrackingState/App, not to the sampler API.
-// @ts-expect-error autoCaptureEnabled must not be accepted by the sampler.
useAutoCaptureSampler({
+ // @ts-expect-error autoCaptureEnabled must not be accepted by the sampler.
autoCaptureEnabled: ref(true),
autoCaptureFeedback: ref(""),
tx: (_zh, en) => en,
From 67e323ef839a102a7543c85b840eedde70a29a2c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:20:45 +0800
Subject: [PATCH 074/117] test(pet): require persisted tracking mood contract
---
src/pet.tracking.contract.ts | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 src/pet.tracking.contract.ts
diff --git a/src/pet.tracking.contract.ts b/src/pet.tracking.contract.ts
new file mode 100644
index 0000000..1ea505a
--- /dev/null
+++ b/src/pet.tracking.contract.ts
@@ -0,0 +1,6 @@
+import { petTrackingMood } from "./pet";
+
+const tx = (zh: string, en: string) => `${zh}|${en}`;
+
+export const activePetTrackingMoodContract: string = petTrackingMood(true, tx);
+export const pausedPetTrackingMoodContract: string = petTrackingMood(false, tx);
From 94a318e8f97818cd850d17a095c4a9b970e2d0cb Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:24:55 +0800
Subject: [PATCH 075/117] feat(pet): show persisted tracking status
---
src/pet.ts | 23 ++++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/src/pet.ts b/src/pet.ts
index 0ae6ac7..9e2db50 100644
--- a/src/pet.ts
+++ b/src/pet.ts
@@ -1,8 +1,8 @@
-import { invoke } from "@tauri-apps/api/core";
+import { invoke } from "@tauri-apps/api/core";
import { LogicalPosition } from "@tauri-apps/api/dpi";
import { Menu } from "@tauri-apps/api/menu";
import { currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
-import { getTodaySummary, type TodaySummary } from "./api";
+import { getTodaySummary, getTrackingState } from "./api";
import {
LOCALE_STORAGE_KEY,
getStoredOrBrowserLocale,
@@ -57,6 +57,8 @@ import "./pet.css";
const petWindow = getCurrentWindow();
const EDGE_SNAP_THRESHOLD = 72;
+type PetTranslateFn = (zh: string, en: string) => string;
+
function getLocale(): LocaleCode {
return getStoredOrBrowserLocale();
}
@@ -65,6 +67,15 @@ function tx(zh: string, en: string): string {
return translateForLocale(getLocale(), zh, en);
}
+export function petTrackingMood(
+ autoCaptureEnabled: boolean,
+ translate: PetTranslateFn,
+): string {
+ return autoCaptureEnabled
+ ? translate("自动记录中", "Auto tracking")
+ : translate("记录已暂停", "Tracking paused");
+}
+
let petState: PetDockState = "free";
let isDragging = false;
let expanded = false;
@@ -408,12 +419,15 @@ function scheduleSettleRetry(attempt = 0) {
async function refreshSummary() {
try {
- const summary: TodaySummary = await getTodaySummary();
+ const [summary, tracking] = await Promise.all([
+ getTodaySummary(),
+ getTrackingState(),
+ ]);
renderPetSummary(
{ learn, rest, mood },
formatSeconds(summary.learn_seconds),
formatSeconds(summary.rest_seconds),
- tx("自动记录中", "Auto tracking"),
+ petTrackingMood(tracking.auto_capture_enabled, tx),
);
} catch {
setMood(tx("状态同步失败,请打开设置查看详情", "Sync failed, open settings for details"));
@@ -564,4 +578,3 @@ window.addEventListener("beforeunload", () => {
closeContextMenu();
});
-
From aff2660c59658d9d2146c375ba3d7141035f785b Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:27:50 +0800
Subject: [PATCH 076/117] test(ui): require compare-and-clear error ownership
---
src/composables/errorOwnership.contract.ts | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 src/composables/errorOwnership.contract.ts
diff --git a/src/composables/errorOwnership.contract.ts b/src/composables/errorOwnership.contract.ts
new file mode 100644
index 0000000..de8aa7b
--- /dev/null
+++ b/src/composables/errorOwnership.contract.ts
@@ -0,0 +1,17 @@
+import type { Ref } from "vue";
+import { useErrorMessage } from "./useErrorMessage";
+import { useHomeData } from "./useHomeData";
+
+const errorApi = useErrorMessage();
+
+export const errorOwnershipContract = errorApi satisfies {
+ error: Ref;
+ setErrorMessage: (error: unknown) => void;
+ clearErrorMessage: (expected?: unknown) => void;
+};
+
+type HomeDataOptions = Parameters[0];
+
+export const homeErrorOwnershipContract = {
+ clearErrorMessage: (_expected?: unknown) => undefined,
+} satisfies Pick;
From 56f305bf9f24c60c68132714952316755b9fa063 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:30:03 +0800
Subject: [PATCH 077/117] fix(ui): add compare-and-clear error API
---
src/composables/useErrorMessage.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/composables/useErrorMessage.ts b/src/composables/useErrorMessage.ts
index 11efe7d..133d284 100644
--- a/src/composables/useErrorMessage.ts
+++ b/src/composables/useErrorMessage.ts
@@ -7,8 +7,15 @@ export function useErrorMessage() {
error.value = `${e}`;
}
+ function clearErrorMessage(expected?: unknown) {
+ if (expected === undefined || error.value === `${expected}`) {
+ error.value = "";
+ }
+ }
+
return {
error,
setErrorMessage,
+ clearErrorMessage,
};
}
From 9f3aeb1d94f7671b3a9276c62b11b167755e9792 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:30:19 +0800
Subject: [PATCH 078/117] fix(ui): preserve newer errors during home recovery
---
src/composables/useHomeData.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/composables/useHomeData.ts b/src/composables/useHomeData.ts
index 543a7c5..6b3716c 100644
--- a/src/composables/useHomeData.ts
+++ b/src/composables/useHomeData.ts
@@ -27,6 +27,7 @@ type UseHomeDataOptions = {
getHeatmapFetchDays: () => number;
getHeatmapGoalSeconds: () => number;
setErrorMessage: (error: unknown) => void;
+ clearErrorMessage: (expected?: unknown) => void;
};
export function useHomeData({
@@ -40,10 +41,12 @@ export function useHomeData({
getHeatmapFetchDays,
getHeatmapGoalSeconds,
setErrorMessage,
+ clearErrorMessage,
}: UseHomeDataOptions) {
const loadingHomeCore = ref(false);
const loadingHomeAnalytics = ref(false);
let initialHomeRefresh = true;
+ let lastHomeCoreError = "";
async function refreshHomeCoreData() {
if (loadingHomeCore.value) {
@@ -64,7 +67,12 @@ export function useHomeData({
pendingRuleProcesses.value = pendingRules;
idlePrompts.value = pendingIdle;
reminders.value = reminderRows;
+ if (lastHomeCoreError) {
+ clearErrorMessage(lastHomeCoreError);
+ lastHomeCoreError = "";
+ }
} catch (e) {
+ lastHomeCoreError = `${e}`;
setErrorMessage(e);
} finally {
loadingHomeCore.value = false;
From ebf65931df48cc53bf999e7e5eba6cb49fe7b06c Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:31:05 +0800
Subject: [PATCH 079/117] fix(ui): clear only recovered home read errors
---
src/App.vue | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/App.vue b/src/App.vue
index b1cbe3c..0ca7050 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -46,7 +46,7 @@ import { trackingUiMode } from "./lib/trackingUi";
const { locale, tx, initLocale, watchLocaleChanges, onLocaleChange } = useLocale();
watchLocaleChanges();
const { themeMode, toggleThemeMode, initThemeModeSafely } = useThemeMode();
-const { error, setErrorMessage } = useErrorMessage();
+const { error, setErrorMessage, clearErrorMessage } = useErrorMessage();
const {
trackingReady,
onboardingCompleted,
@@ -349,6 +349,7 @@ const { refreshHomeData } = useHomeData({
getHeatmapFetchDays,
getHeatmapGoalSeconds,
setErrorMessage,
+ clearErrorMessage,
});
bindMainDataRefreshHandlers({
refreshHomeData,
From 1970a4946244dd947819861f6e8defa008fe056d Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:33:51 +0800
Subject: [PATCH 080/117] docs: add first-run tracking smoke gates
---
docs/SMOKE_TESTS.md | 44 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/docs/SMOKE_TESTS.md b/docs/SMOKE_TESTS.md
index a71a15b..f6203d8 100644
--- a/docs/SMOKE_TESTS.md
+++ b/docs/SMOKE_TESTS.md
@@ -25,6 +25,50 @@ Cleanup:
Remove-Item -LiteralPath .\dist-codex-check -Recurse -Force
```
+## S-050 Fresh Install And First-Run Tracking
+
+Use a clean or sandboxed TimePrism app-data directory so this path exercises a truly fresh database.
+
+1. Launch TimePrism with no existing app database.
+2. Confirm no `no such table: app_usage_logs` or other pre-initialization database error appears.
+3. Before activation, switch foreground apps for more than 10 seconds and confirm no usage rows are captured.
+4. Confirm onboarding explains:
+ - foreground application/window metadata is observed for duration tracking;
+ - core data is stored in local SQLite;
+ - browser titles receive privacy processing and private/incognito windows are skipped;
+ - TimePrism does not capture screenshots or keystroke contents;
+ - Insights shows observed application activity while pet `学 / 休` counters include only activity classified as `LEARN / REST`.
+5. Click `开始使用 TimePrism` and confirm tracking becomes active without restarting the app.
+6. Switch foreground apps and confirm Insights begins showing observed activity after the normal sampling interval.
+7. Confirm pet `学 / 休` remains classification-based and refreshes on the existing several-second cadence rather than synthetic per-second increments.
+8. Pause capture from Focus Guard, switch foreground apps for more than 10 seconds, and confirm no new samples are appended.
+9. Fully quit and reopen TimePrism; confirm the pause persists and onboarding does not reappear.
+10. Resume capture, fully quit and reopen again, and confirm automatic tracking resumes.
+11. While capture is paused, confirm the pet mood reports `记录已暂停` / `Tracking paused`.
+12. Confirm close-to-background, pet summon/hide, and `Quit TimePrism` behavior still works.
+
+Expected:
+
+- Database initialization completes before product WebViews query SQLite.
+- Fresh installs remain capture-off until explicit onboarding activation succeeds.
+- Pause/resume is persisted, not reset by restart.
+- Tracking, Home, and pet updates retain the existing approximately 5-second cadence.
+
+## S-060 Upgrade From A Pre-Tracking-State Database
+
+Use a backed-up database created by a TimePrism build from before the `onboarding_completed` and `auto_capture_enabled` keys existed.
+
+1. Launch the current build against the backed-up pre-change database.
+2. Confirm existing records remain present.
+3. Confirm onboarding does not appear.
+4. Confirm tracking starts enabled by default for this recognized legacy database.
+5. Pause capture, fully quit, and reopen; confirm the explicit pause is preserved.
+
+Expected:
+
+- Recognizable legacy databases migrate to onboarded + capture-enabled defaults without overwriting later explicit tracking choices.
+- Existing user data survives migration.
+
## S-100 Main Window
1. Launch the app.
From c34410a2f544db9dcd90df375c05cdf8f8d00980 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Tue, 8 Sep 2026 23:34:15 +0800
Subject: [PATCH 081/117] docs: add first-run tracking release gates
---
docs/RELEASE_CHECKLIST.md | 49 ++++++++++++++++++++++++++++++++++-----
1 file changed, 43 insertions(+), 6 deletions(-)
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index 4465f7c..75051b0 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -20,24 +20,59 @@ Installer validation is deliberately split into two layers:
A green packaging job is therefore necessary evidence, but it is not sufficient by itself to publish a Release.
+## Mandatory first-run and upgrade acceptance
+
+Before a public release, both tracking-state migration paths must pass on a real Windows machine using the exact release candidate build.
+
+### Fresh-install gate
+
+Use a clean or sandboxed TimePrism app-data directory and complete `S-050 Fresh Install And First-Run Tracking` in `docs/SMOKE_TESTS.md`.
+
+Release-blocking assertions include:
+
+- SQLite is initialized before product WebViews query it; no `no such table` startup failure appears.
+- No foreground capture occurs before the user accepts the first-run tracking disclosure.
+- The disclosure accurately describes foreground app/window metadata, local SQLite storage, browser privacy processing, no screenshot/keystroke-content capture, and the distinction between observed activity and classified `LEARN / REST` time.
+- Activation begins tracking without an application restart.
+- Pause persists across full quit/reopen; resume also persists and restores automatic tracking after restart.
+- The desktop pet reports active versus paused tracking while keeping `学 / 休` counters classification-based.
+
+### Upgrade gate
+
+Use a backed-up recognizable TimePrism database created before the tracking-state keys existed and complete `S-060 Upgrade From A Pre-Tracking-State Database`.
+
+Release-blocking assertions include:
+
+- existing rows survive migration;
+- onboarding does not unexpectedly reappear for an existing user;
+- the legacy database starts onboarded with capture enabled;
+- a later explicit pause is preserved across reinitialization and restart.
+
+Do not publish a release, mark a release candidate accepted, or promote a tracking-state PR solely from automated CI evidence if either interactive gate has not passed.
+
## Before tagging
1. Confirm `main` CI is green.
2. Confirm the latest relevant packaging PR produced both installer formats successfully.
-3. Update the same semantic version in:
+3. Confirm both mandatory acceptance paths above passed on the release candidate:
+ - fresh install (`S-050`);
+ - pre-tracking-state upgrade (`S-060`).
+4. Update the same semantic version in:
- `package.json`
- `src-tauri/Cargo.toml`
- `src-tauri/tauri.conf.json`
-4. Run or verify:
+5. Run or verify:
```text
+ pnpm install --frozen-lockfile
pnpm run typecheck
pnpm run build:check
+ cargo check --manifest-path src-tauri/Cargo.toml --all-targets
cargo test --manifest-path src-tauri/Cargo.toml
```
-5. Smoke-test the desktop flows listed in `docs/SMOKE_TESTS.md`.
-6. Commit the synchronized version bump to `main`.
+6. Smoke-test the remaining desktop flows listed in `docs/SMOKE_TESTS.md`.
+7. Commit the synchronized version bump to `main`.
The release workflow rejects mismatched Node/Cargo/Tauri versions. Tag-triggered runs also reject a tag that does not equal `v`.
@@ -68,10 +103,11 @@ Download the generated artifact and perform the interactive Windows smoke before
At minimum, verify one generated installer end to end on Windows:
- install from the NSIS `-setup.exe` or MSI artifact;
-- launch TimePrism and confirm the main window renders normally;
+- run the fresh-install tracking acceptance with a clean/sandboxed app-data directory;
+- separately run the upgrade acceptance against a backed-up pre-tracking-state database;
- verify Home, Insights, Focus Guard, reminders, privacy settings, and desktop pet flows;
- verify main-window close/background behavior and full-app quit behavior;
-- restart the app and confirm local data survives;
+- restart the app and confirm local data and tracking pause/resume state survive as expected;
- uninstall the tested installer and confirm the uninstall flow completes normally;
- retain the unsigned/SmartScreen note until Windows code signing exists.
@@ -94,6 +130,7 @@ Before publishing the draft Release:
- confirm the expected NSIS and MSI assets are attached;
- install at least one draft asset if it differs from the previously smoke-tested artifact;
+- confirm both fresh-install and upgrade acceptance evidence corresponds to the same code/release candidate;
- confirm Release notes describe any known limitations;
- explicitly retain the unsigned/SmartScreen note until Windows code signing exists.
From f297c1bf16d7811e0cf1274d00d1b75af4652846 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:20:52 +0800
Subject: [PATCH 082/117] test(idle): require previous-app attribution contract
---
src/api/idleAttribution.contract.ts | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 src/api/idleAttribution.contract.ts
diff --git a/src/api/idleAttribution.contract.ts b/src/api/idleAttribution.contract.ts
new file mode 100644
index 0000000..ce9d173
--- /dev/null
+++ b/src/api/idleAttribution.contract.ts
@@ -0,0 +1,17 @@
+import { resolveIdlePrompt } from "./commands/foreground";
+import type { IdlePrompt } from "./types";
+
+const prompt: IdlePrompt = {
+ id: 1,
+ start_timestamp: 100,
+ end_timestamp: 400,
+ duration_ms: 300_000,
+ attribution_process_name: "code.exe",
+};
+
+export const idleAttributionProcessContract: string | null = prompt.attribution_process_name;
+
+void resolveIdlePrompt({
+ prompt_id: prompt.id,
+ decision: "APP",
+});
From 8062ce1ac7b1defbcd6cbbb4c57eb80f24aaac5b Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:22:38 +0800
Subject: [PATCH 083/117] feat(api): expose idle attribution candidate
---
src/api/types.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/api/types.ts b/src/api/types.ts
index 4619393..d9328fb 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -100,6 +100,7 @@ export type IdlePrompt = {
end_timestamp: number;
duration_ms: number;
deferred_until_timestamp?: number | null;
+ attribution_process_name: string | null;
};
export type IdleMemoryState = {
@@ -121,4 +122,4 @@ export type Reminder = {
completed_at: number | null;
created_at: number;
updated_at: number;
-};
+};
\ No newline at end of file
From 0264cd2b7a9ae7ef34d3664ff3c019f79418d28d Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:22:50 +0800
Subject: [PATCH 084/117] feat(api): accept idle app attribution decision
---
src/api/commands/foreground.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/api/commands/foreground.ts b/src/api/commands/foreground.ts
index 5f4cdd3..aa36c68 100644
--- a/src/api/commands/foreground.ts
+++ b/src/api/commands/foreground.ts
@@ -18,7 +18,7 @@ export async function listPendingIdlePrompts(limit = 5): Promise {
export async function resolveIdlePrompt(input: {
prompt_id: number;
- decision: "LEARN" | "REST" | "IDLE" | "SKIP";
+ decision: "APP" | "LEARN" | "REST" | "IDLE" | "SKIP";
remember_this_session?: boolean;
}): Promise {
return invokeCommand("resolve_idle_prompt", { input });
From 2875b7cd89ab01dcc1bacd3483381e6fa905d7db Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:23:33 +0800
Subject: [PATCH 085/117] test(idle): require app-attributed interval
persistence
---
src-tauri/src/services/usage.rs | 77 ++++++++++++++++++++++++++++++++-
1 file changed, 76 insertions(+), 1 deletion(-)
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index 44fa885..994af84 100644
--- a/src-tauri/src/services/usage.rs
+++ b/src-tauri/src/services/usage.rs
@@ -137,7 +137,7 @@ pub(crate) fn append_usage_log_record(
mod tests {
use rusqlite::{params, Connection};
- use super::persist_idle_prompt_decision;
+ use super::{persist_idle_prompt_app_decision, persist_idle_prompt_decision};
use crate::domain::idle::IdlePromptEntry;
fn usage_test_conn() -> Connection {
@@ -218,4 +218,79 @@ mod tests {
]
);
}
+
+ #[test]
+ fn idle_app_decision_attributes_interval_without_overwriting_app_rule() {
+ let conn = usage_test_conn();
+ conn.execute(
+ "INSERT INTO app_rules (process_name, mapped_type, privacy_level, created_at, updated_at)
+ VALUES ('code.exe', 'REST', 'NORMAL', 1, 1)",
+ [],
+ )
+ .expect("seed app rule");
+ conn.execute(
+ "INSERT INTO app_usage_logs (process_name, window_title, start_timestamp, duration_ms)
+ VALUES (?1, ?2, ?3, ?4)",
+ params!["code.exe", "Project", 100_i64, 400_000_i64],
+ )
+ .expect("insert foreground segment crossing idle start");
+ conn.execute(
+ "INSERT INTO app_usage_logs (process_name, window_title, start_timestamp, duration_ms)
+ VALUES (?1, ?2, ?3, ?4)",
+ params!["lockapp.exe", "Lock Screen", 350_i64, 100_000_i64],
+ )
+ .expect("insert foreground segment inside idle interval");
+
+ let prompt = IdlePromptEntry {
+ id: 2,
+ start_timestamp: 300,
+ end_timestamp: 600,
+ duration_ms: 300_000,
+ deferred_until_timestamp: None,
+ };
+
+ persist_idle_prompt_app_decision(&conn, &prompt, "code.exe")
+ .expect("persist app-attributed idle decision");
+
+ let rows = conn
+ .prepare(
+ "SELECT process_name, window_title, start_timestamp, duration_ms
+ FROM app_usage_logs
+ ORDER BY start_timestamp, id",
+ )
+ .expect("prepare usage rows")
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, i64>(3)?,
+ ))
+ })
+ .expect("query usage rows")
+ .collect::, _>>()
+ .expect("collect usage rows");
+
+ assert_eq!(
+ rows,
+ vec![
+ ("code.exe".to_string(), "Project".to_string(), 100, 200_000),
+ (
+ "code.exe".to_string(),
+ "Idle Confirmed · Previous App".to_string(),
+ 300,
+ 300_000,
+ ),
+ ]
+ );
+
+ let mapped_type: String = conn
+ .query_row(
+ "SELECT mapped_type FROM app_rules WHERE process_name = 'code.exe'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("query app rule");
+ assert_eq!(mapped_type, "REST");
+ }
}
From 229b463974e22816f6b9897a06e288bffdc8563e Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:26:07 +0800
Subject: [PATCH 086/117] feat(idle): persist app-attributed idle intervals
---
src-tauri/src/services/usage.rs | 53 ++++++++++++++++++++++++++-------
1 file changed, 42 insertions(+), 11 deletions(-)
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index 994af84..0f642e6 100644
--- a/src-tauri/src/services/usage.rs
+++ b/src-tauri/src/services/usage.rs
@@ -59,19 +59,18 @@ fn append_usage_log_direct(
Ok(true)
}
-pub(crate) fn persist_idle_prompt_decision(
+fn replace_idle_interval_with_usage(
conn: &Connection,
prompt: &IdlePromptEntry,
- decision: &str,
+ process_name: &str,
+ title: &str,
+ mapped_type: Option<&str>,
) -> Result {
- let (process_name, mapped_type, title) = match decision {
- "LEARN" => ("__idle_learn__.exe", "LEARN", "Idle Segment · Learn"),
- "REST" => ("__idle_rest__.exe", "REST", "Idle Segment · Rest"),
- "IDLE" => ("__idle__.exe", "IGNORE", "Idle Segment · Unclassified"),
- _ => return Err("invalid idle decision".to_string()),
- };
-
let process_key = normalize_process_key(process_name);
+ if process_key.trim().is_empty() {
+ return Err("idle app attribution requires a process name".to_string());
+ }
+
let idle_start = prompt.start_timestamp.max(0);
let idle_end = prompt.end_timestamp.max(idle_start);
let tx = conn
@@ -80,7 +79,7 @@ pub(crate) fn persist_idle_prompt_decision(
// Foreground samples can be persisted during the idle threshold window before the sampler
// knows the interval is idle. Resolving the prompt commits the retroactive classification,
- // so replace those provisional rows instead of adding an overlapping idle row on top.
+ // so replace those provisional rows instead of adding an overlapping row on top.
tx.execute(
"UPDATE app_usage_logs
SET duration_ms = MAX(0, (?1 - start_timestamp) * 1000)
@@ -97,7 +96,10 @@ pub(crate) fn persist_idle_prompt_decision(
)
.map_err(|e| format!("failed to remove usage inside idle interval: {e}"))?;
- upsert_app_rule_entry(&tx, &process_key, mapped_type, "NORMAL")?;
+ if let Some(mapped_type) = mapped_type {
+ upsert_app_rule_entry(&tx, &process_key, mapped_type, "NORMAL")?;
+ }
+
let stored = append_usage_log_direct(
&tx,
&process_key,
@@ -111,6 +113,35 @@ pub(crate) fn persist_idle_prompt_decision(
Ok(stored)
}
+pub(crate) fn persist_idle_prompt_decision(
+ conn: &Connection,
+ prompt: &IdlePromptEntry,
+ decision: &str,
+) -> Result {
+ let (process_name, mapped_type, title) = match decision {
+ "LEARN" => ("__idle_learn__.exe", "LEARN", "Idle Segment · Learn"),
+ "REST" => ("__idle_rest__.exe", "REST", "Idle Segment · Rest"),
+ "IDLE" => ("__idle__.exe", "IGNORE", "Idle Segment · Unclassified"),
+ _ => return Err("invalid idle decision".to_string()),
+ };
+
+ replace_idle_interval_with_usage(conn, prompt, process_name, title, Some(mapped_type))
+}
+
+pub(crate) fn persist_idle_prompt_app_decision(
+ conn: &Connection,
+ prompt: &IdlePromptEntry,
+ process_name: &str,
+) -> Result {
+ replace_idle_interval_with_usage(
+ conn,
+ prompt,
+ process_name,
+ "Idle Confirmed · Previous App",
+ None,
+ )
+}
+
pub(crate) fn append_usage_log_record(
conn: &Connection,
process_name: &str,
From 60325f1a9ee0ebe754dec2a17f7095b934c185df Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:26:38 +0800
Subject: [PATCH 087/117] feat(idle): carry previous-app attribution candidate
---
src-tauri/src/domain/idle.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-tauri/src/domain/idle.rs b/src-tauri/src/domain/idle.rs
index 6e6076d..eba3168 100644
--- a/src-tauri/src/domain/idle.rs
+++ b/src-tauri/src/domain/idle.rs
@@ -7,6 +7,7 @@ pub(crate) struct IdlePromptEntry {
pub(crate) end_timestamp: i64,
pub(crate) duration_ms: i64,
pub(crate) deferred_until_timestamp: Option,
+ pub(crate) attribution_process_name: Option,
}
#[derive(Deserialize)]
From a60f0ebbc55cd302f3d8fc92ad8fc001d4bff6aa Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:27:25 +0800
Subject: [PATCH 088/117] test(idle): carry attribution field through usage
fixtures
---
src-tauri/src/services/usage.rs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index 0f642e6..1ed53eb 100644
--- a/src-tauri/src/services/usage.rs
+++ b/src-tauri/src/services/usage.rs
@@ -218,6 +218,7 @@ mod tests {
end_timestamp: 600,
duration_ms: 300_000,
deferred_until_timestamp: None,
+ attribution_process_name: None,
};
persist_idle_prompt_decision(&conn, &prompt, "REST").expect("persist idle decision");
@@ -278,6 +279,7 @@ mod tests {
end_timestamp: 600,
duration_ms: 300_000,
deferred_until_timestamp: None,
+ attribution_process_name: Some("code.exe".to_string()),
};
persist_idle_prompt_app_decision(&conn, &prompt, "code.exe")
From 27d8f6668069560e30bd2a2c1a530c8c49044563 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:30:42 +0800
Subject: [PATCH 089/117] feat(idle): capture and resolve previous-app
attribution
---
src-tauri/src/services/foreground.rs | 84 ++++++++++++++++++++++++++--
1 file changed, 78 insertions(+), 6 deletions(-)
diff --git a/src-tauri/src/services/foreground.rs b/src-tauri/src/services/foreground.rs
index 4707813..7874d16 100644
--- a/src-tauri/src/services/foreground.rs
+++ b/src-tauri/src/services/foreground.rs
@@ -21,7 +21,9 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
use super::privacy::normalize_process_key;
use super::rules::resolve_rule_mapping;
-use super::usage::{append_usage_log_record, persist_idle_prompt_decision};
+use super::usage::{
+ append_usage_log_record, persist_idle_prompt_app_decision, persist_idle_prompt_decision,
+};
use crate::db::connection::open_connection;
use crate::domain::idle::{IdleMemoryState, IdlePromptEntry, ResolveIdlePromptInput};
use crate::domain::window::ForegroundCaptureDiagnostic;
@@ -35,6 +37,7 @@ struct ForegroundSampleState {
last: Option,
diagnostics: Vec,
idle_segment_start_ms: Option,
+ idle_attribution_process_name: Option,
pending_idle_prompts: Vec,
next_idle_prompt_id: i64,
remembered_idle_decision: Option,
@@ -47,6 +50,18 @@ struct ForegroundSnapshot {
captured_at_ms: i64,
}
+fn idle_attribution_candidate(snapshot: Option<&ForegroundSnapshot>) -> Option {
+ let process_name = snapshot.map(|item| normalize_process_key(&item.process_name))?;
+ if process_name == "unknown.exe"
+ || process_name == "lockapp.exe"
+ || process_name == "logonui.exe"
+ || process_name.starts_with("pid-")
+ {
+ return None;
+ }
+ Some(process_name)
+}
+
fn push_foreground_diagnostic(mut entry: ForegroundCaptureDiagnostic) -> Result<(), String> {
entry.observed_window_title = "Hidden for Privacy".to_string();
@@ -151,6 +166,9 @@ pub(crate) fn capture_foreground_once(
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
.map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
+ if state.idle_segment_start_ms.is_none() {
+ state.idle_attribution_process_name = idle_attribution_candidate(state.last.as_ref());
+ }
let seg_start = state.idle_segment_start_ms.get_or_insert(idle_start_ms);
if idle_start_ms < *seg_start {
*seg_start = idle_start_ms;
@@ -177,6 +195,7 @@ pub(crate) fn capture_foreground_once(
.lock()
.map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
if let Some(idle_start_ms) = state.idle_segment_start_ms.take() {
+ let attribution_process_name = state.idle_attribution_process_name.take();
let duration = (now_ms - idle_start_ms).max(0);
if duration >= IDLE_PROMPT_THRESHOLD_MS {
let prompt_id = if state.next_idle_prompt_id <= 0 {
@@ -191,6 +210,7 @@ pub(crate) fn capture_foreground_once(
end_timestamp: (now_ms / 1000).max(0),
duration_ms: duration,
deferred_until_timestamp: None,
+ attribution_process_name,
};
if let Some(remembered) = state.remembered_idle_decision.clone() {
@@ -214,6 +234,8 @@ pub(crate) fn capture_foreground_once(
}
}
}
+ } else {
+ state.idle_attribution_process_name = None;
}
}
@@ -353,8 +375,8 @@ pub(crate) fn resolve_idle_prompt(
) -> Result {
let decision = input.decision.trim().to_uppercase();
let remember_this_session = input.remember_this_session.unwrap_or(false);
- if !matches!(decision.as_str(), "LEARN" | "REST" | "IDLE" | "SKIP") {
- return Err("decision must be LEARN, REST, IDLE, or SKIP".to_string());
+ if !matches!(decision.as_str(), "APP" | "LEARN" | "REST" | "IDLE" | "SKIP") {
+ return Err("decision must be APP, LEARN, REST, IDLE, or SKIP".to_string());
}
if decision == "SKIP" {
@@ -373,6 +395,21 @@ pub(crate) fn resolve_idle_prompt(
return Ok(false);
}
+ if decision == "APP" {
+ let state = FOREGROUND_SAMPLE_STATE
+ .lock()
+ .map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
+ let has_candidate = state
+ .pending_idle_prompts
+ .iter()
+ .find(|item| item.id == input.prompt_id)
+ .and_then(|item| item.attribution_process_name.as_ref())
+ .is_some();
+ if !has_candidate {
+ return Err("idle prompt has no previous-app attribution candidate".to_string());
+ }
+ }
+
let prompt = {
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
@@ -388,9 +425,17 @@ pub(crate) fn resolve_idle_prompt(
return Ok(false);
};
let conn = open_connection(app)?;
- let stored = persist_idle_prompt_decision(&conn, &prompt, &decision)?;
+ let stored = if decision == "APP" {
+ let process_name = prompt
+ .attribution_process_name
+ .as_deref()
+ .ok_or_else(|| "idle prompt has no previous-app attribution candidate".to_string())?;
+ persist_idle_prompt_app_decision(&conn, &prompt, process_name)?
+ } else {
+ persist_idle_prompt_decision(&conn, &prompt, &decision)?
+ };
- if remember_this_session {
+ if remember_this_session && matches!(decision.as_str(), "LEARN" | "REST" | "IDLE") {
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
.map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
@@ -468,7 +513,8 @@ pub(crate) fn list_foreground_capture_diagnostics(
#[cfg(test)]
mod tests {
use super::{
- list_foreground_capture_diagnostics, push_foreground_diagnostic, FOREGROUND_SAMPLE_STATE,
+ idle_attribution_candidate, list_foreground_capture_diagnostics, push_foreground_diagnostic,
+ ForegroundSnapshot, FOREGROUND_SAMPLE_STATE,
};
use crate::domain::window::ForegroundCaptureDiagnostic;
@@ -479,6 +525,32 @@ mod tests {
state.diagnostics.clear();
}
+ #[test]
+ fn idle_attribution_uses_normalized_previous_app_without_title() {
+ let snapshot = ForegroundSnapshot {
+ process_name: r#"C:\Program Files\Microsoft VS Code\Code.exe"#.to_string(),
+ window_title: "Secret project title".to_string(),
+ captured_at_ms: 1,
+ };
+
+ assert_eq!(
+ idle_attribution_candidate(Some(&snapshot)),
+ Some("code.exe".to_string())
+ );
+ }
+
+ #[test]
+ fn idle_attribution_rejects_lock_and_unknown_processes() {
+ for process_name in ["LockApp.exe", "LogonUI.exe", "unknown.exe", "pid-42.exe"] {
+ let snapshot = ForegroundSnapshot {
+ process_name: process_name.to_string(),
+ window_title: "Hidden".to_string(),
+ captured_at_ms: 1,
+ };
+ assert_eq!(idle_attribution_candidate(Some(&snapshot)), None);
+ }
+ }
+
#[test]
fn diagnostics_do_not_expose_raw_window_titles() {
clear_diagnostics();
From bdbdc080918b7053b7fd6eb53552b5fe1d07cba1 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:31:48 +0800
Subject: [PATCH 090/117] test(idle): require app attribution in guard
decisions
---
src/lib/guardIdleAttribution.contract.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 src/lib/guardIdleAttribution.contract.ts
diff --git a/src/lib/guardIdleAttribution.contract.ts b/src/lib/guardIdleAttribution.contract.ts
new file mode 100644
index 0000000..ed7ee4b
--- /dev/null
+++ b/src/lib/guardIdleAttribution.contract.ts
@@ -0,0 +1,13 @@
+import {
+ guardIdleDecisionFeedback,
+ shouldRememberGuardIdleDecision,
+ type GuardIdleDecision,
+} from "./guardIdle";
+
+const decision: GuardIdleDecision = "APP";
+export const rememberAppAttributionContract: boolean = shouldRememberGuardIdleDecision(true, decision);
+export const appAttributionFeedbackContract: string = guardIdleDecisionFeedback(
+ decision,
+ false,
+ (_zh, en) => en,
+).text;
From fe5d44aa411884182f5b55455155ed8ce917c49f Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:33:17 +0800
Subject: [PATCH 091/117] feat(idle): add app attribution guard decision
---
src/lib/guardIdle.ts | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/lib/guardIdle.ts b/src/lib/guardIdle.ts
index aae8ae5..afd295f 100644
--- a/src/lib/guardIdle.ts
+++ b/src/lib/guardIdle.ts
@@ -3,7 +3,7 @@ import type { IdlePrompt } from "../api";
type TranslateFn = (zh: string, en: string) => string;
type GuardFeedbackTone = "info" | "ok" | "warn" | "error";
-export type GuardIdleDecision = "LEARN" | "REST" | "IDLE" | "SKIP";
+export type GuardIdleDecision = "APP" | "LEARN" | "REST" | "IDLE" | "SKIP";
export type GuardIdleDecisionFeedback = {
type: GuardFeedbackTone;
@@ -24,7 +24,7 @@ export function shouldRememberGuardIdleDecision(
rememberChoice: boolean,
decision: GuardIdleDecision,
): boolean {
- return rememberChoice && decision !== "SKIP";
+ return rememberChoice && decision !== "SKIP" && decision !== "APP";
}
export function guardIdleDecisionFeedback(
@@ -32,6 +32,16 @@ export function guardIdleDecisionFeedback(
rememberChoice: boolean,
tx: TranslateFn,
): GuardIdleDecisionFeedback {
+ if (decision === "APP") {
+ return {
+ type: "ok",
+ text: tx(
+ "已将该时段归入无输入前使用的应用。",
+ "This segment is attributed to the app used before the idle period.",
+ ),
+ };
+ }
+
if (decision === "LEARN") {
return {
type: "ok",
From 23f7061203aa2d713a9bffe42b6204d882900016 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:33:58 +0800
Subject: [PATCH 092/117] feat(idle): expose app attribution to view contexts
---
src/components/viewContexts.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/components/viewContexts.ts b/src/components/viewContexts.ts
index 77c7ce3..da71b05 100644
--- a/src/components/viewContexts.ts
+++ b/src/components/viewContexts.ts
@@ -19,7 +19,7 @@ import type { ThemeMode } from "../composables/useThemeMode";
export type TranslateFn = (zh: string, en: string) => string;
export type AllTimeFilter = UsageRootFilter;
export type RuleMappedType = AppRuleMappedType;
-export type IdleDecision = "LEARN" | "REST" | "IDLE" | "SKIP";
+export type IdleDecision = "APP" | "LEARN" | "REST" | "IDLE" | "SKIP";
export type RuleSortKey = "alpha_asc" | "alpha_desc" | "time_desc" | "time_asc";
export type FeedbackTone = "info" | "ok" | "warn" | "error";
@@ -105,6 +105,7 @@ export type IdlePromptBannerContext = {
tx: TranslateFn;
currentIdlePrompt: IdlePrompt;
formatIdlePromptSpan: (item: IdlePrompt) => string;
+ cleanProcessName: (name: string) => string;
idleRememberChoice: boolean;
onIdleRememberChoiceChange: (event: Event) => void;
idleActionLoading: boolean;
From 7c165bed5ec60101103451bcde08ab82b04d0e1a Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:34:41 +0800
Subject: [PATCH 093/117] feat(idle): pass app name formatter to idle banner
---
src/composables/useIdlePromptBannerContext.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/composables/useIdlePromptBannerContext.ts b/src/composables/useIdlePromptBannerContext.ts
index e7f71ac..2d4a068 100644
--- a/src/composables/useIdlePromptBannerContext.ts
+++ b/src/composables/useIdlePromptBannerContext.ts
@@ -9,6 +9,7 @@ type IdlePromptBannerContextOptions = {
tx: TranslateFn;
currentIdlePrompt: Readonly[>;
formatIdlePromptSpan: IdlePromptBannerContext["formatIdlePromptSpan"];
+ cleanProcessName: IdlePromptBannerContext["cleanProcessName"];
idleRememberChoice: Readonly][>;
onIdleRememberChoiceChange: IdlePromptBannerContext["onIdleRememberChoiceChange"];
idleActionLoading: Readonly][>;
@@ -19,6 +20,7 @@ export function useIdlePromptBannerContext({
tx,
currentIdlePrompt,
formatIdlePromptSpan,
+ cleanProcessName,
idleRememberChoice,
onIdleRememberChoiceChange,
idleActionLoading,
@@ -33,6 +35,7 @@ export function useIdlePromptBannerContext({
tx,
currentIdlePrompt: currentIdlePrompt.value,
formatIdlePromptSpan,
+ cleanProcessName,
idleRememberChoice: idleRememberChoice.value,
onIdleRememberChoiceChange,
idleActionLoading: idleActionLoading.value,
From 6558b0237a70ae0184072f13d5e52c3477ce8c6a Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:35:55 +0800
Subject: [PATCH 094/117] refactor(idle): keep banner context minimal
---
src/components/viewContexts.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/components/viewContexts.ts b/src/components/viewContexts.ts
index da71b05..2199b1d 100644
--- a/src/components/viewContexts.ts
+++ b/src/components/viewContexts.ts
@@ -105,7 +105,6 @@ export type IdlePromptBannerContext = {
tx: TranslateFn;
currentIdlePrompt: IdlePrompt;
formatIdlePromptSpan: (item: IdlePrompt) => string;
- cleanProcessName: (name: string) => string;
idleRememberChoice: boolean;
onIdleRememberChoiceChange: (event: Event) => void;
idleActionLoading: boolean;
From d10272832ddfb256b5f77547c452c5cfd647d554 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:36:08 +0800
Subject: [PATCH 095/117] refactor(idle): keep banner formatter local
---
src/composables/useIdlePromptBannerContext.ts | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/composables/useIdlePromptBannerContext.ts b/src/composables/useIdlePromptBannerContext.ts
index 2d4a068..e7f71ac 100644
--- a/src/composables/useIdlePromptBannerContext.ts
+++ b/src/composables/useIdlePromptBannerContext.ts
@@ -9,7 +9,6 @@ type IdlePromptBannerContextOptions = {
tx: TranslateFn;
currentIdlePrompt: Readonly][>;
formatIdlePromptSpan: IdlePromptBannerContext["formatIdlePromptSpan"];
- cleanProcessName: IdlePromptBannerContext["cleanProcessName"];
idleRememberChoice: Readonly][>;
onIdleRememberChoiceChange: IdlePromptBannerContext["onIdleRememberChoiceChange"];
idleActionLoading: Readonly][>;
@@ -20,7 +19,6 @@ export function useIdlePromptBannerContext({
tx,
currentIdlePrompt,
formatIdlePromptSpan,
- cleanProcessName,
idleRememberChoice,
onIdleRememberChoiceChange,
idleActionLoading,
@@ -35,7 +33,6 @@ export function useIdlePromptBannerContext({
tx,
currentIdlePrompt: currentIdlePrompt.value,
formatIdlePromptSpan,
- cleanProcessName,
idleRememberChoice: idleRememberChoice.value,
onIdleRememberChoiceChange,
idleActionLoading: idleActionLoading.value,
From 6ee3ff49973211d716faad1bce44e2e3c7da2080 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:37:04 +0800
Subject: [PATCH 096/117] feat(idle): add progressive idle attribution banner
---
src/components/IdlePromptBanner.vue | 113 +++++++++++++++++++++++++---
1 file changed, 102 insertions(+), 11 deletions(-)
diff --git a/src/components/IdlePromptBanner.vue b/src/components/IdlePromptBanner.vue
index 2a8c8a0..71f22a1 100644
--- a/src/components/IdlePromptBanner.vue
+++ b/src/components/IdlePromptBanner.vue
@@ -1,23 +1,114 @@
]
{{ props.ctx.tx("离开时段待确认", "Idle Segment Confirmation") }}
- {{ props.ctx.tx("系统空闲超过5分钟后触发,已回补完整时段。", "Triggered after 5+ minutes idle; full segment has been backfilled.") }}
+
+ {{
+ props.ctx.tx(
+ "系统只知道这段时间没有键鼠输入,需要你确认实际发生了什么。",
+ "TimePrism only knows there was no keyboard or mouse input; confirm what actually happened.",
+ )
+ }}
+
{{ props.ctx.formatIdlePromptSpan(props.ctx.currentIdlePrompt) }}
-
-
- {{ props.ctx.tx("记住本次选择(后续离开时段自动应用)", "Remember this choice for later idle segments") }}
-
-
- {{ props.ctx.tx("标记为学习", "Mark as Learn") }}
- {{ props.ctx.tx("标记为休息", "Mark as Break") }}
- {{ props.ctx.tx("标记为离开", "Mark as Away") }}
- {{ props.ctx.tx("稍后提醒", "Remind me later") }}
-
+
+
+
+ {{ props.ctx.tx("无输入前正在使用:", "App used before inactivity: ") }}{{ attributionAppName }}
+
+
+ {{
+ props.ctx.tx(
+ `这段时间你还在使用 ${attributionAppName} 吗?`,
+ `Were you still using ${attributionAppName} during this time?`,
+ )
+ }}
+
+
+
+ {{ props.ctx.tx(`继续使用 ${attributionAppName}`, `Continue ${attributionAppName}`) }}
+
+
+ {{ props.ctx.tx("离开电脑", "Away from computer") }}
+
+
+ {{ showOther ? props.ctx.tx("收起", "Less") : props.ctx.tx("其他…", "Other…") }}
+
+
+
+
+
+ {{
+ props.ctx.tx(
+ "如果只确定这段时间属于学习或休息,而不确定是否一直在这个应用里,可以只记录活动类型。",
+ "If you only know this was Learn or Break time, record the activity type without attributing it to the app.",
+ )
+ }}
+
+
+
+ {{ props.ctx.tx("学习", "Learn") }}
+
+
+ {{ props.ctx.tx("休息", "Break") }}
+
+
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
+
+
+
+
+ {{ props.ctx.tx("记住学习 / 休息 / 离开选择(不记住应用归因)", "Remember Learn / Break / Away choices (never app attribution)") }}
+
+
+
+
+
+
+
+
+ {{ props.ctx.tx("学习", "Learn") }}
+
+
+ {{ props.ctx.tx("休息", "Break") }}
+
+
+ {{ props.ctx.tx("离开电脑", "Away from computer") }}
+
+
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
+
+
+
+
+ {{ props.ctx.tx("记住本次选择(后续离开时段自动应用)", "Remember this choice for later idle segments") }}
+
+
+
From acda1d865aa13d2e4174a363bf956589211e7d99 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:37:58 +0800
Subject: [PATCH 097/117] feat(idle): simplify attribution choices in guard
---
src/components/GuardView.vue | 33 ++++++++++++++++++++++++++++++---
1 file changed, 30 insertions(+), 3 deletions(-)
diff --git a/src/components/GuardView.vue b/src/components/GuardView.vue
index e8c8fa2..cf4e544 100644
--- a/src/components/GuardView.vue
+++ b/src/components/GuardView.vue
@@ -67,11 +67,38 @@ const props = defineProps<{ ctx: GuardViewContext }>();
{{ props.ctx.formatIdlePromptSpan(item) }}
-
+
+
+ {{ props.ctx.tx("无输入前:", "Before inactivity: ") }}
+ {{ props.ctx.cleanProcessName(item.attribution_process_name) }}
+
+
+
+
+
+ {{ props.ctx.tx(`继续使用 ${props.ctx.cleanProcessName(item.attribution_process_name)}`, `Continue ${props.ctx.cleanProcessName(item.attribution_process_name)}`) }}
+
+
{{ props.ctx.tx("离开电脑", "Away") }}
+
+ {{ props.ctx.tx("其他…", "Other…") }}
+
+ {{ props.ctx.tx("学习", "Learn") }}
+ {{ props.ctx.tx("休息", "Break") }}
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
+
+
+
+
{{ props.ctx.tx("学习", "Learn") }}
{{ props.ctx.tx("休息", "Break") }}
-
{{ props.ctx.tx("离开", "Away") }}
-
{{ props.ctx.tx("稍后提醒", "Remind later") }}
+
{{ props.ctx.tx("离开电脑", "Away") }}
+
+ {{ props.ctx.tx("其他…", "Other…") }}
+
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
+
{{ props.ctx.tx("暂无待确认离开时段", "No idle segments pending") }}
From 75a40f0e0f4c539d6d91b58981084781a335e420 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:38:22 +0800
Subject: [PATCH 098/117] feat(pet): carry idle attribution candidate
---
src/lib/petPrompts.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/lib/petPrompts.ts b/src/lib/petPrompts.ts
index 6ae1805..7c21294 100644
--- a/src/lib/petPrompts.ts
+++ b/src/lib/petPrompts.ts
@@ -3,6 +3,7 @@ export type IdlePromptLite = {
start_timestamp: number;
end_timestamp: number;
duration_ms: number;
+ attribution_process_name?: string | null;
};
export type PendingRuleProcessLite = {
From 8f72ebbfc41ebb8103272a7e8f174ac595fe6512 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:38:57 +0800
Subject: [PATCH 099/117] feat(pet): simplify idle attribution prompt
---
src/lib/petPromptDescriptors.ts | 66 +++++++++++++++++++++------------
1 file changed, 43 insertions(+), 23 deletions(-)
diff --git a/src/lib/petPromptDescriptors.ts b/src/lib/petPromptDescriptors.ts
index 643b7e2..65991b3 100644
--- a/src/lib/petPromptDescriptors.ts
+++ b/src/lib/petPromptDescriptors.ts
@@ -7,13 +7,14 @@ import type {
} from "./petPrompts";
type TranslateFn = (zh: string, en: string) => string;
-type IdleDecision = "LEARN" | "REST" | "IDLE" | "SKIP";
+type IdleDecision = "APP" | "LEARN" | "REST" | "IDLE" | "SKIP";
type RuleMappedType = AppRuleMappedType;
type PetPromptDescriptorActions = {
completeReminder: (reminder: ReminderLite) => Promise
;
snoozeReminder: (reminder: ReminderLite) => Promise;
resolveIdle: (idle: IdlePromptLite, decision: IdleDecision, promptKey: string) => Promise;
+ openIdleDetails: (idle: IdlePromptLite) => Promise;
saveRule: (processName: string, mappedType: RuleMappedType) => Promise;
postponeRule: (promptKey: string) => Promise;
};
@@ -62,31 +63,50 @@ export function buildPetPromptDescriptors({
for (const idle of idleItems) {
const idleSeconds = Math.max(0, Math.floor(idle.duration_ms / 1000));
const key = `idle-${idle.id}`;
+ const processName = idle.attribution_process_name ?? "";
+ const appName = processName ? cleanProcessName(processName) : "";
+
descriptors.push({
key,
title: tx("离开时段待确认", "Idle Segment Confirmation"),
- detail: tx(
- `持续 ${formatSeconds(idleSeconds)},请尽快归类`,
- `${formatSeconds(idleSeconds)} idle time, please classify`,
- ),
- actions: [
- {
- label: tx("学习", "Learn"),
- run: () => actions.resolveIdle(idle, "LEARN", key),
- },
- {
- label: tx("休息", "Break"),
- run: () => actions.resolveIdle(idle, "REST", key),
- },
- {
- label: tx("离开", "Away"),
- run: () => actions.resolveIdle(idle, "IDLE", key),
- },
- {
- label: tx("稍后提醒", "Remind later"),
- run: () => actions.resolveIdle(idle, "SKIP", key),
- },
- ],
+ detail: appName
+ ? tx(
+ `${formatSeconds(idleSeconds)} 无输入 · 之前在 ${appName}`,
+ `${formatSeconds(idleSeconds)} inactive · previously ${appName}`,
+ )
+ : tx(
+ `持续 ${formatSeconds(idleSeconds)},请选择实际状态`,
+ `${formatSeconds(idleSeconds)} inactive, choose what happened`,
+ ),
+ actions: appName
+ ? [
+ {
+ label: tx(`继续 ${appName}`, `Continue ${appName}`),
+ run: () => actions.resolveIdle(idle, "APP", key),
+ },
+ {
+ label: tx("离开", "Away"),
+ run: () => actions.resolveIdle(idle, "IDLE", key),
+ },
+ {
+ label: tx("其他…", "Other…"),
+ run: () => actions.openIdleDetails(idle),
+ },
+ ]
+ : [
+ {
+ label: tx("学习", "Learn"),
+ run: () => actions.resolveIdle(idle, "LEARN", key),
+ },
+ {
+ label: tx("休息", "Break"),
+ run: () => actions.resolveIdle(idle, "REST", key),
+ },
+ {
+ label: tx("离开", "Away"),
+ run: () => actions.resolveIdle(idle, "IDLE", key),
+ },
+ ],
});
}
From 6e95509ca552768cc5cef018db4356921ae76dde Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:40:53 +0800
Subject: [PATCH 100/117] feat(pet): route idle attribution other choice to
guard
---
src/pet.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/pet.ts b/src/pet.ts
index 9e2db50..533e507 100644
--- a/src/pet.ts
+++ b/src/pet.ts
@@ -264,7 +264,9 @@ async function refreshPromptBubble() {
await invoke("resolve_idle_prompt", {
input: { prompt_id: idle.id, decision, remember_this_session: false },
});
- if (decision === "LEARN") {
+ if (decision === "APP") {
+ setTransientMood(tx("已归入之前使用的应用", "Attributed to previous app"));
+ } else if (decision === "LEARN") {
setTransientMood(tx("已标记为学习", "Marked as Learn"));
} else if (decision === "REST") {
setTransientMood(tx("已标记为休息", "Marked as Break"));
@@ -275,6 +277,11 @@ async function refreshPromptBubble() {
}
await refreshPromptBubble();
},
+ openIdleDetails: async (idle) => {
+ await invoke("show_main_window_section", { section: "guard" });
+ promptSnoozeUntilByKey.set(`idle-${idle.id}`, Date.now() + 60_000);
+ await refreshPromptBubble();
+ },
saveRule: async (processName, mappedType) => {
await invoke("save_app_rule", {
input: { process_name: processName, mapped_type: mappedType, privacy_level: "NORMAL" },
@@ -577,4 +584,3 @@ window.addEventListener("beforeunload", () => {
clearMoodResetTimer();
closeContextMenu();
});
-
From 03e472f4d5b8e46a5d89e085a600aec2b32373e1 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:42:44 +0800
Subject: [PATCH 101/117] test(idle): forbid attribution privacy bypass
---
src-tauri/src/services/usage.rs | 36 +++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index 1ed53eb..f45536c 100644
--- a/src-tauri/src/services/usage.rs
+++ b/src-tauri/src/services/usage.rs
@@ -175,6 +175,11 @@ mod tests {
let conn = Connection::open_in_memory().expect("open in-memory usage db");
conn.execute_batch(
r#"
+ CREATE TABLE app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+
CREATE TABLE app_rules (
process_name TEXT PRIMARY KEY,
mapped_type TEXT NOT NULL,
@@ -183,6 +188,10 @@ mod tests {
updated_at INTEGER NOT NULL DEFAULT 0
);
+ CREATE TABLE app_whitelist (
+ process_name TEXT PRIMARY KEY
+ );
+
CREATE TABLE app_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
process_name TEXT NOT NULL,
@@ -326,4 +335,31 @@ mod tests {
.expect("query app rule");
assert_eq!(mapped_type, "REST");
}
+
+ #[test]
+ fn idle_app_decision_cannot_bypass_curtain_privacy() {
+ let conn = usage_test_conn();
+ conn.execute(
+ "INSERT INTO app_config (key, value) VALUES ('curtain_enabled', 'true')",
+ [],
+ )
+ .expect("enable curtain");
+
+ let prompt = IdlePromptEntry {
+ id: 3,
+ start_timestamp: 300,
+ end_timestamp: 600,
+ duration_ms: 300_000,
+ deferred_until_timestamp: None,
+ attribution_process_name: Some("code.exe".to_string()),
+ };
+
+ let result = persist_idle_prompt_app_decision(&conn, &prompt, "code.exe");
+ assert!(result.is_err(), "privacy curtain must block app attribution");
+
+ let count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM app_usage_logs", [], |row| row.get(0))
+ .expect("count usage rows");
+ assert_eq!(count, 0);
+ }
}
From b17e63360b79ca9b4beaffa4b4cfd12edd388450 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:48:54 +0800
Subject: [PATCH 102/117] fix(idle): enforce privacy before app attribution
---
src-tauri/src/services/usage.rs | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index f45536c..4ca5741 100644
--- a/src-tauri/src/services/usage.rs
+++ b/src-tauri/src/services/usage.rs
@@ -133,11 +133,20 @@ pub(crate) fn persist_idle_prompt_app_decision(
prompt: &IdlePromptEntry,
process_name: &str,
) -> Result {
+ let (processed, block_reason) =
+ process_log_with_privacy(conn, process_name, "Idle Confirmed · Previous App")?;
+ let Some((safe_process_name, safe_window_title)) = processed else {
+ let reason = block_reason.unwrap_or_else(|| "privacy_policy".to_string());
+ return Err(format!(
+ "idle app attribution blocked by privacy policy: {reason}"
+ ));
+ };
+
replace_idle_interval_with_usage(
conn,
prompt,
- process_name,
- "Idle Confirmed · Previous App",
+ &safe_process_name,
+ &safe_window_title,
None,
)
}
From b5791e16b047b63332ef36507d4421d9e2d9ed8b Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:51:34 +0800
Subject: [PATCH 103/117] fix(idle): restore prompt when attribution
persistence fails
---
src-tauri/src/services/foreground.rs | 53 +++++++++++++++-------------
1 file changed, 29 insertions(+), 24 deletions(-)
diff --git a/src-tauri/src/services/foreground.rs b/src-tauri/src/services/foreground.rs
index 7874d16..99eb59d 100644
--- a/src-tauri/src/services/foreground.rs
+++ b/src-tauri/src/services/foreground.rs
@@ -395,21 +395,6 @@ pub(crate) fn resolve_idle_prompt(
return Ok(false);
}
- if decision == "APP" {
- let state = FOREGROUND_SAMPLE_STATE
- .lock()
- .map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
- let has_candidate = state
- .pending_idle_prompts
- .iter()
- .find(|item| item.id == input.prompt_id)
- .and_then(|item| item.attribution_process_name.as_ref())
- .is_some();
- if !has_candidate {
- return Err("idle prompt has no previous-app attribution candidate".to_string());
- }
- }
-
let prompt = {
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
@@ -424,15 +409,35 @@ pub(crate) fn resolve_idle_prompt(
let Some(prompt) = prompt else {
return Ok(false);
};
- let conn = open_connection(app)?;
- let stored = if decision == "APP" {
- let process_name = prompt
- .attribution_process_name
- .as_deref()
- .ok_or_else(|| "idle prompt has no previous-app attribution candidate".to_string())?;
- persist_idle_prompt_app_decision(&conn, &prompt, process_name)?
- } else {
- persist_idle_prompt_decision(&conn, &prompt, &decision)?
+
+ let persist_result = (|| -> Result {
+ let conn = open_connection(app)?;
+ if decision == "APP" {
+ let process_name = prompt
+ .attribution_process_name
+ .as_deref()
+ .ok_or_else(|| "idle prompt has no previous-app attribution candidate".to_string())?;
+ persist_idle_prompt_app_decision(&conn, &prompt, process_name)
+ } else {
+ persist_idle_prompt_decision(&conn, &prompt, &decision)
+ }
+ })();
+
+ let stored = match persist_result {
+ Ok(stored) => stored,
+ Err(err) => {
+ let mut state = FOREGROUND_SAMPLE_STATE
+ .lock()
+ .map_err(|e| format!("failed to restore idle prompt after persistence failure: {e}"))?;
+ if !state.pending_idle_prompts.iter().any(|item| item.id == prompt.id) {
+ state.pending_idle_prompts.push(prompt);
+ if state.pending_idle_prompts.len() > 20 {
+ let drop_count = state.pending_idle_prompts.len() - 20;
+ state.pending_idle_prompts.drain(0..drop_count);
+ }
+ }
+ return Err(err);
+ }
};
if remember_this_session && matches!(decision.as_str(), "LEARN" | "REST" | "IDLE") {
From 3ba2267fc2415dfd33f3ad2e1e85e235f34d8ee2 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:54:47 +0800
Subject: [PATCH 104/117] docs(ui): explain idle app attribution semantics
---
src/components/IdlePromptBanner.vue | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/components/IdlePromptBanner.vue b/src/components/IdlePromptBanner.vue
index 71f22a1..7379c44 100644
--- a/src/components/IdlePromptBanner.vue
+++ b/src/components/IdlePromptBanner.vue
@@ -47,6 +47,14 @@ watch(
)
}}
+
+ {{
+ props.ctx.tx(
+ `选择“继续使用 ${attributionAppName}”会把整段归入该应用,并沿用它现有的学习 / 休息 / 未分类规则。`,
+ `Choosing “Continue ${attributionAppName}” attributes the whole segment to that app and keeps its existing Learn / Break / Unclassified rule.`,
+ )
+ }}
+
{{ props.ctx.tx(`继续使用 ${attributionAppName}`, `Continue ${attributionAppName}`) }}
From 4712ee475773a7513c4a60b8ab2802d9dfc84f45 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:55:35 +0800
Subject: [PATCH 105/117] docs: add idle attribution smoke coverage
---
docs/SMOKE_TESTS.md | 44 +++++++++++++++++++++++++++++---------------
1 file changed, 29 insertions(+), 15 deletions(-)
diff --git a/docs/SMOKE_TESTS.md b/docs/SMOKE_TESTS.md
index f6203d8..05d8a54 100644
--- a/docs/SMOKE_TESTS.md
+++ b/docs/SMOKE_TESTS.md
@@ -127,22 +127,29 @@ Expected:
- Due text is understandable.
- Pet can see due reminders when due.
-## S-500 Guard
+## S-500 Guard And Idle Attribution
1. Open Focus Guard.
2. Confirm pending apps list renders.
-3. Save a pending app as Learn.
-4. Save a pending app as Break.
-5. Save a pending app as Unclassified.
-6. Resolve an idle prompt as Learn/Break/Away if one exists.
-7. Defer an idle prompt if one exists.
-8. Search existing rules.
-9. Edit a rule and save.
-10. Review diagnostics.
+3. Save a pending app as Learn, another as Break, and another as Unclassified when available.
+4. Create or encounter an idle segment after spending time in a known foreground app.
+5. When a previous-app candidate exists, confirm the prompt shows only the app/process identity and never the previous raw window title.
+6. Confirm the compact choice set is `Continue `, `Away`, and `Other…` rather than five peer actions.
+7. Choose `Continue ` and confirm the replacement interval is attributed to that process while its existing Learn/Break/Unclassified rule is unchanged.
+8. Confirm classification semantics remain separate from attribution: a Learn/Break app contributes to the corresponding pet counter; an Unclassified app can gain usage time without increasing pet `学 / 休`.
+9. Create another candidate idle segment, open `Other…`, and confirm Learn, Break, and Decide later remain available as semantic fallbacks.
+10. Confirm app attribution is never remembered for later idle segments even when the remember checkbox is enabled for Learn/Break/Away decisions.
+11. When no trustworthy previous-app candidate exists, confirm the prompt falls back directly to Learn / Break / Away with Decide later secondary.
+12. Resolve an idle prompt as Away and defer another prompt if available.
+13. Search existing rules, edit a rule, and save.
+14. Review diagnostics.
Expected:
- Rule changes persist.
+- `Continue ` changes interval attribution without rewriting that app's classification rule.
+- Learn/Break/Away fallback remains available without requiring app attribution.
+- App attribution is explicit per interval and is never auto-remembered.
- Guard steps unlock correctly.
- Diagnostics show stored/blocked status.
@@ -158,10 +165,14 @@ Expected:
8. Confirm non-whitelisted processes do not store normal logs.
9. Add a process to whitelist.
10. Confirm whitelisted process can be stored.
+11. With an idle prompt that offers previous-app attribution, enable the privacy curtain (or make the candidate fail whitelist-only policy) before choosing `Continue `.
+12. Confirm attribution is rejected rather than bypassing privacy, and confirm the idle prompt remains available so it can still be resolved as Learn / Break / Away.
Expected:
-- Privacy behavior affects future storage.
+- Privacy behavior affects future storage and user-confirmed app attribution.
+- App attribution never bypasses curtain or whitelist policy.
+- A privacy rejection does not silently discard the unresolved idle prompt.
- Diagnostics do not leak sensitive raw titles.
## S-700 Insights
@@ -184,16 +195,19 @@ Expected:
1. Summon pet.
2. Drag pet.
3. Dock near left or right screen edge.
-4. Right-click pet and open context menu.
-5. Open main window from pet.
-6. Hide pet.
-7. Summon pet again.
-8. Close pet.
+4. When an idle prompt has a previous-app candidate, confirm the pet bubble offers `Continue `, `Away`, and `Other…` only.
+5. Choose `Other…` and confirm TimePrism opens Focus Guard for the full fallback choices instead of expanding a dense five-button pet prompt.
+6. Right-click pet and open context menu.
+7. Open main window from pet.
+8. Hide pet.
+9. Summon pet again.
+10. Close pet.
Expected:
- Pet appears quickly.
- Pet window remains transparent/frameless.
+- Idle confirmation stays compact on the pet surface.
- Context menu actions work.
- No taskbar pollution.
From 4f9da17ac8f4cedbcff407e529d00c8a09068df5 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 20:59:34 +0800
Subject: [PATCH 106/117] fix(idle): anchor attribution to last user input
---
src-tauri/src/services/foreground.rs | 121 ++++++++++++++++++++++-----
1 file changed, 100 insertions(+), 21 deletions(-)
diff --git a/src-tauri/src/services/foreground.rs b/src-tauri/src/services/foreground.rs
index 99eb59d..42135bf 100644
--- a/src-tauri/src/services/foreground.rs
+++ b/src-tauri/src/services/foreground.rs
@@ -19,7 +19,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId,
};
-use super::privacy::normalize_process_key;
+use super::privacy::{normalize_process_key, process_log_with_privacy};
use super::rules::resolve_rule_mapping;
use super::usage::{
append_usage_log_record, persist_idle_prompt_app_decision, persist_idle_prompt_decision,
@@ -38,6 +38,8 @@ struct ForegroundSampleState {
diagnostics: Vec,
idle_segment_start_ms: Option,
idle_attribution_process_name: Option,
+ last_input_at_ms: Option,
+ last_input_attribution_process_name: Option,
pending_idle_prompts: Vec,
next_idle_prompt_id: i64,
remembered_idle_decision: Option,
@@ -50,18 +52,42 @@ struct ForegroundSnapshot {
captured_at_ms: i64,
}
-fn idle_attribution_candidate(snapshot: Option<&ForegroundSnapshot>) -> Option {
- let process_name = snapshot.map(|item| normalize_process_key(&item.process_name))?;
+fn idle_attribution_candidate(process_name: &str) -> Option {
+ let process_name = normalize_process_key(process_name);
if process_name == "unknown.exe"
|| process_name == "lockapp.exe"
|| process_name == "logonui.exe"
+ || process_name == "desktop.shell.exe"
+ || process_name == "timeprism.exe"
|| process_name.starts_with("pid-")
+ || process_name.starts_with("__idle")
{
return None;
}
Some(process_name)
}
+fn should_refresh_idle_attribution(
+ state: &ForegroundSampleState,
+ observed_input_at_ms: i64,
+) -> bool {
+ state
+ .last_input_at_ms
+ .map(|last_input_at_ms| observed_input_at_ms > last_input_at_ms)
+ .unwrap_or(true)
+}
+
+fn update_idle_attribution_on_input(
+ state: &mut ForegroundSampleState,
+ observed_input_at_ms: i64,
+ candidate: Option,
+) {
+ if should_refresh_idle_attribution(state, observed_input_at_ms) {
+ state.last_input_at_ms = Some(observed_input_at_ms);
+ state.last_input_attribution_process_name = candidate;
+ }
+}
+
fn push_foreground_diagnostic(mut entry: ForegroundCaptureDiagnostic) -> Result<(), String> {
entry.observed_window_title = "Hidden for Privacy".to_string();
@@ -159,6 +185,7 @@ pub(crate) fn capture_foreground_once(
) -> Result {
let now_ms = Local::now().timestamp_millis();
let idle_ms = current_idle_millis().unwrap_or(0).max(0);
+ let observed_input_at_ms = (now_ms - idle_ms).max(0);
if idle_ms >= IDLE_PROMPT_THRESHOLD_MS {
let idle_start_ms = now_ms - idle_ms;
@@ -167,7 +194,8 @@ pub(crate) fn capture_foreground_once(
.lock()
.map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
if state.idle_segment_start_ms.is_none() {
- state.idle_attribution_process_name = idle_attribution_candidate(state.last.as_ref());
+ state.idle_attribution_process_name =
+ state.last_input_attribution_process_name.clone();
}
let seg_start = state.idle_segment_start_ms.get_or_insert(idle_start_ms);
if idle_start_ms < *seg_start {
@@ -244,6 +272,7 @@ pub(crate) fn capture_foreground_once(
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
.map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
+ update_idle_attribution_on_input(&mut state, observed_input_at_ms, None);
state.last = None;
}
let diag = ForegroundCaptureDiagnostic {
@@ -266,6 +295,25 @@ pub(crate) fn capture_foreground_once(
captured_at_ms: now_ms,
};
+ let should_refresh_candidate = {
+ let state = FOREGROUND_SAMPLE_STATE
+ .lock()
+ .map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
+ should_refresh_idle_attribution(&state, observed_input_at_ms)
+ };
+ if should_refresh_candidate {
+ let conn = open_connection(app)?;
+ let (processed, _) =
+ process_log_with_privacy(&conn, ¤t.process_name, ¤t.window_title)?;
+ let candidate = processed
+ .as_ref()
+ .and_then(|(safe_process_name, _)| idle_attribution_candidate(safe_process_name));
+ let mut state = FOREGROUND_SAMPLE_STATE
+ .lock()
+ .map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
+ update_idle_attribution_on_input(&mut state, observed_input_at_ms, candidate);
+ }
+
let previous = {
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
@@ -519,7 +567,8 @@ pub(crate) fn list_foreground_capture_diagnostics(
mod tests {
use super::{
idle_attribution_candidate, list_foreground_capture_diagnostics, push_foreground_diagnostic,
- ForegroundSnapshot, FOREGROUND_SAMPLE_STATE,
+ update_idle_attribution_on_input, ForegroundSampleState, ForegroundSnapshot,
+ FOREGROUND_SAMPLE_STATE,
};
use crate::domain::window::ForegroundCaptureDiagnostic;
@@ -531,31 +580,61 @@ mod tests {
}
#[test]
- fn idle_attribution_uses_normalized_previous_app_without_title() {
- let snapshot = ForegroundSnapshot {
- process_name: r#"C:\Program Files\Microsoft VS Code\Code.exe"#.to_string(),
- window_title: "Secret project title".to_string(),
- captured_at_ms: 1,
- };
-
+ fn idle_attribution_normalizes_safe_process_identity() {
assert_eq!(
- idle_attribution_candidate(Some(&snapshot)),
+ idle_attribution_candidate(r#"C:\Program Files\Microsoft VS Code\Code.exe"#),
Some("code.exe".to_string())
);
}
#[test]
- fn idle_attribution_rejects_lock_and_unknown_processes() {
- for process_name in ["LockApp.exe", "LogonUI.exe", "unknown.exe", "pid-42.exe"] {
- let snapshot = ForegroundSnapshot {
- process_name: process_name.to_string(),
- window_title: "Hidden".to_string(),
- captured_at_ms: 1,
- };
- assert_eq!(idle_attribution_candidate(Some(&snapshot)), None);
+ fn idle_attribution_rejects_system_self_and_virtual_processes() {
+ for process_name in [
+ "LockApp.exe",
+ "LogonUI.exe",
+ "unknown.exe",
+ "pid-42.exe",
+ "desktop.shell.exe",
+ "timeprism.exe",
+ "__idle_learn__.exe",
+ ] {
+ assert_eq!(idle_attribution_candidate(process_name), None);
}
}
+ #[test]
+ fn idle_attribution_changes_only_when_last_input_timestamp_advances() {
+ let mut state = ForegroundSampleState::default();
+
+ update_idle_attribution_on_input(
+ &mut state,
+ 1_000,
+ Some("code.exe".to_string()),
+ );
+ update_idle_attribution_on_input(
+ &mut state,
+ 1_000,
+ Some("spotify.exe".to_string()),
+ );
+ assert_eq!(
+ state.last_input_attribution_process_name.as_deref(),
+ Some("code.exe")
+ );
+
+ update_idle_attribution_on_input(
+ &mut state,
+ 2_000,
+ Some("msedge.exe".to_string()),
+ );
+ assert_eq!(
+ state.last_input_attribution_process_name.as_deref(),
+ Some("msedge.exe")
+ );
+
+ update_idle_attribution_on_input(&mut state, 3_000, None);
+ assert_eq!(state.last_input_attribution_process_name, None);
+ }
+
#[test]
fn diagnostics_do_not_expose_raw_window_titles() {
clear_diagnostics();
From 19320df14c3e36b977f3abc6e87715303bb722de Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Fri, 11 Sep 2026 21:57:44 +0800
Subject: [PATCH 107/117] Track idle attribution provenance
Fold the approved compact Idle attribution behavior and explicit usage provenance into the hardening branch after Windows CI #137 passed with 52 Rust tests.
---
src-tauri/src/db/migrations.rs | 50 ++++++-
src-tauri/src/db/mod.rs | 2 +
src-tauri/src/db/provenance_tests.rs | 121 ++++++++++++++++
src-tauri/src/services/mod.rs | 2 +
src-tauri/src/services/usage.rs | 38 ++++-
.../src/services/usage_provenance_tests.rs | 135 ++++++++++++++++++
src/components/GuardView.vue | 49 +++++--
src/components/IdlePromptBanner.vue | 40 +++++-
8 files changed, 411 insertions(+), 26 deletions(-)
create mode 100644 src-tauri/src/db/provenance_tests.rs
create mode 100644 src-tauri/src/services/usage_provenance_tests.rs
diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs
index 1d14263..0cab093 100644
--- a/src-tauri/src/db/migrations.rs
+++ b/src-tauri/src/db/migrations.rs
@@ -52,6 +52,51 @@ fn ensure_app_rules_time_columns(conn: &Connection) -> Result<(), String> {
Ok(())
}
+fn ensure_app_usage_log_source(conn: &Connection) -> Result<(), String> {
+ let mut stmt = conn
+ .prepare("PRAGMA table_info(app_usage_logs)")
+ .map_err(|e| format!("failed to prepare app_usage_logs table info query: {e}"))?;
+ let rows = stmt
+ .query_map([], |row| row.get::<_, String>(1))
+ .map_err(|e| format!("failed to query app_usage_logs table info rows: {e}"))?;
+
+ let mut has_source = false;
+ for row in rows {
+ let column =
+ row.map_err(|e| format!("failed to parse app_usage_logs table info row: {e}"))?;
+ if column == "source" {
+ has_source = true;
+ break;
+ }
+ }
+
+ if has_source {
+ return Ok(());
+ }
+
+ conn.execute(
+ "ALTER TABLE app_usage_logs
+ ADD COLUMN source TEXT NOT NULL DEFAULT 'FOREGROUND'
+ CHECK(source IN ('FOREGROUND', 'IDLE_CONFIRMED'))",
+ [],
+ )
+ .map_err(|e| format!("failed to add source to app_usage_logs: {e}"))?;
+
+ conn.execute(
+ "UPDATE app_usage_logs
+ SET source = 'IDLE_CONFIRMED'
+ WHERE source = 'FOREGROUND'
+ AND (
+ process_name IN ('__idle_learn__.exe', '__idle_rest__.exe', '__idle__.exe')
+ OR window_title = 'Idle Confirmed · Previous App'
+ )",
+ [],
+ )
+ .map_err(|e| format!("failed to backfill idle-confirmed usage provenance: {e}"))?;
+
+ Ok(())
+}
+
fn ensure_reminders_weekly_columns(conn: &Connection) -> Result<(), String> {
let mut stmt = conn
.prepare("PRAGMA table_info(reminders)")
@@ -217,7 +262,9 @@ pub(super) fn initialize_connection(conn: &Connection) -> Result<(), String> {
process_name TEXT NOT NULL,
window_title TEXT NOT NULL,
start_timestamp INTEGER NOT NULL,
- duration_ms INTEGER NOT NULL
+ duration_ms INTEGER NOT NULL,
+ source TEXT NOT NULL DEFAULT 'FOREGROUND'
+ CHECK(source IN ('FOREGROUND', 'IDLE_CONFIRMED'))
);
CREATE TABLE IF NOT EXISTS app_rules (
@@ -261,6 +308,7 @@ pub(super) fn initialize_connection(conn: &Connection) -> Result<(), String> {
.map_err(|e| format!("failed to run schema init: {e}"))?;
ensure_app_rules_time_columns(conn)?;
+ ensure_app_usage_log_source(conn)?;
ensure_heatmap_snapshot_table(conn)?;
ensure_reminders_weekly_columns(conn)?;
ensure_reminders_sort_order(conn)?;
diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs
index 45fc809..f2fa094 100644
--- a/src-tauri/src/db/mod.rs
+++ b/src-tauri/src/db/mod.rs
@@ -1,5 +1,7 @@
pub mod connection;
pub mod migrations;
+#[cfg(test)]
+mod provenance_tests;
#[cfg(test)]
mod tests;
diff --git a/src-tauri/src/db/provenance_tests.rs b/src-tauri/src/db/provenance_tests.rs
new file mode 100644
index 0000000..540c9c6
--- /dev/null
+++ b/src-tauri/src/db/provenance_tests.rs
@@ -0,0 +1,121 @@
+use rusqlite::Connection;
+
+fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
+ let mut stmt = conn
+ .prepare(&format!("PRAGMA table_info({table})"))
+ .expect("prepare table info");
+ let has_column = stmt
+ .query_map([], |row| row.get::<_, String>(1))
+ .expect("query table info")
+ .map(|row| row.expect("read column name"))
+ .any(|name| name == column);
+ has_column
+}
+
+#[test]
+fn fresh_usage_schema_records_foreground_source_by_default() {
+ let conn = Connection::open_in_memory().expect("open in-memory db");
+ super::migrations::initialize_connection(&conn).expect("initialize fresh database");
+
+ assert!(column_exists(&conn, "app_usage_logs", "source"));
+
+ conn.execute(
+ "INSERT INTO app_usage_logs (process_name, window_title, start_timestamp, duration_ms)
+ VALUES ('code.exe', 'Project', 100, 5000)",
+ [],
+ )
+ .expect("insert usage row with schema default");
+
+ let source: String = conn
+ .query_row(
+ "SELECT source FROM app_usage_logs WHERE process_name = 'code.exe'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read default source");
+ assert_eq!(source, "FOREGROUND");
+}
+
+#[test]
+fn legacy_usage_rows_gain_source_and_known_idle_rows_are_backfilled() {
+ let conn = Connection::open_in_memory().expect("open in-memory db");
+ conn.execute_batch(
+ r#"
+ CREATE TABLE app_usage_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ process_name TEXT NOT NULL,
+ window_title TEXT NOT NULL,
+ start_timestamp INTEGER NOT NULL,
+ duration_ms INTEGER NOT NULL
+ );
+ INSERT INTO app_usage_logs (process_name, window_title, start_timestamp, duration_ms)
+ VALUES
+ ('code.exe', 'Project', 100, 5000),
+ ('__idle_learn__.exe', 'Idle Segment · Learn', 200, 5000),
+ ('code.exe', 'Idle Confirmed · Previous App', 300, 5000);
+ "#,
+ )
+ .expect("create pre-provenance usage schema");
+
+ super::migrations::initialize_connection(&conn).expect("upgrade database");
+
+ assert!(column_exists(&conn, "app_usage_logs", "source"));
+
+ let mut stmt = conn
+ .prepare(
+ "SELECT process_name, window_title, source
+ FROM app_usage_logs
+ ORDER BY id",
+ )
+ .expect("prepare migrated usage query");
+ let rows = stmt
+ .query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ ))
+ })
+ .expect("query migrated usage rows")
+ .collect::, _>>()
+ .expect("collect migrated usage rows");
+
+ assert_eq!(rows[0].2, "FOREGROUND");
+ assert_eq!(rows[1].2, "IDLE_CONFIRMED");
+ assert_eq!(rows[2].2, "IDLE_CONFIRMED");
+}
+
+#[test]
+fn initialized_provenance_schema_is_not_reclassified_on_reopen() {
+ let conn = Connection::open_in_memory().expect("open in-memory db");
+ conn.execute_batch(
+ r#"
+ CREATE TABLE app_usage_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ process_name TEXT NOT NULL,
+ window_title TEXT NOT NULL,
+ start_timestamp INTEGER NOT NULL,
+ duration_ms INTEGER NOT NULL,
+ source TEXT NOT NULL DEFAULT 'FOREGROUND'
+ CHECK(source IN ('FOREGROUND', 'IDLE_CONFIRMED'))
+ );
+ INSERT INTO app_usage_logs (
+ process_name, window_title, start_timestamp, duration_ms, source
+ ) VALUES (
+ '__idle_learn__.exe', 'Imported Legacy Label', 400, 5000, 'FOREGROUND'
+ );
+ "#,
+ )
+ .expect("create already-migrated provenance schema");
+
+ super::migrations::initialize_connection(&conn).expect("reinitialize database");
+
+ let source: String = conn
+ .query_row(
+ "SELECT source FROM app_usage_logs WHERE start_timestamp = 400",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read preserved source");
+ assert_eq!(source, "FOREGROUND");
+}
diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs
index 278effe..07aa2fc 100644
--- a/src-tauri/src/services/mod.rs
+++ b/src-tauri/src/services/mod.rs
@@ -15,5 +15,7 @@ pub mod usage;
pub mod window;
pub mod window_factory;
+#[cfg(test)]
+mod usage_provenance_tests;
#[cfg(test)]
mod window_config_tests;
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index 4ca5741..0d16abe 100644
--- a/src-tauri/src/services/usage.rs
+++ b/src-tauri/src/services/usage.rs
@@ -4,18 +4,26 @@ use super::privacy::{normalize_process_key, process_log_with_privacy};
use super::rules::upsert_app_rule_entry;
use crate::domain::idle::IdlePromptEntry;
+const SOURCE_FOREGROUND: &str = "FOREGROUND";
+const SOURCE_IDLE_CONFIRMED: &str = "IDLE_CONFIRMED";
+
fn append_usage_log_direct(
conn: &Connection,
process_name: &str,
window_title: &str,
start_timestamp: i64,
duration_ms: i64,
+ source: &str,
) -> Result {
+ if !matches!(source, SOURCE_FOREGROUND | SOURCE_IDLE_CONFIRMED) {
+ return Err(format!("invalid app usage source: {source}"));
+ }
+
let span = duration_ms.max(0);
let last_row = conn
.query_row(
- "SELECT id, process_name, window_title, start_timestamp, duration_ms
+ "SELECT id, process_name, window_title, start_timestamp, duration_ms, source
FROM app_usage_logs
ORDER BY id DESC
LIMIT 1",
@@ -27,14 +35,25 @@ fn append_usage_log_direct(
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
+ row.get::<_, String>(5)?,
))
},
)
.ok();
- if let Some((last_id, last_process, last_title, last_start, last_duration_ms)) = last_row {
+ if let Some((
+ last_id,
+ last_process,
+ last_title,
+ last_start,
+ last_duration_ms,
+ last_source,
+ )) = last_row
+ {
let last_end = last_start + (last_duration_ms.max(0) / 1000);
- let is_same_signature = last_process == process_name && last_title == window_title;
+ let is_same_signature = last_process == process_name
+ && last_title == window_title
+ && last_source == source;
let is_contiguous = start_timestamp >= last_start && start_timestamp <= last_end + 2;
if is_same_signature && is_contiguous {
@@ -50,9 +69,10 @@ fn append_usage_log_direct(
}
conn.execute(
- "INSERT INTO app_usage_logs (process_name, window_title, start_timestamp, duration_ms)
- VALUES (?1, ?2, ?3, ?4)",
- params![process_name, window_title, start_timestamp, span],
+ "INSERT INTO app_usage_logs (
+ process_name, window_title, start_timestamp, duration_ms, source
+ ) VALUES (?1, ?2, ?3, ?4, ?5)",
+ params![process_name, window_title, start_timestamp, span, source],
)
.map_err(|e| format!("failed to append app usage log: {e}"))?;
@@ -106,6 +126,7 @@ fn replace_idle_interval_with_usage(
title,
prompt.start_timestamp,
prompt.duration_ms,
+ SOURCE_IDLE_CONFIRMED,
)?;
tx.commit()
@@ -169,6 +190,7 @@ pub(crate) fn append_usage_log_record(
&safe_window_title,
start_timestamp,
duration_ms,
+ SOURCE_FOREGROUND,
)?;
Ok((stored, block_reason))
}
@@ -206,7 +228,9 @@ mod tests {
process_name TEXT NOT NULL,
window_title TEXT NOT NULL,
start_timestamp INTEGER NOT NULL,
- duration_ms INTEGER NOT NULL
+ duration_ms INTEGER NOT NULL,
+ source TEXT NOT NULL DEFAULT 'FOREGROUND'
+ CHECK(source IN ('FOREGROUND', 'IDLE_CONFIRMED'))
);
"#,
)
diff --git a/src-tauri/src/services/usage_provenance_tests.rs b/src-tauri/src/services/usage_provenance_tests.rs
new file mode 100644
index 0000000..d80265a
--- /dev/null
+++ b/src-tauri/src/services/usage_provenance_tests.rs
@@ -0,0 +1,135 @@
+use rusqlite::{params, Connection};
+
+use super::usage::{
+ append_usage_log_record, persist_idle_prompt_app_decision, persist_idle_prompt_decision,
+};
+use crate::domain::idle::IdlePromptEntry;
+
+fn provenance_test_conn() -> Connection {
+ let conn = Connection::open_in_memory().expect("open in-memory provenance db");
+ conn.execute_batch(
+ r#"
+ CREATE TABLE app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+ CREATE TABLE app_rules (
+ process_name TEXT PRIMARY KEY,
+ mapped_type TEXT NOT NULL,
+ privacy_level TEXT NOT NULL,
+ created_at INTEGER NOT NULL DEFAULT 0,
+ updated_at INTEGER NOT NULL DEFAULT 0
+ );
+ CREATE TABLE app_whitelist (
+ process_name TEXT PRIMARY KEY
+ );
+ CREATE TABLE app_usage_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ process_name TEXT NOT NULL,
+ window_title TEXT NOT NULL,
+ start_timestamp INTEGER NOT NULL,
+ duration_ms INTEGER NOT NULL,
+ source TEXT NOT NULL DEFAULT 'FOREGROUND'
+ CHECK(source IN ('FOREGROUND', 'IDLE_CONFIRMED'))
+ );
+ "#,
+ )
+ .expect("create provenance test schema");
+ conn
+}
+
+#[test]
+fn generic_idle_decision_is_marked_idle_confirmed() {
+ let conn = provenance_test_conn();
+ let prompt = IdlePromptEntry {
+ id: 1,
+ start_timestamp: 300,
+ end_timestamp: 600,
+ duration_ms: 300_000,
+ deferred_until_timestamp: None,
+ attribution_process_name: None,
+ };
+
+ persist_idle_prompt_decision(&conn, &prompt, "LEARN").expect("persist idle decision");
+
+ let source: String = conn
+ .query_row(
+ "SELECT source FROM app_usage_logs WHERE start_timestamp = 300",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read idle source");
+ assert_eq!(source, "IDLE_CONFIRMED");
+}
+
+#[test]
+fn app_attributed_idle_decision_is_marked_idle_confirmed() {
+ let conn = provenance_test_conn();
+ conn.execute(
+ "INSERT INTO app_rules (process_name, mapped_type, privacy_level, created_at, updated_at)
+ VALUES ('code.exe', 'LEARN', 'NORMAL', 1, 1)",
+ [],
+ )
+ .expect("seed app rule");
+ conn.execute(
+ "INSERT INTO app_usage_logs (
+ process_name, window_title, start_timestamp, duration_ms, source
+ ) VALUES (?1, ?2, ?3, ?4, 'FOREGROUND')",
+ params!["code.exe", "Project", 100_i64, 400_000_i64],
+ )
+ .expect("seed foreground segment");
+
+ let prompt = IdlePromptEntry {
+ id: 2,
+ start_timestamp: 300,
+ end_timestamp: 600,
+ duration_ms: 300_000,
+ deferred_until_timestamp: None,
+ attribution_process_name: Some("code.exe".to_string()),
+ };
+
+ persist_idle_prompt_app_decision(&conn, &prompt, "code.exe")
+ .expect("persist app-attributed idle decision");
+
+ let source: String = conn
+ .query_row(
+ "SELECT source FROM app_usage_logs WHERE start_timestamp = 300",
+ [],
+ |row| row.get(0),
+ )
+ .expect("read attributed idle source");
+ assert_eq!(source, "IDLE_CONFIRMED");
+}
+
+#[test]
+fn foreground_append_does_not_merge_into_idle_confirmed_segment() {
+ let conn = provenance_test_conn();
+ conn.execute(
+ "INSERT INTO app_usage_logs (
+ process_name, window_title, start_timestamp, duration_ms, source
+ ) VALUES ('code.exe', 'Project', 100, 5000, 'IDLE_CONFIRMED')",
+ [],
+ )
+ .expect("seed user-confirmed segment");
+
+ append_usage_log_record(&conn, "code.exe", "Project", 105, 5000)
+ .expect("append foreground segment");
+
+ let rows = conn
+ .prepare("SELECT source, duration_ms FROM app_usage_logs ORDER BY id")
+ .expect("prepare provenance rows")
+ .query_map([], |row| {
+ Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
+ })
+ .expect("query provenance rows")
+ .collect::, _>>()
+ .expect("collect provenance rows");
+
+ assert_eq!(
+ rows,
+ vec![
+ ("IDLE_CONFIRMED".to_string(), 5000),
+ ("FOREGROUND".to_string(), 5000),
+ ]
+ );
+}
diff --git a/src/components/GuardView.vue b/src/components/GuardView.vue
index cf4e544..1779dfb 100644
--- a/src/components/GuardView.vue
+++ b/src/components/GuardView.vue
@@ -84,22 +84,19 @@ const props = defineProps<{ ctx: GuardViewContext }>();
{{ props.ctx.tx("学习", "Learn") }}
{{ props.ctx.tx("休息", "Break") }}
- {{ props.ctx.tx("稍后处理", "Decide later") }}
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
-
-
{{ props.ctx.tx("学习", "Learn") }}
-
{{ props.ctx.tx("休息", "Break") }}
-
{{ props.ctx.tx("离开电脑", "Away") }}
-
- {{ props.ctx.tx("其他…", "Other…") }}
-
- {{ props.ctx.tx("稍后处理", "Decide later") }}
-
-
-
+
+
+ {{ props.ctx.tx("学习", "Learn") }}
+ {{ props.ctx.tx("休息", "Break") }}
+ {{ props.ctx.tx("离开电脑", "Away") }}
+
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
{{ props.ctx.tx("暂无待确认离开时段", "No idle segments pending") }}
@@ -173,3 +170,31 @@ const props = defineProps<{ ctx: GuardViewContext }>();
+
+
diff --git a/src/components/IdlePromptBanner.vue b/src/components/IdlePromptBanner.vue
index 7379c44..a157ae3 100644
--- a/src/components/IdlePromptBanner.vue
+++ b/src/components/IdlePromptBanner.vue
@@ -83,10 +83,10 @@ watch(
{{ props.ctx.tx("休息", "Break") }}
-
- {{ props.ctx.tx("稍后处理", "Decide later") }}
-
+
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
@@ -107,10 +107,10 @@ watch(
{{ props.ctx.tx("离开电脑", "Away from computer") }}
-
- {{ props.ctx.tx("稍后处理", "Decide later") }}
-
+
+ {{ props.ctx.tx("稍后处理", "Decide later") }}
+
@@ -120,3 +120,31 @@ watch(
+
+
From cb95d2a5ff3e9e5b6462c9bad853134345e6ec78 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Sun, 13 Sep 2026 14:50:58 +0800
Subject: [PATCH 108/117] Fix pet prompt clipping on high-DPI displays
* test(window): pin DPI-safe pet sizing
* fix(window): size pet in logical DPI units
* fix(window): align configured pet height
---
src-tauri/src/services/window.rs | 23 ++++++------
src-tauri/tauri.conf.json | 2 +-
src-tauri/tests/pet_window_dpi_contract.rs | 41 ++++++++++++++++++++++
3 files changed, 55 insertions(+), 11 deletions(-)
create mode 100644 src-tauri/tests/pet_window_dpi_contract.rs
diff --git a/src-tauri/src/services/window.rs b/src-tauri/src/services/window.rs
index 11eaef4..c92ab4a 100644
--- a/src-tauri/src/services/window.rs
+++ b/src-tauri/src/services/window.rs
@@ -2,6 +2,16 @@ use tauri::{AppHandle, Emitter, Manager};
use crate::domain::window::{PetWindowSettleResult, SettlePetWindowInput};
+const PET_WINDOW_WIDTH: f64 = 220.0;
+const PET_WINDOW_HEIGHT: f64 = 262.0;
+
+fn pet_window_target_size() -> tauri::Size {
+ tauri::Size::Logical(tauri::LogicalSize {
+ width: PET_WINDOW_WIDTH,
+ height: PET_WINDOW_HEIGHT,
+ })
+}
+
pub(crate) fn set_main_close_behavior(main_window: &tauri::WebviewWindow) {
let main_clone = main_window.clone();
main_window.on_window_event(move |event| {
@@ -118,13 +128,9 @@ fn settle_pet_window_internal(
.get_webview_window("pet")
.ok_or_else(|| "pet window not found".to_string())?;
let normalized_mode = normalize_pet_settle_mode(mode)?;
- let (target_width, target_height) = (220_u32, 262_u32);
pet_window
- .set_size(tauri::Size::Physical(tauri::PhysicalSize {
- width: target_width,
- height: target_height,
- }))
+ .set_size(pet_window_target_size())
.map_err(|e| format!("failed to resize pet window during settle: {e}"))?;
let pet_size = pet_window
@@ -231,7 +237,7 @@ pub(crate) fn summon_pet_window(app: &AppHandle) -> Result<(), String> {
} else {
tauri::WebviewWindowBuilder::new(app, "pet", tauri::WebviewUrl::App("pet.html".into()))
.title("TimePrism Pet")
- .inner_size(220.0, 262.0)
+ .inner_size(PET_WINDOW_WIDTH, PET_WINDOW_HEIGHT)
.decorations(false)
.transparent(true)
.resizable(false)
@@ -243,10 +249,7 @@ pub(crate) fn summon_pet_window(app: &AppHandle) -> Result<(), String> {
};
pet_window
- .set_size(tauri::Size::Physical(tauri::PhysicalSize {
- width: 220,
- height: 262,
- }))
+ .set_size(pet_window_target_size())
.map_err(|e| format!("failed to resize pet window: {e}"))?;
ensure_pet_window_position(app, true)?;
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index d67ad28..a8e82f9 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -26,7 +26,7 @@
"title": "TimePrism Pet",
"url": "pet.html",
"width": 220,
- "height": 232,
+ "height": 262,
"decorations": false,
"transparent": true,
"resizable": false,
diff --git a/src-tauri/tests/pet_window_dpi_contract.rs b/src-tauri/tests/pet_window_dpi_contract.rs
new file mode 100644
index 0000000..3a02d7f
--- /dev/null
+++ b/src-tauri/tests/pet_window_dpi_contract.rs
@@ -0,0 +1,41 @@
+const WINDOW_SERVICE_SOURCE: &str = include_str!("../src/services/window.rs");
+const TAURI_CONFIG: &str = include_str!("../tauri.conf.json");
+
+#[test]
+fn pet_window_uses_logical_size_instead_of_physical_pixels() {
+ assert!(
+ WINDOW_SERVICE_SOURCE.contains("fn pet_window_target_size() -> tauri::Size"),
+ "pet window sizing should be centralized in a logical-size helper"
+ );
+ assert!(
+ WINDOW_SERVICE_SOURCE.matches("pet_window_target_size()").count() >= 3,
+ "both settle and summon paths should use the same logical-size helper"
+ );
+ assert!(
+ !WINDOW_SERVICE_SOURCE.contains(
+ ".set_size(tauri::Size::Physical(tauri::PhysicalSize {\n width: target_width,\n height: target_height,\n }))"
+ ),
+ "settling the pet must not force a DPI-dependent physical-pixel size"
+ );
+ assert!(
+ !WINDOW_SERVICE_SOURCE.contains(
+ ".set_size(tauri::Size::Physical(tauri::PhysicalSize {\n width: 220,\n height: 262,\n }))"
+ ),
+ "summoning the pet must not force a DPI-dependent physical-pixel size"
+ );
+}
+
+#[test]
+fn configured_pet_height_matches_runtime_logical_height() {
+ let config: serde_json::Value = serde_json::from_str(TAURI_CONFIG).expect("parse tauri config");
+ let windows = config["app"]["windows"]
+ .as_array()
+ .expect("window config array");
+ let pet = windows
+ .iter()
+ .find(|item| item["label"] == "pet")
+ .expect("pet window config");
+
+ assert_eq!(pet["width"], 220, "pet logical width changed unexpectedly");
+ assert_eq!(pet["height"], 262, "pet config must match runtime logical height");
+}
From 1aa5fd36451c0a05d154494a47826864b1dfd8f6 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:18:48 +0800
Subject: [PATCH 109/117] Fix pet scaling across mixed-display sizes
---
src-tauri/src/services/window.rs | 107 +++++++++++++++++++--
src-tauri/tests/pet_window_dpi_contract.rs | 33 ++++---
2 files changed, 117 insertions(+), 23 deletions(-)
diff --git a/src-tauri/src/services/window.rs b/src-tauri/src/services/window.rs
index c92ab4a..52c9ac2 100644
--- a/src-tauri/src/services/window.rs
+++ b/src-tauri/src/services/window.rs
@@ -4,14 +4,85 @@ use crate::domain::window::{PetWindowSettleResult, SettlePetWindowInput};
const PET_WINDOW_WIDTH: f64 = 220.0;
const PET_WINDOW_HEIGHT: f64 = 262.0;
+const PET_MIN_MONITOR_SCALE: f64 = 0.75;
-fn pet_window_target_size() -> tauri::Size {
+fn pet_window_target_size(scale: f64) -> tauri::Size {
tauri::Size::Logical(tauri::LogicalSize {
- width: PET_WINDOW_WIDTH,
- height: PET_WINDOW_HEIGHT,
+ width: PET_WINDOW_WIDTH * scale,
+ height: PET_WINDOW_HEIGHT * scale,
})
}
+fn monitor_logical_work_area(monitor: &tauri::window::Monitor) -> (f64, f64) {
+ let work_area = monitor.work_area();
+ let scale_factor = monitor.scale_factor();
+ if !scale_factor.is_finite() || scale_factor <= 0.0 {
+ return (work_area.size.width as f64, work_area.size.height as f64);
+ }
+
+ (
+ work_area.size.width as f64 / scale_factor,
+ work_area.size.height as f64 / scale_factor,
+ )
+}
+
+fn pet_scale_for_logical_work_areas(
+ current_width: f64,
+ current_height: f64,
+ baseline_width: f64,
+ baseline_height: f64,
+) -> f64 {
+ if current_width <= 0.0
+ || current_height <= 0.0
+ || baseline_width <= 0.0
+ || baseline_height <= 0.0
+ {
+ return 1.0;
+ }
+
+ (current_width / baseline_width)
+ .min(current_height / baseline_height)
+ .clamp(PET_MIN_MONITOR_SCALE, 1.0)
+}
+
+fn pet_monitor_scale(
+ app: &AppHandle,
+ pet_window: &tauri::WebviewWindow,
+) -> Result {
+ let primary = app
+ .primary_monitor()
+ .map_err(|e| format!("failed to get primary monitor for pet sizing: {e}"))?;
+ let current = pet_window
+ .current_monitor()
+ .map_err(|e| format!("failed to get current pet monitor for sizing: {e}"))?
+ .or_else(|| primary.clone())
+ .ok_or_else(|| "pet monitor unavailable for sizing".to_string())?;
+ let baseline = primary.unwrap_or_else(|| current.clone());
+
+ let (current_width, current_height) = monitor_logical_work_area(¤t);
+ let (baseline_width, baseline_height) = monitor_logical_work_area(&baseline);
+ Ok(pet_scale_for_logical_work_areas(
+ current_width,
+ current_height,
+ baseline_width,
+ baseline_height,
+ ))
+}
+
+fn apply_pet_window_scale(
+ app: &AppHandle,
+ pet_window: &tauri::WebviewWindow,
+) -> Result {
+ let scale = pet_monitor_scale(app, pet_window)?;
+ pet_window
+ .set_size(pet_window_target_size(scale))
+ .map_err(|e| format!("failed to resize pet window for monitor: {e}"))?;
+ pet_window
+ .set_zoom(scale)
+ .map_err(|e| format!("failed to scale pet content for monitor: {e}"))?;
+ Ok(scale)
+}
+
pub(crate) fn set_main_close_behavior(main_window: &tauri::WebviewWindow) {
let main_clone = main_window.clone();
main_window.on_window_event(move |event| {
@@ -129,9 +200,7 @@ fn settle_pet_window_internal(
.ok_or_else(|| "pet window not found".to_string())?;
let normalized_mode = normalize_pet_settle_mode(mode)?;
- pet_window
- .set_size(pet_window_target_size())
- .map_err(|e| format!("failed to resize pet window during settle: {e}"))?;
+ apply_pet_window_scale(app, &pet_window)?;
let pet_size = pet_window
.outer_size()
@@ -248,9 +317,7 @@ pub(crate) fn summon_pet_window(app: &AppHandle) -> Result<(), String> {
.map_err(|e| format!("failed to recreate pet window: {e}"))?
};
- pet_window
- .set_size(pet_window_target_size())
- .map_err(|e| format!("failed to resize pet window: {e}"))?;
+ apply_pet_window_scale(app, &pet_window)?;
ensure_pet_window_position(app, true)?;
@@ -442,7 +509,9 @@ pub(crate) fn show_main_window_section(app: &AppHandle, section: String) -> Resu
#[cfg(test)]
mod tests {
- use super::{clamp_pet_window_position, normalize_pet_settle_mode};
+ use super::{
+ clamp_pet_window_position, normalize_pet_settle_mode, pet_scale_for_logical_work_areas,
+ };
#[test]
fn normalizes_pet_settle_modes() {
@@ -463,4 +532,22 @@ mod tests {
(700, 500)
);
}
+
+ #[test]
+ fn shrinks_pet_on_smaller_logical_monitor() {
+ let scale = pet_scale_for_logical_work_areas(1536.0, 864.0, 1920.0, 1080.0);
+ assert!((scale - 0.8).abs() < f64::EPSILON);
+ }
+
+ #[test]
+ fn never_enlarges_pet_beyond_primary_baseline() {
+ let scale = pet_scale_for_logical_work_areas(2560.0, 1440.0, 1920.0, 1080.0);
+ assert!((scale - 1.0).abs() < f64::EPSILON);
+ }
+
+ #[test]
+ fn clamps_pet_to_minimum_monitor_scale() {
+ let scale = pet_scale_for_logical_work_areas(1024.0, 576.0, 1920.0, 1080.0);
+ assert!((scale - 0.75).abs() < f64::EPSILON);
+ }
}
diff --git a/src-tauri/tests/pet_window_dpi_contract.rs b/src-tauri/tests/pet_window_dpi_contract.rs
index 3a02d7f..431fb9b 100644
--- a/src-tauri/tests/pet_window_dpi_contract.rs
+++ b/src-tauri/tests/pet_window_dpi_contract.rs
@@ -2,31 +2,38 @@ const WINDOW_SERVICE_SOURCE: &str = include_str!("../src/services/window.rs");
const TAURI_CONFIG: &str = include_str!("../tauri.conf.json");
#[test]
-fn pet_window_uses_logical_size_instead_of_physical_pixels() {
+fn pet_window_uses_monitor_adaptive_logical_size_and_zoom() {
assert!(
- WINDOW_SERVICE_SOURCE.contains("fn pet_window_target_size() -> tauri::Size"),
- "pet window sizing should be centralized in a logical-size helper"
+ WINDOW_SERVICE_SOURCE.contains("fn pet_window_target_size(scale: f64) -> tauri::Size"),
+ "pet window sizing should remain centralized in logical units"
);
assert!(
- WINDOW_SERVICE_SOURCE.matches("pet_window_target_size()").count() >= 3,
- "both settle and summon paths should use the same logical-size helper"
+ WINDOW_SERVICE_SOURCE.contains("fn pet_monitor_scale("),
+ "pet window sizing should derive an adaptive scale from its monitor"
);
assert!(
- !WINDOW_SERVICE_SOURCE.contains(
- ".set_size(tauri::Size::Physical(tauri::PhysicalSize {\n width: target_width,\n height: target_height,\n }))"
- ),
- "settling the pet must not force a DPI-dependent physical-pixel size"
+ WINDOW_SERVICE_SOURCE.contains(".current_monitor()"),
+ "pet sizing should use the monitor that contains the pet after a cross-display drag"
+ );
+ assert!(
+ WINDOW_SERVICE_SOURCE.contains("monitor.work_area()")
+ && WINDOW_SERVICE_SOURCE.contains("monitor.scale_factor()"),
+ "pet sizing should compare effective logical monitor work areas"
+ );
+ assert!(
+ WINDOW_SERVICE_SOURCE.contains(".set_zoom(scale)"),
+ "pet content must scale with the window so prompts do not wrap and clip again"
);
assert!(
!WINDOW_SERVICE_SOURCE.contains(
".set_size(tauri::Size::Physical(tauri::PhysicalSize {\n width: 220,\n height: 262,\n }))"
),
- "summoning the pet must not force a DPI-dependent physical-pixel size"
+ "pet window must not return to fixed physical-pixel sizing"
);
}
#[test]
-fn configured_pet_height_matches_runtime_logical_height() {
+fn configured_pet_size_remains_the_base_design_canvas() {
let config: serde_json::Value = serde_json::from_str(TAURI_CONFIG).expect("parse tauri config");
let windows = config["app"]["windows"]
.as_array()
@@ -36,6 +43,6 @@ fn configured_pet_height_matches_runtime_logical_height() {
.find(|item| item["label"] == "pet")
.expect("pet window config");
- assert_eq!(pet["width"], 220, "pet logical width changed unexpectedly");
- assert_eq!(pet["height"], 262, "pet config must match runtime logical height");
+ assert_eq!(pet["width"], 220, "pet base logical width changed unexpectedly");
+ assert_eq!(pet["height"], 262, "pet base logical height changed unexpectedly");
}
From 421aed0af31164fc73d7da1c773e2ff981999b2d Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:23:08 +0800
Subject: [PATCH 110/117] Handle mixed-resolution pet scaling
---
src-tauri/src/services/window.rs | 90 ++++++++++++++++------
src-tauri/tests/pet_window_dpi_contract.rs | 4 +-
2 files changed, 70 insertions(+), 24 deletions(-)
diff --git a/src-tauri/src/services/window.rs b/src-tauri/src/services/window.rs
index 52c9ac2..f668dd8 100644
--- a/src-tauri/src/services/window.rs
+++ b/src-tauri/src/services/window.rs
@@ -26,7 +26,7 @@ fn monitor_logical_work_area(monitor: &tauri::window::Monitor) -> (f64, f64) {
)
}
-fn pet_scale_for_logical_work_areas(
+fn relative_monitor_scale(
current_width: f64,
current_height: f64,
baseline_width: f64,
@@ -40,8 +40,30 @@ fn pet_scale_for_logical_work_areas(
return 1.0;
}
- (current_width / baseline_width)
- .min(current_height / baseline_height)
+ (current_width / baseline_width).min(current_height / baseline_height)
+}
+
+fn pet_scale_for_monitor_areas(
+ current_physical: (f64, f64),
+ current_logical: (f64, f64),
+ baseline_physical: (f64, f64),
+ baseline_logical: (f64, f64),
+) -> f64 {
+ let physical_scale = relative_monitor_scale(
+ current_physical.0,
+ current_physical.1,
+ baseline_physical.0,
+ baseline_physical.1,
+ );
+ let logical_scale = relative_monitor_scale(
+ current_logical.0,
+ current_logical.1,
+ baseline_logical.0,
+ baseline_logical.1,
+ );
+
+ physical_scale
+ .min(logical_scale)
.clamp(PET_MIN_MONITOR_SCALE, 1.0)
}
@@ -59,13 +81,24 @@ fn pet_monitor_scale(
.ok_or_else(|| "pet monitor unavailable for sizing".to_string())?;
let baseline = primary.unwrap_or_else(|| current.clone());
- let (current_width, current_height) = monitor_logical_work_area(¤t);
- let (baseline_width, baseline_height) = monitor_logical_work_area(&baseline);
- Ok(pet_scale_for_logical_work_areas(
- current_width,
- current_height,
- baseline_width,
- baseline_height,
+ let current_work_area = current.work_area();
+ let baseline_work_area = baseline.work_area();
+ let current_physical = (
+ current_work_area.size.width as f64,
+ current_work_area.size.height as f64,
+ );
+ let baseline_physical = (
+ baseline_work_area.size.width as f64,
+ baseline_work_area.size.height as f64,
+ );
+ let current_logical = monitor_logical_work_area(¤t);
+ let baseline_logical = monitor_logical_work_area(&baseline);
+
+ Ok(pet_scale_for_monitor_areas(
+ current_physical,
+ current_logical,
+ baseline_physical,
+ baseline_logical,
))
}
@@ -509,9 +542,7 @@ pub(crate) fn show_main_window_section(app: &AppHandle, section: String) -> Resu
#[cfg(test)]
mod tests {
- use super::{
- clamp_pet_window_position, normalize_pet_settle_mode, pet_scale_for_logical_work_areas,
- };
+ use super::{clamp_pet_window_position, normalize_pet_settle_mode, pet_scale_for_monitor_areas};
#[test]
fn normalizes_pet_settle_modes() {
@@ -534,20 +565,35 @@ mod tests {
}
#[test]
- fn shrinks_pet_on_smaller_logical_monitor() {
- let scale = pet_scale_for_logical_work_areas(1536.0, 864.0, 1920.0, 1080.0);
- assert!((scale - 0.8).abs() < f64::EPSILON);
+ fn shrinks_pet_when_smaller_monitor_has_less_logical_space() {
+ let scale = pet_scale_for_monitor_areas(
+ (1920.0, 1080.0),
+ (1536.0, 864.0),
+ (2560.0, 1440.0),
+ (2048.0, 1152.0),
+ );
+ assert!((scale - 0.75).abs() < f64::EPSILON);
}
#[test]
- fn never_enlarges_pet_beyond_primary_baseline() {
- let scale = pet_scale_for_logical_work_areas(2560.0, 1440.0, 1920.0, 1080.0);
- assert!((scale - 1.0).abs() < f64::EPSILON);
+ fn shrinks_pet_when_physical_resolution_is_smaller_even_if_logical_space_matches() {
+ let scale = pet_scale_for_monitor_areas(
+ (1920.0, 1080.0),
+ (1920.0, 1080.0),
+ (3840.0, 2160.0),
+ (1920.0, 1080.0),
+ );
+ assert!((scale - 0.75).abs() < f64::EPSILON);
}
#[test]
- fn clamps_pet_to_minimum_monitor_scale() {
- let scale = pet_scale_for_logical_work_areas(1024.0, 576.0, 1920.0, 1080.0);
- assert!((scale - 0.75).abs() < f64::EPSILON);
+ fn never_enlarges_pet_beyond_primary_baseline() {
+ let scale = pet_scale_for_monitor_areas(
+ (3840.0, 2160.0),
+ (2560.0, 1440.0),
+ (2560.0, 1440.0),
+ (1920.0, 1080.0),
+ );
+ assert!((scale - 1.0).abs() < f64::EPSILON);
}
}
diff --git a/src-tauri/tests/pet_window_dpi_contract.rs b/src-tauri/tests/pet_window_dpi_contract.rs
index 431fb9b..f428ca4 100644
--- a/src-tauri/tests/pet_window_dpi_contract.rs
+++ b/src-tauri/tests/pet_window_dpi_contract.rs
@@ -16,9 +16,9 @@ fn pet_window_uses_monitor_adaptive_logical_size_and_zoom() {
"pet sizing should use the monitor that contains the pet after a cross-display drag"
);
assert!(
- WINDOW_SERVICE_SOURCE.contains("monitor.work_area()")
+ WINDOW_SERVICE_SOURCE.contains("current_work_area.size.width")
&& WINDOW_SERVICE_SOURCE.contains("monitor.scale_factor()"),
- "pet sizing should compare effective logical monitor work areas"
+ "pet sizing should compare both physical and effective logical monitor work areas"
);
assert!(
WINDOW_SERVICE_SOURCE.contains(".set_zoom(scale)"),
From eea12aa839c9f5202c396d576724463611f5fa73 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:44:50 +0800
Subject: [PATCH 111/117] Restore editable Home goal duration
---
src-tauri/tests/home_goal_editor_contract.rs | 60 ++++++++++
src/App.vue | 5 +-
src/components/HomeView.vue | 114 ++++++++++++++++++-
src/components/viewContexts.ts | 2 +
src/composables/useHeatmapGoalSetting.ts | 12 +-
src/composables/useHomeViewContext.ts | 6 +
6 files changed, 191 insertions(+), 8 deletions(-)
create mode 100644 src-tauri/tests/home_goal_editor_contract.rs
diff --git a/src-tauri/tests/home_goal_editor_contract.rs b/src-tauri/tests/home_goal_editor_contract.rs
new file mode 100644
index 0000000..38c6787
--- /dev/null
+++ b/src-tauri/tests/home_goal_editor_contract.rs
@@ -0,0 +1,60 @@
+const APP_SOURCE: &str = include_str!("../../src/App.vue");
+const HOME_VIEW_SOURCE: &str = include_str!("../../src/components/HomeView.vue");
+const HOME_CONTEXT_SOURCE: &str = include_str!("../../src/components/viewContexts.ts");
+const HOME_CONTEXT_BUILDER_SOURCE: &str = include_str!("../../src/composables/useHomeViewContext.ts");
+const GOAL_SETTING_SOURCE: &str = include_str!("../../src/composables/useHeatmapGoalSetting.ts");
+
+#[test]
+fn home_goal_editor_is_wired_to_the_persisted_goal_setting() {
+ assert!(
+ GOAL_SETTING_SOURCE.contains("function setHeatmapGoalMinutes(minutes: number)"),
+ "goal setting composable must expose an explicit user-edit path"
+ );
+ assert!(
+ GOAL_SETTING_SOURCE.contains("schedulePersistHeatmapGoal();"),
+ "user goal edits must keep the existing debounced persistence path"
+ );
+ assert!(
+ HOME_CONTEXT_SOURCE.contains("learnGoalMinutes: number;")
+ && HOME_CONTEXT_SOURCE.contains("setLearnGoalMinutes: (minutes: number) => void;"),
+ "Home context must expose both the current goal and its mutation handler"
+ );
+ assert!(
+ HOME_CONTEXT_BUILDER_SOURCE.contains("learnGoalMinutes: learnGoalMinutes.value")
+ && HOME_CONTEXT_BUILDER_SOURCE.contains("setLearnGoalMinutes,"),
+ "Home context builder must pass the editable goal through"
+ );
+ assert!(
+ APP_SOURCE.contains("setHeatmapGoalMinutes,")
+ && APP_SOURCE.contains("learnGoalMinutes: learnGoalSliderMinutes")
+ && APP_SOURCE.contains("setLearnGoalMinutes: setHeatmapGoalMinutes"),
+ "App wiring must connect the persisted setting composable to Home"
+ );
+ assert!(
+ HOME_VIEW_SOURCE.contains("id=\"home-goal-minutes\"")
+ && HOME_VIEW_SOURCE.contains("type=\"range\"")
+ && HOME_VIEW_SOURCE.contains("min=\"0\"")
+ && HOME_VIEW_SOURCE.contains("max=\"1440\"")
+ && HOME_VIEW_SOURCE.contains("step=\"15\"")
+ && HOME_VIEW_SOURCE.contains("@input=\"handleGoalSliderInput\""),
+ "Home must render an interactive 0-24h goal editor with the existing 15-minute step"
+ );
+}
+
+#[test]
+fn reading_goal_seconds_does_not_schedule_a_write() {
+ let marker = "function getHeatmapGoalSeconds(): number {";
+ let start = GOAL_SETTING_SOURCE
+ .find(marker)
+ .expect("getHeatmapGoalSeconds function");
+ let function_source = &GOAL_SETTING_SOURCE[start..];
+ let end = function_source
+ .find("\n }\n\n")
+ .expect("getHeatmapGoalSeconds function end");
+ let body = &function_source[..end];
+
+ assert!(
+ !body.contains("schedulePersistHeatmapGoal") && !body.contains("syncGoalFromSlider"),
+ "reading the goal for Home/Insights refreshes must not enqueue persistence writes"
+ );
+}
diff --git a/src/App.vue b/src/App.vue
index 0ca7050..3259e42 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -143,6 +143,7 @@ const {
const {
learnGoalSliderMinutes,
getHeatmapGoalSeconds,
+ setHeatmapGoalMinutes,
loadHeatmapGoalSecondsSetting,
cleanupHeatmapGoalSetting,
} = useHeatmapGoalSetting({ setErrorMessage });
@@ -374,6 +375,8 @@ const homeCtx = useHomeViewContext({
goalProgressPct,
goalProgressFillNum,
goalOverflowTier,
+ learnGoalMinutes: learnGoalSliderMinutes,
+ setLearnGoalMinutes: setHeatmapGoalMinutes,
recentSummary,
homeScheduleItems,
reminderListForPanel,
@@ -527,4 +530,4 @@ useMainWindowLifecycle({
grid-column: 1 / -1;
color: var(--danger);
}
-
+
\ No newline at end of file
diff --git a/src/components/HomeView.vue b/src/components/HomeView.vue
index 83d8082..b58a399 100644
--- a/src/components/HomeView.vue
+++ b/src/components/HomeView.vue
@@ -1,4 +1,4 @@
-
@@ -101,6 +110,40 @@ const {
{{ props.ctx.formatSeconds(props.ctx.todayLearnSeconds) }}
+
+ {{ props.ctx.tx("目标", "Target") }} {{ props.ctx.timeMinutesLabel(props.ctx.learnGoalMinutes) }}
+
+
+
+
+ −
+
+
+
+ +
+
@@ -379,3 +422,72 @@ const {
+
+
diff --git a/src/components/viewContexts.ts b/src/components/viewContexts.ts
index 2199b1d..47607eb 100644
--- a/src/components/viewContexts.ts
+++ b/src/components/viewContexts.ts
@@ -58,6 +58,8 @@ export type HomeViewContext = {
goalProgressPct: string;
goalProgressFillNum: number;
goalOverflowTier: "none" | "active";
+ learnGoalMinutes: number;
+ setLearnGoalMinutes: (minutes: number) => void;
recentSummary: string;
homeScheduleItems: Reminder[];
reminderListForPanel: Reminder[];
diff --git a/src/composables/useHeatmapGoalSetting.ts b/src/composables/useHeatmapGoalSetting.ts
index d03b64d..1555c4a 100644
--- a/src/composables/useHeatmapGoalSetting.ts
+++ b/src/composables/useHeatmapGoalSetting.ts
@@ -25,16 +25,15 @@ export function useHeatmapGoalSetting({
return total;
}
- function syncGoalFromSlider() {
- normalizeGoalFromSlider();
- schedulePersistHeatmapGoal();
- }
-
function getHeatmapGoalSeconds(): number {
- syncGoalFromSlider();
return heatmapGoalMinutesToSeconds(learnGoalSliderMinutes.value);
}
+ function setHeatmapGoalMinutes(minutes: number) {
+ learnGoalSliderMinutes.value = normalizeHeatmapGoalMinutes(minutes);
+ schedulePersistHeatmapGoal();
+ }
+
function schedulePersistHeatmapGoal() {
if (heatmapGoalSaveTimer !== null) {
window.clearTimeout(heatmapGoalSaveTimer);
@@ -73,6 +72,7 @@ export function useHeatmapGoalSetting({
return {
learnGoalSliderMinutes,
getHeatmapGoalSeconds,
+ setHeatmapGoalMinutes,
loadHeatmapGoalSecondsSetting,
cleanupHeatmapGoalSetting,
};
diff --git a/src/composables/useHomeViewContext.ts b/src/composables/useHomeViewContext.ts
index 6c2f41e..9f9ed42 100644
--- a/src/composables/useHomeViewContext.ts
+++ b/src/composables/useHomeViewContext.ts
@@ -17,6 +17,8 @@ type HomeViewContextOptions = {
goalProgressPct: Readonly[>;
goalProgressFillNum: Readonly][>;
goalOverflowTier: Readonly][>;
+ learnGoalMinutes: Readonly][>;
+ setLearnGoalMinutes: HomeViewContext["setLearnGoalMinutes"];
recentSummary: Readonly][>;
homeScheduleItems: Readonly][>;
reminderListForPanel: Readonly][>;
@@ -50,6 +52,8 @@ export function useHomeViewContext({
goalProgressPct,
goalProgressFillNum,
goalOverflowTier,
+ learnGoalMinutes,
+ setLearnGoalMinutes,
recentSummary,
homeScheduleItems,
reminderListForPanel,
@@ -82,6 +86,8 @@ export function useHomeViewContext({
goalProgressPct: goalProgressPct.value,
goalProgressFillNum: goalProgressFillNum.value,
goalOverflowTier: goalOverflowTier.value,
+ learnGoalMinutes: learnGoalMinutes.value,
+ setLearnGoalMinutes,
recentSummary: recentSummary.value,
homeScheduleItems: homeScheduleItems.value,
reminderListForPanel: reminderListForPanel.value,
From 1dbfac5c4267375f20095f887310bf3b6b0b4d00 Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:45:47 +0800
Subject: [PATCH 112/117] Strengthen Home goal editor regression coverage
---
src-tauri/tests/home_goal_editor_contract.rs | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/src-tauri/tests/home_goal_editor_contract.rs b/src-tauri/tests/home_goal_editor_contract.rs
index 38c6787..f17e74f 100644
--- a/src-tauri/tests/home_goal_editor_contract.rs
+++ b/src-tauri/tests/home_goal_editor_contract.rs
@@ -11,8 +11,9 @@ fn home_goal_editor_is_wired_to_the_persisted_goal_setting() {
"goal setting composable must expose an explicit user-edit path"
);
assert!(
- GOAL_SETTING_SOURCE.contains("schedulePersistHeatmapGoal();"),
- "user goal edits must keep the existing debounced persistence path"
+ GOAL_SETTING_SOURCE.contains("learnGoalSliderMinutes.value = normalizeHeatmapGoalMinutes(minutes);")
+ && GOAL_SETTING_SOURCE.contains("schedulePersistHeatmapGoal();"),
+ "user goal edits must normalize through the existing bounds and use debounced persistence"
);
assert!(
HOME_CONTEXT_SOURCE.contains("learnGoalMinutes: number;")
@@ -39,6 +40,11 @@ fn home_goal_editor_is_wired_to_the_persisted_goal_setting() {
&& HOME_VIEW_SOURCE.contains("@input=\"handleGoalSliderInput\""),
"Home must render an interactive 0-24h goal editor with the existing 15-minute step"
);
+ assert!(
+ HOME_VIEW_SOURCE.contains("@click=\"adjustGoalMinutes(-15)\"")
+ && HOME_VIEW_SOURCE.contains("@click=\"adjustGoalMinutes(15)\""),
+ "Home goal editor must preserve keyboard-friendly 15-minute decrement/increment controls"
+ );
}
#[test]
From 758b4d0b6e2399792b9e2f4a99951fde365250ac Mon Sep 17 00:00:00 2001
From: Air000000 <73988854+Air000000@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:48:37 +0800
Subject: [PATCH 113/117] Make Home goal contract line-ending agnostic
---
src-tauri/tests/home_goal_editor_contract.rs | 21 +++++++++++---------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/src-tauri/tests/home_goal_editor_contract.rs b/src-tauri/tests/home_goal_editor_contract.rs
index f17e74f..5dcbe11 100644
--- a/src-tauri/tests/home_goal_editor_contract.rs
+++ b/src-tauri/tests/home_goal_editor_contract.rs
@@ -49,18 +49,21 @@ fn home_goal_editor_is_wired_to_the_persisted_goal_setting() {
#[test]
fn reading_goal_seconds_does_not_schedule_a_write() {
- let marker = "function getHeatmapGoalSeconds(): number {";
+ let read_marker = "function getHeatmapGoalSeconds(): number {";
+ let next_marker = "function setHeatmapGoalMinutes(minutes: number)";
let start = GOAL_SETTING_SOURCE
- .find(marker)
+ .find(read_marker)
.expect("getHeatmapGoalSeconds function");
- let function_source = &GOAL_SETTING_SOURCE[start..];
- let end = function_source
- .find("\n }\n\n")
- .expect("getHeatmapGoalSeconds function end");
- let body = &function_source[..end];
+ let source_after_read = &GOAL_SETTING_SOURCE[start..];
+ let end = source_after_read
+ .find(next_marker)
+ .expect("setHeatmapGoalMinutes function after goal reader");
+ let body = &source_after_read[..end];
assert!(
- !body.contains("schedulePersistHeatmapGoal") && !body.contains("syncGoalFromSlider"),
- "reading the goal for Home/Insights refreshes must not enqueue persistence writes"
+ body.contains("return heatmapGoalMinutesToSeconds(learnGoalSliderMinutes.value);")
+ && !body.contains("schedulePersistHeatmapGoal")
+ && !body.contains("syncGoalFromSlider"),
+ "reading the goal for Home/Insights refreshes must stay pure and must not enqueue persistence writes"
);
}
From b85bca5a9213147c1fae4fb858ee48157bdc3101 Mon Sep 17 00:00:00 2001
From: Air000000 ]
Date: Mon, 14 Sep 2026 14:11:41 +0800
Subject: [PATCH 114/117] feat: reuse Yin Yue pet spritesheet
---
public/pets/yin-yue/spritesheet.webp | Bin 0 -> 2528188 bytes
src/lib/petCharacter.ts | 2 +-
src/lib/petDom.ts | 7 +++-
src/pet.css | 47 +++++++++++++++++++++++++++
src/pet.ts | 21 +++++++++++-
5 files changed, 74 insertions(+), 3 deletions(-)
create mode 100644 public/pets/yin-yue/spritesheet.webp
diff --git a/public/pets/yin-yue/spritesheet.webp b/public/pets/yin-yue/spritesheet.webp
new file mode 100644
index 0000000000000000000000000000000000000000..cd57ba516586a7be67beffa1aacecb5b6b1534a4
GIT binary patch
literal 2528188
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