mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-17 13:53:52 +02:00
improve activity tab performance by adding indexes
This commit is contained in:
@@ -7,12 +7,12 @@
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:disabled="isLoading"
|
||||
:title="hasRequestedLoad ? t('dialog.user.activity.refresh_hint') : t('dialog.user.activity.load')"
|
||||
:title="t('dialog.user.activity.refresh_hint')"
|
||||
@click="loadData">
|
||||
<Spinner v-if="isLoading" />
|
||||
<RefreshCw v-else />
|
||||
</Button>
|
||||
<span v-if="hasRequestedLoad && !isLoading" class="ml-2 text-xs text-muted-foreground">
|
||||
<span v-if="hasAnyData && !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">
|
||||
@@ -45,28 +45,18 @@
|
||||
<span class="font-medium ml-1">{{ peakTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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-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">
|
||||
<div v-if="isLoading && !hasAnyData" 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"
|
||||
v-else-if="!isLoading && !hasAnyData"
|
||||
class="flex items-center justify-center flex-1 mt-8">
|
||||
<DataTableEmpty type="nodata" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLoading && 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>
|
||||
@@ -224,6 +214,7 @@
|
||||
import { timeToText } from '../../../shared/utils';
|
||||
import { useCurrentUserSessions } from '../../../composables/useCurrentUserSessions';
|
||||
import {
|
||||
buildSessionsFromGamelog,
|
||||
calculateOverlapGrid,
|
||||
filterSessionsByPeriod,
|
||||
findBestOverlapTime,
|
||||
@@ -244,8 +235,6 @@
|
||||
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([]);
|
||||
@@ -579,8 +568,6 @@
|
||||
const userId = userDialog.value.id;
|
||||
if (!userId) return;
|
||||
|
||||
hasRequestedLoad.value = true;
|
||||
|
||||
if (userId !== lastLoadedUserId) {
|
||||
selectedPeriod.value = 'all';
|
||||
}
|
||||
@@ -588,10 +575,17 @@
|
||||
const requestId = ++activeRequestId;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const entry = await activityStore.refreshActivityCache(userId, isSelf.value, {
|
||||
notifyStart: hasAnyData.value,
|
||||
notifyComplete: true
|
||||
});
|
||||
if (isSelf.value) {
|
||||
const rows = await database.getCurrentUserOnlineSessions();
|
||||
if (requestId !== activeRequestId) return;
|
||||
if (userDialog.value.id !== userId) return;
|
||||
|
||||
cachedTargetSessions = buildSessionsFromGamelog(rows);
|
||||
await finishLoadData(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await activityStore.refreshActivityCache(userId);
|
||||
if (requestId !== activeRequestId) return;
|
||||
if (userDialog.value.id !== userId) return;
|
||||
hydrateFromCacheEntry(entry);
|
||||
@@ -652,9 +646,7 @@
|
||||
}
|
||||
|
||||
function resetActivityState() {
|
||||
hasRequestedLoad.value = false;
|
||||
isLoading.value = false;
|
||||
isSessionCacheLoading.value = false;
|
||||
hasAnyData.value = false;
|
||||
peakDayText.value = '';
|
||||
peakTimeText.value = '';
|
||||
@@ -673,13 +665,11 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -691,24 +681,6 @@
|
||||
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);
|
||||
@@ -726,31 +698,22 @@
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
if (isSelf.value) {
|
||||
if (lastLoadedUserId === userId && (hasAnyData.value || isLoading.value)) {
|
||||
return;
|
||||
}
|
||||
void loadData();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (cacheEntry || activityStore.isRefreshing(userId)) {
|
||||
if (!cacheEntry && !isLoading.value) {
|
||||
void loadData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activityStore.isExpired(cacheEntry)) {
|
||||
void scheduleAutoRefresh(userId);
|
||||
}
|
||||
void loadData();
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
@@ -1430,8 +1430,6 @@
|
||||
"load": "Load Activity",
|
||||
"load_hint": "Load activity data from the local database when needed.",
|
||||
"refresh_hint": "Rebuild activity cache",
|
||||
"refresh_started": "Rebuilding activity data. Please wait...",
|
||||
"refresh_complete": "Activity refresh complete",
|
||||
"total_events": "{count} online events",
|
||||
"times_online": "times online",
|
||||
"most_active_day": "Most active day:",
|
||||
|
||||
@@ -195,16 +195,23 @@ async function upsertMeta(entry) {
|
||||
}
|
||||
|
||||
async function upsertSessions(userId, sessions = []) {
|
||||
for (const session of sessions) {
|
||||
const chunkSize = 250;
|
||||
for (let chunkStart = 0; chunkStart < sessions.length; chunkStart += chunkSize) {
|
||||
const chunk = sessions.slice(chunkStart, chunkStart + chunkSize);
|
||||
const args = {};
|
||||
const values = chunk.map((session, index) => {
|
||||
const suffix = `${chunkStart + index}`;
|
||||
args[`@user_id_${suffix}`] = userId;
|
||||
args[`@start_at_${suffix}`] = session.start;
|
||||
args[`@end_at_${suffix}`] = session.end;
|
||||
return `(@user_id_${suffix}, @start_at_${suffix}, @end_at_${suffix})`;
|
||||
});
|
||||
|
||||
await sqliteService.executeNonQuery(
|
||||
`INSERT OR REPLACE INTO ${dbVars.userPrefix}_activity_cache_sessions
|
||||
(user_id, start_at, end_at)
|
||||
VALUES (@user_id, @start_at, @end_at)`,
|
||||
{
|
||||
'@user_id': userId,
|
||||
'@start_at': session.start,
|
||||
'@end_at': session.end
|
||||
}
|
||||
VALUES ${values.join(', ')}`,
|
||||
args
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ const database = {
|
||||
await sqliteService.executeNonQuery(
|
||||
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_feed_online_offline (id INTEGER PRIMARY KEY, created_at TEXT, user_id TEXT, display_name TEXT, type TEXT, location TEXT, world_name TEXT, time INTEGER, group_name TEXT)`
|
||||
);
|
||||
await sqliteService.executeNonQuery(
|
||||
`CREATE INDEX IF NOT EXISTS ${dbVars.userPrefix}_feed_online_offline_user_created_idx ON ${dbVars.userPrefix}_feed_online_offline (user_id, created_at)`
|
||||
);
|
||||
await sqliteService.executeNonQuery(
|
||||
`CREATE TABLE IF NOT EXISTS ${dbVars.userPrefix}_activity_cache_meta (user_id TEXT PRIMARY KEY, updated_at TEXT, is_self INTEGER DEFAULT 0, source_last_created_at TEXT, pending_session_start_at INTEGER)`
|
||||
);
|
||||
|
||||
@@ -15,6 +15,11 @@ const tableAlter = {
|
||||
// await sqliteService.executeNonQuery('PRAGMA user_version = 1');
|
||||
},
|
||||
|
||||
async updateActivityTabDatabaseVersion() {
|
||||
await this.ensureActivityCacheTables();
|
||||
await this.ensureFeedOnlineOfflineIndexes();
|
||||
},
|
||||
|
||||
async updateTableForGroupNames() {
|
||||
var tables = [];
|
||||
await sqliteService.execute((dbRow) => {
|
||||
@@ -100,6 +105,18 @@ const tableAlter = {
|
||||
`CREATE INDEX IF NOT EXISTS ${userPrefix}_activity_cache_sessions_user_start_idx ON ${userPrefix}_activity_cache_sessions (user_id, start_at)`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async ensureFeedOnlineOfflineIndexes() {
|
||||
const tables = [];
|
||||
await sqliteService.execute((dbRow) => {
|
||||
tables.push(dbRow[0]);
|
||||
}, `SELECT name FROM sqlite_schema WHERE type='table' AND name LIKE '%_feed_online_offline'`);
|
||||
for (const tableName of tables) {
|
||||
await sqliteService.executeNonQuery(
|
||||
`CREATE INDEX IF NOT EXISTS ${tableName}_user_created_idx ON ${tableName} (user_id, created_at)`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { database } from '../services/database';
|
||||
import {
|
||||
buildSessionsFromGamelog,
|
||||
ONLINE_SESSION_MERGE_GAP_MS
|
||||
} from '../shared/utils/overlapCalculator';
|
||||
|
||||
const ACTIVITY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
import { ONLINE_SESSION_MERGE_GAP_MS } from '../shared/utils/overlapCalculator';
|
||||
const refreshJobs = new Map();
|
||||
|
||||
function buildSessionsAndPendingFromEvents(events, initialStart = null) {
|
||||
@@ -35,74 +28,25 @@ function buildSessionsAndPendingFromEvents(events, initialStart = null) {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWithLastSession(lastSession, newSessions) {
|
||||
if (!lastSession || newSessions.length === 0) {
|
||||
return { replaceLastSession: null, sessions: newSessions };
|
||||
}
|
||||
|
||||
const firstSession = newSessions[0];
|
||||
if (firstSession.start > lastSession.end + ONLINE_SESSION_MERGE_GAP_MS) {
|
||||
return { replaceLastSession: null, sessions: newSessions };
|
||||
}
|
||||
|
||||
const mergedFirst = {
|
||||
start: Math.min(lastSession.start, firstSession.start),
|
||||
end: Math.max(lastSession.end, firstSession.end)
|
||||
};
|
||||
return {
|
||||
replaceLastSession: lastSession,
|
||||
sessions: [mergedFirst, ...newSessions.slice(1)]
|
||||
};
|
||||
}
|
||||
|
||||
export const useActivityStore = defineStore('Activity', () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
function getCache(userId) {
|
||||
return database.getActivityCache(userId);
|
||||
}
|
||||
|
||||
function isExpired(cacheEntry) {
|
||||
if (!cacheEntry?.updatedAt) {
|
||||
return true;
|
||||
}
|
||||
const updatedAtMs = Date.parse(cacheEntry.updatedAt);
|
||||
if (Number.isNaN(updatedAtMs)) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - updatedAtMs >= ACTIVITY_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function isRefreshing(userId) {
|
||||
return refreshJobs.has(userId);
|
||||
}
|
||||
|
||||
async function fullRefresh(userId, isSelf) {
|
||||
if (isSelf) {
|
||||
const rows = await database.getCurrentUserOnlineSessions();
|
||||
const sessions = buildSessionsFromGamelog(rows);
|
||||
const sourceLastCreatedAt =
|
||||
rows.length > 0 ? rows[rows.length - 1].created_at : '';
|
||||
const entry = {
|
||||
userId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSelf,
|
||||
sourceLastCreatedAt,
|
||||
pendingSessionStartAt: null,
|
||||
sessions
|
||||
};
|
||||
await database.replaceActivityCache(entry);
|
||||
return database.getActivityCache(userId);
|
||||
}
|
||||
|
||||
async function fullRefresh(userId) {
|
||||
const events = await database.getOnlineOfflineSessions(userId);
|
||||
const { sessions, pendingSessionStartAt } = buildSessionsAndPendingFromEvents(events);
|
||||
const { sessions, pendingSessionStartAt } =
|
||||
buildSessionsAndPendingFromEvents(events);
|
||||
const sourceLastCreatedAt =
|
||||
events.length > 0 ? events[events.length - 1].created_at : '';
|
||||
const entry = {
|
||||
userId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSelf,
|
||||
isSelf: false,
|
||||
sourceLastCreatedAt,
|
||||
pendingSessionStartAt,
|
||||
sessions
|
||||
@@ -114,40 +58,8 @@ export const useActivityStore = defineStore('Activity', () => {
|
||||
async function incrementalRefresh(meta) {
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
if (meta.isSelf) {
|
||||
if (!meta.sourceLastCreatedAt) {
|
||||
return fullRefresh(meta.userId, true);
|
||||
}
|
||||
|
||||
const rows = await database.getCurrentUserOnlineSessionsAfter(
|
||||
meta.sourceLastCreatedAt
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
await database.touchActivityCacheMeta({
|
||||
...meta,
|
||||
updatedAt
|
||||
});
|
||||
return database.getActivityCache(meta.userId);
|
||||
}
|
||||
|
||||
const sourceLastCreatedAt = rows[rows.length - 1].created_at;
|
||||
const newSessionsRaw = buildSessionsFromGamelog(rows);
|
||||
const lastSession = await database.getLastActivityCacheSession(meta.userId);
|
||||
const merged = mergeWithLastSession(lastSession, newSessionsRaw);
|
||||
|
||||
await database.appendActivityCache({
|
||||
...meta,
|
||||
updatedAt,
|
||||
sourceLastCreatedAt,
|
||||
pendingSessionStartAt: null,
|
||||
sessions: merged.sessions,
|
||||
replaceLastSession: merged.replaceLastSession
|
||||
});
|
||||
return database.getActivityCache(meta.userId);
|
||||
}
|
||||
|
||||
if (!meta.sourceLastCreatedAt) {
|
||||
return fullRefresh(meta.userId, false);
|
||||
return fullRefresh(meta.userId);
|
||||
}
|
||||
|
||||
const events = await database.getOnlineOfflineSessionsAfter(
|
||||
@@ -162,10 +74,11 @@ export const useActivityStore = defineStore('Activity', () => {
|
||||
return database.getActivityCache(meta.userId);
|
||||
}
|
||||
|
||||
const { sessions, pendingSessionStartAt } = buildSessionsAndPendingFromEvents(
|
||||
events,
|
||||
meta.pendingSessionStartAt
|
||||
);
|
||||
const { sessions, pendingSessionStartAt } =
|
||||
buildSessionsAndPendingFromEvents(
|
||||
events,
|
||||
meta.pendingSessionStartAt
|
||||
);
|
||||
const sourceLastCreatedAt = events[events.length - 1].created_at;
|
||||
|
||||
await database.appendActivityCache({
|
||||
@@ -178,32 +91,18 @@ export const useActivityStore = defineStore('Activity', () => {
|
||||
return database.getActivityCache(meta.userId);
|
||||
}
|
||||
|
||||
function refreshActivityCache(userId, isSelf, options = {}) {
|
||||
const { notifyStart = false, notifyComplete = false } = options;
|
||||
|
||||
function refreshActivityCache(userId) {
|
||||
const existing = refreshJobs.get(userId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (notifyStart) {
|
||||
toast.info(t('dialog.user.activity.refresh_started'), {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
|
||||
const job = (async () => {
|
||||
const meta = await database.getActivityCacheMeta(userId);
|
||||
const entry =
|
||||
meta && meta.isSelf === isSelf
|
||||
? await incrementalRefresh(meta)
|
||||
: await fullRefresh(userId, isSelf);
|
||||
if (notifyComplete) {
|
||||
toast.success(t('dialog.user.activity.refresh_complete'), {
|
||||
position: 'bottom-center'
|
||||
});
|
||||
if (!meta || meta.isSelf) {
|
||||
return fullRefresh(userId);
|
||||
}
|
||||
return entry;
|
||||
return incrementalRefresh(meta);
|
||||
})().finally(() => {
|
||||
refreshJobs.delete(userId);
|
||||
});
|
||||
@@ -214,9 +113,7 @@ export const useActivityStore = defineStore('Activity', () => {
|
||||
|
||||
return {
|
||||
getCache,
|
||||
isExpired,
|
||||
isRefreshing,
|
||||
refreshActivityCache,
|
||||
ttlMs: ACTIVITY_CACHE_TTL_MS
|
||||
refreshActivityCache
|
||||
};
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ export const useVrcxStore = defineStore('Vrcx', () => {
|
||||
*/
|
||||
async function updateDatabaseVersion() {
|
||||
// requires dbVars.userPrefix to be already set
|
||||
const databaseVersion = 14;
|
||||
const databaseVersion = 15;
|
||||
if (state.databaseVersion < databaseVersion) {
|
||||
databaseUpgradeState.value = {
|
||||
visible: state.databaseVersion > 0,
|
||||
@@ -208,6 +208,9 @@ export const useVrcxStore = defineStore('Vrcx', () => {
|
||||
await database.fixCancelFriendRequestTypo(); // fix CancelFriendRequst typo
|
||||
await database.fixBrokenGameLogDisplayNames(); // fix gameLog display names "DisplayName (userId)"
|
||||
await database.upgradeDatabaseVersion(); // update database version
|
||||
if (state.databaseVersion < 15) {
|
||||
await database.updateActivityTabDatabaseVersion(); // improve activity tab performance, ver 15
|
||||
}
|
||||
await database.vacuum(); // succ
|
||||
await database.optimize();
|
||||
await configRepository.setInt(
|
||||
@@ -222,7 +225,9 @@ export const useVrcxStore = defineStore('Vrcx', () => {
|
||||
databaseUpgradeState.value.visible = false;
|
||||
await modalStore.alert({
|
||||
title: t('message.database.upgrade_failed_title'),
|
||||
description: t('message.database.upgrade_failed_description'),
|
||||
description: t(
|
||||
'message.database.upgrade_failed_description'
|
||||
),
|
||||
dismissible: false
|
||||
});
|
||||
AppApi.ShowDevTools();
|
||||
|
||||
Reference in New Issue
Block a user