Skip to content
Closed
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
10 changes: 5 additions & 5 deletions crates/persistence/src/activity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ mod view;
pub use repository::{
activity_bucket_cache_get, activity_bucket_cache_upsert,
activity_friend_presence_first_created_at, activity_friend_presence_last_created_at,
activity_self_sessions_refresh, activity_self_source_bounds, activity_sessions_append,
activity_sessions_get, activity_sessions_replace, activity_sync_state_get,
activity_sync_state_upsert,
activity_friend_status_distribution, activity_self_sessions_refresh,
activity_self_source_bounds, activity_sessions_append, activity_sessions_get,
activity_sessions_replace, activity_sync_state_get, activity_sync_state_upsert,
};
pub use types::{
ActivityBucketCacheInput, ActivityBucketCacheOutput, ActivityBucketCacheQueryInput,
ActivityOverlapViewBuildInput, ActivityOverlapViewOutput, ActivityRefreshMode,
ActivitySelfSessionsRefreshInput, ActivitySelfSessionsRefreshOutput,
ActivitySelfSourceBoundsOutput, ActivitySessionInput, ActivitySessionOutput,
ActivitySyncStateInput, ActivitySyncStateOutput, ActivityViewBuildInput, ActivityViewKind,
ActivityViewOutput,
ActivityStatusDistributionOutput, ActivitySyncStateInput, ActivitySyncStateOutput,
ActivityViewBuildInput, ActivityViewKind, ActivityViewOutput,
};
pub use view::{activity_overlap_view_build, activity_self_sessions_warmup, activity_view_build};
78 changes: 78 additions & 0 deletions crates/persistence/src/activity/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,84 @@ pub fn activity_friend_presence_last_created_at(
activity_friend_presence_bound(db, owner_user_id, user_id, "MAX")
}

pub fn activity_friend_status_distribution(
db: &DatabaseService,
owner_user_id: &str,
user_id: &str,
range_days: i64,
now_ms: i64,
) -> Result<ActivityStatusDistributionOutput, Error> {
let owner_user_id = normalize_text(owner_user_id);
let user_id = normalize_text(user_id);
if owner_user_id.is_empty() || user_id.is_empty() {
return Ok(ActivityStatusDistributionOutput::default());
}

let user_prefix = normalize_user_table_prefix(&owner_user_id)?;
ensure_user_store_tables(db, &user_prefix)?;
let table_name = format!("{user_prefix}_feed_status");
let to_date_iso = activity_iso_from_ms(now_ms);
let mut sql = format!(
"SELECT status, previous_status, COUNT(*) FROM {table_name} WHERE user_id = @user_id AND created_at <= @to_date_iso"
);
let mut params = ParamsBuilder::new()
.set("user_id", user_id)
.set("to_date_iso", to_date_iso);
if range_days > 0 {
let range_days = range_days.clamp(1, ACTIVITY_MAX_RANGE_DAYS);
let from_ms = now_ms.saturating_sub(range_days.saturating_mul(ACTIVITY_DAY_MS));
sql.push_str(" AND created_at >= @from_date_iso");
params = params.set("from_date_iso", activity_iso_from_ms(from_ms));
}
sql.push_str(" GROUP BY status, previous_status");

let mut output = ActivityStatusDistributionOutput::default();
for row in db.execute(&sql, &params.build())? {
let Some(status) = activity_status_bucket(&row_string(&row, 0)) else {
continue;
};
if activity_status_bucket(&row_string(&row, 1)) == Some(status) {
continue;
}
let count = row_i64(&row, 2).max(0);
match status {
ActivityStatusBucket::JoinMe => output.join_me_count += count,
ActivityStatusBucket::Active => output.active_count += count,
ActivityStatusBucket::AskMe => output.ask_me_count += count,
ActivityStatusBucket::Busy => output.busy_count += count,
}
}
output.total_count = output
.join_me_count
.saturating_add(output.active_count)
.saturating_add(output.ask_me_count)
.saturating_add(output.busy_count);
Ok(output)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActivityStatusBucket {
JoinMe,
Active,
AskMe,
Busy,
}

fn activity_status_bucket(value: &str) -> Option<ActivityStatusBucket> {
let normalized = value
.chars()
.filter(|character| !character.is_whitespace() && !matches!(*character, '-' | '_'))
.collect::<String>()
.to_ascii_lowercase();
match normalized.as_str() {
"joinme" => Some(ActivityStatusBucket::JoinMe),
"active" | "online" => Some(ActivityStatusBucket::Active),
"askme" => Some(ActivityStatusBucket::AskMe),
"busy" => Some(ActivityStatusBucket::Busy),
_ => None,
}
}

pub(super) fn activity_self_source_first_created_at(
db: &DatabaseService,
owner_user_id: &str,
Expand Down
11 changes: 11 additions & 0 deletions crates/persistence/src/activity/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ pub struct ActivityViewBuildInput {
pub force_refresh: bool,
}

#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ActivityStatusDistributionOutput {
pub join_me_count: i64,
pub active_count: i64,
pub ask_me_count: i64,
pub busy_count: i64,
pub total_count: i64,
}

#[derive(Debug, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ActivityViewOutput {
Expand All @@ -194,6 +204,7 @@ pub struct ActivityViewOutput {
pub peak_hour_end: i32,
pub filtered_event_count: i64,
pub has_any_data: bool,
pub status_distribution: ActivityStatusDistributionOutput,
pub built_from_cursor: String,
pub built_at: String,
}
Expand Down
29 changes: 23 additions & 6 deletions crates/persistence/src/activity/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ use crate::Error;
use super::repository::{
activity_bucket_cache_get, activity_bucket_cache_upsert,
activity_friend_presence_first_created_at, activity_friend_presence_last_created_at,
activity_friend_presence_slice, activity_iso_from_ms, activity_self_sessions_refresh_auto,
activity_self_source_first_created_at, parse_activity_time_ms,
activity_friend_presence_slice, activity_friend_status_distribution, activity_iso_from_ms,
activity_self_sessions_refresh_auto, activity_self_source_first_created_at,
parse_activity_time_ms,
};
use super::types::{
ActivityBucketCacheInput, ActivityBucketCacheOutput, ActivityBucketCacheQueryInput,
ActivityFriendPresenceSliceInput, ActivityOverlapViewBuildInput, ActivityOverlapViewOutput,
ActivitySelfSessionsRefreshOutput, ActivityViewBuildInput, ActivityViewKind,
ActivityViewOutput,
ActivitySelfSessionsRefreshOutput, ActivityStatusDistributionOutput, ActivityViewBuildInput,
ActivityViewKind, ActivityViewOutput,
};

const BUCKET_COUNT: usize = 168;
Expand All @@ -38,6 +39,17 @@ pub fn activity_view_build(
if owner_user_id.is_empty() || target_user_id.is_empty() {
return Ok(empty_activity_output(String::new(), input.now_ms));
}
let status_distribution = if input.is_self {
ActivityStatusDistributionOutput::default()
} else {
activity_friend_status_distribution(
db,
&owner_user_id,
&target_user_id,
input.range_days,
input.now_ms,
)?
};
let cache_range_days = cache_range_days(input.range_days);
let effective_range_days = resolve_activity_effective_days(
db,
Expand All @@ -55,13 +67,14 @@ pub fn activity_view_build(

if !input.force_refresh && !input.is_self {
let cursor = activity_friend_presence_last_created_at(db, &owner_user_id, &target_user_id)?;
if let Some(cached) = cached_activity_output(
if let Some(mut cached) = cached_activity_output(
db,
&owner_user_id,
&target_cache_id,
cache_range_days,
&cursor,
)? {
cached.status_distribution = status_distribution.clone();
return Ok(cached);
}
}
Expand All @@ -85,13 +98,14 @@ pub fn activity_view_build(
};

if !input.force_refresh && input.is_self {
if let Some(cached) = cached_activity_output(
if let Some(mut cached) = cached_activity_output(
db,
&owner_user_id,
&target_cache_id,
cache_range_days,
&source.cursor,
)? {
cached.status_distribution = status_distribution.clone();
return Ok(cached);
}
}
Expand All @@ -113,6 +127,7 @@ pub fn activity_view_build(
peak_hour_end: view.peak_hour_end,
filtered_event_count: view.filtered_event_count as i64,
has_any_data: source.has_any_data,
status_distribution,
built_from_cursor: source.cursor,
built_at,
};
Expand Down Expand Up @@ -345,6 +360,7 @@ fn cached_activity_output(
peak_hour_end: summary_i32(&cached.summary, "peakHourEnd").unwrap_or(derived.peak_hour_end),
filtered_event_count: summary_i64(&cached.summary, "filteredEventCount").unwrap_or(0),
has_any_data,
status_distribution: ActivityStatusDistributionOutput::default(),
built_from_cursor: cached.built_from_cursor,
built_at: cached.built_at,
};
Expand Down Expand Up @@ -466,6 +482,7 @@ fn empty_activity_output(cursor: String, now_ms: i64) -> ActivityViewOutput {
peak_hour_end: -1,
filtered_event_count: 0,
has_any_data: false,
status_distribution: ActivityStatusDistributionOutput::default(),
built_from_cursor: cursor,
built_at: activity_iso_from_ms(now_ms),
}
Expand Down
1 change: 1 addition & 0 deletions crates/persistence/src/realtime/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ fn realtime_table_statements(user_prefix: &str) -> Vec<String> {
format!("CREATE INDEX IF NOT EXISTS {user_prefix}_feed_gps_location_idx ON {user_prefix}_feed_gps (location)"),
format!("CREATE INDEX IF NOT EXISTS {user_prefix}_feed_gps_created_id_idx ON {user_prefix}_feed_gps (created_at DESC, id DESC)"),
format!("CREATE TABLE IF NOT EXISTS {user_prefix}_feed_status (id INTEGER PRIMARY KEY, created_at TEXT, user_id TEXT, display_name TEXT, status TEXT, status_description TEXT, previous_status TEXT, previous_status_description TEXT)"),
format!("CREATE INDEX IF NOT EXISTS {user_prefix}_feed_status_user_created_idx ON {user_prefix}_feed_status (user_id, created_at)"),
format!("CREATE INDEX IF NOT EXISTS {user_prefix}_feed_status_created_id_idx ON {user_prefix}_feed_status (created_at DESC, id DESC)"),
format!("CREATE TABLE IF NOT EXISTS {user_prefix}_feed_bio (id INTEGER PRIMARY KEY, created_at TEXT, user_id TEXT, display_name TEXT, bio TEXT, previous_bio TEXT)"),
format!("CREATE INDEX IF NOT EXISTS {user_prefix}_feed_bio_created_id_idx ON {user_prefix}_feed_bio (created_at DESC, id DESC)"),
Expand Down
124 changes: 119 additions & 5 deletions crates/persistence/tests/activity_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ use chrono::DateTime;
use serde_json::json;
use vrcx_0_persistence::activity::{
activity_bucket_cache_get, activity_bucket_cache_upsert,
activity_friend_presence_last_created_at, activity_overlap_view_build,
activity_self_sessions_warmup, activity_sessions_replace, activity_sync_state_get,
activity_sync_state_upsert, activity_view_build, ActivityBucketCacheInput,
ActivityBucketCacheQueryInput, ActivityOverlapViewBuildInput, ActivitySessionInput,
ActivitySyncStateInput, ActivityViewBuildInput, ActivityViewKind, ActivityViewOutput,
activity_friend_presence_last_created_at, activity_friend_status_distribution,
activity_overlap_view_build, activity_self_sessions_warmup, activity_sessions_replace,
activity_sync_state_get, activity_sync_state_upsert, activity_view_build,
ActivityBucketCacheInput, ActivityBucketCacheQueryInput, ActivityOverlapViewBuildInput,
ActivitySessionInput, ActivitySyncStateInput, ActivityViewBuildInput, ActivityViewKind,
ActivityViewOutput,
};
use vrcx_0_persistence::game_log::{write_batch, GameLogLocationEntry, GameLogWriteBatch};
use vrcx_0_persistence::realtime::{write_realtime_batch, RealtimePersistenceBatch};
Expand Down Expand Up @@ -112,6 +113,119 @@ fn add_presence(
.unwrap();
}

fn add_status(
db: &DatabaseService,
owner_user_id: &str,
target_user_id: &str,
created_at: &str,
status: &str,
) {
let normalized = status
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>()
.to_ascii_lowercase();
let previous_status = if normalized == "active" || normalized == "online" {
"busy"
} else {
"active"
};
add_status_with_previous(
db,
owner_user_id,
target_user_id,
created_at,
status,
previous_status,
);
}

fn add_status_with_previous(
db: &DatabaseService,
owner_user_id: &str,
target_user_id: &str,
created_at: &str,
status: &str,
previous_status: &str,
) {
write_realtime_batch(
db,
owner_user_id,
&RealtimePersistenceBatch {
feed_entries: vec![json!({
"created_at": created_at,
"userId": target_user_id,
"displayName": "Friend",
"type": "Status",
"status": status,
"statusDescription": "",
"previousStatus": previous_status,
"previousStatusDescription": ""
})],
..RealtimePersistenceBatch::default()
},
)
.unwrap();
}

#[test]
fn activity_friend_status_distribution_counts_four_status_logs_in_range() {
let (_dir, db) = test_db("activity-friend-status-distribution");
let owner = "usr_owner";
let friend = "usr_friend";
add_status(&db, owner, friend, "2024-12-20T00:00:00Z", "join me");
add_status(&db, owner, friend, "2025-01-01T00:00:00Z", "Join_Me");
add_status(&db, owner, friend, "2025-01-02T00:00:00Z", "ACTIVE");
add_status(&db, owner, friend, "2025-01-03T00:00:00Z", "online");
add_status(&db, owner, friend, "2025-01-04T00:00:00Z", "ask-me");
add_status(&db, owner, friend, "2025-01-05T00:00:00Z", "busy");
add_status_with_previous(
&db,
owner,
friend,
"2025-01-05T00:30:00Z",
"active",
"active",
);
add_status(&db, owner, friend, "2025-01-05T01:00:00Z", "offline");
add_status(&db, owner, "usr_other", "2025-01-05T02:00:00Z", "busy");

let recent =
activity_friend_status_distribution(&db, owner, friend, 7, ms("2025-01-06T00:00:00Z"))
.unwrap();
assert_eq!(recent.join_me_count, 1);
assert_eq!(recent.active_count, 2);
assert_eq!(recent.ask_me_count, 1);
assert_eq!(recent.busy_count, 1);
assert_eq!(recent.total_count, 5);

let all =
activity_friend_status_distribution(&db, owner, friend, 0, ms("2025-01-06T00:00:00Z"))
.unwrap();
assert_eq!(all.join_me_count, 2);
assert_eq!(all.total_count, 6);
}

#[test]
fn cached_friend_activity_view_refreshes_status_distribution_independently() {
let (_dir, db) = test_db("activity-friend-status-cache");
let owner = "usr_owner";
let friend = "usr_friend";
add_status(&db, owner, friend, "2025-01-04T00:00:00Z", "active");

let first = build_friend_view(&db, owner, friend, 7, "2025-01-06T00:00:00Z");
assert!(!first.has_any_data);
assert_eq!(first.status_distribution.active_count, 1);
assert_eq!(first.status_distribution.total_count, 1);

add_status(&db, owner, friend, "2025-01-05T00:00:00Z", "busy");
let second = build_friend_view(&db, owner, friend, 7, "2025-01-06T00:00:00Z");
assert!(!second.has_any_data);
assert_eq!(second.status_distribution.active_count, 1);
assert_eq!(second.status_distribution.busy_count, 1);
assert_eq!(second.status_distribution.total_count, 2);
}

#[test]
fn self_activity_warmup_prepares_a_year_without_bucket_cache() {
let (_dir, db) = test_db("activity-self-warmup");
Expand Down
8 changes: 8 additions & 0 deletions src/components/dialogs/UserActivityPanelImpl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';

import { getActivityStatusPercentage } from './user-dialog/userActivityPanelModel';
import { getDisplayDayLabels, getRangeDays } from './UserActivityPanelImpl';

describe('UserActivityPanelImpl helpers', () => {
Expand Down Expand Up @@ -40,4 +41,11 @@ describe('UserActivityPanelImpl helpers', () => {
expect(getRangeDays('bad')).toBe(30);
expect(getRangeDays(undefined)).toBe(30);
});

it('computes status-log percentages without treating missing data as a ratio', () => {
expect(getActivityStatusPercentage(1, 4)).toBe(25);
expect(getActivityStatusPercentage(2, 3)).toBeCloseTo(66.6667, 3);
expect(getActivityStatusPercentage(-1, 4)).toBe(0);
expect(getActivityStatusPercentage(1, 0)).toBe(0);
});
});
Loading
Loading