+ 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; 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
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,18 +181,32 @@ 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
```
+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` separates installer generation from publication on `windows-latest`:
+
+- **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.
+
+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
@@ -210,7 +227,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
@@ -242,7 +259,7 @@ pnpm dev
pnpm run typecheck
pnpm run build:check
cd src-tauri
-cargo check
+cargo check --all-targets
cargo test
```
@@ -277,8 +294,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.
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..75051b0
--- /dev/null
+++ b/docs/RELEASE_CHECKLIST.md
@@ -0,0 +1,137 @@
+# 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.
+
+## 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.
+
+## 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. 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`
+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
+ ```
+
+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`.
+
+## Pull-request packaging validation
+
+When a pull request changes release/package inputs, **Windows Release** runs automatically.
+
+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 both installers as one workflow artifact;
+5. does **not** create a GitHub Release.
+
+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;
+- 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 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.
+
+If the installer or installed application fails this gate, fix the issue and produce a fresh artifact before tagging.
+
+## 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:
+
+- 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.
+
+Only after these checks should the GitHub Release be published.
diff --git a/docs/SMOKE_TESTS.md b/docs/SMOKE_TESTS.md
index a71a15b..05d8a54 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.
@@ -83,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.
@@ -114,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
@@ -140,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.
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.
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.
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..006762c
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-08-timeprism-first-run-tracking.md
@@ -0,0 +1,1145 @@
+# 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 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.
+
+**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 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 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 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.
+
+---
+
+## File Structure
+
+### Create
+
+- `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`, `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` (`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)`.
+- Produces: guaranteed `onboarding_completed` and `auto_capture_enabled` keys after successful DB initialization.
+
+- [ ] **Step 1: Add RED assertions for a fresh DB**
+
+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");
+assert_eq!(onboarding, "false");
+assert_eq!(auto_capture, "false");
+```
+
+- [ ] **Step 2: Add RED assertions for a recognizable legacy DB**
+
+Extend `legacy_schema_is_upgraded_without_losing_existing_rows` after `initialize_connection`:
+
+```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 RED regression for a persisted pause**
+
+```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 focused tests and verify RED**
+
+```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: missing tracking keys cause failure.
+
+- [ ] **Step 5: Detect recognizable TimePrism schema before creating tables**
+
+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}"))?;
+ Ok(count > 0)
+}
+```
+
+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 migration-safe defaults after `app_config` exists**
+
+```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}"))?;
+}
+```
+
+`INSERT OR IGNORE` is required so existing explicit values survive re-initialization.
+
+- [ ] **Step 7: Verify GREEN and full regression**
+
+```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
+```
+
+- [ ] **Step 8: Commit**
+
+```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/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:**
+- 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 and expose 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,
+}
+```
+
+Add `pub mod tracking;` to `domain/mod.rs`.
+
+- [ ] **Step 2: Create the tracking module, expose it, and add RED tests before functions**
+
+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,
+ };
+
+ 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_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_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]
+ 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_eq!(get_tracking_state_entry(&conn).unwrap().auto_capture_enabled, false);
+ }
+}
+```
+
+- [ ] **Step 3: Run and verify RED**
+
+```bash
+cd src-tauri
+cargo test services::tracking::tests
+```
+
+Expected: compile failure because the referenced tracking functions do not exist.
+
+- [ ] **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}"))?;
+
+ 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 with one transaction and UPSERT both keys so the invariant is robust even if a key was unexpectedly missing:
+
+```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}"))?;
+ 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)
+}
+```
+
+Implement `set_auto_capture_enabled_entry` as:
+
+```rust
+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)
+}
+```
+
+- [ ] **Step 5: Verify GREEN**
+
+```bash
+cd src-tauri
+cargo test services::tracking::tests
+cargo test
+```
+
+- [ ] **Step 6: Add thin Tauri commands and register them**
+
+In `lib.rs`:
+
+```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)
+}
+```
+
+Add all three to `tauri::generate_handler!`.
+
+- [ ] **Step 7: Compile all targets and commit**
+
+```bash
+cd src-tauri
+cargo check --all-targets
+cargo test
+cd ..
+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`
+- Modify/Test: `src-tauri/src/services/window.rs`
+- Modify: `src-tauri/src/lib.rs`
+
+**Interfaces:**
+- 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 RED config test**
+
+In `services/window.rs` tests:
+
+```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: Verify RED**
+
+```bash
+cd src-tauri
+cargo test services::window::tests::configured_product_windows_require_manual_creation
+```
+
+- [ ] **Step 3: Set all configured product windows to manual creation**
+
+Add to each `main`, `pet`, `pet-panel` object in `tauri.conf.json`:
+
+```json
+"create": false
+```
+
+Do not change their other config values.
+
+- [ ] **Step 4: Add config-driven creation helper**
+
+```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}"))
+}
+```
+
+- [ ] **Step 5: Make `.setup()` fail closed and order DB before windows**
+
+Required order in `lib.rs`:
+
+```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)?;
+ }
+
+ // register existing shortcut only after DB/window readiness
+ Ok(())
+})
+```
+
+Delete the current `eprintln!("database init warning...")` continue-on-error behavior.
+
+- [ ] **Step 6: Gate both shortcut and Tauri pet command before onboarding**
+
+For the global shortcut handler: open DB, read `TrackingState`, and summon only when `onboarding_completed=true`. On read failure, log and do nothing.
+
+For the `summon_pet_window` Tauri command wrapper, enforce the same backend policy:
+
+```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)
+}
+```
+
+The window service itself remains DB-agnostic.
+
+- [ ] **Step 7: Verify Rust**
+
+```bash
+cd src-tauri
+cargo test services::window::tests::configured_product_windows_require_manual_creation
+cargo check --all-targets
+cargo test
+```
+
+- [ ] **Step 8: Commit**
+
+```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/window APIs and make the sampler mechanism-only
+
+**Files:**
+- 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 `TrackingState`.
+- Produces `getTrackingState`, `completeTrackingOnboarding`, `setAutoCaptureEnabled`.
+- Produces `showMainWindow`, `summonPetWindow`.
+- Produces `useTrackingState` refs/methods.
+- Sampler owns only immediate sample + 5-second interval + stop.
+
+- [ ] **Step 1: Add typed contracts/wrappers**
+
+`src/api/types.ts`:
+
+```ts
+export type TrackingState = {
+ onboarding_completed: boolean;
+ auto_capture_enabled: boolean;
+};
+```
+
+Export `TrackingState` from `src/api.ts`.
+
+`src/api/commands/tracking.ts`:
+
+```ts
+import { invokeCommand } from "../client";
+import type { TrackingState } from "../types";
+
+export const getTrackingState = (): Promise =>
+ invokeCommand("get_tracking_state");
+
+export const completeTrackingOnboarding = (): Promise =>
+ invokeCommand("complete_tracking_onboarding");
+
+export const setAutoCaptureEnabled = (enabled: boolean): Promise =>
+ invokeCommand("set_auto_capture_enabled", { enabled });
+```
+
+`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 new modules from `src/api/commands/index.ts`.
+
+- [ ] **Step 2: Create persistence-only `useTrackingState`**
+
+```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(): Promise {
+ trackingStateLoading.value = true;
+ try {
+ const next = await getTrackingState();
+ trackingState.value = next;
+ return next;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
+ } finally {
+ trackingStateLoading.value = false;
+ }
+ }
+
+ async function activateTracking(): Promise {
+ trackingActionLoading.value = true;
+ try {
+ const next = await completeTrackingOnboarding();
+ trackingState.value = next;
+ return next;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
+ } finally {
+ trackingActionLoading.value = false;
+ }
+ }
+
+ async function persistAutoCaptureEnabled(enabled: boolean): Promise {
+ trackingActionLoading.value = true;
+ try {
+ const next = await setAutoCaptureEnabled(enabled);
+ trackingState.value = next;
+ return next;
+ } catch (error) {
+ setErrorMessage(error);
+ throw error;
+ } finally {
+ trackingActionLoading.value = false;
+ }
+ }
+
+ return {
+ trackingState,
+ trackingStateLoading,
+ trackingActionLoading,
+ onboardingCompleted,
+ autoCaptureEnabled,
+ loadTrackingState,
+ activateTracking,
+ persistAutoCaptureEnabled,
+ };
+}
+```
+
+No optimistic ref mutation is allowed.
+
+- [ ] **Step 3: Remove policy ownership from `useAutoCaptureSampler`**
+
+Delete `autoCaptureEnabled` from `AutoCaptureSamplerOptions` and delete the early return in `sampleAutoCapture()`. The sampler runs only when `startAutoCaptureSampler()` has been called. Preserve:
+
+```ts
+function startAutoCaptureSampler() {
+ stopAutoCaptureSampler();
+ sampleAutoCapture();
+ captureTimer = window.setInterval(sampleAutoCapture, intervalMs);
+}
+```
+
+Keep default `intervalMs = 5000`.
+
+- [ ] **Step 4: Static verification and commit**
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+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 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`
+- Modify: `src/main.ts`
+
+**Interfaces:**
+- 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 2: Refactor lifecycle into shell-init + idempotent product runtime**
+
+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;
+ await loadHeatmapGoalSecondsSetting();
+ resetGuardFeedback();
+ resetPrivacyFeedback();
+ void refreshHomeData();
+ startSettingsWarmup();
+ startMainRefreshPolling();
+ await startInsightsSectionNavigationListener();
+ }
+ syncCaptureRuntime(captureEnabled);
+}
+
+function syncCaptureRuntime(captureEnabled: boolean) {
+ if (!productRuntimeStarted) return;
+ if (captureEnabled) startAutoCaptureSampler();
+ else stopAutoCaptureSampler();
+}
+
+function stopProductRuntime() {
+ stopMainRefreshPolling();
+ stopAutoCaptureSampler();
+ cleanupSettingsWarmup();
+ cleanupInsightsSectionNavigation();
+ cleanupHeatmapGoalSetting();
+ productRuntimeStarted = false;
+}
+```
+
+Return `startProductRuntime`, `syncCaptureRuntime`, `stopProductRuntime`; call `stopProductRuntime()` on unmount.
+
+- [ ] **Step 3: Make Guard consume the authoritative persisted state**
+
+Change `UseGuardDataOptions` to include:
+
+```ts
+autoCaptureEnabled: Readonly>;
+setAutoCaptureEnabled: (enabled: boolean) => Promise;
+```
+
+Import `type Ref` from Vue and delete local `const autoCaptureEnabled = ref(true)`.
+
+`onAutoCaptureToggle` becomes async:
+
+```ts
+async function onAutoCaptureToggle(event: Event) {
+ const enabled = guardCheckedFromEvent(event);
+ try {
+ await setAutoCaptureEnabled(enabled);
+ autoCaptureFeedback.value = guardAutoCaptureStateFeedback(enabled, tx);
+ } catch (error) {
+ setErrorMessage(error);
+ }
+}
+```
+
+Add to `guardFeedback.ts`:
+
+```ts
+export function guardAutoCaptureStateFeedback(enabled: boolean, tx: TranslateFn): string {
+ return enabled
+ ? tx("自动采样已开启", "Auto capture enabled")
+ : tx("记录已暂停;重启后仍保持暂停", "Tracking paused; pause persists after restart");
+}
+```
+
+- [ ] **Step 4: Wire persistence to sampler only after backend success**
+
+In `App.vue`:
+
+```ts
+async function handleSetAutoCaptureEnabled(enabled: boolean) {
+ const state = await persistAutoCaptureEnabled(enabled);
+ syncCaptureRuntime(state.auto_capture_enabled);
+}
+```
+
+Pass `autoCaptureEnabled` and `handleSetAutoCaptureEnabled` into `useGuardData`.
+
+- [ ] **Step 5: Verify and commit**
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+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 one-time onboarding and activation transition
+
+**Files:**
+- Create: `src/components/OnboardingView.vue`
+- Modify: `src/App.vue`, `src/styles/app.css`
+
+**Interfaces:**
+- Emits `activate` only; no persistent browse-without-tracking branch.
+- Activation order: backend commit -> start runtime -> reveal/create pet.
+
+- [ ] **Step 1: Create disclosure component**
+
+```ts
+const props = defineProps<{
+ tx: (zh: string, en: string) => string;
+ loading: boolean;
+}>();
+const emit = defineEmits<{ activate: [] }>();
+```
+
+Required Chinese meaning (English copy must match):
+
+```text
+欢迎使用 TimePrism
+自动记录你的电脑时间流向,数据保存在本机。
+
+开始后,TimePrism 会:
+• 读取当前前台应用及窗口信息,用于计算使用时长
+• 默认对浏览器标题做隐私处理,并跳过无痕/隐私窗口
+• 将核心数据保存在本机 SQLite;当前版本不会上传到云端
+
+TimePrism 不会:
+• 截屏
+• 记录键盘输入内容
+
+“数据看板”展示观察到的应用活动;桌宠的“学 / 休”只统计已分类为学习或休息的应用。
+
+[开始使用 TimePrism]
+```
+
+Disable the primary button while `loading`.
+
+- [ ] **Step 2: Add focused onboarding styles**
+
+Use `.onboarding-shell`, `.onboarding-card`, `.onboarding-points`, `.onboarding-primary`; reuse existing colors/radii/spacing. Fit within 888x900 without default-scale scrolling.
+
+- [ ] **Step 3: Render onboarding before normal product shell**
+
+```vue
+
+
+
+
+
+```
+
+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 activation without optimistic tracking**
+
+```ts
+async function handleActivateTracking() {
+ try {
+ const state = await activateTracking();
+ await startProductRuntime(state.auto_capture_enabled);
+ try {
+ await summonPetWindow();
+ } catch (petError) {
+ setErrorMessage(petError);
+ }
+ } catch (error) {
+ setErrorMessage(error);
+ }
+}
+```
+
+A backend activation failure leaves onboarding visible and sampler stopped. A later pet-show failure does not roll back successfully persisted activation.
+
+- [ ] **Step 5: Verify and commit**
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+cd src-tauri
+cargo check --all-targets
+cargo test
+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 pet show active vs paused tracking state
+
+**Files:**
+- Modify: `src/pet.ts`
+
+**Interfaces:**
+- Consumes `getTrackingState` + `getTodaySummary`.
+- Counters remain classified `LEARN` / `REST`; only mood reflects tracking state.
+
+- [ ] **Step 1: Read state in existing summary refresh**
+
+```ts
+const [summary, tracking] = await Promise.all([
+ getTodaySummary(),
+ getTrackingState(),
+]);
+const trackingMood = tracking.auto_capture_enabled
+ ? tx("自动记录中", "Auto tracking")
+ : tx("记录已暂停", "Tracking paused");
+```
+
+Pass `formatSeconds(summary.learn_seconds)`, `formatSeconds(summary.rest_seconds)`, and `trackingMood` to `renderPetSummary`.
+
+- [ ] **Step 2: Preserve 5-second cadence**
+
+Keep the existing `window.setInterval(..., 5000)` and baseline-only capture behavior. Do not synthesize per-second increments.
+
+- [ ] **Step 3: Verify and commit**
+
+```bash
+pnpm run typecheck
+pnpm run build:check
+git add src/pet.ts
+git commit -m "feat(pet): show persisted tracking status"
+```
+
+---
+
+### 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`
+
+**Interfaces:**
+- 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.
+
+- [ ] **Step 1: Add compare-and-clear semantics**
+
+```ts
+function clearErrorMessage(expected?: unknown) {
+ if (expected === undefined || error.value === `${expected}`) {
+ error.value = "";
+ }
+}
+```
+
+Return it with `error` and `setErrorMessage`.
+
+- [ ] **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;
+recentLogs.value = logs;
+pendingRuleProcesses.value = pendingRules;
+idlePrompts.value = pendingIdle;
+reminders.value = reminderRows;
+if (lastHomeCoreError) {
+ clearErrorMessage(lastHomeCoreError);
+ lastHomeCoreError = "";
+}
+```
+
+On failure:
+
+```ts
+lastHomeCoreError = `${e}`;
+setErrorMessage(e);
+```
+
+If a newer tracking write error has replaced the global message, `clearErrorMessage(lastHomeCoreError)` does nothing, so polling cannot erase the newer failure.
+
+- [ ] **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 only recovered home read errors"
+```
+
+---
+
+### Task 9: Update acceptance docs and run final automated gates
+
+**Files:**
+- 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 exact fresh-install smoke assertions**
+
+Document:
+
+```text
+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 upgrade test: pre-change DB without the new keys starts onboarded + capture enabled without showing onboarding.
+
+- [ ] **Step 2: Add fresh + upgrade gates to release checklist**
+
+Require both before public release; keep unsigned/SmartScreen note.
+
+- [ ] **Step 3: Run repository gates**
+
+```bash
+pnpm install --frozen-lockfile
+pnpm run typecheck
+pnpm run build:check
+cd src-tauri
+cargo check --all-targets
+cargo test
+```
+
+Record actual test count from output; do not reuse `35 passed` if it changes.
+
+- [ ] **Step 4: Push final HEAD and require both Windows workflows GREEN**
+
+CI must pass typecheck, Vite build, `cargo check --all-targets`, `cargo test`.
+
+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**
+
+```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 for real Windows acceptance**
+
+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 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
+
+- [ ] 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.
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.
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",
diff --git a/public/pets/yin-yue/dock-left.webp b/public/pets/yin-yue/dock-left.webp
new file mode 100644
index 0000000..89d8e49
Binary files /dev/null and b/public/pets/yin-yue/dock-left.webp differ
diff --git a/public/pets/yin-yue/dock-right.webp b/public/pets/yin-yue/dock-right.webp
new file mode 100644
index 0000000..d83e29a
Binary files /dev/null and b/public/pets/yin-yue/dock-right.webp differ
diff --git a/public/pets/yin-yue/spritesheet.webp b/public/pets/yin-yue/spritesheet.webp
new file mode 100644
index 0000000..cd57ba5
Binary files /dev/null and b/public/pets/yin-yue/spritesheet.webp differ
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"
-
diff --git a/src-tauri/src/db/connection.rs b/src-tauri/src/db/connection.rs
index e93e680..f2f2019 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,15 @@ 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> {
+ if target_path.exists() {
+ return Ok(());
+ }
+
+ 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 +85,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()
+ )
+ }),
}
}
-
diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs
index 7605a12..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)")
@@ -174,8 +219,23 @@ pub fn ensure_heatmap_snapshot_table(conn: &Connection) -> Result<(), String> {
Ok(())
}
-pub fn init_database(app: &AppHandle) -> Result<(), String> {
- let conn = open_connection(app)?;
+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}"))?;
@@ -202,7 +262,9 @@ pub fn init_database(app: &AppHandle) -> 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 (
@@ -211,44 +273,45 @@ 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_app_usage_log_source(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);
@@ -259,17 +322,29 @@ pub fn init_database(app: &AppHandle) -> 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"),
@@ -308,6 +383,25 @@ pub fn init_database(app: &AppHandle) -> 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",
@@ -325,3 +419,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)
+}
diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs
index 221c82c..f2fa094 100644
--- a/src-tauri/src/db/mod.rs
+++ b/src-tauri/src/db/mod.rs
@@ -1,2 +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/db/tests.rs b/src-tauri/src/db/tests.rs
new file mode 100644
index 0000000..8b30853
--- /dev/null
+++ b/src-tauri/src/db/tests.rs
@@ -0,0 +1,383 @@
+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");
+ 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_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 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'",
+ [],
+ |row| row.get(0),
+ )
+ .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, "休息");
+ }
+
+ {
+ 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 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");
+ 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");
+ 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 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'",
+ [],
+ |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);
+}
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)]
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;
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,
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index c61906d..5c94ca6 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(
@@ -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,
diff --git a/src-tauri/src/services/foreground.rs b/src-tauri/src/services/foreground.rs
index 4707813..42135bf 100644
--- a/src-tauri/src/services/foreground.rs
+++ b/src-tauri/src/services/foreground.rs
@@ -19,9 +19,11 @@ 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_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,9 @@ struct ForegroundSampleState {
last: Option,
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,
@@ -47,6 +52,42 @@ struct ForegroundSnapshot {
captured_at_ms: i64,
}
+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();
@@ -144,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;
@@ -151,6 +193,10 @@ 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 =
+ 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 {
*seg_start = idle_start_ms;
@@ -177,6 +223,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 +238,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 +262,8 @@ pub(crate) fn capture_foreground_once(
}
}
}
+ } else {
+ state.idle_attribution_process_name = None;
}
}
@@ -222,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 {
@@ -244,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()
@@ -353,8 +423,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" {
@@ -387,10 +457,38 @@ pub(crate) fn resolve_idle_prompt(
let Some(prompt) = prompt else {
return Ok(false);
};
- let conn = open_connection(app)?;
- let stored = persist_idle_prompt_decision(&conn, &prompt, &decision)?;
- if remember_this_session {
+ 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") {
let mut state = FOREGROUND_SAMPLE_STATE
.lock()
.map_err(|e| format!("failed to lock foreground sample state: {e}"))?;
@@ -468,7 +566,9 @@ 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,
+ update_idle_attribution_on_input, ForegroundSampleState, ForegroundSnapshot,
+ FOREGROUND_SAMPLE_STATE,
};
use crate::domain::window::ForegroundCaptureDiagnostic;
@@ -479,6 +579,62 @@ mod tests {
state.diagnostics.clear();
}
+ #[test]
+ fn idle_attribution_normalizes_safe_process_identity() {
+ assert_eq!(
+ idle_attribution_candidate(r#"C:\Program Files\Microsoft VS Code\Code.exe"#),
+ Some("code.exe".to_string())
+ );
+ }
+
+ #[test]
+ 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();
diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs
index 6b2c937..07aa2fc 100644
--- a/src-tauri/src/services/mod.rs
+++ b/src-tauri/src/services/mod.rs
@@ -10,5 +10,12 @@ pub mod rules;
pub mod sessions;
pub mod startup;
pub mod time;
+pub mod tracking;
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/tracking.rs b/src-tauri/src/services/tracking.rs
new file mode 100644
index 0000000..1b0a96b
--- /dev/null
+++ b/src-tauri/src/services/tracking.rs
@@ -0,0 +1,146 @@
+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};
+
+ 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);
+ }
+
+ #[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"));
+ }
+}
diff --git a/src-tauri/src/services/usage.rs b/src-tauri/src/services/usage.rs
index 44fa885..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,28 +69,28 @@ 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}"))?;
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 +99,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,13 +116,17 @@ 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,
title,
prompt.start_timestamp,
prompt.duration_ms,
+ SOURCE_IDLE_CONFIRMED,
)?;
tx.commit()
@@ -111,6 +134,44 @@ 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 {
+ 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,
+ &safe_process_name,
+ &safe_window_title,
+ None,
+ )
+}
+
pub(crate) fn append_usage_log_record(
conn: &Connection,
process_name: &str,
@@ -129,6 +190,7 @@ pub(crate) fn append_usage_log_record(
&safe_window_title,
start_timestamp,
duration_ms,
+ SOURCE_FOREGROUND,
)?;
Ok((stored, block_reason))
}
@@ -137,13 +199,18 @@ 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 {
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,
@@ -152,12 +219,18 @@ 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,
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'))
);
"#,
)
@@ -187,6 +260,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");
@@ -218,4 +292,107 @@ 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,
+ attribution_process_name: Some("code.exe".to_string()),
+ };
+
+ 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");
+ }
+
+ #[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);
+ }
}
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-tauri/src/services/window.rs b/src-tauri/src/services/window.rs
index 11eaef4..f668dd8 100644
--- a/src-tauri/src/services/window.rs
+++ b/src-tauri/src/services/window.rs
@@ -2,6 +2,120 @@ 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;
+const PET_MIN_MONITOR_SCALE: f64 = 0.75;
+
+fn pet_window_target_size(scale: f64) -> tauri::Size {
+ tauri::Size::Logical(tauri::LogicalSize {
+ 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 relative_monitor_scale(
+ 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)
+}
+
+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)
+}
+
+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_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,
+ ))
+}
+
+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| {
@@ -118,14 +232,8 @@ 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,
- }))
- .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()
@@ -231,7 +339,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)
@@ -242,12 +350,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(tauri::Size::Physical(tauri::PhysicalSize {
- width: 220,
- height: 262,
- }))
- .map_err(|e| format!("failed to resize pet window: {e}"))?;
+ apply_pet_window_scale(app, &pet_window)?;
ensure_pet_window_position(app, true)?;
@@ -439,7 +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};
+ use super::{clamp_pet_window_position, normalize_pet_settle_mode, pet_scale_for_monitor_areas};
#[test]
fn normalizes_pet_settle_modes() {
@@ -460,4 +563,37 @@ mod tests {
(700, 500)
);
}
+
+ #[test]
+ 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 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 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/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"
+ );
+ }
+}
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}"))
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 93b7bd6..a8e82f9 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,20 +18,22 @@
"height": 900,
"minWidth": 888,
"minHeight": 900,
- "visible": false
+ "visible": false,
+ "create": false
},
{
"label": "pet",
"title": "TimePrism Pet",
"url": "pet.html",
"width": 220,
- "height": 232,
+ "height": 262,
"decorations": false,
"transparent": true,
"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": {
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..5dcbe11
--- /dev/null
+++ b/src-tauri/tests/home_goal_editor_contract.rs
@@ -0,0 +1,69 @@
+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("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;")
+ && 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"
+ );
+ 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]
+fn reading_goal_seconds_does_not_schedule_a_write() {
+ let read_marker = "function getHeatmapGoalSeconds(): number {";
+ let next_marker = "function setHeatmapGoalMinutes(minutes: number)";
+ let start = GOAL_SETTING_SOURCE
+ .find(read_marker)
+ .expect("getHeatmapGoalSeconds function");
+ 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("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"
+ );
+}
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..f428ca4
--- /dev/null
+++ b/src-tauri/tests/pet_window_dpi_contract.rs
@@ -0,0 +1,48 @@
+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_monitor_adaptive_logical_size_and_zoom() {
+ assert!(
+ 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.contains("fn pet_monitor_scale("),
+ "pet window sizing should derive an adaptive scale from its monitor"
+ );
+ assert!(
+ 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("current_work_area.size.width")
+ && WINDOW_SERVICE_SOURCE.contains("monitor.scale_factor()"),
+ "pet sizing should compare both physical and 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 }))"
+ ),
+ "pet window must not return to fixed physical-pixel sizing"
+ );
+}
+
+#[test]
+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()
+ .expect("window config array");
+ let pet = windows
+ .iter()
+ .find(|item| item["label"] == "pet")
+ .expect("pet window config");
+
+ assert_eq!(pet["width"], 220, "pet base logical width changed unexpectedly");
+ assert_eq!(pet["height"], 262, "pet base logical height changed unexpectedly");
+}
diff --git a/src/App.vue b/src/App.vue
index d27393e..3259e42 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,10 +1,13 @@
-
+
+
+
TP
+
+
{{ tx("正在准备 TimePrism", "Preparing TimePrism") }}
+
+ {{
+ tx(
+ "正在读取本地记录设置。自动采样会保持关闭,直到状态确认完成。",
+ "Reading local tracking settings. Auto capture remains off until the state is confirmed.",
+ )
+ }}
+
+
+
{{ error }}
+
+
+
+
+
+
+
+
\ No newline at end of file
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,
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 });
diff --git a/src/api/commands/index.ts b/src/api/commands/index.ts
index 8c8a3d2..c86838a 100644
--- a/src/api/commands/index.ts
+++ b/src/api/commands/index.ts
@@ -6,4 +6,6 @@ export * from "./reminders";
export * from "./rules";
export * from "./sessions";
export * from "./startup";
+export * from "./tracking";
export * from "./usage";
+export * from "./window";
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 });
+}
diff --git a/src/api/commands/window.ts b/src/api/commands/window.ts
new file mode 100644
index 0000000..f428a83
--- /dev/null
+++ b/src/api/commands/window.ts
@@ -0,0 +1,5 @@
+import { invokeCommand } from "../client";
+
+export function summonPetWindow(): Promise {
+ return invokeCommand("summon_pet_window");
+}
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",
+});
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,
+};
diff --git a/src/api/types.ts b/src/api/types.ts
index d162f94..d9328fb 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;
@@ -95,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 = {
@@ -116,4 +122,4 @@ export type Reminder = {
completed_at: number | null;
created_at: number;
updated_at: number;
-};
+};
\ No newline at end of file
diff --git a/src/api/window.contract.ts b/src/api/window.contract.ts
new file mode 100644
index 0000000..68b7620
--- /dev/null
+++ b/src/api/window.contract.ts
@@ -0,0 +1,3 @@
+import { summonPetWindow } from "../api";
+
+export const summonPetWindowContract = summonPetWindow satisfies () => Promise;
diff --git a/src/components/GuardView.vue b/src/components/GuardView.vue
index e8c8fa2..1779dfb 100644
--- a/src/components/GuardView.vue
+++ b/src/components/GuardView.vue
@@ -67,12 +67,36 @@ const props = defineProps<{ ctx: GuardViewContext }>();
{{ 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.tx("无输入前正在使用:", "App used before inactivity: ") }}{{ attributionAppName }}
+
+
+ {{
+ props.ctx.tx(
+ `这段时间你还在使用 ${attributionAppName} 吗?`,
+ `Were you still using ${attributionAppName} during this time?`,
+ )
+ }}
+
+
+ {{
+ 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(
+ "如果只确定这段时间属于学习或休息,而不确定是否一直在这个应用里,可以只记录活动类型。",
+ "If you only know this was Learn or Break time, record the activity type without attributing it to the app.",
+ )
+ }}
+
+ {{ props.tx("先确认记录边界,再开始使用", "Confirm the tracking boundary before you begin") }}
+
+
+ {{
+ props.tx(
+ "TimePrism 用前台应用采样帮助你回顾时间与注意力。首次启动默认不采样,只有你点击下方按钮后才会开始。",
+ "TimePrism samples the foreground app to help you review time and attention. First launch starts with capture off; sampling begins only after you continue below.",
+ )
+ }}
+
+
+
+
+
+
+ 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.",
+ )
+ }}
+