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

View File

@@ -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();
})();
}

View File

@@ -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:",

View File

@@ -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
);
}
}

View File

@@ -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)`
);

View File

@@ -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)`
);
}
}
};

View File

@@ -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
};
});

View File

@@ -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();