diff --git a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue index 9e397a7e..09065b19 100644 --- a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue +++ b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue @@ -136,14 +136,43 @@
- {{ t('dialog.user.activity.most_visited_worlds.header') }} +
+ + {{ t('dialog.user.activity.most_visited_worlds.header') }} + + +
+
+ {{ t('common.sort_by') }} + +
-
+
+ + {{ t('dialog.user.activity.most_visited_worlds.loading') }} +
+
{{ t('dialog.user.activity.no_data_in_period') }}
+ :style="{ + width: getTopWorldBarWidth( + topWorldsSortBy === 'time' ? world.totalTime : world.visitCount + ) + }" />
@@ -226,7 +265,9 @@ const hasOverlapData = ref(false); const overlapPercent = ref(0); const bestOverlapTime = ref(''); + const topWorldsLoading = ref(false); const topWorlds = ref([]); + const topWorldsSortBy = ref('time'); const excludeHoursEnabled = ref(false); const excludeStartHour = ref('1'); const excludeEndHour = ref('6'); @@ -241,10 +282,12 @@ let activeRequestId = 0; let activeOverlapRequestId = 0; + let activeTopWorldsRequestId = 0; let lastLoadedUserId = ''; const pendingWorldThumbnailFetches = new Set(); const isSelf = computed(() => userDialog.value.id === currentUser.value.id); + const sortedTopWorlds = computed(() => topWorlds.value); const dayLabels = computed(() => [ t('dialog.user.activity.days.sun'), t('dialog.user.activity.days.mon'), @@ -277,14 +320,45 @@ hasOverlapData.value = false; overlapPercent.value = 0; bestOverlapTime.value = ''; + topWorldsLoading.value = false; topWorlds.value = []; mainHeatmapView.value = { rawBuckets: [], normalizedBuckets: [] }; overlapHeatmapView.value = { rawBuckets: [], normalizedBuckets: [] }; activeRequestId++; activeOverlapRequestId++; + activeTopWorldsRequestId++; lastLoadedUserId = ''; } + async function loadTopWorldsSection({ userId, rangeDays, sortBy, period }) { + const requestId = ++activeTopWorldsRequestId; + topWorldsLoading.value = true; + + try { + const result = await activityStore.loadTopWorldsView({ + userId, + rangeDays, + limit: 5, + sortBy + }); + if ( + requestId !== activeTopWorldsRequestId || + userDialog.value.id !== userId || + topWorldsSortBy.value !== sortBy || + selectedPeriod.value !== period + ) { + return; + } + + topWorlds.value = result; + void fetchMissingTopWorldThumbnails(topWorlds.value); + } finally { + if (requestId === activeTopWorldsRequestId) { + topWorldsLoading.value = false; + } + } + } + async function refreshData({ silent = false, forceRefresh = false } = {}) { const userId = userDialog.value.id; if (!userId) { @@ -323,17 +397,20 @@ if (!hasAnyData.value) { hasOverlapData.value = false; topWorlds.value = []; + topWorldsLoading.value = false; return; } if (isSelf.value) { - topWorlds.value = await activityStore.loadTopWorldsView({ + await loadTopWorldsSection({ userId, rangeDays, - limit: 5, - forceRefresh + sortBy: topWorldsSortBy.value, + period: selectedPeriod.value }); - void fetchMissingTopWorldThumbnails(topWorlds.value); + if (requestId !== activeRequestId || userDialog.value.id !== userId) { + return; + } hasOverlapData.value = false; return; } @@ -486,15 +563,16 @@ return timeToText(ms); } - function getTopWorldBarWidth(totalTime) { - if (topWorlds.value.length === 0) { + function getTopWorldBarWidth(value) { + if (sortedTopWorlds.value.length === 0) { return '0%'; } - const maxTime = Math.max(...topWorlds.value.map((world) => world.totalTime || 0), 0); - if (maxTime <= 0) { + const key = topWorldsSortBy.value === 'count' ? 'visitCount' : 'totalTime'; + const maxVal = Math.max(...sortedTopWorlds.value.map((world) => world[key] || 0), 0); + if (maxVal <= 0) { return '0%'; } - return `${Math.max((totalTime / maxTime) * 100, 8)}%`; + return `${Math.max((value / maxVal) * 100, 8)}%`; } const activityChartRef = ref(null); @@ -664,6 +742,26 @@ } } ); + watch( + () => topWorldsSortBy.value, + async (newSortBy) => { + if (!isSelf.value || !hasAnyData.value) { + return; + } + const userId = userDialog.value.id; + if (!userId) { + return; + } + const period = selectedPeriod.value; + const rangeDays = parseInt(period, 10) || 30; + await loadTopWorldsSection({ + userId, + rangeDays, + sortBy: newSortBy, + period + }); + } + ); watch( () => mainHeatmapView.value, () => { diff --git a/src/localization/en.json b/src/localization/en.json index 1acfd0ef..953128ca 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -17,6 +17,7 @@ "reset": "Reset", "view_details": "View Details" }, + "sort_by": "Sort by:", "time_units": { "y": "y", "d": "d", @@ -1460,7 +1461,11 @@ "minutes_overlap": "min overlap" }, "most_visited_worlds": { - "header": "Most Visited Worlds" + "header": "Most Visited Worlds", + "loading": "Loading most visited worlds...", + "sort_by_time": "By Time", + "sort_by_count": "By Visits", + "visit_count_label": "{count} visits" }, "preparing_data": "Preparing activity data...", "preparing_data_hint": "This may take a moment on first load.", diff --git a/src/services/database/activityV2.js b/src/services/database/activityV2.js index cdae0dfa..8ae4d829 100644 --- a/src/services/database/activityV2.js +++ b/src/services/database/activityV2.js @@ -28,10 +28,6 @@ function bucketCacheTable() { return `${dbVars.userPrefix}_activity_bucket_cache_v2`; } -function topWorldsCacheTable() { - return `${dbVars.userPrefix}_activity_top_worlds_cache_v2`; -} - function parseJson(value, fallback) { if (!value) { return fallback; @@ -52,16 +48,24 @@ const activityV2 = { ACTIVITY_RANGE_CACHE_KIND, async getActivitySourceSliceV2({ userId, isSelf, fromDays, toDays = 0 }) { - const fromDateIso = new Date(Date.now() - fromDays * 86400000).toISOString(); - const toDateIso = toDays > 0 - ? new Date(Date.now() - toDays * 86400000).toISOString() - : ''; + const fromDateIso = new Date( + Date.now() - fromDays * 86400000 + ).toISOString(); + const toDateIso = + toDays > 0 + ? new Date(Date.now() - toDays * 86400000).toISOString() + : ''; return isSelf ? this.getCurrentUserLocationSliceV2(fromDateIso, toDateIso) : this.getFriendPresenceSliceV2(userId, fromDateIso, toDateIso); }, - async getActivitySourceAfterV2({ userId, isSelf, afterCreatedAt, inclusive = false }) { + async getActivitySourceAfterV2({ + userId, + isSelf, + afterCreatedAt, + inclusive = false + }) { return isSelf ? this.getCurrentUserLocationAfterV2(afterCreatedAt, inclusive) : this.getFriendPresenceAfterV2(userId, afterCreatedAt); @@ -122,7 +126,9 @@ const activityV2 = { ); } - return rows.sort((left, right) => left.created_at.localeCompare(right.created_at)); + return rows.sort((left, right) => + left.created_at.localeCompare(right.created_at) + ); }, async getFriendPresenceAfterV2(userId, afterCreatedAt) { @@ -167,8 +173,9 @@ const activityV2 = { FROM gamelog_location WHERE created_at >= @fromDateIso ${toDateIso ? 'AND created_at < @toDateIso' : ''} - ${toDateIso - ? `UNION ALL + ${ + toDateIso + ? `UNION ALL SELECT created_at, time, 2 AS sort_group FROM ( SELECT created_at, time @@ -177,7 +184,8 @@ const activityV2 = { ORDER BY created_at LIMIT 1 )` - : ''} + : '' + } ) ORDER BY created_at ASC, sort_group ASC `, @@ -214,7 +222,8 @@ const activityV2 = { updatedAt: dbRow[1] || '', isSelf: Boolean(dbRow[2]), sourceLastCreatedAt: dbRow[3] || '', - pendingSessionStartAt: typeof dbRow[4] === 'number' ? dbRow[4] : null, + pendingSessionStartAt: + typeof dbRow[4] === 'number' ? dbRow[4] : null, cachedRangeDays: dbRow[5] || 0 }; }, @@ -277,7 +286,11 @@ const activityV2 = { } }, - async appendActivitySessionsV2({ userId, sessions = [], replaceFromStartAt = null }) { + async appendActivitySessionsV2({ + userId, + sessions = [], + replaceFromStartAt = null + }) { await sqliteService.executeNonQuery('BEGIN'); try { if (replaceFromStartAt !== null) { @@ -391,86 +404,15 @@ const activityV2 = { '@bucketVersion': entry.bucketVersion || 1, '@builtFromCursor': entry.builtFromCursor || '', '@rawBucketsJson': JSON.stringify(entry.rawBuckets || []), - '@normalizedBucketsJson': JSON.stringify(entry.normalizedBuckets || []), + '@normalizedBucketsJson': JSON.stringify( + entry.normalizedBuckets || [] + ), '@summaryJson': JSON.stringify(entry.summary || {}), '@builtAt': entry.builtAt || '' } ); }, - async getActivityTopWorldsCacheV2(userId, rangeDays) { - const worlds = []; - let builtFromCursor = ''; - let builtAt = ''; - await sqliteService.execute( - (dbRow) => { - builtFromCursor = dbRow[0] || builtFromCursor; - builtAt = dbRow[1] || builtAt; - worlds.push({ - worldId: dbRow[3], - worldName: dbRow[4], - visitCount: dbRow[5] || 0, - totalTime: dbRow[6] || 0 - }); - }, - `SELECT built_from_cursor, built_at, rank_index, world_id, world_name, visit_count, total_time - FROM ${topWorldsCacheTable()} - WHERE user_id = @userId AND range_days = @rangeDays - ORDER BY rank_index`, - { - '@userId': userId, - '@rangeDays': rangeDays - } - ); - if (worlds.length === 0) { - return null; - } - return { - userId, - rangeDays, - builtFromCursor, - builtAt, - worlds - }; - }, - - async replaceActivityTopWorldsCacheV2(entry) { - await sqliteService.executeNonQuery('BEGIN'); - try { - await sqliteService.executeNonQuery( - `DELETE FROM ${topWorldsCacheTable()} WHERE user_id = @userId AND range_days = @rangeDays`, - { - '@userId': entry.userId, - '@rangeDays': entry.rangeDays - } - ); - - for (let index = 0; index < entry.worlds.length; index++) { - const world = entry.worlds[index]; - await sqliteService.executeNonQuery( - `INSERT OR REPLACE INTO ${topWorldsCacheTable()} - (user_id, range_days, rank_index, world_id, world_name, visit_count, total_time, built_from_cursor, built_at) - VALUES (@userId, @rangeDays, @rankIndex, @worldId, @worldName, @visitCount, @totalTime, @builtFromCursor, @builtAt)`, - { - '@userId': entry.userId, - '@rangeDays': entry.rangeDays, - '@rankIndex': index, - '@worldId': world.worldId, - '@worldName': world.worldName || world.worldId, - '@visitCount': world.visitCount || 0, - '@totalTime': world.totalTime || 0, - '@builtFromCursor': entry.builtFromCursor || '', - '@builtAt': entry.builtAt || '' - } - ); - } - - await sqliteService.executeNonQuery('COMMIT'); - } catch (error) { - await sqliteService.executeNonQuery('ROLLBACK'); - throw error; - } - } }; async function insertSessions(userId, sessions = []) { @@ -479,7 +421,11 @@ async function insertSessions(userId, sessions = []) { } const chunkSize = 250; - for (let chunkStart = 0; chunkStart < sessions.length; chunkStart += chunkSize) { + for ( + let chunkStart = 0; + chunkStart < sessions.length; + chunkStart += chunkSize + ) { const chunk = sessions.slice(chunkStart, chunkStart + chunkSize); const args = {}; const values = chunk.map((session, index) => { diff --git a/src/services/database/gameLog.js b/src/services/database/gameLog.js index 238686a5..7e997c2e 100644 --- a/src/services/database/gameLog.js +++ b/src/services/database/gameLog.js @@ -1390,7 +1390,9 @@ const gameLog = { const where = []; if (fromDays > 0) { - const fromDate = new Date(now.getTime() - fromDays * 86400000).toISOString(); + const fromDate = new Date( + now.getTime() - fromDays * 86400000 + ).toISOString(); params['@fromDate'] = fromDate; where.push('created_at >= @fromDate'); @@ -1403,12 +1405,15 @@ const gameLog = { ); } if (toDays > 0) { - const toDate = new Date(now.getTime() - toDays * 86400000).toISOString(); + const toDate = new Date( + now.getTime() - toDays * 86400000 + ).toISOString(); params['@toDate'] = toDate; where.push('created_at < @toDate'); } - const dateClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + const dateClause = + where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; await sqliteService.execute( (dbRow) => { data.push({ created_at: dbRow[0], time: dbRow[1] || 0 }); @@ -1443,12 +1448,15 @@ const gameLog = { * Groups by world_id and aggregates visit count and total time. * @param {number} [days] - Number of days to look back. Omit or 0 for all time. * @param {number} [limit=5] - Maximum number of worlds to return. + * @param {'time'|'count'} [sortBy='time'] - Sort by total time or visit count. * @returns {Promise>} */ - async getMyTopWorlds(days = 0, limit = 5) { + async getMyTopWorlds(days = 0, limit = 5, sortBy = 'time') { const results = []; const whereClause = days > 0 ? `AND created_at >= datetime('now', @daysOffset)` : ''; + const orderBy = + sortBy === 'count' ? 'visit_count DESC' : 'total_time DESC'; const params = { '@limit': limit }; if (days > 0) { params['@daysOffset'] = `-${days} days`; @@ -1473,7 +1481,7 @@ const gameLog = { AND world_id LIKE 'wrld_%' ${whereClause} GROUP BY world_id - ORDER BY total_time DESC + ORDER BY ${orderBy} LIMIT @limit`, params ); diff --git a/src/services/database/index.js b/src/services/database/index.js index b6c47082..cea00552 100644 --- a/src/services/database/index.js +++ b/src/services/database/index.js @@ -128,20 +128,6 @@ const database = { PRIMARY KEY (user_id, target_user_id, range_days, view_kind, exclude_key) )` ); - await sqliteService.executeNonQuery( - `CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_activity_top_worlds_cache_v2 ( - user_id TEXT NOT NULL, - range_days INTEGER NOT NULL, - rank_index INTEGER NOT NULL, - world_id TEXT NOT NULL, - world_name TEXT NOT NULL, - visit_count INTEGER NOT NULL DEFAULT 0, - total_time INTEGER NOT NULL DEFAULT 0, - built_from_cursor TEXT NOT NULL DEFAULT '', - built_at TEXT NOT NULL DEFAULT '', - PRIMARY KEY (user_id, range_days, rank_index) - )` - ); 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/stores/activity.js b/src/stores/activity.js index 6541b86f..a98fc005 100644 --- a/src/stores/activity.js +++ b/src/stores/activity.js @@ -41,8 +41,7 @@ function createSnapshot(userId, isSelf) { }, sessions: [], activityViews: new Map(), - overlapViews: new Map(), - topWorldsViews: new Map() + overlapViews: new Map() }; } @@ -93,7 +92,6 @@ function isUserInFlight(userId) { function clearDerivedViews(snapshot) { snapshot.activityViews.clear(); snapshot.overlapViews.clear(); - snapshot.topWorldsViews.clear(); } function overlapExcludeKey(excludeHours) { @@ -298,53 +296,10 @@ export const useActivityStore = defineStore('Activity', () => { async function loadTopWorlds( userId, - { rangeDays = 30, limit = 5, isSelf = true, forceRefresh = false } + { rangeDays = 30, limit = 5, sortBy = 'time' } ) { - const snapshot = await ensureSnapshot(userId, { - isSelf, - rangeDays, - forceRefresh - }); - const cacheKey = `${rangeDays}:${limit}`; - const currentCursor = snapshot.sync.sourceLastCreatedAt || ''; - - let cached = snapshot.topWorldsViews.get(cacheKey); - if (!forceRefresh && cached?.builtFromCursor === currentCursor) { - return cached.worlds; - } - - if (!forceRefresh) { - const persisted = await database.getActivityTopWorldsCacheV2( - userId, - rangeDays - ); - if (persisted?.builtFromCursor === currentCursor) { - snapshot.topWorldsViews.set(cacheKey, persisted); - return persisted.worlds; - } - } - - const worlds = await database.getMyTopWorlds(rangeDays, limit); - const entry = { - userId, - rangeDays, - worlds, - builtFromCursor: currentCursor, - builtAt: new Date().toISOString() - }; - snapshot.topWorldsViews.set(cacheKey, entry); - deferWrite(() => database.replaceActivityTopWorldsCacheV2(entry)); - deferWrite(() => - database.upsertActivityRangeCacheV2({ - userId, - rangeDays, - cacheKind: database.ACTIVITY_RANGE_CACHE_KIND.TOP_WORLDS, - isComplete: true, - builtFromCursor: currentCursor, - builtAt: entry.builtAt - }) - ); - return worlds; + void userId; + return database.getMyTopWorlds(rangeDays, limit, sortBy); } async function refreshActivity(userId, options) { @@ -403,13 +358,13 @@ export const useActivityStore = defineStore('Activity', () => { userId, rangeDays = 30, limit = 5, - forceRefresh = false + sortBy = 'time' }) { return loadTopWorlds(userId, { rangeDays, limit, - isSelf: true, - forceRefresh + sortBy, + isSelf: true }); }