Files
VRCX/src/stores/activity.js
2026-03-23 21:43:18 +09:00

604 lines
20 KiB
JavaScript

import { defineStore } from 'pinia';
import { database } from '../services/database';
import { mergeSessions } from '../shared/utils/activityEngine.js';
import { runActivityWorkerTask } from '../workers/activityWorkerRunner.js';
const snapshotMap = new Map();
const inFlightJobs = new Map();
const workerCall = runActivityWorkerTask;
const MAX_SNAPSHOT_ENTRIES = 12;
let deferredWriteQueue = Promise.resolve();
function deferWrite(task) {
const run = () => {
deferredWriteQueue = deferredWriteQueue
.catch(() => {})
.then(task)
.catch((error) => {
console.error('[Activity] deferred write failed:', error);
});
return deferredWriteQueue;
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run);
return;
}
setTimeout(run, 0);
}
function createSnapshot(userId, isSelf) {
return {
userId,
isSelf,
sync: {
userId,
updatedAt: '',
isSelf,
sourceLastCreatedAt: '',
pendingSessionStartAt: null,
cachedRangeDays: 0
},
sessions: [],
activityViews: new Map(),
overlapViews: new Map(),
topWorldsViews: new Map()
};
}
function getSnapshot(userId, isSelf) {
let snapshot = snapshotMap.get(userId);
if (!snapshot) {
snapshot = createSnapshot(userId, isSelf);
snapshotMap.set(userId, snapshot);
} else if (typeof isSelf === 'boolean') {
snapshot.isSelf = isSelf;
snapshot.sync.isSelf = isSelf;
}
touchSnapshot(userId, snapshot);
pruneSnapshots();
return snapshot;
}
function touchSnapshot(userId, snapshot) {
snapshotMap.delete(userId);
snapshotMap.set(userId, snapshot);
}
function pruneSnapshots() {
if (snapshotMap.size <= MAX_SNAPSHOT_ENTRIES) {
return;
}
for (const [userId] of snapshotMap) {
if (isUserInFlight(userId)) {
continue;
}
snapshotMap.delete(userId);
if (snapshotMap.size <= MAX_SNAPSHOT_ENTRIES) {
break;
}
}
}
function isUserInFlight(userId) {
for (const key of inFlightJobs.keys()) {
if (key.startsWith(`${userId}:`)) {
return true;
}
}
return false;
}
function clearDerivedViews(snapshot) {
snapshot.activityViews.clear();
snapshot.overlapViews.clear();
snapshot.topWorldsViews.clear();
}
function overlapExcludeKey(excludeHours) {
if (!excludeHours?.enabled) {
return '';
}
return `${excludeHours.startHour}-${excludeHours.endHour}`;
}
function pairCursor(leftCursor, rightCursor) {
return `${leftCursor || ''}|${rightCursor || ''}`;
}
export const useActivityStore = defineStore('Activity', () => {
async function getCache(userId, isSelf = false) {
const snapshot = await hydrateSnapshot(userId, isSelf);
return {
userId: snapshot.userId,
isSelf: snapshot.isSelf,
updatedAt: snapshot.sync.updatedAt,
sourceLastCreatedAt: snapshot.sync.sourceLastCreatedAt,
pendingSessionStartAt: snapshot.sync.pendingSessionStartAt,
cachedRangeDays: snapshot.sync.cachedRangeDays,
sessions: snapshot.sessions
};
}
function getCachedDays(userId) {
return snapshotMap.get(userId)?.sync.cachedRangeDays || 0;
}
function isRefreshing(userId) {
for (const key of inFlightJobs.keys()) {
if (key.startsWith(`${userId}:`)) {
return true;
}
}
return false;
}
async function loadActivity(userId, { isSelf = false, rangeDays = 30, normalizeConfig, dayLabels, forceRefresh = false }) {
const snapshot = await ensureSnapshot(userId, { isSelf, rangeDays, forceRefresh });
const cacheKey = String(rangeDays);
const currentCursor = snapshot.sync.sourceLastCreatedAt || '';
let view = snapshot.activityViews.get(cacheKey);
if (!forceRefresh && view?.builtFromCursor === currentCursor) {
return buildActivityResponse(snapshot, view);
}
if (!forceRefresh) {
const persisted = await database.getActivityBucketCacheV2({
ownerUserId: userId,
rangeDays,
viewKind: database.ACTIVITY_VIEW_KIND.ACTIVITY
});
if (persisted?.builtFromCursor === currentCursor) {
view = {
...persisted.summary,
rawBuckets: persisted.rawBuckets,
normalizedBuckets: persisted.normalizedBuckets,
builtFromCursor: persisted.builtFromCursor,
builtAt: persisted.builtAt
};
snapshot.activityViews.set(cacheKey, view);
return buildActivityResponse(snapshot, view);
}
}
const computed = await workerCall('computeActivityView', {
sessions: snapshot.sessions,
dayLabels,
rangeDays,
normalizeConfig
});
view = {
...computed,
builtFromCursor: currentCursor,
builtAt: new Date().toISOString()
};
snapshot.activityViews.set(cacheKey, view);
deferWrite(() => database.upsertActivityBucketCacheV2({
ownerUserId: userId,
rangeDays,
viewKind: database.ACTIVITY_VIEW_KIND.ACTIVITY,
builtFromCursor: currentCursor,
rawBuckets: view.rawBuckets,
normalizedBuckets: view.normalizedBuckets,
summary: {
peakDay: view.peakDay,
peakTime: view.peakTime,
filteredEventCount: view.filteredEventCount
},
builtAt: view.builtAt
}));
return buildActivityResponse(snapshot, view);
}
async function loadOverlap(currentUserId, targetUserId, {
rangeDays = 30,
dayLabels,
normalizeConfig,
excludeHours,
forceRefresh = false
}) {
const [selfSnapshot, targetSnapshot] = await Promise.all([
ensureSnapshot(currentUserId, { isSelf: true, rangeDays, forceRefresh }),
ensureSnapshot(targetUserId, { isSelf: false, rangeDays, forceRefresh })
]);
const excludeKey = overlapExcludeKey(excludeHours);
const cacheKey = `${targetUserId}:${rangeDays}:${excludeKey}`;
const cursor = pairCursor(selfSnapshot.sync.sourceLastCreatedAt, targetSnapshot.sync.sourceLastCreatedAt);
let view = targetSnapshot.overlapViews.get(cacheKey);
if (view?.builtFromCursor === cursor) {
return view;
}
const persisted = await database.getActivityBucketCacheV2({
ownerUserId: currentUserId,
targetUserId,
rangeDays,
viewKind: database.ACTIVITY_VIEW_KIND.OVERLAP,
excludeKey
});
if (persisted?.builtFromCursor === cursor) {
view = {
...persisted.summary,
rawBuckets: persisted.rawBuckets,
normalizedBuckets: persisted.normalizedBuckets,
builtFromCursor: persisted.builtFromCursor,
builtAt: persisted.builtAt
};
targetSnapshot.overlapViews.set(cacheKey, view);
return view;
}
view = await workerCall('computeOverlapView', {
selfSessions: selfSnapshot.sessions,
targetSessions: targetSnapshot.sessions,
dayLabels,
rangeDays,
excludeHours: excludeHours?.enabled ? excludeHours : null,
normalizeConfig
});
view = {
...view,
builtFromCursor: cursor,
builtAt: new Date().toISOString()
};
targetSnapshot.overlapViews.set(cacheKey, view);
deferWrite(() => database.upsertActivityBucketCacheV2({
ownerUserId: currentUserId,
targetUserId,
rangeDays,
viewKind: database.ACTIVITY_VIEW_KIND.OVERLAP,
excludeKey,
builtFromCursor: cursor,
rawBuckets: view.rawBuckets,
normalizedBuckets: view.normalizedBuckets,
summary: {
overlapPercent: view.overlapPercent,
bestOverlapTime: view.bestOverlapTime
},
builtAt: view.builtAt
}));
return view;
}
async function loadTopWorlds(userId, { rangeDays = 30, limit = 5, isSelf = true, forceRefresh = false }) {
const snapshot = await ensureSnapshot(userId, { isSelf, rangeDays, forceRefresh });
const cacheKey = `${rangeDays}:${limit}`;
const currentCursor = snapshot.sync.sourceLastCreatedAt || '';
let cached = snapshot.topWorldsViews.get(cacheKey);
if (!forceRefresh && cached?.builtFromCursor === currentCursor) {
return cached.worlds;
}
if (!forceRefresh) {
const persisted = await database.getActivityTopWorldsCacheV2(userId, rangeDays);
if (persisted?.builtFromCursor === currentCursor) {
snapshot.topWorldsViews.set(cacheKey, persisted);
return persisted.worlds;
}
}
const worlds = await database.getMyTopWorlds(rangeDays, limit);
const entry = {
userId,
rangeDays,
worlds,
builtFromCursor: currentCursor,
builtAt: new Date().toISOString()
};
snapshot.topWorldsViews.set(cacheKey, entry);
deferWrite(() => database.replaceActivityTopWorldsCacheV2(entry));
deferWrite(() => database.upsertActivityRangeCacheV2({
userId,
rangeDays,
cacheKind: database.ACTIVITY_RANGE_CACHE_KIND.TOP_WORLDS,
isComplete: true,
builtFromCursor: currentCursor,
builtAt: entry.builtAt
}));
return worlds;
}
async function refreshActivity(userId, options) {
return loadActivity(userId, { ...options, forceRefresh: true });
}
async function loadActivityView({ userId, isSelf = false, rangeDays = 30, dayLabels, forceRefresh = false }) {
const response = await loadActivity(userId, {
isSelf,
rangeDays,
dayLabels,
forceRefresh,
normalizeConfig: pickActivityNormalizeConfig(isSelf, rangeDays)
});
return {
hasAnyData: response.sessions.length > 0,
filteredEventCount: response.view.filteredEventCount,
peakDay: response.view.peakDay,
peakTime: response.view.peakTime,
rawBuckets: response.view.rawBuckets,
normalizedBuckets: response.view.normalizedBuckets
};
}
async function loadOverlapView({ currentUserId, targetUserId, rangeDays = 30, dayLabels, excludeHours, forceRefresh = false }) {
const response = await loadOverlap(currentUserId, targetUserId, {
rangeDays,
dayLabels,
excludeHours,
forceRefresh,
normalizeConfig: pickOverlapNormalizeConfig(rangeDays)
});
return {
hasOverlapData: response.rawBuckets.some((value) => value > 0),
overlapPercent: response.overlapPercent,
bestOverlapTime: response.bestOverlapTime,
rawBuckets: response.rawBuckets,
normalizedBuckets: response.normalizedBuckets
};
}
async function loadTopWorldsView({ userId, rangeDays = 30, limit = 5, forceRefresh = false }) {
return loadTopWorlds(userId, {
rangeDays,
limit,
isSelf: true,
forceRefresh
});
}
function invalidateUser(userId) {
if (!userId) {
return;
}
snapshotMap.delete(userId);
}
return {
getCache,
getCachedDays,
isRefreshing,
loadActivity,
loadActivityView,
loadOverlap,
loadOverlapView,
loadTopWorlds,
loadTopWorldsView,
refreshActivity,
invalidateUser,
workerCall: runActivityWorkerTask
};
});
function buildActivityResponse(snapshot, view) {
return {
userId: snapshot.userId,
isSelf: snapshot.isSelf,
cachedRangeDays: snapshot.sync.cachedRangeDays,
sessions: snapshot.sessions,
view
};
}
async function hydrateSnapshot(userId, isSelf) {
const snapshot = getSnapshot(userId, isSelf);
if (snapshot.sync.updatedAt || snapshot.sessions.length > 0) {
return snapshot;
}
const [syncState, sessions] = await Promise.all([
database.getActivitySyncStateV2(userId),
database.getActivitySessionsV2(userId)
]);
if (syncState) {
snapshot.sync = {
...snapshot.sync,
...syncState,
isSelf: typeof syncState.isSelf === 'boolean' ? syncState.isSelf : snapshot.isSelf
};
}
if (sessions.length > 0) {
snapshot.sessions = sessions;
}
return snapshot;
}
async function ensureSnapshot(userId, { isSelf, rangeDays, forceRefresh = false }) {
const jobKey = `${userId}:${isSelf}:${rangeDays}:${forceRefresh ? 'force' : 'normal'}`;
const existingJob = inFlightJobs.get(jobKey);
if (existingJob) {
return existingJob;
}
const job = (async () => {
const snapshot = await hydrateSnapshot(userId, isSelf);
if (forceRefresh || !snapshot.sync.updatedAt) {
await fullRefresh(snapshot, rangeDays);
} else {
await incrementalRefresh(snapshot);
if (rangeDays > snapshot.sync.cachedRangeDays) {
await expandRange(snapshot, rangeDays);
}
}
return snapshot;
})().finally(() => {
inFlightJobs.delete(jobKey);
});
inFlightJobs.set(jobKey, job);
return job;
}
async function fullRefresh(snapshot, rangeDays) {
const sourceItems = await database.getActivitySourceSliceV2({
userId: snapshot.userId,
isSelf: snapshot.isSelf,
fromDays: rangeDays
});
const sourceLastCreatedAt = sourceItems.length > 0 ? sourceItems[sourceItems.length - 1].created_at : '';
const result = await workerCall('computeSessionsSnapshot', {
sourceType: snapshot.isSelf ? 'self_gamelog' : 'friend_presence',
rows: snapshot.isSelf ? sourceItems : undefined,
events: snapshot.isSelf ? undefined : sourceItems,
initialStart: null,
nowMs: Date.now(),
mayHaveOpenTail: snapshot.isSelf,
sourceRevision: sourceLastCreatedAt
});
snapshot.sessions = result.sessions;
snapshot.sync = {
...snapshot.sync,
updatedAt: new Date().toISOString(),
isSelf: snapshot.isSelf,
sourceLastCreatedAt,
pendingSessionStartAt: result.pendingSessionStartAt,
cachedRangeDays: rangeDays
};
clearDerivedViews(snapshot);
deferWrite(() => database.replaceActivitySessionsV2(snapshot.userId, snapshot.sessions));
deferWrite(() => database.upsertActivitySyncStateV2(snapshot.sync));
deferWrite(() => database.upsertActivityRangeCacheV2({
userId: snapshot.userId,
rangeDays,
cacheKind: database.ACTIVITY_RANGE_CACHE_KIND.SESSIONS,
isComplete: true,
builtFromCursor: snapshot.sync.sourceLastCreatedAt,
builtAt: snapshot.sync.updatedAt
}));
}
async function incrementalRefresh(snapshot) {
if (!snapshot.sync.sourceLastCreatedAt) {
return;
}
const sourceItems = await database.getActivitySourceAfterV2({
userId: snapshot.userId,
isSelf: snapshot.isSelf,
afterCreatedAt: snapshot.sync.sourceLastCreatedAt,
inclusive: snapshot.isSelf
});
if (sourceItems.length === 0) {
snapshot.sync.updatedAt = new Date().toISOString();
deferWrite(() => database.upsertActivitySyncStateV2(snapshot.sync));
return;
}
const sourceLastCreatedAt = sourceItems[sourceItems.length - 1].created_at;
const result = await workerCall('computeSessionsSnapshot', {
sourceType: snapshot.isSelf ? 'self_gamelog' : 'friend_presence',
rows: snapshot.isSelf ? sourceItems : undefined,
events: snapshot.isSelf ? undefined : sourceItems,
initialStart: snapshot.isSelf ? null : snapshot.sync.pendingSessionStartAt,
nowMs: Date.now(),
mayHaveOpenTail: snapshot.isSelf,
sourceRevision: sourceLastCreatedAt
});
const replaceFromStartAt = snapshot.sessions.length > 0
? snapshot.sessions[Math.max(snapshot.sessions.length - 1, 0)].start
: null;
const merged = mergeSessions(snapshot.sessions, result.sessions);
snapshot.sessions = merged;
snapshot.sync = {
...snapshot.sync,
updatedAt: new Date().toISOString(),
sourceLastCreatedAt,
pendingSessionStartAt: result.pendingSessionStartAt
};
clearDerivedViews(snapshot);
const tailSessions = replaceFromStartAt === null
? merged
: merged.filter((session) => session.start >= replaceFromStartAt);
deferWrite(() => database.appendActivitySessionsV2({
userId: snapshot.userId,
sessions: tailSessions,
replaceFromStartAt
}));
deferWrite(() => database.upsertActivitySyncStateV2(snapshot.sync));
}
async function expandRange(snapshot, rangeDays) {
const currentDays = snapshot.sync.cachedRangeDays || 0;
if (rangeDays <= currentDays) {
return;
}
const sourceItems = await database.getActivitySourceSliceV2({
userId: snapshot.userId,
isSelf: snapshot.isSelf,
fromDays: rangeDays,
toDays: currentDays
});
const result = await workerCall('computeSessionsSnapshot', {
sourceType: snapshot.isSelf ? 'self_gamelog' : 'friend_presence',
rows: snapshot.isSelf ? sourceItems : undefined,
events: snapshot.isSelf ? undefined : sourceItems,
initialStart: null,
nowMs: Date.now(),
mayHaveOpenTail: false,
sourceRevision: snapshot.sync.sourceLastCreatedAt
});
if (result.sessions.length > 0) {
snapshot.sessions = mergeSessions(result.sessions, snapshot.sessions);
deferWrite(() => database.replaceActivitySessionsV2(snapshot.userId, snapshot.sessions));
}
snapshot.sync.cachedRangeDays = rangeDays;
snapshot.sync.updatedAt = new Date().toISOString();
clearDerivedViews(snapshot);
deferWrite(() => database.upsertActivitySyncStateV2(snapshot.sync));
deferWrite(() => database.upsertActivityRangeCacheV2({
userId: snapshot.userId,
rangeDays,
cacheKind: database.ACTIVITY_RANGE_CACHE_KIND.SESSIONS,
isComplete: true,
builtFromCursor: snapshot.sync.sourceLastCreatedAt,
builtAt: snapshot.sync.updatedAt
}));
}
function pickActivityNormalizeConfig(isSelf, rangeDays) {
const role = isSelf ? 'self' : 'friend';
return {
self: {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' },
90: { thresholdMinutes: 20, capPercentile: 90, mode: 'log' },
180: { thresholdMinutes: 30, capPercentile: 85, mode: 'log' }
},
friend: {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' },
90: { thresholdMinutes: 20, capPercentile: 90, mode: 'log' },
180: { thresholdMinutes: 30, capPercentile: 85, mode: 'log' }
}
}[role][rangeDays] || {
thresholdMinutes: 10,
capPercentile: 95,
mode: 'sqrt'
};
}
function pickOverlapNormalizeConfig(rangeDays) {
return {
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
30: { thresholdMinutes: 5, capPercentile: 95, mode: 'sqrt' },
90: { thresholdMinutes: 10, capPercentile: 90, mode: 'log' },
180: { thresholdMinutes: 15, capPercentile: 85, mode: 'log' }
}[rangeDays] || {
thresholdMinutes: 5,
capPercentile: 95,
mode: 'sqrt'
};
}