app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 9fad5b5a6cf1b4e22e6519c0e6332be25d5b1601
parent 3e1716151601f0e8bff2f8d36c7e354c7e93f1f8
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 00:43:03 +0000

app: add typed activity journal foundation

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 157++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/launchers/desktop/src/window.rs | 7++++---
Mcrates/shared/models/src/lib.rs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/shared/sqlite/migrations/0002_activity_journal.sql | 33+++++++++++++++++++++++++++++++++
Acrates/shared/sqlite/src/activity.rs | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/sqlite/src/error.rs | 2++
Mcrates/shared/sqlite/src/lib.rs | 31++++++++++++++++++++++++++++++-
Mcrates/shared/sqlite/src/migrations.rs | 14++++++++++----
Mcrates/shared/state/src/lib.rs | 12+++---------
9 files changed, 713 insertions(+), 31 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -1,13 +1,20 @@ +use std::fmt; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use radroots_app_core::{AppRuntimePathsError, AppRuntimeRoots}; -use radroots_app_models::{AppMode, SettingsSection, TodayAgendaProjection}; -use radroots_app_sqlite::{AppSqliteError, AppSqliteStore, DatabaseTarget}; +use radroots_app_models::{ + AppActivityContext, AppActivityKind, AppMode, SettingsPreference, SettingsSection, + TodayAgendaProjection, +}; +use radroots_app_sqlite::{ + APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, +}; use radroots_app_state::{ AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, - InMemoryAppStateRepository, SettingsPreference, + InMemoryAppStateRepository, }; use thiserror::Error; +use tracing::error; const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; @@ -45,18 +52,34 @@ impl DesktopAppRuntime { } pub fn select_settings_section(&self, section: SettingsSection) -> bool { - self.lock_state_mut() + let changed = self + .lock_state_mut() .state_store - .apply_in_memory(AppStateCommand::select_settings_section(section)) + .apply_in_memory(AppStateCommand::select_settings_section(section)); + + if changed { + let _ = self.record_activity(AppActivityKind::SettingsSectionSelected { section }); + } + + changed } pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool { - self.lock_state_mut() - .state_store - .apply_in_memory(AppStateCommand::SetSettingsPreference { + let changed = self.lock_state_mut().state_store.apply_in_memory( + AppStateCommand::SetSettingsPreference { + preference, + enabled, + }, + ); + + if changed { + let _ = self.record_activity(AppActivityKind::SettingsPreferenceUpdated { preference, enabled, - }) + }); + } + + changed } #[allow(dead_code)] @@ -66,6 +89,23 @@ impl DesktopAppRuntime { .apply_in_memory(AppStateCommand::replace_today_agenda(projection)) } + pub fn record_home_opened(&self) -> bool { + self.record_activity(AppActivityKind::HomeOpened) + } + + pub fn record_settings_opened(&self, section: SettingsSection) -> bool { + self.record_activity(AppActivityKind::SettingsOpened { section }) + } + + #[allow(dead_code)] + pub fn activity_context(&self, limit: Option<usize>) -> Option<AppActivityContext> { + self.lock_state().sqlite_store.as_ref().and_then(|store| { + store + .load_activity_context(limit.unwrap_or(APP_ACTIVITY_CONTEXT_LIMIT)) + .ok() + }) + } + fn from_state(state: DesktopAppRuntimeState) -> Self { Self { state: Arc::new(Mutex::new(state)), @@ -79,6 +119,22 @@ impl DesktopAppRuntime { fn lock_state_mut(&self) -> MutexGuard<'_, DesktopAppRuntimeState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } + + fn record_activity(&self, kind: AppActivityKind) -> bool { + let result = self.lock_state().record_activity(kind.clone()); + if let Err(error) = result { + error!( + target: "activity", + event = "activity.record_failed", + activity_kind = kind.storage_key(), + error = %error, + "failed to record activity event" + ); + return false; + } + + true + } } #[derive(Clone, Debug)] @@ -88,12 +144,26 @@ pub struct DesktopAppRuntimeSummary { pub startup_issue: Option<String>, } -#[derive(Debug)] struct DesktopAppRuntimeState { state_store: AppStateStore<InMemoryAppStateRepository>, + sqlite_store: Option<AppSqliteStore>, startup_issue: Option<String>, } +impl fmt::Debug for DesktopAppRuntimeState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DesktopAppRuntimeState") + .field("state_store", &self.state_store) + .field( + "sqlite_store", + &self.sqlite_store.as_ref().map(|_| "available"), + ) + .field("startup_issue", &self.startup_issue) + .finish() + } +} + impl DesktopAppRuntimeState { fn try_bootstrap() -> Result<Self, DesktopAppRuntimeBootstrapError> { let roots = AppRuntimeRoots::current_desktop()?; @@ -106,6 +176,7 @@ impl DesktopAppRuntimeState { Ok(Self { state_store, + sqlite_store: Some(sqlite_store), startup_issue: None, }) } @@ -116,9 +187,17 @@ impl DesktopAppRuntimeState { app_mode: AppMode::Farmer, ..AppShellProjection::default() }), + sqlite_store: None, startup_issue: Some(error.to_string()), } } + + fn record_activity(&self, kind: AppActivityKind) -> Result<(), AppSqliteError> { + match self.sqlite_store.as_ref() { + Some(store) => store.record_activity_event(&kind), + None => Ok(()), + } + } } #[derive(Debug, Error)] @@ -137,12 +216,12 @@ mod tests { use radroots_app_core::{AppRuntimeHostEnvironment, AppRuntimePlatform, AppRuntimeRoots}; use radroots_app_models::{ - AppMode, FarmReadiness, FarmSummary, SettingsSection, ShellSection, TodayAgendaProjection, - TodaySetupTask, TodaySetupTaskKind, TodaySummary, + AppActivityKind, AppMode, FarmReadiness, FarmSummary, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; + use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ AppStateRepositoryError, AppStateStore, AppStateStoreError, InMemoryAppStateRepository, - SettingsPreference, }; use super::{APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeState}; @@ -177,6 +256,10 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), startup_issue: None, }); let cloned_runtime = runtime.clone(); @@ -206,6 +289,10 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), startup_issue: None, }); let cloned_runtime = runtime.clone(); @@ -271,4 +358,48 @@ mod tests { Some("app state repository load failed: state unavailable") ); } + + #[test] + fn runtime_records_activity_context_for_user_visible_actions() { + let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }); + + assert!(runtime.record_home_opened()); + assert!(runtime.record_settings_opened(SettingsSection::About)); + assert!(runtime.select_settings_section(SettingsSection::Settings)); + assert!(runtime.set_settings_preference(SettingsPreference::LaunchAtLogin, true)); + + let context = runtime + .activity_context(Some(8)) + .expect("activity context should load"); + + assert_eq!(context.recent_events.len(), 4); + assert_eq!( + context.recent_events[0].kind, + AppActivityKind::SettingsPreferenceUpdated { + preference: SettingsPreference::LaunchAtLogin, + enabled: true, + } + ); + assert_eq!( + context.recent_events[1].kind, + AppActivityKind::SettingsSectionSelected { + section: SettingsSection::Settings, + } + ); + assert_eq!( + context.recent_events[2].kind, + AppActivityKind::SettingsOpened { + section: SettingsSection::About, + } + ); + assert_eq!(context.recent_events[3].kind, AppActivityKind::HomeOpened); + } } diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -7,10 +7,9 @@ use gpui_component::{IconName, Root}; use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ - FulfillmentWindowSummary, OrderListRow, ProductListRow, TodayAgendaProjection, - TodaySetupTaskKind, + FulfillmentWindowSummary, OrderListRow, ProductListRow, SettingsPreference, + TodayAgendaProjection, TodaySetupTaskKind, }; -use radroots_app_state::SettingsPreference; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, action_button_compact, action_icon_button, app_checkbox_field, app_shared_label_text, @@ -41,6 +40,7 @@ pub fn open_home_window( cx: &mut App, runtime: DesktopAppRuntime, ) -> gpui::Entity<Root> { + let _ = runtime.record_home_opened(); let view = cx.new(|_| HomeView::new(runtime)); cx.new(|cx| Root::new(view, window, cx)) } @@ -51,6 +51,7 @@ pub fn open_settings_window( runtime: DesktopAppRuntime, initial_view: SettingsPanelViewKey, ) -> gpui::Entity<Root> { + let _ = runtime.record_settings_opened(initial_view); let _ = runtime.select_settings_section(initial_view); let view = cx.new(|_| SettingsWindowView::new(runtime)); cx.new(|cx| Root::new(view, window, cx)) diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -84,6 +84,26 @@ impl SettingsSection { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettingsPreference { + AllowRelayConnections, + UseMediaServers, + UseNip05, + LaunchAtLogin, +} + +impl SettingsPreference { + pub const fn storage_key(self) -> &'static str { + match self { + Self::AllowRelayConnections => "allow_relay_connections", + Self::UseMediaServers => "use_media_servers", + Self::UseNip05 => "use_nip05", + Self::LaunchAtLogin => "launch_at_login", + } + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "surface", content = "section", rename_all = "snake_case")] pub enum ShellSection { @@ -204,6 +224,7 @@ typed_id!(FarmId); typed_id!(ProductId); typed_id!(OrderId); typed_id!(FulfillmentWindowId); +typed_id!(ActivityEventId); #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -260,6 +281,50 @@ impl TodaySummary { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum AppActivityKind { + HomeOpened, + SettingsOpened { + section: SettingsSection, + }, + SettingsSectionSelected { + section: SettingsSection, + }, + SettingsPreferenceUpdated { + preference: SettingsPreference, + enabled: bool, + }, +} + +impl AppActivityKind { + pub const fn storage_key(&self) -> &'static str { + match self { + Self::HomeOpened => "home_opened", + Self::SettingsOpened { .. } => "settings_opened", + Self::SettingsSectionSelected { .. } => "settings_section_selected", + Self::SettingsPreferenceUpdated { .. } => "settings_preference_updated", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppActivityEvent { + pub activity_event_id: ActivityEventId, + pub recorded_at: String, + pub kind: AppActivityKind, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppActivityContext { + pub recent_events: Vec<AppActivityEvent>, +} + +impl AppActivityContext { + pub fn from_recent_events(recent_events: Vec<AppActivityEvent>) -> Self { + Self { recent_events } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ProductListRow { pub product_id: ProductId, pub farm_id: FarmId, @@ -320,7 +385,8 @@ impl TodayAgendaProjection { #[cfg(test)] mod tests { use super::{ - AppMode, BuyerSection, FarmId, FarmerSection, OrderListRow, ProductListRow, + ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, AppMode, + BuyerSection, FarmId, FarmerSection, OrderListRow, ProductListRow, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; @@ -459,4 +525,67 @@ mod tests { assert_eq!(projection.low_stock_products[0].stock_count, 2); assert!(projection.has_attention_items()); } + + #[test] + fn settings_preference_storage_keys_are_stable() { + assert_eq!( + SettingsPreference::AllowRelayConnections.storage_key(), + "allow_relay_connections" + ); + assert_eq!( + SettingsPreference::UseMediaServers.storage_key(), + "use_media_servers" + ); + assert_eq!(SettingsPreference::UseNip05.storage_key(), "use_nip05"); + assert_eq!( + SettingsPreference::LaunchAtLogin.storage_key(), + "launch_at_login" + ); + } + + #[test] + fn activity_kind_storage_keys_are_stable() { + assert_eq!(AppActivityKind::HomeOpened.storage_key(), "home_opened"); + assert_eq!( + AppActivityKind::SettingsOpened { + section: SettingsSection::About, + } + .storage_key(), + "settings_opened" + ); + assert_eq!( + AppActivityKind::SettingsSectionSelected { + section: SettingsSection::Settings, + } + .storage_key(), + "settings_section_selected" + ); + assert_eq!( + AppActivityKind::SettingsPreferenceUpdated { + preference: SettingsPreference::LaunchAtLogin, + enabled: true, + } + .storage_key(), + "settings_preference_updated" + ); + } + + #[test] + fn activity_context_preserves_recent_event_order() { + let first = AppActivityEvent { + activity_event_id: ActivityEventId::new(), + recorded_at: "2026-04-18T00:00:00.000Z".to_owned(), + kind: AppActivityKind::HomeOpened, + }; + let second = AppActivityEvent { + activity_event_id: ActivityEventId::new(), + recorded_at: "2026-04-18T00:01:00.000Z".to_owned(), + kind: AppActivityKind::SettingsOpened { + section: SettingsSection::About, + }, + }; + let context = AppActivityContext::from_recent_events(vec![second.clone(), first.clone()]); + + assert_eq!(context.recent_events, vec![second, first]); + } } diff --git a/crates/shared/sqlite/migrations/0002_activity_journal.sql b/crates/shared/sqlite/migrations/0002_activity_journal.sql @@ -0,0 +1,33 @@ +CREATE TABLE activity_events ( + activity_event_id TEXT PRIMARY KEY, + recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + event_kind TEXT NOT NULL, + settings_section TEXT, + settings_preference TEXT, + preference_enabled INTEGER, + CHECK ( + event_kind IN ( + 'home_opened', + 'settings_opened', + 'settings_section_selected', + 'settings_preference_updated' + ) + ), + CHECK ( + settings_section IS NULL + OR settings_section IN ('account', 'settings', 'about') + ), + CHECK ( + settings_preference IS NULL + OR settings_preference IN ( + 'allow_relay_connections', + 'use_media_servers', + 'use_nip05', + 'launch_at_login' + ) + ), + CHECK (preference_enabled IS NULL OR preference_enabled IN (0, 1)) +); + +CREATE INDEX activity_events_recorded_at_idx + ON activity_events(recorded_at DESC, activity_event_id DESC); diff --git a/crates/shared/sqlite/src/activity.rs b/crates/shared/sqlite/src/activity.rs @@ -0,0 +1,357 @@ +use radroots_app_models::{ + ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, SettingsPreference, + SettingsSection, +}; +use rusqlite::{Connection, params}; + +use crate::AppSqliteError; + +pub const APP_ACTIVITY_CONTEXT_LIMIT: usize = 64; +pub const APP_ACTIVITY_RETENTION_LIMIT: i64 = 5_000; + +pub struct AppActivityRepository<'a> { + connection: &'a Connection, +} + +impl<'a> AppActivityRepository<'a> { + pub fn new(connection: &'a Connection) -> Self { + Self { connection } + } + + pub fn record(&self, kind: &AppActivityKind) -> Result<(), AppSqliteError> { + let activity_event_id = ActivityEventId::new().to_string(); + let event_kind = kind.storage_key(); + let settings_section = settings_section_value(kind); + let settings_preference = settings_preference_value(kind); + let preference_enabled = preference_enabled_value(kind); + + self.connection + .execute( + "INSERT INTO activity_events ( + activity_event_id, + event_kind, + settings_section, + settings_preference, + preference_enabled + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + activity_event_id, + event_kind, + settings_section, + settings_preference, + preference_enabled, + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "record activity event", + source, + })?; + + self.trim_retained_events(APP_ACTIVITY_RETENTION_LIMIT)?; + + Ok(()) + } + + pub fn load_recent(&self, limit: usize) -> Result<Vec<AppActivityEvent>, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "SELECT + activity_event_id, + recorded_at, + event_kind, + settings_section, + settings_preference, + preference_enabled + FROM activity_events + ORDER BY recorded_at DESC, activity_event_id DESC + LIMIT ?1", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare recent activity query", + source, + })?; + let rows = statement + .query_map([limit as i64], |row| { + let activity_event_id = row.get::<_, String>(0)?; + let recorded_at = row.get::<_, String>(1)?; + let event_kind = row.get::<_, String>(2)?; + let settings_section = row.get::<_, Option<String>>(3)?; + let settings_preference = row.get::<_, Option<String>>(4)?; + let preference_enabled = row.get::<_, Option<i64>>(5)?; + + Ok(( + activity_event_id, + recorded_at, + event_kind, + settings_section, + settings_preference, + preference_enabled, + )) + }) + .map_err(|source| AppSqliteError::Query { + operation: "query recent activity events", + source, + })?; + + rows.map(|row| { + let ( + activity_event_id, + recorded_at, + event_kind, + settings_section, + settings_preference, + preference_enabled, + ) = row.map_err(|source| AppSqliteError::Query { + operation: "read recent activity event row", + source, + })?; + + decode_activity_event( + &activity_event_id, + recorded_at, + event_kind, + settings_section, + settings_preference, + preference_enabled, + ) + }) + .collect() + } + + pub fn load_context(&self, limit: usize) -> Result<AppActivityContext, AppSqliteError> { + Ok(AppActivityContext::from_recent_events( + self.load_recent(limit)?, + )) + } + + fn trim_retained_events(&self, retention_limit: i64) -> Result<(), AppSqliteError> { + self.connection + .execute( + "DELETE FROM activity_events + WHERE activity_event_id IN ( + SELECT activity_event_id + FROM activity_events + ORDER BY recorded_at DESC, activity_event_id DESC + LIMIT -1 OFFSET ?1 + )", + [retention_limit], + ) + .map_err(|source| AppSqliteError::Query { + operation: "trim retained activity events", + source, + })?; + + Ok(()) + } +} + +fn decode_activity_event( + activity_event_id: &str, + recorded_at: String, + event_kind: String, + settings_section: Option<String>, + settings_preference: Option<String>, + preference_enabled: Option<i64>, +) -> Result<AppActivityEvent, AppSqliteError> { + let kind = match event_kind.as_str() { + "home_opened" => AppActivityKind::HomeOpened, + "settings_opened" => AppActivityKind::SettingsOpened { + section: decode_settings_section("settings_section", settings_section)?, + }, + "settings_section_selected" => AppActivityKind::SettingsSectionSelected { + section: decode_settings_section("settings_section", settings_section)?, + }, + "settings_preference_updated" => AppActivityKind::SettingsPreferenceUpdated { + preference: decode_settings_preference("settings_preference", settings_preference)?, + enabled: decode_preference_enabled(preference_enabled)?, + }, + other => { + return Err(AppSqliteError::DecodeEnum { + field: "event_kind", + value: other.to_owned(), + }); + } + }; + + Ok(AppActivityEvent { + activity_event_id: activity_event_id + .parse() + .map_err(|_| AppSqliteError::DecodeId { + field: "activity_event_id", + value: activity_event_id.to_owned(), + })?, + recorded_at, + kind, + }) +} + +fn decode_settings_section( + field: &'static str, + value: Option<String>, +) -> Result<SettingsSection, AppSqliteError> { + match value.as_deref() { + Some("account") => Ok(SettingsSection::Account), + Some("settings") => Ok(SettingsSection::Settings), + Some("about") => Ok(SettingsSection::About), + Some(other) => Err(AppSqliteError::DecodeEnum { + field, + value: other.to_owned(), + }), + None => Err(AppSqliteError::MissingColumn { field }), + } +} + +fn decode_settings_preference( + field: &'static str, + value: Option<String>, +) -> Result<SettingsPreference, AppSqliteError> { + match value.as_deref() { + Some("allow_relay_connections") => Ok(SettingsPreference::AllowRelayConnections), + Some("use_media_servers") => Ok(SettingsPreference::UseMediaServers), + Some("use_nip05") => Ok(SettingsPreference::UseNip05), + Some("launch_at_login") => Ok(SettingsPreference::LaunchAtLogin), + Some(other) => Err(AppSqliteError::DecodeEnum { + field, + value: other.to_owned(), + }), + None => Err(AppSqliteError::MissingColumn { field }), + } +} + +fn decode_preference_enabled(value: Option<i64>) -> Result<bool, AppSqliteError> { + match value { + Some(0) => Ok(false), + Some(1) => Ok(true), + Some(other) => Err(AppSqliteError::DecodeEnum { + field: "preference_enabled", + value: other.to_string(), + }), + None => Err(AppSqliteError::MissingColumn { + field: "preference_enabled", + }), + } +} + +fn settings_section_value(kind: &AppActivityKind) -> Option<&'static str> { + match kind { + AppActivityKind::SettingsOpened { section } + | AppActivityKind::SettingsSectionSelected { section } => Some(match section { + SettingsSection::Account => "account", + SettingsSection::Settings => "settings", + SettingsSection::About => "about", + }), + _ => None, + } +} + +fn settings_preference_value(kind: &AppActivityKind) -> Option<&'static str> { + match kind { + AppActivityKind::SettingsPreferenceUpdated { preference, .. } => { + Some(preference.storage_key()) + } + _ => None, + } +} + +fn preference_enabled_value(kind: &AppActivityKind) -> Option<i64> { + match kind { + AppActivityKind::SettingsPreferenceUpdated { enabled, .. } => Some(i64::from(*enabled)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use radroots_app_models::{AppActivityKind, SettingsPreference, SettingsSection}; + use rusqlite::Connection; + + use crate::{AppSqliteStore, DatabaseTarget}; + + use super::{APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository}; + + #[test] + fn activity_repository_records_and_loads_typed_recent_events() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let repository = store.activity_repository(); + + repository + .record(&AppActivityKind::HomeOpened) + .expect("record home opened"); + repository + .record(&AppActivityKind::SettingsOpened { + section: SettingsSection::About, + }) + .expect("record settings opened"); + repository + .record(&AppActivityKind::SettingsPreferenceUpdated { + preference: SettingsPreference::LaunchAtLogin, + enabled: true, + }) + .expect("record settings preference"); + + let recent = repository.load_recent(8).expect("load recent events"); + + assert_eq!(recent.len(), 3); + assert_eq!( + recent[0].kind, + AppActivityKind::SettingsPreferenceUpdated { + preference: SettingsPreference::LaunchAtLogin, + enabled: true, + } + ); + assert_eq!( + recent[1].kind, + AppActivityKind::SettingsOpened { + section: SettingsSection::About, + } + ); + assert_eq!(recent[2].kind, AppActivityKind::HomeOpened); + } + + #[test] + fn activity_repository_load_context_uses_default_context_limit() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let repository = store.activity_repository(); + + repository + .record(&AppActivityKind::HomeOpened) + .expect("record home opened"); + + let context = repository + .load_context(APP_ACTIVITY_CONTEXT_LIMIT) + .expect("load activity context"); + + assert_eq!(context.recent_events.len(), 1); + assert_eq!(context.recent_events[0].kind, AppActivityKind::HomeOpened); + } + + #[test] + fn activity_repository_trims_events_to_retention_limit() { + let connection = Connection::open_in_memory().expect("open in-memory connection"); + connection + .execute_batch(include_str!("../migrations/0001_init.sql")) + .expect("apply init migration"); + connection + .execute_batch(include_str!("../migrations/0002_activity_journal.sql")) + .expect("apply activity migration"); + let repository = AppActivityRepository::new(&connection); + + for _ in 0..(APP_ACTIVITY_RETENTION_LIMIT + 8) { + repository + .record(&AppActivityKind::HomeOpened) + .expect("record activity event"); + } + + let retained = count_rows(&connection, "activity_events"); + + assert_eq!(retained, APP_ACTIVITY_RETENTION_LIMIT); + } + + fn count_rows(connection: &Connection, table_name: &str) -> i64 { + let sql = format!("SELECT COUNT(*) FROM {table_name}"); + connection + .query_row(&sql, [], |row| row.get(0)) + .expect("row count query should succeed") + } +} diff --git a/crates/shared/sqlite/src/error.rs b/crates/shared/sqlite/src/error.rs @@ -73,6 +73,8 @@ pub enum AppSqliteError { }, #[error("invalid sqlite id in `{field}`: `{value}`")] DecodeId { field: &'static str, value: String }, + #[error("missing required sqlite column `{field}`")] + MissingColumn { field: &'static str }, #[error("invalid sqlite enum value in `{field}`: `{value}`")] DecodeEnum { field: &'static str, value: String }, } diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -1,14 +1,20 @@ #![forbid(unsafe_code)] +mod activity; mod error; mod migrations; mod today; use std::{fs, path::PathBuf, time::Duration}; -use radroots_app_models::{FarmId, TodayAgendaProjection}; +use radroots_app_models::{ + AppActivityContext, AppActivityEvent, AppActivityKind, FarmId, TodayAgendaProjection, +}; use rusqlite::Connection; +pub use activity::{ + APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository, +}; pub use error::AppSqliteError; pub use migrations::latest_schema_version; pub use today::{ @@ -51,12 +57,34 @@ impl AppSqliteStore { AppTodayAgendaRepository::new(&self.connection) } + pub fn activity_repository(&self) -> AppActivityRepository<'_> { + AppActivityRepository::new(&self.connection) + } + pub fn load_today_agenda( &self, farm_id: Option<FarmId>, ) -> Result<TodayAgendaProjection, AppSqliteError> { self.today_agenda_repository().load(farm_id) } + + pub fn record_activity_event(&self, kind: &AppActivityKind) -> Result<(), AppSqliteError> { + self.activity_repository().record(kind) + } + + pub fn load_recent_activity_events( + &self, + limit: usize, + ) -> Result<Vec<AppActivityEvent>, AppSqliteError> { + self.activity_repository().load_recent(limit) + } + + pub fn load_activity_context( + &self, + limit: usize, + ) -> Result<AppActivityContext, AppSqliteError> { + self.activity_repository().load_context(limit) + } } fn open_connection(target: &DatabaseTarget) -> Result<Connection, AppSqliteError> { @@ -185,6 +213,7 @@ mod tests { assert!(table_exists(connection, "local_outbox")); assert!(table_exists(connection, "local_conflicts")); assert!(table_exists(connection, "sync_checkpoints")); + assert!(table_exists(connection, "activity_events")); assert_eq!(row_count(connection, "sync_checkpoints"), 1); drop(store); diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs @@ -3,10 +3,16 @@ struct Migration { sql: &'static str, } -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - sql: include_str!("../migrations/0001_init.sql"), -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + sql: include_str!("../migrations/0001_init.sql"), + }, + Migration { + version: 2, + sql: include_str!("../migrations/0002_activity_journal.sql"), + }, +]; pub fn latest_schema_version() -> u32 { MIGRATIONS.last().map_or(0, |migration| migration.version) diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] -use radroots_app_models::{AppMode, SettingsSection, ShellSection, TodayAgendaProjection}; +use radroots_app_models::{ + AppMode, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, +}; use thiserror::Error; #[derive(Clone, Debug, Eq, PartialEq)] @@ -123,14 +125,6 @@ impl AppProjection { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SettingsPreference { - AllowRelayConnections, - UseMediaServers, - UseNip05, - LaunchAtLogin, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppStateCommand { SelectSection(ShellSection),