diff --git a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue index 82f039ef..dbdf24e5 100644 --- a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue +++ b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue @@ -142,21 +142,34 @@ -
- {{ t('common.sort_by') }} - +
+
+ + + {{ t('dialog.user.activity.most_visited_worlds.exclude_home_world') }} + +
+
+ {{ t('common.sort_by') }} + +
userDialog.value.id === currentUser.value.id); + const currentHomeWorldId = computed(() => { + const homeLocation = currentUser.value.homeLocation; + if (!homeLocation) { + return ''; + } + return parseLocation(homeLocation).worldId || homeLocation; + }); const sortedTopWorlds = computed(() => topWorlds.value); const dayLabels = computed(() => [ t('dialog.user.activity.days.sun'), @@ -330,6 +351,7 @@ topWorldsLoading.value = false; topWorldsLoadingVisible.value = false; topWorlds.value = []; + excludeHomeWorldEnabled.value = false; isOverlapLoading.value = false; isOverlapLoadingVisible.value = false; mainHeatmapView.value = { rawBuckets: [], normalizedBuckets: [] }; @@ -427,7 +449,8 @@ userId, rangeDays, limit: 5, - sortBy + sortBy, + excludeWorldId: excludeHomeWorldEnabled.value ? currentHomeWorldId.value : '' }); if ( requestId !== activeTopWorldsRequestId || @@ -445,6 +468,21 @@ } } + async function refreshTopWorldsOnly() { + const userId = userDialog.value.id; + if (!isSelf.value || !hasAnyData.value || !userId) { + return; + } + + const rangeDays = parseInt(selectedPeriod.value, 10) || 30; + await loadTopWorldsSection({ + userId, + rangeDays, + sortBy: topWorldsSortBy.value, + period: selectedPeriod.value + }); + } + async function refreshData({ silent = false, forceRefresh = false } = {}) { const userId = userDialog.value.id; if (!userId) { @@ -589,6 +627,11 @@ await refreshOverlapOnly(); } + async function onExcludeHomeWorldToggle(value) { + excludeHomeWorldEnabled.value = value; + await refreshTopWorldsOnly(); + } + async function fetchMissingTopWorldThumbnails(worlds) { const missingWorldIds = worlds .map((world) => world.worldId) @@ -819,22 +862,16 @@ ); watch( () => topWorldsSortBy.value, - async (newSortBy) => { - if (!isSelf.value || !hasAnyData.value) { - return; + () => { + void refreshTopWorldsOnly(); + } + ); + watch( + () => currentUser.value.homeLocation, + () => { + if (excludeHomeWorldEnabled.value) { + void refreshTopWorldsOnly(); } - 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( diff --git a/src/localization/en.json b/src/localization/en.json index 44d11b30..6f59cb93 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -1463,6 +1463,7 @@ "most_visited_worlds": { "header": "Most Visited Worlds", "loading": "Loading most visited worlds...", + "exclude_home_world": "Exclude home world", "sort_by_time": "By Time", "sort_by_count": "By Visits", "visit_count_label": "{count} visits" diff --git a/src/services/database/__tests__/gameLog.test.js b/src/services/database/__tests__/gameLog.test.js new file mode 100644 index 00000000..22bf0b48 --- /dev/null +++ b/src/services/database/__tests__/gameLog.test.js @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn() +})); + +vi.mock('../../sqlite.js', () => ({ + default: { + execute: mocks.execute, + executeNonQuery: vi.fn() + } +})); +vi.mock('../index.js', () => ({ + dbVars: { + maxTableSize: 500, + userPrefix: '' + } +})); + +import { gameLog } from '../gameLog.js'; + +describe('gameLog.getMyTopWorlds', () => { + beforeEach(() => { + mocks.execute.mockReset(); + }); + + test('adds an exclude clause when a home world id is provided', async () => { + mocks.execute.mockImplementation(async (callback, sql, params) => { + callback(['wrld_1', 'World One', 3, 9000]); + return undefined; + }); + + const result = await gameLog.getMyTopWorlds(30, 5, 'time', 'wrld_home'); + + expect(result).toEqual([ + { + worldId: 'wrld_1', + worldName: 'World One', + visitCount: 3, + totalTime: 9000 + } + ]); + expect(mocks.execute).toHaveBeenCalledTimes(1); + expect(mocks.execute.mock.calls[0][1]).toContain('AND world_id != @excludeWorldId'); + expect(mocks.execute.mock.calls[0][2]).toMatchObject({ + '@limit': 5, + '@daysOffset': '-30 days', + '@excludeWorldId': 'wrld_home' + }); + }); +}); diff --git a/src/services/database/gameLog.js b/src/services/database/gameLog.js index 7e997c2e..c03dc88e 100644 --- a/src/services/database/gameLog.js +++ b/src/services/database/gameLog.js @@ -1449,18 +1449,23 @@ const gameLog = { * @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. + * @param {string} [excludeWorldId=''] - Optional world ID to exclude from results. * @returns {Promise>} */ - async getMyTopWorlds(days = 0, limit = 5, sortBy = 'time') { + async getMyTopWorlds(days = 0, limit = 5, sortBy = 'time', excludeWorldId = '') { const results = []; const whereClause = days > 0 ? `AND created_at >= datetime('now', @daysOffset)` : ''; + const excludeClause = excludeWorldId ? 'AND world_id != @excludeWorldId' : ''; const orderBy = sortBy === 'count' ? 'visit_count DESC' : 'total_time DESC'; const params = { '@limit': limit }; if (days > 0) { params['@daysOffset'] = `-${days} days`; } + if (excludeWorldId) { + params['@excludeWorldId'] = excludeWorldId; + } await sqliteService.execute( (dbRow) => { results.push({ @@ -1480,6 +1485,7 @@ const gameLog = { AND world_id != '' AND world_id LIKE 'wrld_%' ${whereClause} + ${excludeClause} GROUP BY world_id ORDER BY ${orderBy} LIMIT @limit`, diff --git a/src/stores/__tests__/activity.test.js b/src/stores/__tests__/activity.test.js new file mode 100644 index 00000000..e9fc4ec1 --- /dev/null +++ b/src/stores/__tests__/activity.test.js @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +const mocks = vi.hoisted(() => ({ + getMyTopWorlds: vi.fn() +})); + +vi.mock('../../services/database', () => ({ + database: { + getMyTopWorlds: mocks.getMyTopWorlds + } +})); +vi.mock('../../workers/activityWorkerRunner', () => ({ + runActivityWorkerTask: vi.fn() +})); + +import { useActivityStore } from '../activity'; + +describe('useActivityStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + test('forwards excludeWorldId to top worlds query', async () => { + mocks.getMyTopWorlds.mockResolvedValue([{ worldId: 'wrld_1' }]); + const store = useActivityStore(); + + const result = await store.loadTopWorldsView({ + userId: 'usr_me', + rangeDays: 30, + limit: 5, + sortBy: 'time', + excludeWorldId: 'wrld_home' + }); + + expect(result).toEqual([{ worldId: 'wrld_1' }]); + expect(mocks.getMyTopWorlds).toHaveBeenCalledWith(30, 5, 'time', 'wrld_home'); + }); +}); diff --git a/src/stores/activity.js b/src/stores/activity.js index 6ef961a3..a75f627c 100644 --- a/src/stores/activity.js +++ b/src/stores/activity.js @@ -296,10 +296,10 @@ export const useActivityStore = defineStore('Activity', () => { async function loadTopWorlds( userId, - { rangeDays = 30, limit = 5, sortBy = 'time' } + { rangeDays = 30, limit = 5, sortBy = 'time', excludeWorldId = '' } ) { void userId; - return database.getMyTopWorlds(rangeDays, limit, sortBy); + return database.getMyTopWorlds(rangeDays, limit, sortBy, excludeWorldId); } async function refreshActivity(userId, options) { @@ -358,12 +358,14 @@ export const useActivityStore = defineStore('Activity', () => { userId, rangeDays = 30, limit = 5, - sortBy = 'time' + sortBy = 'time', + excludeWorldId = '' }) { return loadTopWorlds(userId, { rangeDays, limit, sortBy, + excludeWorldId, isSelf: true }); }