feat: add "Most Visited Worlds" section to Activity tab

This commit is contained in:
pa
2026-03-19 18:40:51 +09:00
parent bbb7d596bb
commit 6618966ebc
4 changed files with 290 additions and 37 deletions
@@ -42,7 +42,7 @@
<UserDialogAvatarsTab ref="avatarsTabRef" /> <UserDialogAvatarsTab ref="avatarsTabRef" />
</template> </template>
<template v-if="userDialog.id !== currentUser.id" #Activity> <template #Activity>
<UserDialogActivityTab ref="activityTabRef" /> <UserDialogActivityTab ref="activityTabRef" />
</template> </template>
@@ -74,7 +74,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { TabsUnderline } from '@/components/ui/tabs'; import { TabsUnderline } from '@/components/ui/tabs';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -130,11 +130,9 @@
if (userDialog.value.id !== currentUser.value.id && !currentUser.value.hasSharedConnectionsOptOut) { if (userDialog.value.id !== currentUser.value.id && !currentUser.value.hasSharedConnectionsOptOut) {
tabs.splice(1, 0, { value: 'mutual', label: t('dialog.user.mutual_friends.header') }); tabs.splice(1, 0, { value: 'mutual', label: t('dialog.user.mutual_friends.header') });
} }
if (userDialog.value.id !== currentUser.value.id) {
// Insert Activity before JSON // Insert Activity before JSON
const jsonIdx = tabs.findIndex((tab) => tab.value === 'JSON'); const jsonIdx = tabs.findIndex((tab) => tab.value === 'JSON');
tabs.splice(jsonIdx, 0, { value: 'Activity', label: t('dialog.user.activity.header') }); tabs.splice(jsonIdx, 0, { value: 'Activity', label: t('dialog.user.activity.header') });
}
return tabs; return tabs;
}); });
const infoTabRef = ref(null); const infoTabRef = ref(null);
@@ -206,6 +204,30 @@
} }
); );
watch(
() => userDialog.value.visible,
(visible) => {
if (visible && !userDialog.value.loading) {
loadLastActiveTab();
}
}
);
watch(
() => userDialog.value.activeTab,
(activeTab) => {
if (activeTab === 'Activity' && userDialog.value.visible && !userDialog.value.loading) {
activityTabRef.value?.loadOnlineFrequency(userDialog.value.id);
}
}
);
onMounted(() => {
if (userDialog.value.visible && !userDialog.value.loading) {
loadLastActiveTab();
}
});
const userDialogLastMutualFriends = ref(''); const userDialogLastMutualFriends = ref('');
const userDialogLastGroup = ref(''); const userDialogLastGroup = ref('');
const userDialogLastAvatar = ref(''); const userDialogLastAvatar = ref('');
@@ -348,13 +370,7 @@
* *
*/ */
function loadLastActiveTab() { function loadLastActiveTab() {
let tab = userDialog.value.lastActiveTab; const tab = userDialog.value.lastActiveTab;
// Activity tab is not available for own profile; fall back to Info
if (tab === 'Activity' && userDialog.value.id === currentUser.value.id) {
tab = 'Info';
userDialog.value.lastActiveTab = 'Info';
userDialog.value.activeTab = 'Info';
}
handleUserDialogTab(tab); handleUserDialogTab(tab);
} }
@@ -50,14 +50,14 @@
style="width: 100%; height: 240px" style="width: 100%; height: 240px"
@contextmenu.prevent="onChartRightClick"></div> @contextmenu.prevent="onChartRightClick"></div>
<!-- Online Overlap Section --> <!-- Online Overlap Section (friends only) -->
<div v-if="hasAnyData" class="mt-4 border-t border-border pt-3"> <div v-if="hasAnyData && !isSelf" class="mt-4 border-t border-border pt-3">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ t('dialog.user.activity.overlap.header') }}</span> <span class="text-sm font-medium">{{ t('dialog.user.activity.overlap.header') }}</span>
<Spinner v-if="isOverlapLoading" class="h-3.5 w-3.5" /> <Spinner v-if="isOverlapLoading" class="h-3.5 w-3.5" />
</div> </div>
<div v-if="hasOverlapData" class="flex items-center gap-1.5 flex-shrink-0"> <div v-if="hasOverlapData" class="flex items-center gap-1.5 shrink-0">
<Switch :model-value="excludeHoursEnabled" class="scale-75" @update:model-value="onExcludeToggle" /> <Switch :model-value="excludeHoursEnabled" class="scale-75" @update:model-value="onExcludeToggle" />
<span class="text-sm text-muted-foreground whitespace-nowrap">{{ <span class="text-sm text-muted-foreground whitespace-nowrap">{{
t('dialog.user.activity.overlap.exclude_hours') t('dialog.user.activity.overlap.exclude_hours')
@@ -118,16 +118,69 @@
{{ t('dialog.user.activity.overlap.no_data') }} {{ t('dialog.user.activity.overlap.no_data') }}
</div> </div>
</div> </div>
<!-- Top Worlds Section (self only) -->
<div v-if="isSelf && hasAnyData" class="mt-4 border-t border-border pt-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ t('dialog.user.activity.most_visited_worlds.header') }}</span>
</div>
<div v-if="topWorlds.length === 0 && !isLoading" class="text-sm text-muted-foreground py-2">
{{ t('dialog.user.activity.no_data_in_period') }}
</div>
<div v-else class="flex flex-col gap-0.5">
<button
v-for="(world, index) in topWorlds"
:key="world.worldId"
type="button"
class="group flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
:class="index === 0 ? 'bg-primary/4' : ''"
@click="openWorldDialog(world.worldId)">
<span
class="mt-1 w-5 shrink-0 text-right font-mono text-xs font-bold"
:class="index === 0 ? 'text-primary' : 'text-muted-foreground'">
#{{ index + 1 }}
</span>
<Avatar class="rounded-sm size-8 mt-0.5 shrink-0">
<AvatarImage
v-if="getWorldThumbnail(world.worldId)"
:src="getWorldThumbnail(world.worldId)"
loading="lazy"
decoding="async"
class="rounded-sm object-cover" />
<AvatarFallback class="rounded-sm">
<ImageIcon class="size-3.5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-2">
<span class="truncate text-sm font-medium">{{ world.worldName }}</span>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">
{{ formatWorldTime(world.totalTime) }}
</span>
</div>
<div
class="mt-1 h-1.5 w-full overflow-hidden rounded-full"
:class="isDarkMode ? 'bg-white/8' : 'bg-black/6'">
<div
class="h-full rounded-full transition-all duration-500"
:class="isDarkMode ? 'bg-white/45' : 'bg-black/25'"
:style="{ width: getTopWorldBarWidth(world.totalTime) }" />
</div>
</div>
</button>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, h, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DataTableEmpty } from '@/components/ui/data-table'; import { DataTableEmpty } from '@/components/ui/data-table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { RefreshCw, Tractor, Sprout } from 'lucide-vue-next'; import { Image as ImageIcon, RefreshCw, Tractor, Sprout } from 'lucide-vue-next';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
@@ -138,7 +191,11 @@
import { database } from '../../../services/database'; import { database } from '../../../services/database';
import configRepository from '../../../services/config'; import configRepository from '../../../services/config';
import { worldRequest } from '../../../api';
import { useAppearanceSettingsStore, useUserStore } from '../../../stores'; import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
import { useWorldStore } from '../../../stores/world';
import { showWorldDialog } from '../../../coordinators/worldCoordinator';
import { timeToText } from '../../../shared/utils';
import { import {
buildSessionsFromEvents, buildSessionsFromEvents,
buildSessionsFromGamelog, buildSessionsFromGamelog,
@@ -149,8 +206,9 @@
} from '../../../shared/utils/overlapCalculator'; } from '../../../shared/utils/overlapCalculator';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { userDialog } = storeToRefs(useUserStore()); const { userDialog, currentUser } = storeToRefs(useUserStore());
const { isDarkMode, weekStartsOn } = storeToRefs(useAppearanceSettingsStore()); const { isDarkMode, weekStartsOn } = storeToRefs(useAppearanceSettingsStore());
const worldStore = useWorldStore();
const chartRef = ref(null); const chartRef = ref(null);
const isLoading = ref(false); const isLoading = ref(false);
@@ -161,6 +219,9 @@
const selectedPeriod = ref('all'); const selectedPeriod = ref('all');
const filteredEventCount = ref(0); const filteredEventCount = ref(0);
const isSelf = computed(() => userDialog.value.id === currentUser.value.id);
const topWorlds = ref([]);
const overlapChartRef = ref(null); const overlapChartRef = ref(null);
const isOverlapLoading = ref(false); const isOverlapLoading = ref(false);
const hasOverlapData = ref(false); const hasOverlapData = ref(false);
@@ -179,6 +240,7 @@
let overlapResizeObserver = null; let overlapResizeObserver = null;
let cachedTargetSessions = []; let cachedTargetSessions = [];
let cachedCurrentSessions = []; let cachedCurrentSessions = [];
const pendingWorldThumbnailFetches = new Set();
const dayLabels = computed(() => [ const dayLabels = computed(() => [
t('dialog.user.activity.days.sun'), t('dialog.user.activity.days.sun'),
@@ -229,15 +291,54 @@
if (cachedTargetSessions.length > 0 && echartsInstance) { if (cachedTargetSessions.length > 0 && echartsInstance) {
initChart(); initChart();
} }
if (!isSelf.value) {
updateOverlapChart(); updateOverlapChart();
} else {
loadTopWorlds();
}
}); });
// Resize echarts when dialog becomes visible again (e.g. breadcrumb return)
watch(
() => userDialog.value.visible,
(visible) => {
if (visible) {
nextTick(() => {
if (echartsInstance && chartRef.value) {
echartsInstance.resize();
}
if (overlapEchartsInstance && overlapChartRef.value) {
overlapEchartsInstance.resize();
}
});
if (userDialog.value.activeTab === 'Activity') {
loadOnlineFrequency(userDialog.value.id, 'visible-watch');
}
}
}
);
watch(
() => userDialog.value.activeTab,
(activeTab) => {
if (activeTab === 'Activity' && userDialog.value.visible) {
loadOnlineFrequency(userDialog.value.id, 'active-tab-watch');
}
}
);
(async () => { (async () => {
excludeHoursEnabled.value = await configRepository.getBool('VRCX_overlapExcludeEnabled', false); excludeHoursEnabled.value = await configRepository.getBool('VRCX_overlapExcludeEnabled', false);
excludeStartHour.value = await configRepository.getString('VRCX_overlapExcludeStart', '1'); excludeStartHour.value = await configRepository.getString('VRCX_overlapExcludeStart', '1');
excludeEndHour.value = await configRepository.getString('VRCX_overlapExcludeEnd', '6'); excludeEndHour.value = await configRepository.getString('VRCX_overlapExcludeEnd', '6');
})(); })();
onMounted(() => {
if (userDialog.value.visible && userDialog.value.activeTab === 'Activity') {
loadOnlineFrequency(userDialog.value.id, 'mounted');
}
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
disposeChart(); disposeChart();
}); });
@@ -454,6 +555,16 @@
const requestId = ++activeRequestId; const requestId = ++activeRequestId;
isLoading.value = true; isLoading.value = true;
try { 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([ const [timestamps, events] = await Promise.all([
database.getOnlineFrequencyData(userId), database.getOnlineFrequencyData(userId),
database.getOnlineOfflineSessions(userId) database.getOnlineOfflineSessions(userId)
@@ -463,13 +574,15 @@
cachedTimestamps = timestamps; cachedTimestamps = timestamps;
cachedTargetSessions = buildSessionsFromEvents(events); cachedTargetSessions = buildSessionsFromEvents(events);
hasAnyData.value = timestamps.length > 0; }
totalOnlineEvents.value = timestamps.length;
hasAnyData.value = cachedTimestamps.length > 0;
totalOnlineEvents.value = cachedTimestamps.length;
lastLoadedUserId = userId; lastLoadedUserId = userId;
await nextTick(); await nextTick();
if (timestamps.length > 0) { if (cachedTimestamps.length > 0) {
const filteredTs = getFilteredTimestamps(); const filteredTs = getFilteredTimestamps();
filteredEventCount.value = filteredTs.length; filteredEventCount.value = filteredTs.length;
@@ -503,10 +616,12 @@
} }
} }
// Load overlap data after main data (target sessions already cached) if (hasAnyData.value && !isSelf.value) {
if (hasAnyData.value) {
loadOverlapData(userId); loadOverlapData(userId);
} }
if (hasAnyData.value && isSelf.value) {
loadTopWorlds();
}
} }
function getFilteredTimestamps() { function getFilteredTimestamps() {
@@ -770,5 +885,85 @@
overlapEchartsInstance.setOption(option, { notMerge: true }); overlapEchartsInstance.setOption(option, { notMerge: true });
} }
async function loadTopWorlds() {
const days = selectedPeriod.value === 'all' ? 0 : parseInt(selectedPeriod.value, 10);
try {
topWorlds.value = await database.getMyTopWorlds(days, 5);
void fetchMissingTopWorldThumbnails(topWorlds.value);
} catch (error) {
console.error('Error loading top worlds:', error);
topWorlds.value = [];
}
}
/**
* @param {Array<{worldId: string}>} worlds
*/
async function fetchMissingTopWorldThumbnails(worlds) {
const missingWorldIds = worlds
.map((world) => world.worldId)
.filter((worldId) => {
if (!worldId || pendingWorldThumbnailFetches.has(worldId)) {
return false;
}
return !worldStore.cachedWorlds.get(worldId)?.thumbnailImageUrl;
});
if (missingWorldIds.length === 0) {
return;
}
const fetches = missingWorldIds.map(async (worldId) => {
pendingWorldThumbnailFetches.add(worldId);
try {
await worldRequest.getWorld({ worldId });
} catch (error) {
console.error(`Error fetching missing top world thumbnail for ${worldId}:`, error);
} finally {
pendingWorldThumbnailFetches.delete(worldId);
}
});
await Promise.allSettled(fetches);
topWorlds.value = [...topWorlds.value];
}
/**
* @param {string} worldId
* @returns {string|null}
*/
function getWorldThumbnail(worldId) {
const cached = worldStore.cachedWorlds.get(worldId);
if (!cached?.thumbnailImageUrl) return null;
return cached.thumbnailImageUrl.replace('256', '128');
}
/**
* @param {string} worldId
*/
function openWorldDialog(worldId) {
showWorldDialog(worldId);
}
/**
* @param {number} ms
* @returns {string}
*/
function formatWorldTime(ms) {
if (!ms || ms <= 0) return '0m';
return timeToText(ms);
}
/**
* @param {number} totalTime
* @returns {string}
*/
function getTopWorldBarWidth(totalTime) {
if (topWorlds.value.length === 0) return '0%';
const maxTime = topWorlds.value[0].totalTime;
if (maxTime <= 0) return '0%';
return `${Math.round((totalTime / maxTime) * 100)}%`;
}
defineExpose({ loadOnlineFrequency }); defineExpose({ loadOnlineFrequency });
</script> </script>
+3
View File
@@ -1455,6 +1455,9 @@
"exclude_hours": "Exclude hours", "exclude_hours": "Exclude hours",
"no_data": "Not enough data to calculate overlap", "no_data": "Not enough data to calculate overlap",
"times_overlap": "times overlapping" "times_overlap": "times overlapping"
},
"most_visited_worlds": {
"header": "Most Visited Worlds"
} }
}, },
"note_memo": { "note_memo": {
+42 -3
View File
@@ -1380,13 +1380,52 @@ const gameLog = {
*/ */
async getCurrentUserOnlineSessions() { async getCurrentUserOnlineSessions() {
const data = []; const data = [];
await sqliteService.execute((dbRow) => {
data.push({ created_at: dbRow[0], time: dbRow[1] || 0 });
}, `SELECT created_at, time FROM gamelog_location ORDER BY created_at`);
return data;
},
/**
* Get current user's top visited worlds from gamelog_location.
* 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.
* @returns {Promise<Array<{worldId: string, worldName: string, visitCount: number, totalTime: number}>>}
*/
async getMyTopWorlds(days = 0, limit = 5) {
const results = [];
const whereClause =
days > 0 ? `AND created_at >= datetime('now', @daysOffset)` : '';
const params = { '@limit': limit };
if (days > 0) {
params['@daysOffset'] = `-${days} days`;
}
await sqliteService.execute( await sqliteService.execute(
(dbRow) => { (dbRow) => {
data.push({ created_at: dbRow[0], time: dbRow[1] || 0 }); results.push({
worldId: dbRow[0],
worldName: dbRow[1] || dbRow[0],
visitCount: dbRow[2],
totalTime: dbRow[3] || 0
});
}, },
`SELECT created_at, time FROM gamelog_location ORDER BY created_at` `SELECT
world_id,
world_name,
COUNT(*) AS visit_count,
SUM(time) AS total_time
FROM gamelog_location
WHERE world_id IS NOT NULL
AND world_id != ''
AND world_id LIKE 'wrld_%'
${whereClause}
GROUP BY world_id
ORDER BY total_time DESC
LIMIT @limit`,
params
); );
return data; return results;
}, },
async getUserIdFromDisplayName(displayName) { async getUserIdFromDisplayName(displayName) {