improve activity tab performance by adding indexes

This commit is contained in:
pa
2026-03-20 17:46:41 +09:00
parent 4570f254ea
commit ad5b9ab48d
7 changed files with 89 additions and 199 deletions
@@ -7,12 +7,12 @@
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
:disabled="isLoading" :disabled="isLoading"
:title="hasRequestedLoad ? t('dialog.user.activity.refresh_hint') : t('dialog.user.activity.load')" :title="t('dialog.user.activity.refresh_hint')"
@click="loadData"> @click="loadData">
<Spinner v-if="isLoading" /> <Spinner v-if="isLoading" />
<RefreshCw v-else /> <RefreshCw v-else />
</Button> </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') }} {{ t('dialog.user.activity.refresh_hint') }}
</span> </span>
<span v-if="filteredEventCount > 0" class="text-accent-foreground ml-1"> <span v-if="filteredEventCount > 0" class="text-accent-foreground ml-1">
@@ -45,28 +45,18 @@
<span class="font-medium ml-1">{{ peakTimeText }}</span> <span class="font-medium ml-1">{{ peakTimeText }}</span>
</div> </div>
</div> </div>
<div <div v-if="isLoading && !hasAnyData" class="flex flex-col items-center justify-center flex-1 mt-8 gap-2">
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">
<Spinner class="h-5 w-5" /> <Spinner class="h-5 w-5" />
<span class="text-sm text-muted-foreground">{{ t('dialog.user.activity.preparing_data') }}</span> <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> <span class="text-xs text-muted-foreground">{{ t('dialog.user.activity.preparing_data_hint') }}</span>
</div> </div>
<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"> 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> <span class="text-muted-foreground text-sm">{{ t('dialog.user.activity.no_data_in_period') }}</span>
</div> </div>
@@ -224,6 +214,7 @@
import { timeToText } from '../../../shared/utils'; import { timeToText } from '../../../shared/utils';
import { useCurrentUserSessions } from '../../../composables/useCurrentUserSessions'; import { useCurrentUserSessions } from '../../../composables/useCurrentUserSessions';
import { import {
buildSessionsFromGamelog,
calculateOverlapGrid, calculateOverlapGrid,
filterSessionsByPeriod, filterSessionsByPeriod,
findBestOverlapTime, findBestOverlapTime,
@@ -244,8 +235,6 @@
const peakTimeText = ref(''); const peakTimeText = ref('');
const selectedPeriod = ref('all'); const selectedPeriod = ref('all');
const filteredEventCount = ref(0); const filteredEventCount = ref(0);
const isSessionCacheLoading = ref(false);
const hasRequestedLoad = ref(false);
const isSelf = computed(() => userDialog.value.id === currentUser.value.id); const isSelf = computed(() => userDialog.value.id === currentUser.value.id);
const topWorlds = ref([]); const topWorlds = ref([]);
@@ -579,8 +568,6 @@
const userId = userDialog.value.id; const userId = userDialog.value.id;
if (!userId) return; if (!userId) return;
hasRequestedLoad.value = true;
if (userId !== lastLoadedUserId) { if (userId !== lastLoadedUserId) {
selectedPeriod.value = 'all'; selectedPeriod.value = 'all';
} }
@@ -588,10 +575,17 @@
const requestId = ++activeRequestId; const requestId = ++activeRequestId;
isLoading.value = true; isLoading.value = true;
try { try {
const entry = await activityStore.refreshActivityCache(userId, isSelf.value, { if (isSelf.value) {
notifyStart: hasAnyData.value, const rows = await database.getCurrentUserOnlineSessions();
notifyComplete: true 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 (requestId !== activeRequestId) return;
if (userDialog.value.id !== userId) return; if (userDialog.value.id !== userId) return;
hydrateFromCacheEntry(entry); hydrateFromCacheEntry(entry);
@@ -652,9 +646,7 @@
} }
function resetActivityState() { function resetActivityState() {
hasRequestedLoad.value = false;
isLoading.value = false; isLoading.value = false;
isSessionCacheLoading.value = false;
hasAnyData.value = false; hasAnyData.value = false;
peakDayText.value = ''; peakDayText.value = '';
peakTimeText.value = ''; peakTimeText.value = '';
@@ -673,13 +665,11 @@
function hydrateFromCacheEntry(entry) { function hydrateFromCacheEntry(entry) {
cachedTargetSessions = Array.isArray(entry?.sessions) ? entry.sessions : []; cachedTargetSessions = Array.isArray(entry?.sessions) ? entry.sessions : [];
hasRequestedLoad.value = Boolean(entry);
} }
async function loadCachedActivity(userId) { async function loadCachedActivity(userId) {
const entry = await activityStore.getCache(userId); const entry = await activityStore.getCache(userId);
if (!entry) { if (!entry) {
hasRequestedLoad.value = false;
return null; return null;
} }
@@ -691,24 +681,6 @@
return entry; 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() { function getFilteredEventCount() {
if (selectedPeriod.value === 'all') return cachedTargetSessions.length; if (selectedPeriod.value === 'all') return cachedTargetSessions.length;
const days = parseInt(selectedPeriod.value, 10); const days = parseInt(selectedPeriod.value, 10);
@@ -726,31 +698,22 @@
if (!userId) { if (!userId) {
return; return;
} }
if (isSelf.value) {
if (lastLoadedUserId === userId && (hasAnyData.value || isLoading.value)) {
return;
}
void loadData();
return;
}
void (async () => { void (async () => {
const cacheEntry = await loadCachedActivity(userId); const cacheEntry = await loadCachedActivity(userId);
if (!cacheEntry) { if (cacheEntry || activityStore.isRefreshing(userId)) {
if (activityStore.isRefreshing(userId)) { if (!cacheEntry && !isLoading.value) {
hasRequestedLoad.value = true; void loadData();
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; return;
} }
void loadData();
if (activityStore.isExpired(cacheEntry)) {
void scheduleAutoRefresh(userId);
}
})(); })();
} }
-2
View File
@@ -1430,8 +1430,6 @@
"load": "Load Activity", "load": "Load Activity",
"load_hint": "Load activity data from the local database when needed.", "load_hint": "Load activity data from the local database when needed.",
"refresh_hint": "Rebuild activity cache", "refresh_hint": "Rebuild activity cache",
"refresh_started": "Rebuilding activity data. Please wait...",
"refresh_complete": "Activity refresh complete",
"total_events": "{count} online events", "total_events": "{count} online events",
"times_online": "times online", "times_online": "times online",
"most_active_day": "Most active day:", "most_active_day": "Most active day:",
+14 -7
View File
@@ -195,16 +195,23 @@ async function upsertMeta(entry) {
} }
async function upsertSessions(userId, sessions = []) { 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( await sqliteService.executeNonQuery(
`INSERT OR REPLACE INTO ${dbVars.userPrefix}_activity_cache_sessions `INSERT OR REPLACE INTO ${dbVars.userPrefix}_activity_cache_sessions
(user_id, start_at, end_at) (user_id, start_at, end_at)
VALUES (@user_id, @start_at, @end_at)`, VALUES ${values.join(', ')}`,
{ args
'@user_id': userId,
'@start_at': session.start,
'@end_at': session.end
}
); );
} }
} }
+3
View File
@@ -72,6 +72,9 @@ const database = {
await sqliteService.executeNonQuery( 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)` `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( 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)` `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)`
); );
+17
View File
@@ -15,6 +15,11 @@ const tableAlter = {
// await sqliteService.executeNonQuery('PRAGMA user_version = 1'); // await sqliteService.executeNonQuery('PRAGMA user_version = 1');
}, },
async updateActivityTabDatabaseVersion() {
await this.ensureActivityCacheTables();
await this.ensureFeedOnlineOfflineIndexes();
},
async updateTableForGroupNames() { async updateTableForGroupNames() {
var tables = []; var tables = [];
await sqliteService.execute((dbRow) => { 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)` `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)`
);
}
} }
}; };
+16 -119
View File
@@ -1,14 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { database } from '../services/database'; import { database } from '../services/database';
import { import { ONLINE_SESSION_MERGE_GAP_MS } from '../shared/utils/overlapCalculator';
buildSessionsFromGamelog,
ONLINE_SESSION_MERGE_GAP_MS
} from '../shared/utils/overlapCalculator';
const ACTIVITY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const refreshJobs = new Map(); const refreshJobs = new Map();
function buildSessionsAndPendingFromEvents(events, initialStart = null) { 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', () => { export const useActivityStore = defineStore('Activity', () => {
const { t } = useI18n();
function getCache(userId) { function getCache(userId) {
return database.getActivityCache(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) { function isRefreshing(userId) {
return refreshJobs.has(userId); return refreshJobs.has(userId);
} }
async function fullRefresh(userId, isSelf) { async function fullRefresh(userId) {
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);
}
const events = await database.getOnlineOfflineSessions(userId); const events = await database.getOnlineOfflineSessions(userId);
const { sessions, pendingSessionStartAt } = buildSessionsAndPendingFromEvents(events); const { sessions, pendingSessionStartAt } =
buildSessionsAndPendingFromEvents(events);
const sourceLastCreatedAt = const sourceLastCreatedAt =
events.length > 0 ? events[events.length - 1].created_at : ''; events.length > 0 ? events[events.length - 1].created_at : '';
const entry = { const entry = {
userId, userId,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
isSelf, isSelf: false,
sourceLastCreatedAt, sourceLastCreatedAt,
pendingSessionStartAt, pendingSessionStartAt,
sessions sessions
@@ -114,40 +58,8 @@ export const useActivityStore = defineStore('Activity', () => {
async function incrementalRefresh(meta) { async function incrementalRefresh(meta) {
const updatedAt = new Date().toISOString(); 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) { if (!meta.sourceLastCreatedAt) {
return fullRefresh(meta.userId, false); return fullRefresh(meta.userId);
} }
const events = await database.getOnlineOfflineSessionsAfter( const events = await database.getOnlineOfflineSessionsAfter(
@@ -162,10 +74,11 @@ export const useActivityStore = defineStore('Activity', () => {
return database.getActivityCache(meta.userId); return database.getActivityCache(meta.userId);
} }
const { sessions, pendingSessionStartAt } = buildSessionsAndPendingFromEvents( const { sessions, pendingSessionStartAt } =
events, buildSessionsAndPendingFromEvents(
meta.pendingSessionStartAt events,
); meta.pendingSessionStartAt
);
const sourceLastCreatedAt = events[events.length - 1].created_at; const sourceLastCreatedAt = events[events.length - 1].created_at;
await database.appendActivityCache({ await database.appendActivityCache({
@@ -178,32 +91,18 @@ export const useActivityStore = defineStore('Activity', () => {
return database.getActivityCache(meta.userId); return database.getActivityCache(meta.userId);
} }
function refreshActivityCache(userId, isSelf, options = {}) { function refreshActivityCache(userId) {
const { notifyStart = false, notifyComplete = false } = options;
const existing = refreshJobs.get(userId); const existing = refreshJobs.get(userId);
if (existing) { if (existing) {
return existing; return existing;
} }
if (notifyStart) {
toast.info(t('dialog.user.activity.refresh_started'), {
position: 'bottom-center'
});
}
const job = (async () => { const job = (async () => {
const meta = await database.getActivityCacheMeta(userId); const meta = await database.getActivityCacheMeta(userId);
const entry = if (!meta || meta.isSelf) {
meta && meta.isSelf === isSelf return fullRefresh(userId);
? await incrementalRefresh(meta)
: await fullRefresh(userId, isSelf);
if (notifyComplete) {
toast.success(t('dialog.user.activity.refresh_complete'), {
position: 'bottom-center'
});
} }
return entry; return incrementalRefresh(meta);
})().finally(() => { })().finally(() => {
refreshJobs.delete(userId); refreshJobs.delete(userId);
}); });
@@ -214,9 +113,7 @@ export const useActivityStore = defineStore('Activity', () => {
return { return {
getCache, getCache,
isExpired,
isRefreshing, isRefreshing,
refreshActivityCache, refreshActivityCache
ttlMs: ACTIVITY_CACHE_TTL_MS
}; };
}); });
+7 -2
View File
@@ -187,7 +187,7 @@ export const useVrcxStore = defineStore('Vrcx', () => {
*/ */
async function updateDatabaseVersion() { async function updateDatabaseVersion() {
// requires dbVars.userPrefix to be already set // requires dbVars.userPrefix to be already set
const databaseVersion = 14; const databaseVersion = 15;
if (state.databaseVersion < databaseVersion) { if (state.databaseVersion < databaseVersion) {
databaseUpgradeState.value = { databaseUpgradeState.value = {
visible: state.databaseVersion > 0, visible: state.databaseVersion > 0,
@@ -208,6 +208,9 @@ export const useVrcxStore = defineStore('Vrcx', () => {
await database.fixCancelFriendRequestTypo(); // fix CancelFriendRequst typo await database.fixCancelFriendRequestTypo(); // fix CancelFriendRequst typo
await database.fixBrokenGameLogDisplayNames(); // fix gameLog display names "DisplayName (userId)" await database.fixBrokenGameLogDisplayNames(); // fix gameLog display names "DisplayName (userId)"
await database.upgradeDatabaseVersion(); // update database version 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.vacuum(); // succ
await database.optimize(); await database.optimize();
await configRepository.setInt( await configRepository.setInt(
@@ -222,7 +225,9 @@ export const useVrcxStore = defineStore('Vrcx', () => {
databaseUpgradeState.value.visible = false; databaseUpgradeState.value.visible = false;
await modalStore.alert({ await modalStore.alert({
title: t('message.database.upgrade_failed_title'), title: t('message.database.upgrade_failed_title'),
description: t('message.database.upgrade_failed_description'), description: t(
'message.database.upgrade_failed_description'
),
dismissible: false dismissible: false
}); });
AppApi.ShowDevTools(); AppApi.ShowDevTools();