diff --git a/crates/persistence/src/activity/mod.rs b/crates/persistence/src/activity/mod.rs index cb7a38386..1850b2c94 100644 --- a/crates/persistence/src/activity/mod.rs +++ b/crates/persistence/src/activity/mod.rs @@ -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}; diff --git a/crates/persistence/src/activity/repository.rs b/crates/persistence/src/activity/repository.rs index a46f9f4e5..cd62bffe3 100644 --- a/crates/persistence/src/activity/repository.rs +++ b/crates/persistence/src/activity/repository.rs @@ -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 { + 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, ¶ms.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 { + let normalized = value + .chars() + .filter(|character| !character.is_whitespace() && !matches!(*character, '-' | '_')) + .collect::() + .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, diff --git a/crates/persistence/src/activity/types.rs b/crates/persistence/src/activity/types.rs index 555494a1f..cf945b431 100644 --- a/crates/persistence/src/activity/types.rs +++ b/crates/persistence/src/activity/types.rs @@ -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 { @@ -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, } diff --git a/crates/persistence/src/activity/view.rs b/crates/persistence/src/activity/view.rs index 0fe6f7036..ba8d5b42f 100644 --- a/crates/persistence/src/activity/view.rs +++ b/crates/persistence/src/activity/view.rs @@ -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; @@ -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, @@ -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); } } @@ -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); } } @@ -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, }; @@ -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, }; @@ -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), } diff --git a/crates/persistence/src/realtime/schema.rs b/crates/persistence/src/realtime/schema.rs index 6edbcbd9d..79a76184d 100644 --- a/crates/persistence/src/realtime/schema.rs +++ b/crates/persistence/src/realtime/schema.rs @@ -57,6 +57,7 @@ fn realtime_table_statements(user_prefix: &str) -> Vec { 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)"), diff --git a/crates/persistence/tests/activity_view.rs b/crates/persistence/tests/activity_view.rs index 3b38db8fe..c8a33ba0e 100644 --- a/crates/persistence/tests/activity_view.rs +++ b/crates/persistence/tests/activity_view.rs @@ -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}; @@ -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::() + .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"); diff --git a/src/components/dialogs/UserActivityPanelImpl.test.tsx b/src/components/dialogs/UserActivityPanelImpl.test.tsx index 58969c29e..6deb2838e 100644 --- a/src/components/dialogs/UserActivityPanelImpl.test.tsx +++ b/src/components/dialogs/UserActivityPanelImpl.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { getActivityStatusPercentage } from './user-dialog/userActivityPanelModel'; import { getDisplayDayLabels, getRangeDays } from './UserActivityPanelImpl'; describe('UserActivityPanelImpl helpers', () => { @@ -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); + }); }); diff --git a/src/components/dialogs/UserActivityPanelImpl.tsx b/src/components/dialogs/UserActivityPanelImpl.tsx index c42efdf52..08ac1ae83 100644 --- a/src/components/dialogs/UserActivityPanelImpl.tsx +++ b/src/components/dialogs/UserActivityPanelImpl.tsx @@ -27,6 +27,7 @@ import { } from './user-dialog/components/UserActivityPanelParts'; import { UserActivityOverlapSection, + UserActivityStatusDistributionSection, UserActivityTopWorldsSection } from './user-dialog/components/UserActivityPanelSections'; import { @@ -107,6 +108,7 @@ export function UserActivityPanel({ peakTimeText, refreshData, selectedPeriod, + statusDistribution, topWorlds, topWorldsLoading, topWorldsLoadingVisible, @@ -200,6 +202,9 @@ export function UserActivityPanel({ [isDarkMode] ); const emptyColor = isDarkMode ? 'hsl(220, 15%, 12%)' : 'hsl(210, 30%, 95%)'; + const hasStatusDistributionData = + !isCurrentUser && statusDistribution.totalCount > 0; + const hasVisibleActivityData = hasAnyData || hasStatusDistributionData; return (
) : null} - {loading && !hasAnyData ? ( + {loading && !hasVisibleActivityData ? (
@@ -349,10 +354,13 @@ export function UserActivityPanel({ {error} ) : null} - {!loading && !error && !hasAnyData ? ( + {!loading && !error && !hasVisibleActivityData ? ( ) : null} - {!loading && hasAnyData && filteredEventCount === 0 ? ( + {!loading && + hasVisibleActivityData && + filteredEventCount === 0 && + !hasStatusDistributionData ? ( @@ -373,6 +381,12 @@ export function UserActivityPanel({ /> ) : null} + {!isCurrentUser && hasVisibleActivityData ? ( + + ) : null} + {!isCurrentUser && hasAnyData ? ( >(() => ({ + 'dialog.user.activity.status_distribution.header': + 'Status change log share', + 'dialog.user.activity.status_distribution.description': + 'Recorded changes, not online time.', + 'dialog.user.activity.status_distribution.chart_center_label': 'logs', + 'dialog.user.activity.status_distribution.no_data': 'No status changes', + 'dialog.user.status.join_me': 'Join Me', + 'dialog.user.status.online': 'Online', + 'dialog.user.status.ask_me': 'Ask Me', + 'dialog.user.status.busy': 'Do Not Disturb' +})); + +vi.mock('react-i18next', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + i18n: { resolvedLanguage: 'en' }, + t: (key: string) => translations[key] || key + }) +})); + +describe('UserActivityStatusDistributionSection', () => { + it('renders all four friend-status colors with log percentages and counts', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('Status change log share'); + expect(html).toContain('Recorded changes, not online time.'); + expect(html).toContain('stroke="var(--status-joinme)"'); + expect(html).toContain('stroke="var(--status-online)"'); + expect(html).toContain('stroke="var(--status-askme)"'); + expect(html).toContain('stroke="var(--status-busy)"'); + expect(html).toContain('Join Me 50%'); + expect(html).toContain('(4)'); + expect(html).toContain('role="img"'); + }); + + it('shows a status-log-specific empty message when online history exists alone', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('No status changes'); + expect(html).not.toContain('role="img"'); + }); +}); diff --git a/src/components/dialogs/user-dialog/components/UserActivityPanelSections.tsx b/src/components/dialogs/user-dialog/components/UserActivityPanelSections.tsx index 26f117dcb..c60b2f2e8 100644 --- a/src/components/dialogs/user-dialog/components/UserActivityPanelSections.tsx +++ b/src/components/dialogs/user-dialog/components/UserActivityPanelSections.tsx @@ -16,12 +16,165 @@ import { Switch } from '@/ui/shadcn/switch'; import { OVERLAP_RENDER_DELAY_MS, USER_ACTIVITY_HOUR_LABELS, + getActivityStatusPercentage, type ActivityHeatmapData, type TopWorldsSort, + type UserActivityStatusDistribution, type UserActivityTopWorld } from '../userActivityPanelModel'; import { HeatmapChart, TopWorldRows } from './UserActivityPanelParts'; +const STATUS_DISTRIBUTION_SEGMENTS = [ + { + countKey: 'joinMeCount', + labelKey: 'dialog.user.status.join_me', + color: 'var(--status-joinme)' + }, + { + countKey: 'activeCount', + labelKey: 'dialog.user.status.online', + color: 'var(--status-online)' + }, + { + countKey: 'askMeCount', + labelKey: 'dialog.user.status.ask_me', + color: 'var(--status-askme)' + }, + { + countKey: 'busyCount', + labelKey: 'dialog.user.status.busy', + color: 'var(--status-busy)' + } +] as const; + +export function UserActivityStatusDistributionSection({ + distribution +}: { + distribution: UserActivityStatusDistribution; +}) { + const { i18n, t } = useTranslation(); + const total = distribution.totalCount; + const percentageFormatter = new Intl.NumberFormat( + i18n.resolvedLanguage || 'en', + { maximumFractionDigits: 1 } + ); + let offset = 0; + const segments = STATUS_DISTRIBUTION_SEGMENTS.map((segment) => { + const count = distribution[segment.countKey]; + const percentage = getActivityStatusPercentage(count, total); + const result = { + ...segment, + count, + percentage, + offset + }; + offset += percentage; + return result; + }); + const chartLabel = segments + .filter((segment) => segment.count > 0) + .map( + (segment) => + `${t(segment.labelKey)} ${percentageFormatter.format(segment.percentage)}%` + ) + .join(', '); + + return ( +
+
+

+ {t('dialog.user.activity.status_distribution.header')} +

+

+ {t('dialog.user.activity.status_distribution.description')} +

+
+ {total > 0 ? ( +
+
+ +
+ + {total} + + + {t( + 'dialog.user.activity.status_distribution.chart_center_label' + )} + +
+
+
+ {segments.map((segment) => ( +
+
+ ))} +
+
+ ) : ( +
+ {t('dialog.user.activity.status_distribution.no_data')} +
+ )} +
+ ); +} + export function UserActivityOverlapSection({ bestOverlapTime, changeExcludeHours, diff --git a/src/components/dialogs/user-dialog/useUserActivityPanelController.ts b/src/components/dialogs/user-dialog/useUserActivityPanelController.ts index 3c73b6a6a..837d204b9 100644 --- a/src/components/dialogs/user-dialog/useUserActivityPanelController.ts +++ b/src/components/dialogs/user-dialog/useUserActivityPanelController.ts @@ -10,6 +10,7 @@ import { ACTIVITY_SELF_EXCLUDE_HOME_WORLD_KEY, ACTIVITY_SELF_PERIOD_KEY, ACTIVITY_SELF_TOP_WORLDS_SORT_KEY, + EMPTY_USER_ACTIVITY_STATUS_DISTRIBUTION, getRangeDays, getWorldThumbnailUrl, normalizeActivityPeriod, @@ -21,6 +22,7 @@ import { TOP_WORLDS_LOADING_DELAY_MS, type ActivityHeatmapData, type TopWorldsSort, + type UserActivityStatusDistribution, type UserActivityTopWorld } from './userActivityPanelModel'; @@ -95,6 +97,10 @@ export function useUserActivityPanelController({ rawBuckets: [], normalizedBuckets: [] }); + const [statusDistribution, setStatusDistribution] = + useState( + EMPTY_USER_ACTIVITY_STATUS_DISTRIBUTION + ); const [topWorlds, setTopWorlds] = useState([]); const [topWorldsLoading, setTopWorldsLoading] = useState(false); const [topWorldsLoadingVisible, setTopWorldsLoadingVisible] = @@ -196,6 +202,7 @@ export function useUserActivityPanelController({ setPeakDayText(''); setPeakTimeText(''); setMainHeatmap({ rawBuckets: [], normalizedBuckets: [] }); + setStatusDistribution(EMPTY_USER_ACTIVITY_STATUS_DISTRIBUTION); setTopWorlds([]); setTopWorldsLoading(false); setTopWorldsLoadingVisible(false); @@ -430,6 +437,10 @@ export function useUserActivityPanelController({ rawBuckets: activityView.rawBuckets || [], normalizedBuckets: activityView.normalizedBuckets || [] }); + setStatusDistribution( + activityView.statusDistribution || + EMPTY_USER_ACTIVITY_STATUS_DISTRIBUTION + ); lastLoadedContextRef.current = activityContextKey; if (!activityView.hasAnyData) { @@ -682,6 +693,7 @@ export function useUserActivityPanelController({ peakTimeText, refreshData, selectedPeriod, + statusDistribution, topWorlds, topWorldsLoading, topWorldsLoadingVisible, diff --git a/src/components/dialogs/user-dialog/userActivityPanelModel.ts b/src/components/dialogs/user-dialog/userActivityPanelModel.ts index 449a60143..33a6ccccb 100644 --- a/src/components/dialogs/user-dialog/userActivityPanelModel.ts +++ b/src/components/dialogs/user-dialog/userActivityPanelModel.ts @@ -20,6 +20,23 @@ export type ActivityHeatmapData = { rawBuckets: number[]; }; +export type UserActivityStatusDistribution = { + activeCount: number; + askMeCount: number; + busyCount: number; + joinMeCount: number; + totalCount: number; +}; + +export const EMPTY_USER_ACTIVITY_STATUS_DISTRIBUTION: UserActivityStatusDistribution = + Object.freeze({ + activeCount: 0, + askMeCount: 0, + busyCount: 0, + joinMeCount: 0, + totalCount: 0 + }); + export type TopWorldsSort = 'time' | 'count'; export type UserActivityTopWorld = Record & { @@ -74,6 +91,13 @@ export function normalizeTopWorldsSort(sortBy: unknown): TopWorldsSort { return sortBy === 'time' || sortBy === 'count' ? sortBy : 'time'; } +export function getActivityStatusPercentage(count: number, total: number) { + if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) { + return 0; + } + return (Math.max(0, count) / total) * 100; +} + export function getWorldThumbnailUrl( world: UserActivityTopWorld | null | undefined ) { diff --git a/src/localization/en.json b/src/localization/en.json index af6076aa8..2bfc841af 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -3456,6 +3456,12 @@ "no_data": "Not enough data to calculate overlap", "minutes_overlap": "min overlap" }, + "status_distribution": { + "header": "Status change log share", + "description": "Based on this friend's recorded color-status changes, not online time.", + "chart_center_label": "logs", + "no_data": "No color-status changes were recorded in the selected period." + }, "most_visited_worlds": { "header": "Most Visited Worlds", "loading": "Loading most visited worlds...", diff --git a/src/localization/ja.json b/src/localization/ja.json index f97e6321b..c159343b5 100644 --- a/src/localization/ja.json +++ b/src/localization/ja.json @@ -3451,6 +3451,12 @@ "no_data": "重なりを計算するにはデータが不足しています", "minutes_overlap": "分の重なり" }, + "status_distribution": { + "header": "ステータス変更ログの割合", + "description": "オンライン時間ではなく、このフレンドの記録済みカラーステータス変更を集計しています。", + "chart_center_label": "ログ", + "no_data": "選択期間にカラーステータスの変更ログがありません。" + }, "most_visited_worlds": { "header": "最も訪問したワールド", "loading": "最も訪問したワールドを読み込んでいます...", diff --git a/src/platform/tauri/bindings.ts b/src/platform/tauri/bindings.ts index 3d5a67fa8..fcd11fb9d 100644 --- a/src/platform/tauri/bindings.ts +++ b/src/platform/tauri/bindings.ts @@ -2807,6 +2807,13 @@ export type ActivitySessionOutput = { isOpenTail: boolean; sourceRevision: string; }; +export type ActivityStatusDistributionOutput = { + joinMeCount: number; + activeCount: number; + askMeCount: number; + busyCount: number; + totalCount: number; +}; export type ActivitySyncStateInput = { userId?: string; updatedAt?: string; @@ -2841,6 +2848,7 @@ export type ActivityViewOutput = { peakHourEnd: number; filteredEventCount: number; hasAnyData: boolean; + statusDistribution: ActivityStatusDistributionOutput; builtFromCursor: string; builtAt: string; }; diff --git a/src/services/userActivityViewService.test.ts b/src/services/userActivityViewService.test.ts index 782eabe07..a5271582a 100644 --- a/src/services/userActivityViewService.test.ts +++ b/src/services/userActivityViewService.test.ts @@ -28,6 +28,13 @@ describe('userActivityViewService Rust activity views', () => { peakHourEnd: 3, filteredEventCount: 1, hasAnyData: true, + statusDistribution: { + joinMeCount: 2, + activeCount: 3, + askMeCount: 1, + busyCount: 0, + totalCount: 999 + }, builtFromCursor: 'cursor', builtAt: '2025-01-06T00:00:00.000Z' }); @@ -56,7 +63,14 @@ describe('userActivityViewService Rust activity views', () => { filteredEventCount: 1, hasAnyData: true, peakDay: 'Sun', - peakTime: '01:00-03:00' + peakTime: '01:00-03:00', + statusDistribution: { + joinMeCount: 2, + activeCount: 3, + askMeCount: 1, + busyCount: 0, + totalCount: 6 + } }); }); @@ -69,6 +83,13 @@ describe('userActivityViewService Rust activity views', () => { peakHourEnd: 10, filteredEventCount: 1, hasAnyData: true, + statusDistribution: { + joinMeCount: 0, + activeCount: 0, + askMeCount: 0, + busyCount: 0, + totalCount: 0 + }, builtFromCursor: 'cursor', builtAt: '2025-01-06T00:00:00.000Z' }); diff --git a/src/services/userActivityViewService.ts b/src/services/userActivityViewService.ts index 585a39301..882d59722 100644 --- a/src/services/userActivityViewService.ts +++ b/src/services/userActivityViewService.ts @@ -1,3 +1,4 @@ +import type { UserActivityStatusDistribution } from '@/components/dialogs/user-dialog/userActivityPanelModel'; import { commands } from '@/platform/tauri/bindings'; import gameLogRepository from '@/repositories/gameLogRepository'; @@ -48,6 +49,7 @@ type UserActivityViewService = { hasAnyData: boolean; peakDay?: string; peakTime?: string; + statusDistribution: UserActivityStatusDistribution; } >; loadOverlapView(options: LoadOverlapViewOptions): Promise< @@ -69,6 +71,26 @@ function normalizeNumberArray(value: unknown): number[] { return Array.isArray(value) ? value.map(normalizeNumber) : []; } +function normalizeStatusDistribution( + value: unknown +): UserActivityStatusDistribution { + const source = + value && typeof value === 'object' + ? (value as Record) + : {}; + const joinMeCount = Math.max(0, normalizeNumber(source.joinMeCount)); + const activeCount = Math.max(0, normalizeNumber(source.activeCount)); + const askMeCount = Math.max(0, normalizeNumber(source.askMeCount)); + const busyCount = Math.max(0, normalizeNumber(source.busyCount)); + return { + joinMeCount, + activeCount, + askMeCount, + busyCount, + totalCount: joinMeCount + activeCount + askMeCount + busyCount + }; +} + function utcOffsetMinutes() { return -new Date().getTimezoneOffset(); } @@ -137,7 +159,10 @@ async function loadActivityView({ peakDay, peakTime, rawBuckets: normalizeNumberArray(output.rawBuckets), - normalizedBuckets: normalizeNumberArray(output.normalizedBuckets) + normalizedBuckets: normalizeNumberArray(output.normalizedBuckets), + statusDistribution: normalizeStatusDistribution( + output.statusDistribution + ) }; }