mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 14:23:51 +02:00
fix: add activity store and user activity caching
This commit is contained in:
53
src/components/dialogs/DatabaseUpgradeDialog.vue
Normal file
53
src/components/dialogs/DatabaseUpgradeDialog.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<AlertDialog :open="isOpen">
|
||||
<AlertDialogContent
|
||||
class="sm:max-w-[460px]"
|
||||
@interact-outside.prevent
|
||||
@escape-key-down.prevent
|
||||
@pointer-down-outside.prevent
|
||||
@close-auto-focus.prevent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ t('message.database.upgrade_in_progress_title') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ description }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<Spinner class="h-5 w-5" />
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('message.database.upgrade_in_progress_wait') }}
|
||||
</span>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useVrcxStore } from '../../stores';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { databaseUpgradeState } = storeToRefs(useVrcxStore());
|
||||
|
||||
const isOpen = computed(() => databaseUpgradeState.value.visible);
|
||||
const description = computed(() => {
|
||||
if (databaseUpgradeState.value.fromVersion > 0) {
|
||||
return t('message.database.upgrade_in_progress_description', {
|
||||
from: databaseUpgradeState.value.fromVersion,
|
||||
to: databaseUpgradeState.value.toVersion
|
||||
});
|
||||
}
|
||||
return t('message.database.upgrade_in_progress_initializing');
|
||||
});
|
||||
</script>
|
||||
@@ -2,10 +2,19 @@
|
||||
<div class="flex flex-col" style="min-height: 200px">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button class="rounded-full" variant="ghost" size="icon-sm" :disabled="isLoading" @click="loadData">
|
||||
<Button
|
||||
class="rounded-full"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="isLoading"
|
||||
:title="hasRequestedLoad ? t('dialog.user.activity.refresh_hint') : t('dialog.user.activity.load')"
|
||||
@click="loadData">
|
||||
<Spinner v-if="isLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<span v-if="hasRequestedLoad && !isLoading" class="ml-2 text-xs text-muted-foreground">
|
||||
{{ t('dialog.user.activity.refresh_hint') }}
|
||||
</span>
|
||||
<span v-if="filteredEventCount > 0" class="text-accent-foreground ml-1">
|
||||
{{ t('dialog.user.activity.total_events', { count: filteredEventCount }) }}
|
||||
</span>
|
||||
@@ -36,11 +45,28 @@
|
||||
<span class="font-medium ml-1">{{ peakTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isLoading && !hasAnyData" class="flex items-center justify-center flex-1 mt-8">
|
||||
<DataTableEmpty type="nodata" />
|
||||
<div
|
||||
v-if="!hasRequestedLoad && !isLoading && !isSessionCacheLoading"
|
||||
class="flex flex-col items-center justify-center flex-1 mt-8 gap-3">
|
||||
<Button variant="outline" @click="loadData">
|
||||
{{ t('dialog.user.activity.load') }}
|
||||
</Button>
|
||||
<span class="text-xs text-muted-foreground text-center">
|
||||
{{ t('dialog.user.activity.load_hint') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLoading && hasAnyData && filteredEventCount === 0"
|
||||
v-else-if="!isLoading && !isSessionCacheLoading && !hasAnyData"
|
||||
class="flex items-center justify-center flex-1 mt-8">
|
||||
<DataTableEmpty type="nodata" />
|
||||
</div>
|
||||
<div v-if="isSessionCacheLoading" class="flex flex-col items-center justify-center flex-1 mt-8 gap-2">
|
||||
<Spinner class="h-5 w-5" />
|
||||
<span class="text-sm text-muted-foreground">{{ t('dialog.user.activity.preparing_data') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t('dialog.user.activity.preparing_data_hint') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLoading && !isSessionCacheLoading && hasAnyData && filteredEventCount === 0"
|
||||
class="flex items-center justify-center flex-1 mt-8">
|
||||
<span class="text-muted-foreground text-sm">{{ t('dialog.user.activity.no_data_in_period') }}</span>
|
||||
</div>
|
||||
@@ -192,13 +218,12 @@
|
||||
import { database } from '../../../services/database';
|
||||
import configRepository from '../../../services/config';
|
||||
import { worldRequest } from '../../../api';
|
||||
import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
|
||||
import { useActivityStore, useAppearanceSettingsStore, useUserStore } from '../../../stores';
|
||||
import { useWorldStore } from '../../../stores/world';
|
||||
import { showWorldDialog } from '../../../coordinators/worldCoordinator';
|
||||
import { timeToText } from '../../../shared/utils';
|
||||
import { useCurrentUserSessions } from '../../../composables/useCurrentUserSessions';
|
||||
import {
|
||||
buildSessionsFromEvents,
|
||||
buildSessionsFromGamelog,
|
||||
calculateOverlapGrid,
|
||||
filterSessionsByPeriod,
|
||||
findBestOverlapTime,
|
||||
@@ -209,15 +234,18 @@
|
||||
const { userDialog, currentUser } = storeToRefs(useUserStore());
|
||||
const { isDarkMode, weekStartsOn } = storeToRefs(useAppearanceSettingsStore());
|
||||
const worldStore = useWorldStore();
|
||||
const sessionCache = useCurrentUserSessions();
|
||||
const activityStore = useActivityStore();
|
||||
|
||||
const chartRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const totalOnlineEvents = ref(0);
|
||||
const hasAnyData = ref(false);
|
||||
const peakDayText = ref('');
|
||||
const peakTimeText = ref('');
|
||||
const selectedPeriod = ref('all');
|
||||
const filteredEventCount = ref(0);
|
||||
const isSessionCacheLoading = ref(false);
|
||||
const hasRequestedLoad = ref(false);
|
||||
|
||||
const isSelf = computed(() => userDialog.value.id === currentUser.value.id);
|
||||
const topWorlds = ref([]);
|
||||
@@ -287,6 +315,12 @@
|
||||
watch(() => isDarkMode.value, rebuildChart);
|
||||
watch(locale, rebuildChart);
|
||||
watch(weekStartsOn, rebuildChart);
|
||||
watch(
|
||||
() => userDialog.value.id,
|
||||
() => {
|
||||
resetActivityState();
|
||||
}
|
||||
);
|
||||
watch(selectedPeriod, () => {
|
||||
if (cachedTargetSessions.length > 0 && echartsInstance) {
|
||||
initChart();
|
||||
@@ -312,7 +346,7 @@
|
||||
}
|
||||
});
|
||||
if (userDialog.value.activeTab === 'Activity') {
|
||||
loadOnlineFrequency(userDialog.value.id, 'visible-watch');
|
||||
loadOnlineFrequency(userDialog.value.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +356,7 @@
|
||||
() => userDialog.value.activeTab,
|
||||
(activeTab) => {
|
||||
if (activeTab === 'Activity' && userDialog.value.visible) {
|
||||
loadOnlineFrequency(userDialog.value.id, 'active-tab-watch');
|
||||
loadOnlineFrequency(userDialog.value.id);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -335,7 +369,7 @@
|
||||
|
||||
onMounted(() => {
|
||||
if (userDialog.value.visible && userDialog.value.activeTab === 'Activity') {
|
||||
loadOnlineFrequency(userDialog.value.id, 'mounted');
|
||||
loadOnlineFrequency(userDialog.value.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -436,9 +470,7 @@
|
||||
|
||||
const filteredSessions = getFilteredSessions();
|
||||
// Use timestamps for event count display
|
||||
const filteredTs = getFilteredTimestamps();
|
||||
filteredEventCount.value = filteredTs.length;
|
||||
totalOnlineEvents.value = filteredTs.length;
|
||||
filteredEventCount.value = getFilteredEventCount();
|
||||
|
||||
if (filteredSessions.length === 0) {
|
||||
peakDayText.value = '';
|
||||
@@ -541,13 +573,14 @@
|
||||
echartsInstance.setOption(option, { notMerge: true });
|
||||
}
|
||||
|
||||
let cachedTimestamps = [];
|
||||
let activeRequestId = 0;
|
||||
|
||||
async function loadData() {
|
||||
const userId = userDialog.value.id;
|
||||
if (!userId) return;
|
||||
|
||||
hasRequestedLoad.value = true;
|
||||
|
||||
if (userId !== lastLoadedUserId) {
|
||||
selectedPeriod.value = 'all';
|
||||
}
|
||||
@@ -555,59 +588,14 @@
|
||||
const requestId = ++activeRequestId;
|
||||
isLoading.value = true;
|
||||
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([
|
||||
database.getOnlineFrequencyData(userId),
|
||||
database.getOnlineOfflineSessions(userId)
|
||||
]);
|
||||
if (requestId !== activeRequestId) return;
|
||||
if (userDialog.value.id !== userId) return;
|
||||
|
||||
cachedTimestamps = timestamps;
|
||||
cachedTargetSessions = buildSessionsFromEvents(events);
|
||||
}
|
||||
|
||||
hasAnyData.value = cachedTimestamps.length > 0;
|
||||
totalOnlineEvents.value = cachedTimestamps.length;
|
||||
lastLoadedUserId = userId;
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (cachedTimestamps.length > 0) {
|
||||
const filteredTs = getFilteredTimestamps();
|
||||
filteredEventCount.value = filteredTs.length;
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!echartsInstance && chartRef.value) {
|
||||
echartsInstance = echarts.init(chartRef.value, isDarkMode.value ? 'dark' : null, { height: 240 });
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (echartsInstance) {
|
||||
echartsInstance.resize({
|
||||
width: entry.contentRect.width
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(chartRef.value);
|
||||
}
|
||||
initChart();
|
||||
} else {
|
||||
peakDayText.value = '';
|
||||
peakTimeText.value = '';
|
||||
hasAnyData.value = false;
|
||||
filteredEventCount.value = 0;
|
||||
}
|
||||
const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
|
||||
notifyStart: hasAnyData.value,
|
||||
notifyComplete: true
|
||||
});
|
||||
if (requestId !== activeRequestId) return;
|
||||
if (userDialog.value.id !== userId) return;
|
||||
hydrateFromCacheEntry(entry);
|
||||
await finishLoadData(userId);
|
||||
} catch (error) {
|
||||
console.error('Error loading online frequency data:', error);
|
||||
} finally {
|
||||
@@ -615,6 +603,45 @@
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared finalization after session data is loaded (both sync and async paths).
|
||||
* @param {string} userId
|
||||
*/
|
||||
async function finishLoadData(userId) {
|
||||
hasAnyData.value = cachedTargetSessions.length > 0;
|
||||
lastLoadedUserId = userId;
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (cachedTargetSessions.length > 0) {
|
||||
filteredEventCount.value = getFilteredEventCount();
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!echartsInstance && chartRef.value) {
|
||||
echartsInstance = echarts.init(chartRef.value, isDarkMode.value ? 'dark' : null, { height: 240 });
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (echartsInstance) {
|
||||
echartsInstance.resize({
|
||||
width: entry.contentRect.width
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(chartRef.value);
|
||||
}
|
||||
initChart();
|
||||
} else {
|
||||
peakDayText.value = '';
|
||||
peakTimeText.value = '';
|
||||
hasAnyData.value = false;
|
||||
filteredEventCount.value = 0;
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
|
||||
if (hasAnyData.value && !isSelf.value) {
|
||||
loadOverlapData(userId);
|
||||
@@ -624,21 +651,107 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredTimestamps() {
|
||||
if (selectedPeriod.value === 'all') return cachedTimestamps;
|
||||
function resetActivityState() {
|
||||
hasRequestedLoad.value = false;
|
||||
isLoading.value = false;
|
||||
isSessionCacheLoading.value = false;
|
||||
hasAnyData.value = false;
|
||||
peakDayText.value = '';
|
||||
peakTimeText.value = '';
|
||||
selectedPeriod.value = 'all';
|
||||
filteredEventCount.value = 0;
|
||||
hasOverlapData.value = false;
|
||||
overlapPercent.value = 0;
|
||||
bestOverlapTime.value = '';
|
||||
isOverlapLoading.value = false;
|
||||
topWorlds.value = [];
|
||||
cachedTargetSessions = [];
|
||||
cachedCurrentSessions = [];
|
||||
lastLoadedUserId = '';
|
||||
activeRequestId++;
|
||||
}
|
||||
|
||||
function hydrateFromCacheEntry(entry) {
|
||||
cachedTargetSessions = Array.isArray(entry?.sessions) ? entry.sessions : [];
|
||||
hasRequestedLoad.value = Boolean(entry);
|
||||
}
|
||||
|
||||
async function loadCachedActivity(userId) {
|
||||
const entry = await activityStore.getCache(userId);
|
||||
if (!entry) {
|
||||
hasRequestedLoad.value = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userDialog.value.id !== userId) {
|
||||
return null;
|
||||
}
|
||||
hydrateFromCacheEntry(entry);
|
||||
await finishLoadData(userId);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function scheduleAutoRefresh(userId) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
|
||||
notifyComplete: true
|
||||
});
|
||||
if (userDialog.value.id !== userId) return;
|
||||
hydrateFromCacheEntry(entry);
|
||||
await finishLoadData(userId);
|
||||
} catch (error) {
|
||||
console.error('Error auto-refreshing activity data:', error);
|
||||
} finally {
|
||||
if (userDialog.value.id === userId) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredEventCount() {
|
||||
if (selectedPeriod.value === 'all') return cachedTargetSessions.length;
|
||||
const days = parseInt(selectedPeriod.value, 10);
|
||||
const cutoff = dayjs().subtract(days, 'day');
|
||||
return cachedTimestamps.filter((ts) => dayjs(ts).isAfter(cutoff));
|
||||
const cutoff = dayjs().subtract(days, 'day').valueOf();
|
||||
return cachedTargetSessions.filter((session) => session.start > cutoff).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
*/
|
||||
function loadOnlineFrequency(userId) {
|
||||
if (lastLoadedUserId === userId && hasAnyData.value) {
|
||||
if (lastLoadedUserId !== userId) {
|
||||
resetActivityState();
|
||||
}
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
loadData();
|
||||
void (async () => {
|
||||
const cacheEntry = await loadCachedActivity(userId);
|
||||
if (!cacheEntry) {
|
||||
if (activityStore.isRefreshing(userId)) {
|
||||
hasRequestedLoad.value = true;
|
||||
isSessionCacheLoading.value = true;
|
||||
try {
|
||||
const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
|
||||
notifyComplete: true
|
||||
});
|
||||
if (userDialog.value.id !== userId) return;
|
||||
hydrateFromCacheEntry(entry);
|
||||
await finishLoadData(userId);
|
||||
} finally {
|
||||
if (userDialog.value.id === userId) {
|
||||
isSessionCacheLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activityStore.isExpired(cacheEntry)) {
|
||||
void scheduleAutoRefresh(userId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
let easterEggTimer = null;
|
||||
@@ -663,13 +776,16 @@
|
||||
isOverlapLoading.value = true;
|
||||
hasOverlapData.value = false;
|
||||
try {
|
||||
// Target sessions already cached from loadData, only fetch current user
|
||||
const currentUserRows = await database.getCurrentUserOnlineSessions();
|
||||
if (!sessionCache.isReady()) {
|
||||
sessionCache.onReady(() => loadOverlapData(userId));
|
||||
sessionCache.triggerLoad();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessions = await sessionCache.getSessions();
|
||||
|
||||
if (userDialog.value.id !== userId) return;
|
||||
|
||||
const currentSessions = buildSessionsFromGamelog(currentUserRows);
|
||||
|
||||
if (cachedTargetSessions.length === 0 || currentSessions.length === 0) {
|
||||
hasOverlapData.value = false;
|
||||
return;
|
||||
@@ -738,9 +854,14 @@
|
||||
if (result.grid[d][h] > result.maxVal) result.maxVal = result.grid[d][h];
|
||||
}
|
||||
}
|
||||
// Recalculate overlap percent excluding those hours
|
||||
const totalGrid = result.grid.flat().reduce((a, b) => a + b, 0);
|
||||
if (totalGrid === 0) {
|
||||
const overlapSessions = computeOverlapSessions(currentSessions, targetSessions);
|
||||
const overlapMs = getIncludedSessionDurationMs(overlapSessions, start, end);
|
||||
const currentMs = getIncludedSessionDurationMs(currentSessions, start, end);
|
||||
const targetMs = getIncludedSessionDurationMs(targetSessions, start, end);
|
||||
const minOnlineMs = Math.min(currentMs, targetMs);
|
||||
result.overlapPercent =
|
||||
minOnlineMs > 0 ? Math.round((overlapMs / minOnlineMs) * 100) : 0;
|
||||
if (overlapMs === 0) {
|
||||
overlapPercent.value = 0;
|
||||
bestOverlapTime.value = '';
|
||||
return;
|
||||
@@ -780,6 +901,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
function computeOverlapSessions(sessionsA, sessionsB) {
|
||||
const overlapSessions = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
while (i < sessionsA.length && j < sessionsB.length) {
|
||||
const a = sessionsA[i];
|
||||
const b = sessionsB[j];
|
||||
const start = Math.max(a.start, b.start);
|
||||
const end = Math.min(a.end, b.end);
|
||||
if (start < end) {
|
||||
overlapSessions.push({ start, end });
|
||||
}
|
||||
if (a.end < b.end) {
|
||||
i++;
|
||||
} else {
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
return overlapSessions;
|
||||
}
|
||||
|
||||
function getIncludedSessionDurationMs(sessions, startHour, endHour) {
|
||||
let total = 0;
|
||||
for (const session of sessions) {
|
||||
let cursor = session.start;
|
||||
while (cursor < session.end) {
|
||||
const segmentEnd = getNextHourBoundaryMs(cursor, session.end);
|
||||
if (!isHourExcluded(cursor, startHour, endHour)) {
|
||||
total += segmentEnd - cursor;
|
||||
}
|
||||
cursor = segmentEnd;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function getNextHourBoundaryMs(cursor, sessionEnd) {
|
||||
const nextHour = new Date(cursor);
|
||||
nextHour.setMinutes(0, 0, 0);
|
||||
nextHour.setHours(nextHour.getHours() + 1);
|
||||
return Math.min(nextHour.getTime(), sessionEnd);
|
||||
}
|
||||
|
||||
function isHourExcluded(cursor, startHour, endHour) {
|
||||
const hour = new Date(cursor).getHours();
|
||||
if (startHour <= endHour) {
|
||||
return hour >= startHour && hour < endHour;
|
||||
}
|
||||
return hour >= startHour || hour < endHour;
|
||||
}
|
||||
|
||||
function onExcludeToggle(value) {
|
||||
excludeHoursEnabled.value = value;
|
||||
configRepository.setBool('VRCX_overlapExcludeEnabled', value);
|
||||
|
||||
Reference in New Issue
Block a user