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:
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),