From 31685ae275f14e48ef591223d5c4833be51d3618 Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Mon, 1 Jun 2026 10:26:00 -0400 Subject: [PATCH 1/4] fix(sync): refresh_reference_data prunes server-side deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix: refresh_reference_data only upserted projects/clients/tasks/ tags returned by Solidtime. Anything deleted server-side lingered locally forever, so the picker showed stale projects after the user reorganized on Solidtime. Fix: after each upsert, prune the local rows whose ids aren't in the remote response. - projects + clients: soft-archived (archived = 1). Historical time entries can still resolve the project / client name via JOIN; the picker hides archived rows. - tasks + tags: hard-deleted. Neither has an `archived` column and adding one for this is overkill — entries with a dangling task_id just show no task name. If the user resurrects a project on Solidtime (archived: false again), the upsert's ON CONFLICT … SET archived = excluded.archived already un-archives it locally on the same refresh tick. Tests: - new: refresh_reconciles_remote_side_deletions - new: refresh_does_not_re_archive_a_resurrected_project - existing happy-path test still green --- crates/stint-core/src/store/reference.rs | 74 +++++++++ crates/stint-core/src/sync/refresh.rs | 52 ++++-- crates/stint-core/tests/sync_refresh.rs | 192 ++++++++++++++++++++++- 3 files changed, 302 insertions(+), 16 deletions(-) diff --git a/crates/stint-core/src/store/reference.rs b/crates/stint-core/src/store/reference.rs index a4fed8c..103e5f9 100644 --- a/crates/stint-core/src/store/reference.rs +++ b/crates/stint-core/src/store/reference.rs @@ -188,4 +188,78 @@ impl Reference { .await?; Ok(rows) } + + /// Soft-archive any project whose id isn't in `keep`. Used by the + /// reference-data refresh path: if a project was deleted on Solidtime + /// we set `archived = 1` locally so the picker hides it but historical + /// time entries can still resolve the project name. + /// + /// An empty `keep` slice archives every project — that's the correct + /// semantics for "remote returned zero projects." + pub async fn archive_projects_not_in(&self, keep: &[&str]) -> Result<()> { + let now = time::now_utc(); + let keep_json = + serde_json::to_string(keep).map_err(|e| crate::Error::Invariant(e.to_string()))?; + sqlx::query( + r#"UPDATE projects + SET archived = 1, fetched_at = ? + WHERE archived = 0 + AND id NOT IN (SELECT value FROM json_each(?))"#, + ) + .bind(&now) + .bind(&keep_json) + .execute(self.store.pool()) + .await?; + Ok(()) + } + + /// Soft-archive any client whose id isn't in `keep`. Same rationale as + /// `archive_projects_not_in`. + pub async fn archive_clients_not_in(&self, keep: &[&str]) -> Result<()> { + let now = time::now_utc(); + let keep_json = + serde_json::to_string(keep).map_err(|e| crate::Error::Invariant(e.to_string()))?; + sqlx::query( + r#"UPDATE clients + SET archived = 1, fetched_at = ? + WHERE archived = 0 + AND id NOT IN (SELECT value FROM json_each(?))"#, + ) + .bind(&now) + .bind(&keep_json) + .execute(self.store.pool()) + .await?; + Ok(()) + } + + /// Hard-delete any task whose id isn't in `keep`. Tasks have no + /// `archived` column so we delete; historical entries that referenced + /// a deleted task will show no task name (the foreign key on + /// entries.task_id is unenforced and resolves via JOIN at display time). + pub async fn delete_tasks_not_in(&self, keep: &[&str]) -> Result<()> { + let keep_json = + serde_json::to_string(keep).map_err(|e| crate::Error::Invariant(e.to_string()))?; + sqlx::query( + r#"DELETE FROM tasks + WHERE id NOT IN (SELECT value FROM json_each(?))"#, + ) + .bind(&keep_json) + .execute(self.store.pool()) + .await?; + Ok(()) + } + + /// Hard-delete any tag whose id isn't in `keep`. + pub async fn delete_tags_not_in(&self, keep: &[&str]) -> Result<()> { + let keep_json = + serde_json::to_string(keep).map_err(|e| crate::Error::Invariant(e.to_string()))?; + sqlx::query( + r#"DELETE FROM tags + WHERE id NOT IN (SELECT value FROM json_each(?))"#, + ) + .bind(&keep_json) + .execute(self.store.pool()) + .await?; + Ok(()) + } } diff --git a/crates/stint-core/src/sync/refresh.rs b/crates/stint-core/src/sync/refresh.rs index c2ff136..5ef265d 100644 --- a/crates/stint-core/src/sync/refresh.rs +++ b/crates/stint-core/src/sync/refresh.rs @@ -7,56 +7,78 @@ use crate::{ Result, }; +/// Pull all four reference-data entity lists from Solidtime and reconcile +/// the local cache. For each entity we: +/// +/// 1. Upsert the rows the server returned. +/// 2. Prune any local row that is no longer in the server's set: +/// - **Projects + clients** are soft-archived (`archived = 1`) so the +/// picker hides them but historical entries can still resolve the +/// project / client name. +/// - **Tasks + tags** are hard-deleted (no `archived` column). Entries +/// with a dangling `task_id` simply show no task name. +/// +/// Without the prune step (the pre-Phase-6 behavior), anything deleted on +/// Solidtime lingered locally forever — the user saw stale projects in +/// every picker until they nuked the database. pub async fn refresh_reference_data(store: &Store, client: &SolidtimeClient) -> Result<()> { let r = Reference::new(store.clone()); let clients = client.list_clients().await?; + let client_ids: Vec<&str> = clients.iter().map(|c| c.id.as_str()).collect(); let client_rows: Vec = clients - .into_iter() + .iter() .map(|c| ClientRow { - id: c.id, - name: c.name, + id: c.id.clone(), + name: c.name.clone(), archived: if c.archived { 1 } else { 0 }, }) .collect(); r.upsert_clients(&client_rows).await?; + r.archive_clients_not_in(&client_ids).await?; let projects = client.list_projects().await?; + let project_ids: Vec<&str> = projects.iter().map(|p| p.id.as_str()).collect(); let proj_rows: Vec = projects - .into_iter() + .iter() .map(|p| ProjectRow { - id: p.id, - name: p.name, - color: p.color, - client_id: p.client_id, + id: p.id.clone(), + name: p.name.clone(), + color: p.color.clone(), + client_id: p.client_id.clone(), client_name: None, archived: if p.archived { 1 } else { 0 }, billable_default: if p.is_billable { 1 } else { 0 }, }) .collect(); r.upsert_projects(&proj_rows).await?; + r.archive_projects_not_in(&project_ids).await?; let tasks = client.list_tasks().await?; + let task_ids: Vec<&str> = tasks.iter().map(|t| t.id.as_str()).collect(); let task_rows: Vec = tasks - .into_iter() + .iter() .map(|t| TaskRow { - id: t.id, - project_id: t.project_id, - name: t.name, + id: t.id.clone(), + project_id: t.project_id.clone(), + name: t.name.clone(), done: if t.done { 1 } else { 0 }, }) .collect(); r.upsert_tasks(&task_rows).await?; + r.delete_tasks_not_in(&task_ids).await?; let tags = client.list_tags().await?; + let tag_ids: Vec<&str> = tags.iter().map(|t| t.id.as_str()).collect(); let tag_rows: Vec = tags - .into_iter() + .iter() .map(|t| TagRow { - id: t.id, - name: t.name, + id: t.id.clone(), + name: t.name.clone(), }) .collect(); r.upsert_tags(&tag_rows).await?; + r.delete_tags_not_in(&tag_ids).await?; Ok(()) } diff --git a/crates/stint-core/tests/sync_refresh.rs b/crates/stint-core/tests/sync_refresh.rs index ec97676..375fefe 100644 --- a/crates/stint-core/tests/sync_refresh.rs +++ b/crates/stint-core/tests/sync_refresh.rs @@ -1,7 +1,7 @@ mod common; use stint_core::solidtime::SolidtimeClient; -use stint_core::store::reference::Reference; +use stint_core::store::reference::{ClientRow, ProjectRow, Reference, TagRow, TaskRow}; use stint_core::sync::refresh::refresh_reference_data; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -50,3 +50,193 @@ async fn refresh_reference_data_writes_clients_projects_tasks_tags() { assert_eq!(r.list_tasks("p1").await.unwrap().len(), 1); assert_eq!(r.list_tags().await.unwrap().len(), 1); } + +/// When a project/client/task/tag is deleted on Solidtime, the next +/// refresh should reconcile: projects + clients soft-archived (so +/// historical entries still resolve names), tasks + tags hard-deleted. +#[tokio::test] +async fn refresh_reconciles_remote_side_deletions() { + let env = common::setup().await; + let server = MockServer::start().await; + + // Seed local state with TWO of each entity. + let r = Reference::new(env.store.clone()); + r.upsert_clients(&[ + ClientRow { + id: "c1".into(), + name: "Keep".into(), + archived: 0, + }, + ClientRow { + id: "c2".into(), + name: "ToArchive".into(), + archived: 0, + }, + ]) + .await + .unwrap(); + r.upsert_projects(&[ + ProjectRow { + id: "p1".into(), + name: "Keep".into(), + color: None, + client_id: None, + client_name: None, + archived: 0, + billable_default: 0, + }, + ProjectRow { + id: "p2".into(), + name: "DeletedRemotely".into(), + color: None, + client_id: None, + client_name: None, + archived: 0, + billable_default: 0, + }, + ]) + .await + .unwrap(); + r.upsert_tasks(&[ + TaskRow { + id: "t1".into(), + project_id: "p1".into(), + name: "Keep".into(), + done: 0, + }, + TaskRow { + id: "t2".into(), + project_id: "p1".into(), + name: "Gone".into(), + done: 0, + }, + ]) + .await + .unwrap(); + r.upsert_tags(&[ + TagRow { + id: "g1".into(), + name: "keep".into(), + }, + TagRow { + id: "g2".into(), + name: "gone".into(), + }, + ]) + .await + .unwrap(); + + // Remote now returns ONLY the c1/p1/t1/g1 rows — c2/p2/t2/g2 disappeared. + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/clients")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{ "id": "c1", "name": "Keep", "archived": false }] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/projects")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{ "id": "p1", "name": "Keep", "color": null, "client_id": null, "archived": false }] + }))) + .mount(&server).await; + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/tasks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{ "id": "t1", "project_id": "p1", "name": "Keep", "done": false }] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/tags")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{ "id": "g1", "name": "keep" }] + }))) + .mount(&server) + .await; + + let client = SolidtimeClient::with_api_token(&server.uri(), "t").with_org("org-1"); + refresh_reference_data(&env.store, &client).await.unwrap(); + + // Projects + clients soft-archived: row still exists, archived = 1. + let projects = r.list_projects().await.unwrap(); + assert_eq!(projects.len(), 2); + let p1 = projects.iter().find(|p| p.id == "p1").unwrap(); + let p2 = projects.iter().find(|p| p.id == "p2").unwrap(); + assert_eq!( + p1.archived, 0, + "p1 remained on remote, must stay un-archived" + ); + assert_eq!(p2.archived, 1, "p2 disappeared remotely, must be archived"); + + let clients = r.list_clients().await.unwrap(); + let c1 = clients.iter().find(|c| c.id == "c1").unwrap(); + let c2 = clients.iter().find(|c| c.id == "c2").unwrap(); + assert_eq!(c1.archived, 0); + assert_eq!(c2.archived, 1); + + // Tasks + tags hard-deleted: row is gone entirely. + let tasks = r.list_tasks("p1").await.unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].id, "t1"); + + let tags = r.list_tags().await.unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].id, "g1"); +} + +/// Edge case: if a row was archived locally but is back active on the +/// server, the upsert should un-archive it (covered by the existing +/// upsert path) — and the prune must not re-archive it. Belt + braces. +#[tokio::test] +async fn refresh_does_not_re_archive_a_resurrected_project() { + let env = common::setup().await; + let server = MockServer::start().await; + + let r = Reference::new(env.store.clone()); + r.upsert_projects(&[ProjectRow { + id: "p1".into(), + name: "WasArchived".into(), + color: None, + client_id: None, + client_name: None, + archived: 1, // locally archived + billable_default: 0, + }]) + .await + .unwrap(); + + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/clients")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/projects")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{ "id": "p1", "name": "WasArchived", "color": null, "client_id": null, "archived": false }] + }))) + .mount(&server).await; + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/tasks")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/v1/organizations/org-1/tags")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": []}))) + .mount(&server) + .await; + + let client = SolidtimeClient::with_api_token(&server.uri(), "t").with_org("org-1"); + refresh_reference_data(&env.store, &client).await.unwrap(); + + let p1 = r + .list_projects() + .await + .unwrap() + .into_iter() + .find(|p| p.id == "p1") + .unwrap(); + assert_eq!(p1.archived, 0, "remote says active → local must un-archive"); +} From 4862cc40f549fb364277a5a2ea328d0893fd73b5 Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Mon, 1 Jun 2026 10:51:24 -0400 Subject: [PATCH 2/4] fix(sync): emit projects:changed event so picker hosts auto-refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the prune fix, refresh_reference_data archives projects/clients locally when they're gone from Solidtime. But the UI surfaces caching the project list (Popover, TimerCard, EditEntryDialog) had no way to know they should refetch — so the user clicked "Sync now" and still saw stale projects in the picker until they relaunched the app. Fix: emit a new "projects:changed" Tauri event from both commands::sync::sync_now and commands::projects::refresh_projects after refresh_reference_data succeeds. UI surfaces that own a projects/tasks createResource now listen and call refetch on the event. refresh_projects signature gains AppHandle as its first parameter (matches the rest of the emit-capable commands). projects_commands.rs tests updated to pass handle.clone() through. End-to-end: User clicks Sync now → drain_once → refresh_reference_data (upsert + prune) → app.emit("projects:changed") → Popover/TimerCard/EditEntryDialog listeners fire refetch → picker shows current state. --- crates/stint-app/src/commands/projects.rs | 12 ++++++-- crates/stint-app/src/commands/sync.rs | 14 ++++++++-- crates/stint-app/src/sync_worker.rs | 4 +++ crates/stint-app/tests/projects_commands.rs | 8 ++++-- ui/src/components/EditEntryDialog.tsx | 31 +++++++++++++++++---- ui/src/components/TimerCard.tsx | 28 ++++++++++++++----- ui/src/routes/Popover.tsx | 22 +++++++++++---- 7 files changed, 93 insertions(+), 26 deletions(-) diff --git a/crates/stint-app/src/commands/projects.rs b/crates/stint-app/src/commands/projects.rs index 8eb8d3f..0c6dd2a 100644 --- a/crates/stint-app/src/commands/projects.rs +++ b/crates/stint-app/src/commands/projects.rs @@ -1,5 +1,6 @@ use crate::app_state::AppState; use crate::commands::{store, AppError}; +use crate::sync_worker::EVENT_PROJECTS_CHANGED; use serde::Serialize; use stint_core::config::{secrets::Secrets, Settings}; use stint_core::solidtime::auth::build_token_provider; @@ -7,7 +8,7 @@ use stint_core::solidtime::SolidtimeClient; use stint_core::store::reference::{ProjectRow, Reference}; use stint_core::sync::refresh::refresh_reference_data; use stint_core::verbs::{self, TaskView}; -use tauri::State; +use tauri::{AppHandle, Emitter, Runtime, State}; use tokio::sync::RwLock; async fn build_client(store: &stint_core::store::Store) -> Result { @@ -84,10 +85,17 @@ pub async fn list_tasks( } #[tauri::command] -pub async fn refresh_projects(state: State<'_, RwLock>) -> Result { +pub async fn refresh_projects( + app: AppHandle, + state: State<'_, RwLock>, +) -> Result { let store = store(&state).await; let client = build_client(&store).await?; refresh_reference_data(&store, &client).await?; + // Notify UI surfaces so the picker reflects the refreshed list + // immediately, including any locally-archived rows the prune step + // produced. + let _ = app.emit(EVENT_PROJECTS_CHANGED, 0u32); let r = Reference::new((*store).clone()); Ok(r.list_projects().await?.len()) } diff --git a/crates/stint-app/src/commands/sync.rs b/crates/stint-app/src/commands/sync.rs index 66d46a5..5b423c5 100644 --- a/crates/stint-app/src/commands/sync.rs +++ b/crates/stint-app/src/commands/sync.rs @@ -1,6 +1,6 @@ use crate::app_state::AppState; use crate::commands::{store, AppError}; -use crate::sync_worker::EVENT_ENTRIES_CHANGED; +use crate::sync_worker::{EVENT_ENTRIES_CHANGED, EVENT_PROJECTS_CHANGED}; use serde::Serialize; use stint_core::config::{secrets::Secrets, Settings}; use stint_core::solidtime::auth::build_token_provider; @@ -168,8 +168,16 @@ pub async fn sync_now( // shouldn't mask a successful queue drain. The background sync loop // re-runs this every 15 ticks anyway, but users expect "Sync now" to // pick up project metadata changes (e.g. is_billable) on demand. - if let Err(e) = refresh_reference_data(&store, &client).await { - tracing::warn!(error = %e, "Sync now: reference refresh failed"); + match refresh_reference_data(&store, &client).await { + Ok(_) => { + // Notify UI surfaces that consume project/task lists so they + // refetch and reflect any server-side additions, edits, or + // (after the prune fix) archives. + let _ = app.emit(EVENT_PROJECTS_CHANGED, 0u32); + } + Err(e) => { + tracing::warn!(error = %e, "Sync now: reference refresh failed"); + } } if n > 0 { let _ = app.emit(EVENT_ENTRIES_CHANGED, n); diff --git a/crates/stint-app/src/sync_worker.rs b/crates/stint-app/src/sync_worker.rs index 4ece48a..a1ccab7 100644 --- a/crates/stint-app/src/sync_worker.rs +++ b/crates/stint-app/src/sync_worker.rs @@ -20,6 +20,10 @@ use tracing::{debug, info, warn}; pub const EVENT_ENTRIES_CHANGED: &str = "entries:changed"; pub const EVENT_PULL_CONFLICT: &str = "pull:conflict"; +/// Emitted after refresh_reference_data succeeds. UI surfaces that fetch +/// projects / tasks / clients listen for this so they refetch and reflect +/// server-side adds, edits, archives, and (post-prune-fix) deletions. +pub const EVENT_PROJECTS_CHANGED: &str = "projects:changed"; const TICK: Duration = Duration::from_secs(30); diff --git a/crates/stint-app/tests/projects_commands.rs b/crates/stint-app/tests/projects_commands.rs index 3ff21bb..e10abf5 100644 --- a/crates/stint-app/tests/projects_commands.rs +++ b/crates/stint-app/tests/projects_commands.rs @@ -178,7 +178,9 @@ async fn refresh_projects_populates_local_cache_from_solidtime() { seed_solidtime_config(&ctx.store, &server.uri(), Some("org-1")).await; let handle = ctx.handle(); - let n = refresh_projects(handle.state()).await.unwrap(); + let n = refresh_projects(handle.clone(), handle.state()) + .await + .unwrap(); assert_eq!(n, 1); let rows = list_projects(handle.state()).await.unwrap(); @@ -190,7 +192,9 @@ async fn refresh_projects_populates_local_cache_from_solidtime() { async fn refresh_projects_errors_when_solidtime_url_missing() { let ctx = common::make_app().await; let handle = ctx.handle(); - let err = refresh_projects(handle.state()).await.unwrap_err(); + let err = refresh_projects(handle.clone(), handle.state()) + .await + .unwrap_err(); assert!( err.message.contains("solidtime.url"), "got: {}", diff --git a/ui/src/components/EditEntryDialog.tsx b/ui/src/components/EditEntryDialog.tsx index 1fecd75..0471014 100644 --- a/ui/src/components/EditEntryDialog.tsx +++ b/ui/src/components/EditEntryDialog.tsx @@ -1,4 +1,11 @@ -import { createMemo, createResource, createSignal, Show } from "solid-js"; +import { + createMemo, + createResource, + createSignal, + onCleanup, + Show, +} from "solid-js"; +import { listen } from "@tauri-apps/api/event"; import { api } from "~/api"; import { fromLocalHHMM, toLocalHHMM } from "~/lib/entryFormat"; import type { Entry } from "~/types"; @@ -26,13 +33,25 @@ export default function EditEntryDialog(props: { const [err, setErr] = createSignal(null); const [confirmingDelete, setConfirmingDelete] = createSignal(false); - const [projects] = createResource(() => api.listProjects(), { - initialValue: [], - }); + const [projects, { refetch: refetchProjects }] = createResource( + () => api.listProjects(), + { initialValue: [] }, + ); // All tasks across all projects, eager-loaded once. The combined picker // groups by project_id itself. - const [tasks] = createResource(() => api.listTasks(null), { - initialValue: [], + const [tasks, { refetch: refetchTasks }] = createResource( + () => api.listTasks(null), + { initialValue: [] }, + ); + + // projects:changed fires after refresh_reference_data finishes. Refetch + // so the pickers reflect server-side adds, edits, archives, and deletions. + const unlistenProjects = listen("projects:changed", () => { + refetchProjects(); + refetchTasks(); + }); + onCleanup(() => { + unlistenProjects.then((fn) => fn()).catch(() => {}); }); const isCompleted = createMemo(() => Boolean(props.entry.end_at)); diff --git a/ui/src/components/TimerCard.tsx b/ui/src/components/TimerCard.tsx index d3dfea7..d4f88d2 100644 --- a/ui/src/components/TimerCard.tsx +++ b/ui/src/components/TimerCard.tsx @@ -1,4 +1,5 @@ -import { Show, createResource, createSignal } from "solid-js"; +import { Show, createResource, createSignal, onCleanup } from "solid-js"; +import { listen } from "@tauri-apps/api/event"; import { api } from "~/api"; import Duration from "./Duration"; import StartAtPicker, { type StartAtValue } from "./StartAtPicker"; @@ -16,16 +17,18 @@ export default function TimerCard() { const [taskId, setTaskId] = createSignal(null); const [billable, setBillable] = createSignal(false); const [startAt, setStartAt] = createSignal(null); - const [projects] = createResource(() => api.listProjects(), { - initialValue: [], - }); + const [projects, { refetch: refetchProjects }] = createResource( + () => api.listProjects(), + { initialValue: [] }, + ); const projectList = () => projects() ?? []; // All tasks across all projects, eager-loaded once. The combined picker // groups by project_id itself, so we don't have to refetch per selection. - const [tasks] = createResource(() => api.listTasks(null), { - initialValue: [], - }); + const [tasks, { refetch: refetchTasks }] = createResource( + () => api.listTasks(null), + { initialValue: [] }, + ); /// Apply a project+task change to a running entry. Always clears the /// task first so a queued patch can't carry a task_id from the old @@ -46,6 +49,17 @@ export default function TimerCard() { await timer.refresh(); } + // projects:changed fires after refresh_reference_data finishes. Refetch + // the project + task lists so the combined picker reflects server-side + // adds, edits, archives, and (post-prune-fix) deletions. + const unlistenProjects = listen("projects:changed", () => { + refetchProjects(); + refetchTasks(); + }); + onCleanup(() => { + unlistenProjects.then((fn) => fn()).catch(() => {}); + }); + return (
api.listToday(), { initialValue: [] }, ); - const [projects] = createResource(() => api.listProjects(), { - initialValue: [], - }); + const [projects, { refetch: refetchProjects }] = createResource( + () => api.listProjects(), + { initialValue: [] }, + ); // All tasks across projects, eager-loaded once. The combined picker // groups by project_id itself. - const [tasks] = createResource(() => api.listTasks(null), { - initialValue: [], - }); + const [tasks, { refetch: refetchTasks }] = createResource( + () => api.listTasks(null), + { initialValue: [] }, + ); const unlistenEntries = listen("entries:changed", () => refetchEntries()); + // projects:changed fires after refresh_reference_data finishes (Sync now, + // refresh_projects). Refetch so the combined picker reflects server-side + // adds, edits, archives, and deletions immediately. + const unlistenProjects = listen("projects:changed", () => { + refetchProjects(); + refetchTasks(); + }); onCleanup(() => { unlistenEntries.then((fn) => fn()).catch(() => {}); + unlistenProjects.then((fn) => fn()).catch(() => {}); }); const totalSeconds = () => From 8a58e1f2cca36dccedb2cd595ff403dc35a6663a Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Mon, 1 Jun 2026 11:13:56 -0400 Subject: [PATCH 3/4] test(ui): mock @tauri-apps/api/event in tests that mount listen() consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After adding listen('projects:changed') to TimerCard / EditEntryDialog / Popover, vitest's coverage run hit 'TypeError: Cannot read properties of undefined (reading transformCallback)' from the real @tauri-apps/api event module — window.__TAURI_INTERNALS__ doesn't exist in jsdom. Plain 'vitest run' still passed (the rejection was async, after tests finished), so the build job was green but coverage exited 1 on the unhandled error. Fix: add the same vi.mock that Popover.test.tsx already uses to TimerCard.test.tsx, EditEntryDialog.test.tsx, and EntryRow.test.tsx (EntryRow mounts EditEntryDialog when the row is clicked). --- ui/src/test/components/EditEntryDialog.test.tsx | 4 ++++ ui/src/test/components/EntryRow.test.tsx | 4 ++++ ui/src/test/components/TimerCard.test.tsx | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/ui/src/test/components/EditEntryDialog.test.tsx b/ui/src/test/components/EditEntryDialog.test.tsx index 75c6726..cc7d517 100644 --- a/ui/src/test/components/EditEntryDialog.test.tsx +++ b/ui/src/test/components/EditEntryDialog.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { fireEvent, render } from "@solidjs/testing-library"; +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + vi.mock("~/api", () => ({ api: { listProjects: vi.fn().mockResolvedValue([ diff --git a/ui/src/test/components/EntryRow.test.tsx b/ui/src/test/components/EntryRow.test.tsx index f151d70..b43511e 100644 --- a/ui/src/test/components/EntryRow.test.tsx +++ b/ui/src/test/components/EntryRow.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { fireEvent, render } from "@solidjs/testing-library"; +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + vi.mock("~/api", () => ({ api: { listProjects: vi.fn().mockResolvedValue([ diff --git a/ui/src/test/components/TimerCard.test.tsx b/ui/src/test/components/TimerCard.test.tsx index feb0e9b..3c141e9 100644 --- a/ui/src/test/components/TimerCard.test.tsx +++ b/ui/src/test/components/TimerCard.test.tsx @@ -18,6 +18,10 @@ vi.mock("~/stores/timer", () => ({ useTimerStore: () => storeMock, })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + vi.mock("~/api", () => ({ api: { listProjects: vi.fn().mockResolvedValue([ From acc879f70fd3fbfdf51c6d833ddddc82c12fa2e7 Mon Sep 17 00:00:00 2001 From: Mario Meyer Date: Tue, 16 Jun 2026 16:26:39 -0400 Subject: [PATCH 4/4] fix(sync): refetch all picker hosts + replay Spotlight slices post-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex P2 flags on PR #30: 1) projects:changed reached TimerCard / Popover / EditEntryDialog but not EntryList (project + task labels on each row) or CalendarSection (project pills on calendar events). Result: after Solidtime-side rename or deletion the picker updates but mounted lists kept showing stale labels until remount. Both now subscribe + refetch. 2) Sync now (and refresh_projects) only emitted the frontend event; they didn't replay the Spotlight slices. pull_worker.rs does it on its 5-min tick, so deleted projects/tasks lingered in Spotlight results until a successful pull tick. New crate-private helper commands::sync::replace_spotlight_slices() mirrors pull_worker's notify_indexer(ProjectsReplaced) + notify_indexer(TasksReplaced) block. Called from both refresh paths. EntryList.test.tsx gains the @tauri-apps/api/event mock alongside the existing ones added in 5396157 — same root cause: jsdom's window has no __TAURI_INTERNALS__ so any listen() call rejects. --- crates/stint-app/src/commands/projects.rs | 3 +++ crates/stint-app/src/commands/sync.rs | 26 +++++++++++++++++++++++ ui/src/components/CalendarSection.tsx | 11 +++++++--- ui/src/components/EntryList.tsx | 23 +++++++++++++++----- ui/src/test/components/EntryList.test.tsx | 4 ++++ 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/stint-app/src/commands/projects.rs b/crates/stint-app/src/commands/projects.rs index 0c6dd2a..4ac36a2 100644 --- a/crates/stint-app/src/commands/projects.rs +++ b/crates/stint-app/src/commands/projects.rs @@ -96,6 +96,9 @@ pub async fn refresh_projects( // immediately, including any locally-archived rows the prune step // produced. let _ = app.emit(EVENT_PROJECTS_CHANGED, 0u32); + // Replace Spotlight slices so deleted-on-Solidtime projects/tasks + // don't keep surfacing in Spotlight after the user manually refreshes. + crate::commands::sync::replace_spotlight_slices(&store).await; let r = Reference::new((*store).clone()); Ok(r.list_projects().await?.len()) } diff --git a/crates/stint-app/src/commands/sync.rs b/crates/stint-app/src/commands/sync.rs index 5b423c5..1eddb26 100644 --- a/crates/stint-app/src/commands/sync.rs +++ b/crates/stint-app/src/commands/sync.rs @@ -3,10 +3,12 @@ use crate::commands::{store, AppError}; use crate::sync_worker::{EVENT_ENTRIES_CHANGED, EVENT_PROJECTS_CHANGED}; use serde::Serialize; use stint_core::config::{secrets::Secrets, Settings}; +use stint_core::ffi::{notify_indexer, IndexerKind}; use stint_core::solidtime::auth::build_token_provider; use stint_core::solidtime::SolidtimeClient; use stint_core::store::queue::{FailedQueueRow, Queue}; use stint_core::sync::{drain_once, refresh::refresh_reference_data}; +use stint_core::verbs; use tauri::{AppHandle, Emitter, Runtime, State}; use tokio::sync::RwLock; @@ -174,6 +176,12 @@ pub async fn sync_now( // refetch and reflect any server-side additions, edits, or // (after the prune fix) archives. let _ = app.emit(EVENT_PROJECTS_CHANGED, 0u32); + // Also replace the Spotlight slices so deleted-on-Solidtime + // projects/tasks don't keep showing up in Spotlight results. + // pull_worker.rs does this on its tick; sync_now must too, + // otherwise the user clicks "Sync now", sees the picker + // update, then hits Spotlight and gets stale hits. + replace_spotlight_slices(&store).await; } Err(e) => { tracing::warn!(error = %e, "Sync now: reference refresh failed"); @@ -184,3 +192,21 @@ pub async fn sync_now( } Ok(n) } + +/// Replay the current project + task slices to the Spotlight indexer so +/// it forgets any rows that were just pruned. Mirrors the post-pull +/// block in pull_worker.rs; called from the user-driven refresh paths +/// (Sync now, Refresh projects) so deleted projects/tasks disappear +/// from Spotlight on the same click that drops them from the picker. +pub(crate) async fn replace_spotlight_slices(store: &stint_core::store::Store) { + if let Ok(projects) = verbs::list_projects(store).await { + if let Ok(payload) = serde_json::to_string(&projects) { + notify_indexer(IndexerKind::ProjectsReplaced, &payload); + } + } + if let Ok(tasks) = verbs::list_tasks(store, None).await { + if let Ok(payload) = serde_json::to_string(&tasks) { + notify_indexer(IndexerKind::TasksReplaced, &payload); + } + } +} diff --git a/ui/src/components/CalendarSection.tsx b/ui/src/components/CalendarSection.tsx index 9d31693..63cdb0d 100644 --- a/ui/src/components/CalendarSection.tsx +++ b/ui/src/components/CalendarSection.tsx @@ -73,9 +73,10 @@ export default function CalendarSection(props: { onEntriesChanged: () => void }) }, ); - const [projects] = createResource(() => api.listProjects(), { - initialValue: [], - }); + const [projects, { refetch: refetchProjects }] = createResource( + () => api.listProjects(), + { initialValue: [] }, + ); const projectNameById = createMemo(() => { const m = new Map(); @@ -90,8 +91,12 @@ export default function CalendarSection(props: { onEntriesChanged: () => void }) }; const unlistenChanged = listen("calendar:changed", () => refetch()); + // Server-side project rename/delete would otherwise leave the mounted + // calendar's project-label pills stale until the section is remounted. + const unlistenProjects = listen("projects:changed", () => refetchProjects()); onCleanup(() => { unlistenChanged.then((fn) => fn()).catch(() => {}); + unlistenProjects.then((fn) => fn()).catch(() => {}); }); const total = createMemo(() => diff --git a/ui/src/components/EntryList.tsx b/ui/src/components/EntryList.tsx index b2007cb..6b54041 100644 --- a/ui/src/components/EntryList.tsx +++ b/ui/src/components/EntryList.tsx @@ -1,4 +1,5 @@ -import { For, Show, createMemo, createResource } from "solid-js"; +import { For, Show, createMemo, createResource, onCleanup } from "solid-js"; +import { listen } from "@tauri-apps/api/event"; import { api } from "~/api"; import type { Entry } from "~/types"; import EntryRow from "./EntryRow"; @@ -11,9 +12,10 @@ export default function EntryList(props: { /// Fires after any save or delete in a row's edit dialog. Callers refetch here. onChange?: () => void; }) { - const [projects] = createResource(() => api.listProjects(), { - initialValue: [], - }); + const [projects, { refetch: refetchProjects }] = createResource( + () => api.listProjects(), + { initialValue: [] }, + ); const projectName = createMemo(() => { const map = new Map(); for (const p of projects() ?? []) map.set(p.id, p.name); @@ -28,11 +30,22 @@ export default function EntryList(props: { const needsTasks = createMemo(() => props.entries.some((e) => e.task_id != null), ); - const [tasks] = createResource( + const [tasks, { refetch: refetchTasks }] = createResource( needsTasks, async (need) => (need ? await api.listTasks() : []), { initialValue: [] }, ); + + // projects:changed fires after refresh_reference_data succeeds. A + // Solidtime-side rename or deletion otherwise leaves stale labels on + // historical entries until the list is unmounted and remounted. + const unlistenProjects = listen("projects:changed", () => { + refetchProjects(); + refetchTasks(); + }); + onCleanup(() => { + unlistenProjects.then((fn) => fn()).catch(() => {}); + }); const taskName = createMemo(() => { const map = new Map(); for (const t of tasks() ?? []) map.set(t.solidtime_id, t.name); diff --git a/ui/src/test/components/EntryList.test.tsx b/ui/src/test/components/EntryList.test.tsx index 9758a49..3e622c4 100644 --- a/ui/src/test/components/EntryList.test.tsx +++ b/ui/src/test/components/EntryList.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render } from "@solidjs/testing-library"; +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + vi.mock("~/api", () => ({ api: { listProjects: vi.fn().mockResolvedValue([