Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions crates/stint-app/src/commands/projects.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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;
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<SolidtimeClient, AppError> {
Expand Down Expand Up @@ -84,10 +85,20 @@ pub async fn list_tasks(
}

#[tauri::command]
pub async fn refresh_projects(state: State<'_, RwLock<AppState>>) -> Result<usize, AppError> {
pub async fn refresh_projects<R: Runtime>(
app: AppHandle<R>,
state: State<'_, RwLock<AppState>>,
) -> Result<usize, AppError> {
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);
Comment on lines 94 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace Spotlight slices after pruning reference data

When a refresh archives a removed project or deletes a removed task, this path only emits the frontend event. I checked the repo-wide ProjectsReplaced and TasksReplaced uses: the only Spotlight slice replacement is in crates/stint-app/src/pull_worker.rs:57-67, so refresh_projects can leave deleted items available through Spotlight until a later successful pull tick. This is especially visible after the Settings-triggered refresh and can persist if pulls are not configured or keep failing. Notify the indexer with the reconciled project and task lists here as well.

Useful? React with 👍 / 👎.

// 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())
}
40 changes: 37 additions & 3 deletions crates/stint-app/src/commands/sync.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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::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;

Expand Down Expand Up @@ -168,11 +170,43 @@ pub async fn sync_now<R: Runtime>(
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refetch every mounted project/task consumer after sync

When sync_now refreshes metadata while the Today view is already mounted, this event updates TimerCard but not EntryList: ui/src/components/EntryList.tsx:14-35 loads its project and task resources only once. A Solidtime-side project rename or task deletion therefore leaves the labels on existing entries stale until the list is remounted, even though the picker updates immediately. The same omission affects the mounted calendar labels in ui/src/components/CalendarSection.tsx:76-94. Subscribe those consumers to projects:changed and refetch both resources where applicable.

Useful? React with 👍 / 👎.

// 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");
}
}
if n > 0 {
let _ = app.emit(EVENT_ENTRIES_CHANGED, n);
}
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);
}
}
}
4 changes: 4 additions & 0 deletions crates/stint-app/src/sync_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 6 additions & 2 deletions crates/stint-app/tests/projects_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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: {}",
Expand Down
74 changes: 74 additions & 0 deletions crates/stint-core/src/store/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
}
52 changes: 37 additions & 15 deletions crates/stint-core/src/sync/refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientRow> = 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<ProjectRow> = 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<TaskRow> = 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<TagRow> = 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(())
}
Loading
Loading