-
+
+
+
+ {{ t('dialog.user.activity.load_hint') }}
+
+
+
+
+
+ {{ t('dialog.user.activity.preparing_data') }}
+ {{ t('dialog.user.activity.preparing_data_hint') }}
+
+
{{ t('dialog.user.activity.no_data_in_period') }}
@@ -192,13 +218,12 @@
import { database } from '../../../services/database';
import configRepository from '../../../services/config';
import { worldRequest } from '../../../api';
- import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
+ import { useActivityStore, useAppearanceSettingsStore, useUserStore } from '../../../stores';
import { useWorldStore } from '../../../stores/world';
import { showWorldDialog } from '../../../coordinators/worldCoordinator';
import { timeToText } from '../../../shared/utils';
+ import { useCurrentUserSessions } from '../../../composables/useCurrentUserSessions';
import {
- buildSessionsFromEvents,
- buildSessionsFromGamelog,
calculateOverlapGrid,
filterSessionsByPeriod,
findBestOverlapTime,
@@ -209,15 +234,18 @@
const { userDialog, currentUser } = storeToRefs(useUserStore());
const { isDarkMode, weekStartsOn } = storeToRefs(useAppearanceSettingsStore());
const worldStore = useWorldStore();
+ const sessionCache = useCurrentUserSessions();
+ const activityStore = useActivityStore();
const chartRef = ref(null);
const isLoading = ref(false);
- const totalOnlineEvents = ref(0);
const hasAnyData = ref(false);
const peakDayText = ref('');
const peakTimeText = ref('');
const selectedPeriod = ref('all');
const filteredEventCount = ref(0);
+ const isSessionCacheLoading = ref(false);
+ const hasRequestedLoad = ref(false);
const isSelf = computed(() => userDialog.value.id === currentUser.value.id);
const topWorlds = ref([]);
@@ -287,6 +315,12 @@
watch(() => isDarkMode.value, rebuildChart);
watch(locale, rebuildChart);
watch(weekStartsOn, rebuildChart);
+ watch(
+ () => userDialog.value.id,
+ () => {
+ resetActivityState();
+ }
+ );
watch(selectedPeriod, () => {
if (cachedTargetSessions.length > 0 && echartsInstance) {
initChart();
@@ -312,7 +346,7 @@
}
});
if (userDialog.value.activeTab === 'Activity') {
- loadOnlineFrequency(userDialog.value.id, 'visible-watch');
+ loadOnlineFrequency(userDialog.value.id);
}
}
}
@@ -322,7 +356,7 @@
() => userDialog.value.activeTab,
(activeTab) => {
if (activeTab === 'Activity' && userDialog.value.visible) {
- loadOnlineFrequency(userDialog.value.id, 'active-tab-watch');
+ loadOnlineFrequency(userDialog.value.id);
}
}
);
@@ -335,7 +369,7 @@
onMounted(() => {
if (userDialog.value.visible && userDialog.value.activeTab === 'Activity') {
- loadOnlineFrequency(userDialog.value.id, 'mounted');
+ loadOnlineFrequency(userDialog.value.id);
}
});
@@ -436,9 +470,7 @@
const filteredSessions = getFilteredSessions();
// Use timestamps for event count display
- const filteredTs = getFilteredTimestamps();
- filteredEventCount.value = filteredTs.length;
- totalOnlineEvents.value = filteredTs.length;
+ filteredEventCount.value = getFilteredEventCount();
if (filteredSessions.length === 0) {
peakDayText.value = '';
@@ -541,13 +573,14 @@
echartsInstance.setOption(option, { notMerge: true });
}
- let cachedTimestamps = [];
let activeRequestId = 0;
async function loadData() {
const userId = userDialog.value.id;
if (!userId) return;
+ hasRequestedLoad.value = true;
+
if (userId !== lastLoadedUserId) {
selectedPeriod.value = 'all';
}
@@ -555,59 +588,14 @@
const requestId = ++activeRequestId;
isLoading.value = true;
try {
- if (isSelf.value) {
- // Self: use gamelog_location for heatmap
- const rows = await database.getCurrentUserOnlineSessions();
- if (requestId !== activeRequestId) return;
- if (userDialog.value.id !== userId) return;
-
- cachedTimestamps = rows.map((r) => r.created_at);
- cachedTargetSessions = buildSessionsFromGamelog(rows);
- } else {
- // Friend: use feed_online_offline
- const [timestamps, events] = await Promise.all([
- database.getOnlineFrequencyData(userId),
- database.getOnlineOfflineSessions(userId)
- ]);
- if (requestId !== activeRequestId) return;
- if (userDialog.value.id !== userId) return;
-
- cachedTimestamps = timestamps;
- cachedTargetSessions = buildSessionsFromEvents(events);
- }
-
- hasAnyData.value = cachedTimestamps.length > 0;
- totalOnlineEvents.value = cachedTimestamps.length;
- lastLoadedUserId = userId;
-
- await nextTick();
-
- if (cachedTimestamps.length > 0) {
- const filteredTs = getFilteredTimestamps();
- filteredEventCount.value = filteredTs.length;
-
- await nextTick();
-
- if (!echartsInstance && chartRef.value) {
- echartsInstance = echarts.init(chartRef.value, isDarkMode.value ? 'dark' : null, { height: 240 });
- resizeObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- if (echartsInstance) {
- echartsInstance.resize({
- width: entry.contentRect.width
- });
- }
- }
- });
- resizeObserver.observe(chartRef.value);
- }
- initChart();
- } else {
- peakDayText.value = '';
- peakTimeText.value = '';
- hasAnyData.value = false;
- filteredEventCount.value = 0;
- }
+ const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
+ notifyStart: hasAnyData.value,
+ notifyComplete: true
+ });
+ if (requestId !== activeRequestId) return;
+ if (userDialog.value.id !== userId) return;
+ hydrateFromCacheEntry(entry);
+ await finishLoadData(userId);
} catch (error) {
console.error('Error loading online frequency data:', error);
} finally {
@@ -615,6 +603,45 @@
isLoading.value = false;
}
}
+ }
+
+ /**
+ * Shared finalization after session data is loaded (both sync and async paths).
+ * @param {string} userId
+ */
+ async function finishLoadData(userId) {
+ hasAnyData.value = cachedTargetSessions.length > 0;
+ lastLoadedUserId = userId;
+
+ await nextTick();
+
+ if (cachedTargetSessions.length > 0) {
+ filteredEventCount.value = getFilteredEventCount();
+
+ await nextTick();
+
+ if (!echartsInstance && chartRef.value) {
+ echartsInstance = echarts.init(chartRef.value, isDarkMode.value ? 'dark' : null, { height: 240 });
+ resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ if (echartsInstance) {
+ echartsInstance.resize({
+ width: entry.contentRect.width
+ });
+ }
+ }
+ });
+ resizeObserver.observe(chartRef.value);
+ }
+ initChart();
+ } else {
+ peakDayText.value = '';
+ peakTimeText.value = '';
+ hasAnyData.value = false;
+ filteredEventCount.value = 0;
+ }
+
+ isLoading.value = false;
if (hasAnyData.value && !isSelf.value) {
loadOverlapData(userId);
@@ -624,21 +651,107 @@
}
}
- function getFilteredTimestamps() {
- if (selectedPeriod.value === 'all') return cachedTimestamps;
+ function resetActivityState() {
+ hasRequestedLoad.value = false;
+ isLoading.value = false;
+ isSessionCacheLoading.value = false;
+ hasAnyData.value = false;
+ peakDayText.value = '';
+ peakTimeText.value = '';
+ selectedPeriod.value = 'all';
+ filteredEventCount.value = 0;
+ hasOverlapData.value = false;
+ overlapPercent.value = 0;
+ bestOverlapTime.value = '';
+ isOverlapLoading.value = false;
+ topWorlds.value = [];
+ cachedTargetSessions = [];
+ cachedCurrentSessions = [];
+ lastLoadedUserId = '';
+ activeRequestId++;
+ }
+
+ function hydrateFromCacheEntry(entry) {
+ cachedTargetSessions = Array.isArray(entry?.sessions) ? entry.sessions : [];
+ hasRequestedLoad.value = Boolean(entry);
+ }
+
+ async function loadCachedActivity(userId) {
+ const entry = await activityStore.getCache(userId);
+ if (!entry) {
+ hasRequestedLoad.value = false;
+ return null;
+ }
+
+ if (userDialog.value.id !== userId) {
+ return null;
+ }
+ hydrateFromCacheEntry(entry);
+ await finishLoadData(userId);
+ return entry;
+ }
+
+ async function scheduleAutoRefresh(userId) {
+ isLoading.value = true;
+ try {
+ const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
+ notifyComplete: true
+ });
+ if (userDialog.value.id !== userId) return;
+ hydrateFromCacheEntry(entry);
+ await finishLoadData(userId);
+ } catch (error) {
+ console.error('Error auto-refreshing activity data:', error);
+ } finally {
+ if (userDialog.value.id === userId) {
+ isLoading.value = false;
+ }
+ }
+ }
+
+ function getFilteredEventCount() {
+ if (selectedPeriod.value === 'all') return cachedTargetSessions.length;
const days = parseInt(selectedPeriod.value, 10);
- const cutoff = dayjs().subtract(days, 'day');
- return cachedTimestamps.filter((ts) => dayjs(ts).isAfter(cutoff));
+ const cutoff = dayjs().subtract(days, 'day').valueOf();
+ return cachedTargetSessions.filter((session) => session.start > cutoff).length;
}
/**
* @param {string} userId
*/
function loadOnlineFrequency(userId) {
- if (lastLoadedUserId === userId && hasAnyData.value) {
+ if (lastLoadedUserId !== userId) {
+ resetActivityState();
+ }
+ if (!userId) {
return;
}
- loadData();
+ void (async () => {
+ const cacheEntry = await loadCachedActivity(userId);
+ if (!cacheEntry) {
+ if (activityStore.isRefreshing(userId)) {
+ hasRequestedLoad.value = true;
+ isSessionCacheLoading.value = true;
+ try {
+ const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
+ notifyComplete: true
+ });
+ if (userDialog.value.id !== userId) return;
+ hydrateFromCacheEntry(entry);
+ await finishLoadData(userId);
+ } finally {
+ if (userDialog.value.id === userId) {
+ isSessionCacheLoading.value = false;
+ }
+ }
+ }
+ return;
+ }
+
+ if (activityStore.isExpired(cacheEntry)) {
+ void scheduleAutoRefresh(userId);
+ }
+ })();
}
let easterEggTimer = null;
@@ -663,13 +776,16 @@
isOverlapLoading.value = true;
hasOverlapData.value = false;
try {
- // Target sessions already cached from loadData, only fetch current user
- const currentUserRows = await database.getCurrentUserOnlineSessions();
+ if (!sessionCache.isReady()) {
+ sessionCache.onReady(() => loadOverlapData(userId));
+ sessionCache.triggerLoad();
+ return;
+ }
+
+ const currentSessions = await sessionCache.getSessions();
if (userDialog.value.id !== userId) return;
- const currentSessions = buildSessionsFromGamelog(currentUserRows);
-
if (cachedTargetSessions.length === 0 || currentSessions.length === 0) {
hasOverlapData.value = false;
return;
@@ -738,9 +854,14 @@
if (result.grid[d][h] > result.maxVal) result.maxVal = result.grid[d][h];
}
}
- // Recalculate overlap percent excluding those hours
- const totalGrid = result.grid.flat().reduce((a, b) => a + b, 0);
- if (totalGrid === 0) {
+ const overlapSessions = computeOverlapSessions(currentSessions, targetSessions);
+ const overlapMs = getIncludedSessionDurationMs(overlapSessions, start, end);
+ const currentMs = getIncludedSessionDurationMs(currentSessions, start, end);
+ const targetMs = getIncludedSessionDurationMs(targetSessions, start, end);
+ const minOnlineMs = Math.min(currentMs, targetMs);
+ result.overlapPercent =
+ minOnlineMs > 0 ? Math.round((overlapMs / minOnlineMs) * 100) : 0;
+ if (overlapMs === 0) {
overlapPercent.value = 0;
bestOverlapTime.value = '';
return;
@@ -780,6 +901,59 @@
}
}
+ function computeOverlapSessions(sessionsA, sessionsB) {
+ const overlapSessions = [];
+ let i = 0;
+ let j = 0;
+
+ while (i < sessionsA.length && j < sessionsB.length) {
+ const a = sessionsA[i];
+ const b = sessionsB[j];
+ const start = Math.max(a.start, b.start);
+ const end = Math.min(a.end, b.end);
+ if (start < end) {
+ overlapSessions.push({ start, end });
+ }
+ if (a.end < b.end) {
+ i++;
+ } else {
+ j++;
+ }
+ }
+
+ return overlapSessions;
+ }
+
+ function getIncludedSessionDurationMs(sessions, startHour, endHour) {
+ let total = 0;
+ for (const session of sessions) {
+ let cursor = session.start;
+ while (cursor < session.end) {
+ const segmentEnd = getNextHourBoundaryMs(cursor, session.end);
+ if (!isHourExcluded(cursor, startHour, endHour)) {
+ total += segmentEnd - cursor;
+ }
+ cursor = segmentEnd;
+ }
+ }
+ return total;
+ }
+
+ function getNextHourBoundaryMs(cursor, sessionEnd) {
+ const nextHour = new Date(cursor);
+ nextHour.setMinutes(0, 0, 0);
+ nextHour.setHours(nextHour.getHours() + 1);
+ return Math.min(nextHour.getTime(), sessionEnd);
+ }
+
+ function isHourExcluded(cursor, startHour, endHour) {
+ const hour = new Date(cursor).getHours();
+ if (startHour <= endHour) {
+ return hour >= startHour && hour < endHour;
+ }
+ return hour >= startHour || hour < endHour;
+ }
+
function onExcludeToggle(value) {
excludeHoursEnabled.value = value;
configRepository.setBool('VRCX_overlapExcludeEnabled', value);
diff --git a/src/composables/useCurrentUserSessions.js b/src/composables/useCurrentUserSessions.js
new file mode 100644
index 00000000..e905e1c1
--- /dev/null
+++ b/src/composables/useCurrentUserSessions.js
@@ -0,0 +1,225 @@
+import { database } from '../services/database';
+import {
+ buildSessionsFromGamelog,
+ ONLINE_SESSION_MERGE_GAP_MS
+} from '../shared/utils/overlapCalculator';
+
+/** @typedef {{ start: number, end: number }} Session */
+
+/**
+ * Module-level singleton cache for the current user's online sessions.
+ * Lazy-loaded on first access, then incrementally updated.
+ */
+
+/** @type {Session[] | null} */
+let cachedSessions = null;
+
+/** @type {string[] | null} */
+let cachedTimestamps = null;
+
+/** @type {string | null} */
+let lastRowCreatedAt = null;
+
+/** @type {'idle' | 'loading' | 'ready'} */
+let status = 'idle';
+
+/** @type {Promise
| null} */
+let loadPromise = null;
+
+/** @type {Array<() => void>} */
+const onReadyCallbacks = [];
+
+/** @type {ReturnType | null} */
+let refreshTimer = null;
+
+const REFRESH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
+
+/**
+ * Executes all onReady callbacks and clears the list.
+ */
+function flushCallbacks() {
+ const cbs = onReadyCallbacks.splice(0);
+ for (const cb of cbs) {
+ try {
+ cb();
+ } catch (e) {
+ console.error('useCurrentUserSessions onReady callback error:', e);
+ }
+ }
+}
+
+/**
+ * Starts the periodic incremental refresh timer.
+ * Only starts if not already running.
+ */
+function startRefreshTimer() {
+ if (refreshTimer) return;
+ refreshTimer = setInterval(async () => {
+ if (status !== 'ready') return;
+ try {
+ await incrementalUpdate();
+ } catch (e) {
+ console.error('useCurrentUserSessions periodic refresh error:', e);
+ }
+ }, REFRESH_INTERVAL_MS);
+}
+
+/**
+ * Full load: queries all gamelog_location rows and builds sessions.
+ * @returns {Promise}
+ */
+async function fullLoad() {
+ status = 'loading';
+ try {
+ const rows = await database.getCurrentUserOnlineSessions();
+ cachedTimestamps = rows.map((r) => r.created_at);
+ cachedSessions = buildSessionsFromGamelog(rows);
+ if (rows.length > 0) {
+ lastRowCreatedAt = rows[rows.length - 1].created_at;
+ }
+ status = 'ready';
+ startRefreshTimer();
+ flushCallbacks();
+ } catch (e) {
+ status = 'idle';
+ throw e;
+ }
+}
+
+/**
+ * Incremental update: only fetches rows newer than lastRowCreatedAt.
+ * Merges new sessions into the cached sessions array.
+ * @returns {Promise}
+ */
+async function incrementalUpdate() {
+ if (!lastRowCreatedAt || status !== 'ready') return;
+
+ const newRows =
+ await database.getCurrentUserOnlineSessionsAfter(lastRowCreatedAt);
+ if (newRows.length === 0) return;
+
+ lastRowCreatedAt = newRows[newRows.length - 1].created_at;
+ cachedTimestamps.push(...newRows.map((r) => r.created_at));
+
+ const newSessions = buildSessionsFromGamelog(newRows);
+ if (newSessions.length === 0) return;
+
+ // Merge: if last cached session and first new session overlap or are close, merge them
+ if (cachedSessions.length > 0 && newSessions.length > 0) {
+ const last = cachedSessions[cachedSessions.length - 1];
+ const first = newSessions[0];
+ if (first.start <= last.end + ONLINE_SESSION_MERGE_GAP_MS) {
+ last.end = Math.max(last.end, first.end);
+ newSessions.shift();
+ }
+ }
+ cachedSessions.push(...newSessions);
+}
+
+/**
+ * Returns whether the cache is ready.
+ * @returns {boolean}
+ */
+function isReady() {
+ return status === 'ready';
+}
+
+/**
+ * Returns whether the cache is currently loading.
+ * @returns {boolean}
+ */
+function isLoading() {
+ return status === 'loading';
+}
+
+/**
+ * Gets the cached sessions. If not loaded yet, triggers a full load.
+ * If already loaded, does an incremental update first.
+ * @returns {Promise}
+ */
+async function getSessions() {
+ if (status === 'ready') {
+ await incrementalUpdate();
+ return cachedSessions;
+ }
+
+ if (status === 'loading') {
+ // Wait for existing load to complete
+ await loadPromise;
+ return cachedSessions;
+ }
+
+ // idle: trigger full load
+ loadPromise = fullLoad();
+ try {
+ await loadPromise;
+ return cachedSessions;
+ } finally {
+ loadPromise = null;
+ }
+}
+
+/**
+ * Gets the cached timestamps (created_at strings from gamelog_location).
+ * Must be called after getSessions() or after onReady fires.
+ * @returns {string[]}
+ */
+function getTimestamps() {
+ return cachedTimestamps || [];
+}
+
+/**
+ * Registers a callback to be called when the cache becomes ready.
+ * If already ready, callback is invoked immediately.
+ * @param {() => void} callback
+ */
+function onReady(callback) {
+ if (status === 'ready') {
+ callback();
+ return;
+ }
+ onReadyCallbacks.push(callback);
+}
+
+/**
+ * Triggers a full load if idle, or returns the existing promise if loading.
+ * Does NOT block the caller — designed for fire-and-forget usage.
+ * Returns the promise so callers can optionally await it.
+ * @returns {Promise}
+ */
+function triggerLoad() {
+ if (status === 'ready') return Promise.resolve();
+ if (status === 'loading') return loadPromise;
+
+ loadPromise = fullLoad().finally(() => {
+ loadPromise = null;
+ });
+ return loadPromise;
+}
+
+/**
+ * Invalidates the cache and stops the refresh timer.
+ */
+function invalidate() {
+ cachedSessions = null;
+ cachedTimestamps = null;
+ lastRowCreatedAt = null;
+ status = 'idle';
+ loadPromise = null;
+ if (refreshTimer) {
+ clearInterval(refreshTimer);
+ refreshTimer = null;
+ }
+}
+
+export function useCurrentUserSessions() {
+ return {
+ isReady,
+ isLoading,
+ getSessions,
+ getTimestamps,
+ onReady,
+ triggerLoad,
+ invalidate
+ };
+}
diff --git a/src/localization/en.json b/src/localization/en.json
index 3e9d1e98..0af2765c 100644
--- a/src/localization/en.json
+++ b/src/localization/en.json
@@ -1427,6 +1427,11 @@
},
"activity": {
"header": "Activity",
+ "load": "Load Activity",
+ "load_hint": "Load activity data from the local database when needed.",
+ "refresh_hint": "Rebuild activity cache",
+ "refresh_started": "Rebuilding activity data. Please wait...",
+ "refresh_complete": "Activity refresh complete",
"total_events": "{count} online events",
"times_online": "times online",
"most_active_day": "Most active day:",
@@ -1458,7 +1463,10 @@
},
"most_visited_worlds": {
"header": "Most Visited Worlds"
- }
+ },
+ "preparing_data": "Preparing activity data...",
+ "preparing_data_hint": "This may take a moment on first load. You'll be notified when ready.",
+ "data_ready": "Activity data is ready"
},
"note_memo": {
"header": "Edit Note And Memo",
@@ -2631,6 +2639,12 @@
},
"database": {
"upgrade_complete": "Database upgrade complete",
+ "upgrade_in_progress_title": "Database upgrade in progress",
+ "upgrade_in_progress_description": "Updating database from version {from} to {to}. Please do not close VRCX.",
+ "upgrade_in_progress_initializing": "Initializing database upgrade. Please do not close VRCX.",
+ "upgrade_in_progress_wait": "User actions are temporarily blocked until the upgrade finishes.",
+ "upgrade_failed_title": "Database upgrade failed",
+ "upgrade_failed_description": "Database upgrade failed. Check the console for details.",
"disk_space": "Please free up some disk space.",
"disk_error": "Please check your disk for errors."
},
diff --git a/src/services/database/activityCache.js b/src/services/database/activityCache.js
new file mode 100644
index 00000000..10a1597a
--- /dev/null
+++ b/src/services/database/activityCache.js
@@ -0,0 +1,212 @@
+import { dbVars } from '../database';
+
+import sqliteService from '../sqlite.js';
+
+const activityCache = {
+ /**
+ * @param {string} userId
+ * @returns {Promise<{
+ * userId: string,
+ * updatedAt: string,
+ * isSelf: boolean,
+ * sourceLastCreatedAt: string,
+ * pendingSessionStartAt: number | null
+ * } | null>}
+ */
+ async getActivityCacheMeta(userId) {
+ let row = null;
+ await sqliteService.execute(
+ (dbRow) => {
+ row = {
+ userId: dbRow[0],
+ updatedAt: dbRow[1],
+ isSelf: Boolean(dbRow[2]),
+ sourceLastCreatedAt: dbRow[3] || '',
+ pendingSessionStartAt:
+ typeof dbRow[4] === 'number' ? dbRow[4] : null
+ };
+ },
+ `SELECT user_id, updated_at, is_self, source_last_created_at, pending_session_start_at
+ FROM ${dbVars.userPrefix}_activity_cache_meta
+ WHERE user_id = @userId`,
+ { '@userId': userId }
+ );
+ return row;
+ },
+
+ /**
+ * @param {string} userId
+ * @returns {Promise>}
+ */
+ async getActivityCacheSessions(userId) {
+ const sessions = [];
+ await sqliteService.execute(
+ (dbRow) => {
+ sessions.push({
+ start: dbRow[0],
+ end: dbRow[1]
+ });
+ },
+ `SELECT start_at, end_at
+ FROM ${dbVars.userPrefix}_activity_cache_sessions
+ WHERE user_id = @userId
+ ORDER BY start_at`,
+ { '@userId': userId }
+ );
+ return sessions;
+ },
+
+ /**
+ * @param {string} userId
+ * @returns {Promise<{
+ * userId: string,
+ * updatedAt: string,
+ * isSelf: boolean,
+ * sourceLastCreatedAt: string,
+ * pendingSessionStartAt: number | null,
+ * sessions: Array<{start: number, end: number}>
+ * } | null>}
+ */
+ async getActivityCache(userId) {
+ const meta = await this.getActivityCacheMeta(userId);
+ if (!meta) {
+ return null;
+ }
+ const sessions = await this.getActivityCacheSessions(userId);
+ return {
+ ...meta,
+ sessions
+ };
+ },
+
+ /**
+ * @param {string} userId
+ * @returns {Promise<{start: number, end: number} | null>}
+ */
+ async getLastActivityCacheSession(userId) {
+ let row = null;
+ await sqliteService.execute(
+ (dbRow) => {
+ row = {
+ start: dbRow[0],
+ end: dbRow[1]
+ };
+ },
+ `SELECT start_at, end_at
+ FROM ${dbVars.userPrefix}_activity_cache_sessions
+ WHERE user_id = @userId
+ ORDER BY start_at DESC
+ LIMIT 1`,
+ { '@userId': userId }
+ );
+ return row;
+ },
+
+ /**
+ * @param {{
+ * userId: string,
+ * updatedAt: string,
+ * isSelf: boolean,
+ * sourceLastCreatedAt: string,
+ * pendingSessionStartAt: number | null,
+ * sessions: Array<{start: number, end: number}>
+ * }} entry
+ * @returns {Promise}
+ */
+ async replaceActivityCache(entry) {
+ await sqliteService.executeNonQuery('BEGIN');
+ try {
+ await sqliteService.executeNonQuery(
+ `DELETE FROM ${dbVars.userPrefix}_activity_cache_sessions WHERE user_id = @userId`,
+ { '@userId': entry.userId }
+ );
+ await upsertSessions(entry.userId, entry.sessions);
+ await upsertMeta(entry);
+ await sqliteService.executeNonQuery('COMMIT');
+ } catch (error) {
+ await sqliteService.executeNonQuery('ROLLBACK');
+ throw error;
+ }
+ },
+
+ /**
+ * @param {{
+ * userId: string,
+ * updatedAt: string,
+ * isSelf: boolean,
+ * sourceLastCreatedAt: string,
+ * pendingSessionStartAt: number | null,
+ * sessions: Array<{start: number, end: number}>,
+ * replaceLastSession?: {start: number, end: number} | null
+ * }} entry
+ * @returns {Promise}
+ */
+ async appendActivityCache(entry) {
+ await sqliteService.executeNonQuery('BEGIN');
+ try {
+ if (entry.replaceLastSession) {
+ await sqliteService.executeNonQuery(
+ `DELETE FROM ${dbVars.userPrefix}_activity_cache_sessions
+ WHERE user_id = @userId AND start_at = @start AND end_at = @end`,
+ {
+ '@userId': entry.userId,
+ '@start': entry.replaceLastSession.start,
+ '@end': entry.replaceLastSession.end
+ }
+ );
+ }
+ await upsertSessions(entry.userId, entry.sessions);
+ await upsertMeta(entry);
+ await sqliteService.executeNonQuery('COMMIT');
+ } catch (error) {
+ await sqliteService.executeNonQuery('ROLLBACK');
+ throw error;
+ }
+ },
+
+ /**
+ * @param {{
+ * userId: string,
+ * updatedAt: string,
+ * isSelf: boolean,
+ * sourceLastCreatedAt: string,
+ * pendingSessionStartAt: number | null
+ * }} entry
+ * @returns {Promise}
+ */
+ async touchActivityCacheMeta(entry) {
+ await upsertMeta(entry);
+ }
+};
+
+async function upsertMeta(entry) {
+ await sqliteService.executeNonQuery(
+ `INSERT OR REPLACE INTO ${dbVars.userPrefix}_activity_cache_meta
+ (user_id, updated_at, is_self, source_last_created_at, pending_session_start_at)
+ VALUES (@user_id, @updated_at, @is_self, @source_last_created_at, @pending_session_start_at)`,
+ {
+ '@user_id': entry.userId,
+ '@updated_at': entry.updatedAt,
+ '@is_self': entry.isSelf ? 1 : 0,
+ '@source_last_created_at': entry.sourceLastCreatedAt || '',
+ '@pending_session_start_at': entry.pendingSessionStartAt
+ }
+ );
+}
+
+async function upsertSessions(userId, sessions = []) {
+ for (const session of sessions) {
+ await sqliteService.executeNonQuery(
+ `INSERT OR REPLACE INTO ${dbVars.userPrefix}_activity_cache_sessions
+ (user_id, start_at, end_at)
+ VALUES (@user_id, @start_at, @end_at)`,
+ {
+ '@user_id': userId,
+ '@start_at': session.start,
+ '@end_at': session.end
+ }
+ );
+ }
+}
+
+export { activityCache };
diff --git a/src/services/database/feed.js b/src/services/database/feed.js
index 20fbdc0c..9421b7a0 100644
--- a/src/services/database/feed.js
+++ b/src/services/database/feed.js
@@ -623,6 +623,30 @@ const feed = {
return data;
},
+ /**
+ * @param {string} userId
+ * @param {string} afterCreatedAt
+ * @returns {Promise>}
+ */
+ async getOnlineOfflineSessionsAfter(userId, afterCreatedAt) {
+ const data = [];
+ await sqliteService.execute(
+ (dbRow) => {
+ data.push({ created_at: dbRow[0], type: dbRow[1] });
+ },
+ `SELECT created_at, type FROM ${dbVars.userPrefix}_feed_online_offline
+ WHERE user_id = @userId
+ AND (type = 'Online' OR type = 'Offline')
+ AND created_at > @afterCreatedAt
+ ORDER BY created_at`,
+ {
+ '@userId': userId,
+ '@afterCreatedAt': afterCreatedAt
+ }
+ );
+ return data;
+ },
+
/**
* @param {number} days - Number of days to look back
* @param {number} limit - Max number of worlds to return
diff --git a/src/services/database/gameLog.js b/src/services/database/gameLog.js
index 35cee3a8..052f2d89 100644
--- a/src/services/database/gameLog.js
+++ b/src/services/database/gameLog.js
@@ -1386,6 +1386,23 @@ const gameLog = {
return data;
},
+ /**
+ * Get current user's online sessions after a given timestamp (incremental).
+ * @param {string} afterCreatedAt - Only return rows created after this timestamp
+ * @returns {Promise>}
+ */
+ async getCurrentUserOnlineSessionsAfter(afterCreatedAt) {
+ const data = [];
+ await sqliteService.execute(
+ (dbRow) => {
+ data.push({ created_at: dbRow[0], time: dbRow[1] || 0 });
+ },
+ `SELECT created_at, time FROM gamelog_location WHERE created_at > @after ORDER BY created_at`,
+ { '@after': afterCreatedAt }
+ );
+ return data;
+ },
+
/**
* Get current user's top visited worlds from gamelog_location.
* Groups by world_id and aggregates visit count and total time.
diff --git a/src/services/database/index.js b/src/services/database/index.js
index 3be56979..0e3d4269 100644
--- a/src/services/database/index.js
+++ b/src/services/database/index.js
@@ -1,3 +1,4 @@
+import { activityCache } from './activityCache.js';
import { avatarFavorites } from './avatarFavorites.js';
import { avatarTags } from './avatarTags.js';
import { feed } from './feed.js';
@@ -25,6 +26,7 @@ const dbVars = {
const database = {
...feed,
+ ...activityCache,
...gameLog,
...notifications,
...moderation,
@@ -70,6 +72,15 @@ const database = {
await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_feed_online_offline (id INTEGER PRIMARY KEY, created_at TEXT, user_id TEXT, display_name TEXT, type TEXT, location TEXT, world_name TEXT, time INTEGER, group_name TEXT)`
);
+ await sqliteService.executeNonQuery(
+ `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_activity_cache_meta (user_id TEXT PRIMARY KEY, updated_at TEXT, is_self INTEGER DEFAULT 0, source_last_created_at TEXT, pending_session_start_at INTEGER)`
+ );
+ await sqliteService.executeNonQuery(
+ `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_activity_cache_sessions (user_id TEXT NOT NULL, start_at INTEGER NOT NULL, end_at INTEGER NOT NULL, PRIMARY KEY (user_id, start_at, end_at))`
+ );
+ await sqliteService.executeNonQuery(
+ `CREATE INDEX IF NOT EXISTS ${dbVars.userPrefix}_activity_cache_sessions_user_start_idx ON ${dbVars.userPrefix}_activity_cache_sessions (user_id, start_at)`
+ );
await sqliteService.executeNonQuery(
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_friend_log_current (user_id TEXT PRIMARY KEY, display_name TEXT, trust_level TEXT, friend_number INTEGER)`
);
diff --git a/src/services/database/tableAlter.js b/src/services/database/tableAlter.js
index 4a10281d..d6a3bc00 100644
--- a/src/services/database/tableAlter.js
+++ b/src/services/database/tableAlter.js
@@ -10,6 +10,7 @@ const tableAlter = {
await this.updateTableForGroupNames();
await this.addFriendLogFriendNumber();
await this.updateTableForAvatarHistory();
+ await this.ensureActivityCacheTables();
// }
// await sqliteService.executeNonQuery('PRAGMA user_version = 1');
},
@@ -80,6 +81,25 @@ const tableAlter = {
}
}
}
+ },
+
+ async ensureActivityCacheTables() {
+ const tables = [];
+ await sqliteService.execute((dbRow) => {
+ tables.push(dbRow[0]);
+ }, `SELECT name FROM sqlite_schema WHERE type='table' AND name LIKE '%_feed_online_offline'`);
+ for (const tableName of tables) {
+ const userPrefix = tableName.replace(/_feed_online_offline$/, '');
+ await sqliteService.executeNonQuery(
+ `CREATE TABLE IF NOT EXISTS ${userPrefix}_activity_cache_meta (user_id TEXT PRIMARY KEY, updated_at TEXT, is_self INTEGER DEFAULT 0, source_last_created_at TEXT, pending_session_start_at INTEGER)`
+ );
+ await sqliteService.executeNonQuery(
+ `CREATE TABLE IF NOT EXISTS ${userPrefix}_activity_cache_sessions (user_id TEXT NOT NULL, start_at INTEGER NOT NULL, end_at INTEGER NOT NULL, PRIMARY KEY (user_id, start_at, end_at))`
+ );
+ await sqliteService.executeNonQuery(
+ `CREATE INDEX IF NOT EXISTS ${userPrefix}_activity_cache_sessions_user_start_idx ON ${userPrefix}_activity_cache_sessions (user_id, start_at)`
+ );
+ }
}
};
diff --git a/src/shared/utils/overlapCalculator.js b/src/shared/utils/overlapCalculator.js
index 0fcda5f7..4ae4d0dc 100644
--- a/src/shared/utils/overlapCalculator.js
+++ b/src/shared/utils/overlapCalculator.js
@@ -1,3 +1,5 @@
+export const ONLINE_SESSION_MERGE_GAP_MS = 5 * 60 * 1000;
+
/**
* Builds online sessions from Online/Offline events.
* @param {Array<{created_at: string, type: string}>} events - Sorted by created_at
@@ -32,7 +34,10 @@ export function buildSessionsFromEvents(events) {
* @param {number} [mergeGapMs] - Merge gap threshold (default 5 min)
* @returns {Array<{start: number, end: number}>}
*/
-export function buildSessionsFromGamelog(rows, mergeGapMs = 5 * 60 * 1000) {
+export function buildSessionsFromGamelog(
+ rows,
+ mergeGapMs = ONLINE_SESSION_MERGE_GAP_MS
+) {
if (rows.length === 0) return [];
const rawSessions = [];
diff --git a/src/stores/activity.js b/src/stores/activity.js
new file mode 100644
index 00000000..033d26c1
--- /dev/null
+++ b/src/stores/activity.js
@@ -0,0 +1,222 @@
+import { defineStore } from 'pinia';
+import { toast } from 'vue-sonner';
+import { useI18n } from 'vue-i18n';
+
+import { database } from '../services/database';
+import {
+ buildSessionsFromGamelog,
+ ONLINE_SESSION_MERGE_GAP_MS
+} from '../shared/utils/overlapCalculator';
+
+const ACTIVITY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+const refreshJobs = new Map();
+
+function buildSessionsAndPendingFromEvents(events, initialStart = null) {
+ const sessions = [];
+ let currentStart = initialStart;
+
+ for (const event of events) {
+ const ts = new Date(event.created_at).getTime();
+ if (event.type === 'Online') {
+ // Treat consecutive Online events as reconnect boundaries.
+ if (currentStart !== null) {
+ sessions.push({ start: currentStart, end: ts });
+ }
+ currentStart = ts;
+ } else if (event.type === 'Offline' && currentStart !== null) {
+ sessions.push({ start: currentStart, end: ts });
+ currentStart = null;
+ }
+ }
+
+ return {
+ pendingSessionStartAt: currentStart,
+ sessions
+ };
+}
+
+function mergeWithLastSession(lastSession, newSessions) {
+ if (!lastSession || newSessions.length === 0) {
+ return { replaceLastSession: null, sessions: newSessions };
+ }
+
+ const firstSession = newSessions[0];
+ if (firstSession.start > lastSession.end + ONLINE_SESSION_MERGE_GAP_MS) {
+ return { replaceLastSession: null, sessions: newSessions };
+ }
+
+ const mergedFirst = {
+ start: Math.min(lastSession.start, firstSession.start),
+ end: Math.max(lastSession.end, firstSession.end)
+ };
+ return {
+ replaceLastSession: lastSession,
+ sessions: [mergedFirst, ...newSessions.slice(1)]
+ };
+}
+
+export const useActivityStore = defineStore('Activity', () => {
+ const { t } = useI18n();
+
+ function getCache(userId) {
+ return database.getActivityCache(userId);
+ }
+
+ function isExpired(cacheEntry) {
+ if (!cacheEntry?.updatedAt) {
+ return true;
+ }
+ const updatedAtMs = Date.parse(cacheEntry.updatedAt);
+ if (Number.isNaN(updatedAtMs)) {
+ return true;
+ }
+ return Date.now() - updatedAtMs >= ACTIVITY_CACHE_TTL_MS;
+ }
+
+ function isRefreshing(userId) {
+ return refreshJobs.has(userId);
+ }
+
+ async function fullRefresh(userId, isSelf) {
+ if (isSelf) {
+ const rows = await database.getCurrentUserOnlineSessions();
+ const sessions = buildSessionsFromGamelog(rows);
+ const sourceLastCreatedAt =
+ rows.length > 0 ? rows[rows.length - 1].created_at : '';
+ const entry = {
+ userId,
+ updatedAt: new Date().toISOString(),
+ isSelf,
+ sourceLastCreatedAt,
+ pendingSessionStartAt: null,
+ sessions
+ };
+ await database.replaceActivityCache(entry);
+ return database.getActivityCache(userId);
+ }
+
+ const events = await database.getOnlineOfflineSessions(userId);
+ const { sessions, pendingSessionStartAt } = buildSessionsAndPendingFromEvents(events);
+ const sourceLastCreatedAt =
+ events.length > 0 ? events[events.length - 1].created_at : '';
+ const entry = {
+ userId,
+ updatedAt: new Date().toISOString(),
+ isSelf,
+ sourceLastCreatedAt,
+ pendingSessionStartAt,
+ sessions
+ };
+ await database.replaceActivityCache(entry);
+ return database.getActivityCache(userId);
+ }
+
+ async function incrementalRefresh(meta) {
+ const updatedAt = new Date().toISOString();
+
+ if (meta.isSelf) {
+ if (!meta.sourceLastCreatedAt) {
+ return fullRefresh(meta.userId, true);
+ }
+
+ const rows = await database.getCurrentUserOnlineSessionsAfter(
+ meta.sourceLastCreatedAt
+ );
+ if (rows.length === 0) {
+ await database.touchActivityCacheMeta({
+ ...meta,
+ updatedAt
+ });
+ return database.getActivityCache(meta.userId);
+ }
+
+ const sourceLastCreatedAt = rows[rows.length - 1].created_at;
+ const newSessionsRaw = buildSessionsFromGamelog(rows);
+ const lastSession = await database.getLastActivityCacheSession(meta.userId);
+ const merged = mergeWithLastSession(lastSession, newSessionsRaw);
+
+ await database.appendActivityCache({
+ ...meta,
+ updatedAt,
+ sourceLastCreatedAt,
+ pendingSessionStartAt: null,
+ sessions: merged.sessions,
+ replaceLastSession: merged.replaceLastSession
+ });
+ return database.getActivityCache(meta.userId);
+ }
+
+ if (!meta.sourceLastCreatedAt) {
+ return fullRefresh(meta.userId, false);
+ }
+
+ const events = await database.getOnlineOfflineSessionsAfter(
+ meta.userId,
+ meta.sourceLastCreatedAt
+ );
+ if (events.length === 0) {
+ await database.touchActivityCacheMeta({
+ ...meta,
+ updatedAt
+ });
+ return database.getActivityCache(meta.userId);
+ }
+
+ const { sessions, pendingSessionStartAt } = buildSessionsAndPendingFromEvents(
+ events,
+ meta.pendingSessionStartAt
+ );
+ const sourceLastCreatedAt = events[events.length - 1].created_at;
+
+ await database.appendActivityCache({
+ ...meta,
+ updatedAt,
+ sourceLastCreatedAt,
+ pendingSessionStartAt,
+ sessions
+ });
+ return database.getActivityCache(meta.userId);
+ }
+
+ function refreshActivityCache(userId, isSelf, options = {}) {
+ const { notifyStart = false, notifyComplete = false } = options;
+
+ const existing = refreshJobs.get(userId);
+ if (existing) {
+ return existing;
+ }
+
+ if (notifyStart) {
+ toast.info(t('dialog.user.activity.refresh_started'), {
+ position: 'bottom-center'
+ });
+ }
+
+ const job = (async () => {
+ const meta = await database.getActivityCacheMeta(userId);
+ const entry =
+ meta && meta.isSelf === isSelf
+ ? await incrementalRefresh(meta)
+ : await fullRefresh(userId, isSelf);
+ if (notifyComplete) {
+ toast.success(t('dialog.user.activity.refresh_complete'), {
+ position: 'bottom-center'
+ });
+ }
+ return entry;
+ })().finally(() => {
+ refreshJobs.delete(userId);
+ });
+
+ refreshJobs.set(userId, job);
+ return job;
+ }
+
+ return {
+ getCache,
+ isExpired,
+ isRefreshing,
+ refreshActivityCache,
+ ttlMs: ACTIVITY_CACHE_TTL_MS
+ };
+});
diff --git a/src/stores/index.js b/src/stores/index.js
index 89e5541f..d0cbcb03 100644
--- a/src/stores/index.js
+++ b/src/stores/index.js
@@ -2,6 +2,7 @@ import { createPinia } from 'pinia';
import { getSentry, isSentryOptedIn } from '../plugins';
import { useAdvancedSettingsStore } from './settings/advanced';
+import { useActivityStore } from './activity';
import { useAppearanceSettingsStore } from './settings/appearance';
import { useAuthStore } from './auth';
import { useAvatarProviderStore } from './avatarProvider';
@@ -124,6 +125,7 @@ export async function initPiniaPlugins() {
export function createGlobalStores() {
return {
advancedSettings: useAdvancedSettingsStore(),
+ activity: useActivityStore(),
appearanceSettings: useAppearanceSettingsStore(),
discordPresenceSettings: useDiscordPresenceSettingsStore(),
generalSettings: useGeneralSettingsStore(),
@@ -186,6 +188,7 @@ export {
useChartsStore,
useDashboardStore,
useAdvancedSettingsStore,
+ useActivityStore,
useAppearanceSettingsStore,
useDiscordPresenceSettingsStore,
useGeneralSettingsStore,
diff --git a/src/stores/vrcx.js b/src/stores/vrcx.js
index 79e7ab7c..e8f2653a 100644
--- a/src/stores/vrcx.js
+++ b/src/stores/vrcx.js
@@ -71,6 +71,11 @@ export const useVrcxStore = defineStore('Vrcx', () => {
windowState: '',
externalNotifierVersion: 0
});
+ const databaseUpgradeState = ref({
+ visible: false,
+ fromVersion: 0,
+ toVersion: 0
+ });
const currentlyDroppingFile = ref(null);
const isRegistryBackupDialogVisible = ref(false);
@@ -182,15 +187,13 @@ export const useVrcxStore = defineStore('Vrcx', () => {
*/
async function updateDatabaseVersion() {
// requires dbVars.userPrefix to be already set
- const databaseVersion = 13;
- let msgBox;
+ const databaseVersion = 14;
if (state.databaseVersion < databaseVersion) {
- if (state.databaseVersion) {
- msgBox = toast.warning(
- 'DO NOT CLOSE VRCX, database upgrade in progress...',
- { duration: Infinity, position: 'bottom-right' }
- );
- }
+ databaseUpgradeState.value = {
+ visible: state.databaseVersion > 0,
+ fromVersion: state.databaseVersion,
+ toVersion: databaseVersion
+ };
console.log(
`Updating database from ${state.databaseVersion} to ${databaseVersion}...`
);
@@ -212,19 +215,16 @@ export const useVrcxStore = defineStore('Vrcx', () => {
databaseVersion
);
console.log('Database update complete.');
- toast.dismiss(msgBox);
- if (state.databaseVersion) {
- // only display when database exists
- toast.success(t('message.database.upgrade_complete'));
- }
state.databaseVersion = databaseVersion;
+ databaseUpgradeState.value.visible = false;
} catch (err) {
console.error(err);
- toast.dismiss(msgBox);
- toast.error(
- 'Database upgrade failed, check console for details',
- { duration: 120000 }
- );
+ databaseUpgradeState.value.visible = false;
+ await modalStore.alert({
+ title: t('message.database.upgrade_failed_title'),
+ description: t('message.database.upgrade_failed_description'),
+ dismissible: false
+ });
AppApi.ShowDevTools();
}
}
@@ -817,6 +817,7 @@ export const useVrcxStore = defineStore('Vrcx', () => {
state,
appStartAt,
+ databaseUpgradeState,
proxyServer,
setProxyServer,
setIpcEnabled,