feat: add Exclude home world for activity tab

This commit is contained in:
pa
2026-03-23 11:23:14 +09:00
parent fb4750e9bc
commit 520c41f280
6 changed files with 173 additions and 36 deletions
@@ -142,6 +142,18 @@
</span> </span>
<Spinner v-if="topWorldsLoadingVisible" class="h-3.5 w-3.5" /> <Spinner v-if="topWorldsLoadingVisible" class="h-3.5 w-3.5" />
</div> </div>
<div class="flex items-center gap-4">
<div
v-if="isSelf && currentHomeWorldId"
class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Switch
:model-value="excludeHomeWorldEnabled"
class="scale-75"
@update:model-value="onExcludeHomeWorldToggle" />
<span class="whitespace-nowrap">
{{ t('dialog.user.activity.most_visited_worlds.exclude_home_world') }}
</span>
</div>
<div v-if="topWorlds.length > 0" class="flex items-center gap-2"> <div v-if="topWorlds.length > 0" class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">{{ t('common.sort_by') }}</span> <span class="text-muted-foreground text-sm">{{ t('common.sort_by') }}</span>
<Select v-model="topWorldsSortBy" :disabled="topWorldsLoading"> <Select v-model="topWorldsSortBy" :disabled="topWorldsLoading">
@@ -159,6 +171,7 @@
</Select> </Select>
</div> </div>
</div> </div>
</div>
<div <div
v-if="topWorldsLoadingVisible && topWorlds.length === 0" v-if="topWorldsLoadingVisible && topWorlds.length === 0"
class="flex items-center gap-2 text-sm text-muted-foreground py-2"> class="flex items-center gap-2 text-sm text-muted-foreground py-2">
@@ -244,7 +257,7 @@
import configRepository from '../../../services/config'; import configRepository from '../../../services/config';
import { worldRequest } from '../../../api'; import { worldRequest } from '../../../api';
import { showWorldDialog } from '../../../coordinators/worldCoordinator'; import { showWorldDialog } from '../../../coordinators/worldCoordinator';
import { timeToText } from '../../../shared/utils'; import { parseLocation, timeToText } from '../../../shared/utils';
import { useActivityStore, useAppearanceSettingsStore, useUserStore } from '../../../stores'; import { useActivityStore, useAppearanceSettingsStore, useUserStore } from '../../../stores';
import { useWorldStore } from '../../../stores/world'; import { useWorldStore } from '../../../stores/world';
import { buildHeatmapOption, toHeatmapSeriesData } from './activity/buildHeatmapOption'; import { buildHeatmapOption, toHeatmapSeriesData } from './activity/buildHeatmapOption';
@@ -270,6 +283,7 @@
const topWorldsLoadingVisible = ref(false); const topWorldsLoadingVisible = ref(false);
const topWorlds = ref([]); const topWorlds = ref([]);
const topWorldsSortBy = ref('time'); const topWorldsSortBy = ref('time');
const excludeHomeWorldEnabled = ref(false);
const excludeHoursEnabled = ref(false); const excludeHoursEnabled = ref(false);
const excludeStartHour = ref('1'); const excludeStartHour = ref('1');
const excludeEndHour = ref('6'); const excludeEndHour = ref('6');
@@ -295,6 +309,13 @@
const OVERLAP_RENDER_DELAY = 80; const OVERLAP_RENDER_DELAY = 80;
const isSelf = computed(() => userDialog.value.id === currentUser.value.id); const isSelf = computed(() => 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 sortedTopWorlds = computed(() => topWorlds.value);
const dayLabels = computed(() => [ const dayLabels = computed(() => [
t('dialog.user.activity.days.sun'), t('dialog.user.activity.days.sun'),
@@ -330,6 +351,7 @@
topWorldsLoading.value = false; topWorldsLoading.value = false;
topWorldsLoadingVisible.value = false; topWorldsLoadingVisible.value = false;
topWorlds.value = []; topWorlds.value = [];
excludeHomeWorldEnabled.value = false;
isOverlapLoading.value = false; isOverlapLoading.value = false;
isOverlapLoadingVisible.value = false; isOverlapLoadingVisible.value = false;
mainHeatmapView.value = { rawBuckets: [], normalizedBuckets: [] }; mainHeatmapView.value = { rawBuckets: [], normalizedBuckets: [] };
@@ -427,7 +449,8 @@
userId, userId,
rangeDays, rangeDays,
limit: 5, limit: 5,
sortBy sortBy,
excludeWorldId: excludeHomeWorldEnabled.value ? currentHomeWorldId.value : ''
}); });
if ( if (
requestId !== activeTopWorldsRequestId || 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 } = {}) { async function refreshData({ silent = false, forceRefresh = false } = {}) {
const userId = userDialog.value.id; const userId = userDialog.value.id;
if (!userId) { if (!userId) {
@@ -589,6 +627,11 @@
await refreshOverlapOnly(); await refreshOverlapOnly();
} }
async function onExcludeHomeWorldToggle(value) {
excludeHomeWorldEnabled.value = value;
await refreshTopWorldsOnly();
}
async function fetchMissingTopWorldThumbnails(worlds) { async function fetchMissingTopWorldThumbnails(worlds) {
const missingWorldIds = worlds const missingWorldIds = worlds
.map((world) => world.worldId) .map((world) => world.worldId)
@@ -819,22 +862,16 @@
); );
watch( watch(
() => topWorldsSortBy.value, () => topWorldsSortBy.value,
async (newSortBy) => { () => {
if (!isSelf.value || !hasAnyData.value) { void refreshTopWorldsOnly();
return;
} }
const userId = userDialog.value.id; );
if (!userId) { watch(
return; () => currentUser.value.homeLocation,
() => {
if (excludeHomeWorldEnabled.value) {
void refreshTopWorldsOnly();
} }
const period = selectedPeriod.value;
const rangeDays = parseInt(period, 10) || 30;
await loadTopWorldsSection({
userId,
rangeDays,
sortBy: newSortBy,
period
});
} }
); );
watch( watch(
+1
View File
@@ -1463,6 +1463,7 @@
"most_visited_worlds": { "most_visited_worlds": {
"header": "Most Visited Worlds", "header": "Most Visited Worlds",
"loading": "Loading most visited worlds...", "loading": "Loading most visited worlds...",
"exclude_home_world": "Exclude home world",
"sort_by_time": "By Time", "sort_by_time": "By Time",
"sort_by_count": "By Visits", "sort_by_count": "By Visits",
"visit_count_label": "{count} visits" "visit_count_label": "{count} visits"
@@ -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'
});
});
});
+7 -1
View File
@@ -1449,18 +1449,23 @@ const gameLog = {
* @param {number} [days] - Number of days to look back. Omit or 0 for all 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 {number} [limit=5] - Maximum number of worlds to return.
* @param {'time'|'count'} [sortBy='time'] - Sort by total time or visit count. * @param {'time'|'count'} [sortBy='time'] - Sort by total time or visit count.
* @param {string} [excludeWorldId=''] - Optional world ID to exclude from results.
* @returns {Promise<Array<{worldId: string, worldName: string, visitCount: number, totalTime: number}>>} * @returns {Promise<Array<{worldId: string, worldName: string, visitCount: number, totalTime: number}>>}
*/ */
async getMyTopWorlds(days = 0, limit = 5, sortBy = 'time') { async getMyTopWorlds(days = 0, limit = 5, sortBy = 'time', excludeWorldId = '') {
const results = []; const results = [];
const whereClause = const whereClause =
days > 0 ? `AND created_at >= datetime('now', @daysOffset)` : ''; days > 0 ? `AND created_at >= datetime('now', @daysOffset)` : '';
const excludeClause = excludeWorldId ? 'AND world_id != @excludeWorldId' : '';
const orderBy = const orderBy =
sortBy === 'count' ? 'visit_count DESC' : 'total_time DESC'; sortBy === 'count' ? 'visit_count DESC' : 'total_time DESC';
const params = { '@limit': limit }; const params = { '@limit': limit };
if (days > 0) { if (days > 0) {
params['@daysOffset'] = `-${days} days`; params['@daysOffset'] = `-${days} days`;
} }
if (excludeWorldId) {
params['@excludeWorldId'] = excludeWorldId;
}
await sqliteService.execute( await sqliteService.execute(
(dbRow) => { (dbRow) => {
results.push({ results.push({
@@ -1480,6 +1485,7 @@ const gameLog = {
AND world_id != '' AND world_id != ''
AND world_id LIKE 'wrld_%' AND world_id LIKE 'wrld_%'
${whereClause} ${whereClause}
${excludeClause}
GROUP BY world_id GROUP BY world_id
ORDER BY ${orderBy} ORDER BY ${orderBy}
LIMIT @limit`, LIMIT @limit`,
+40
View File
@@ -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');
});
});
+5 -3
View File
@@ -296,10 +296,10 @@ export const useActivityStore = defineStore('Activity', () => {
async function loadTopWorlds( async function loadTopWorlds(
userId, userId,
{ rangeDays = 30, limit = 5, sortBy = 'time' } { rangeDays = 30, limit = 5, sortBy = 'time', excludeWorldId = '' }
) { ) {
void userId; void userId;
return database.getMyTopWorlds(rangeDays, limit, sortBy); return database.getMyTopWorlds(rangeDays, limit, sortBy, excludeWorldId);
} }
async function refreshActivity(userId, options) { async function refreshActivity(userId, options) {
@@ -358,12 +358,14 @@ export const useActivityStore = defineStore('Activity', () => {
userId, userId,
rangeDays = 30, rangeDays = 30,
limit = 5, limit = 5,
sortBy = 'time' sortBy = 'time',
excludeWorldId = ''
}) { }) {
return loadTopWorlds(userId, { return loadTopWorlds(userId, {
rangeDays, rangeDays,
limit, limit,
sortBy, sortBy,
excludeWorldId,
isSelf: true isSelf: true
}); });
} }