mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-30 12:13:48 +02:00
refactor activity tab
This commit is contained in:
@@ -1,119 +1,600 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { database } from '../services/database';
|
||||
import { ONLINE_SESSION_MERGE_GAP_MS } from '../shared/utils/overlapCalculator';
|
||||
const refreshJobs = new Map();
|
||||
import { mergeSessions } from '../shared/utils/activityEngine.js';
|
||||
import { runActivityWorkerTask } from '../workers/activityWorkerRunner.js';
|
||||
|
||||
function buildSessionsAndPendingFromEvents(events, initialStart = null) {
|
||||
const sessions = [];
|
||||
let currentStart = initialStart;
|
||||
const snapshotMap = new Map();
|
||||
const inFlightJobs = new Map();
|
||||
const workerCall = runActivityWorkerTask;
|
||||
const MAX_SNAPSHOT_ENTRIES = 12;
|
||||
let deferredWriteQueue = Promise.resolve();
|
||||
|
||||
for (const event of events) {
|
||||
const ts = new Date(event.created_at).getTime();
|
||||
if (event.type === 'Online') {
|
||||
// Treat consecutive Online events as reconnect boundaries.
|
||||
if (currentStart !== null) {
|
||||
sessions.push({ start: currentStart, end: ts });
|
||||
}
|
||||
currentStart = ts;
|
||||
} else if (event.type === 'Offline' && currentStart !== null) {
|
||||
sessions.push({ start: currentStart, end: ts });
|
||||
currentStart = null;
|
||||
}
|
||||
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 {
|
||||
pendingSessionStartAt: currentStart,
|
||||
sessions
|
||||
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', () => {
|
||||
function getCache(userId) {
|
||||
return database.getActivityCache(userId);
|
||||
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) {
|
||||
return refreshJobs.has(userId);
|
||||
for (const key of inFlightJobs.keys()) {
|
||||
if (key.startsWith(`${userId}:`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fullRefresh(userId) {
|
||||
const events = await database.getOnlineOfflineSessions(userId);
|
||||
const { sessions, pendingSessionStartAt } =
|
||||
buildSessionsAndPendingFromEvents(events);
|
||||
const sourceLastCreatedAt =
|
||||
events.length > 0 ? events[events.length - 1].created_at : '';
|
||||
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,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSelf: false,
|
||||
sourceLastCreatedAt,
|
||||
pendingSessionStartAt,
|
||||
sessions
|
||||
rangeDays,
|
||||
worlds,
|
||||
builtFromCursor: currentCursor,
|
||||
builtAt: new Date().toISOString()
|
||||
};
|
||||
await database.replaceActivityCache(entry);
|
||||
return database.getActivityCache(userId);
|
||||
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 incrementalRefresh(meta) {
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
if (!meta.sourceLastCreatedAt) {
|
||||
return fullRefresh(meta.userId);
|
||||
}
|
||||
|
||||
const events = await database.getOnlineOfflineSessionsAfter(
|
||||
meta.userId,
|
||||
meta.sourceLastCreatedAt
|
||||
);
|
||||
if (events.length === 0) {
|
||||
await database.touchActivityCacheMeta({
|
||||
...meta,
|
||||
updatedAt
|
||||
});
|
||||
return database.getActivityCache(meta.userId);
|
||||
}
|
||||
|
||||
const { sessions, pendingSessionStartAt } =
|
||||
buildSessionsAndPendingFromEvents(
|
||||
events,
|
||||
meta.pendingSessionStartAt
|
||||
);
|
||||
const sourceLastCreatedAt = events[events.length - 1].created_at;
|
||||
|
||||
await database.appendActivityCache({
|
||||
...meta,
|
||||
updatedAt,
|
||||
sourceLastCreatedAt,
|
||||
pendingSessionStartAt,
|
||||
sessions
|
||||
});
|
||||
return database.getActivityCache(meta.userId);
|
||||
async function refreshActivity(userId, options) {
|
||||
return loadActivity(userId, { ...options, forceRefresh: true });
|
||||
}
|
||||
|
||||
function refreshActivityCache(userId) {
|
||||
const existing = refreshJobs.get(userId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const job = (async () => {
|
||||
const meta = await database.getActivityCacheMeta(userId);
|
||||
if (!meta || meta.isSelf) {
|
||||
return fullRefresh(userId);
|
||||
}
|
||||
return incrementalRefresh(meta);
|
||||
})().finally(() => {
|
||||
refreshJobs.delete(userId);
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
refreshJobs.set(userId, job);
|
||||
return job;
|
||||
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,
|
||||
refreshActivityCache
|
||||
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' }
|
||||
},
|
||||
friend: {
|
||||
7: { thresholdMinutes: 0, capPercentile: 95, mode: 'sqrt' },
|
||||
30: { thresholdMinutes: 10, capPercentile: 95, mode: 'sqrt' },
|
||||
90: { thresholdMinutes: 20, capPercentile: 90, 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' }
|
||||
}[rangeDays] || {
|
||||
thresholdMinutes: 5,
|
||||
capPercentile: 95,
|
||||
mode: 'sqrt'
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user