diff --git a/src/App.vue b/src/App.vue index a3732ef2..25bf2845 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,7 @@ + @@ -30,6 +31,7 @@ import { initNoty } from './plugins/noty'; import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue'; + import DatabaseUpgradeDialog from './components/dialogs/DatabaseUpgradeDialog.vue'; import MacOSTitleBar from './components/MacOSTitleBar.vue'; import OtpDialogModal from './components/ui/dialog/OtpDialogModal.vue'; import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue'; diff --git a/src/components/dialogs/DatabaseUpgradeDialog.vue b/src/components/dialogs/DatabaseUpgradeDialog.vue new file mode 100644 index 00000000..ae673aff --- /dev/null +++ b/src/components/dialogs/DatabaseUpgradeDialog.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue index 83b0b176..6c4dc240 100644 --- a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue +++ b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue @@ -2,10 +2,19 @@
- + + {{ t('dialog.user.activity.refresh_hint') }} + {{ t('dialog.user.activity.total_events', { count: filteredEventCount }) }} @@ -36,11 +45,28 @@ {{ peakTimeText }}
-
- +
+ + + {{ 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,