mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-06 22:46:06 +02:00
feat: add "Most Visited Worlds" section to Activity tab
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user