From 6c1058a9d52fae5f28e5be91f2faf98f20b4d068 Mon Sep 17 00:00:00 2001 From: pa Date: Thu, 19 Mar 2026 16:15:27 +0900 Subject: [PATCH] feat: Add online overlap visualization in user activity tab --- .../UserDialog/UserDialogActivityTab.vue | 464 ++++++++++++++++-- src/localization/en.json | 9 +- src/services/database/feed.js | 17 + src/services/database/gameLog.js | 17 + .../utils/__tests__/overlapCalculator.test.js | 340 +++++++++++++ src/shared/utils/overlapCalculator.js | 257 ++++++++++ 6 files changed, 1061 insertions(+), 43 deletions(-) create mode 100644 src/shared/utils/__tests__/overlapCalculator.test.js create mode 100644 src/shared/utils/overlapCalculator.js diff --git a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue index 9ee0c698..2159b0d5 100644 --- a/src/components/dialogs/UserDialog/UserDialogActivityTab.vue +++ b/src/components/dialogs/UserDialog/UserDialogActivityTab.vue @@ -53,6 +53,80 @@ style="width: 100%; height: 240px" @contextmenu.prevent="onChartRightClick"> + + +
+
+
+ {{ t('dialog.user.activity.overlap.header') }} + +
+
+ + {{ t('dialog.user.activity.overlap.exclude_hours') }} + + + +
+
+ +
+
+ + {{ overlapPercent }}% + +
+
+
+
+
+ {{ t('dialog.user.activity.overlap.peak_overlap') }} + {{ bestOverlapTime }} +
+
+ +
+ +
+ {{ t('dialog.user.activity.overlap.no_data') }} +
+
@@ -61,7 +135,8 @@ import { Button } from '@/components/ui/button'; import { DataTableEmpty } from '@/components/ui/data-table'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; - import { RefreshCw, Tractor } from 'lucide-vue-next'; + import { Switch } from '@/components/ui/switch'; + import { RefreshCw, Tractor, Sprout } from 'lucide-vue-next'; import { Spinner } from '@/components/ui/spinner'; import { storeToRefs } from 'pinia'; import { toast } from 'vue-sonner'; @@ -71,7 +146,16 @@ import dayjs from 'dayjs'; import { database } from '../../../services/database'; + import configRepository from '../../../services/config'; import { useAppearanceSettingsStore, useUserStore } from '../../../stores'; + import { + buildSessionsFromEvents, + buildSessionsFromGamelog, + calculateOverlapGrid, + filterSessionsByPeriod, + findBestOverlapTime, + aggregateSessionsToGrid + } from '../../../shared/utils/overlapCalculator'; const { t, locale } = useI18n(); const { userDialog } = storeToRefs(useUserStore()); @@ -86,10 +170,25 @@ const selectedPeriod = ref('all'); const filteredEventCount = ref(0); + const overlapChartRef = ref(null); + const isOverlapLoading = ref(false); + const hasOverlapData = ref(false); + const overlapPercent = ref(0); + const bestOverlapTime = ref(''); + + const excludeHoursEnabled = ref(false); + const excludeStartHour = ref('1'); + const excludeEndHour = ref('6'); + let echartsInstance = null; let resizeObserver = null; let lastLoadedUserId = ''; + let overlapEchartsInstance = null; + let overlapResizeObserver = null; + let cachedTargetSessions = []; + let cachedCurrentSessions = []; + const dayLabels = computed(() => [ t('dialog.user.activity.days.sun'), t('dialog.user.activity.days.mon'), @@ -128,16 +227,37 @@ }); } } + if (overlapEchartsInstance) { + overlapEchartsInstance.dispose(); + overlapEchartsInstance = null; + if (hasOverlapData.value && overlapChartRef.value) { + nextTick(() => { + overlapEchartsInstance = echarts.init( + overlapChartRef.value, + isDarkMode.value ? 'dark' : null, + { height: 240 } + ); + updateOverlapChart(); + }); + } + } } watch(() => isDarkMode.value, rebuildChart); watch(locale, rebuildChart); watch(selectedPeriod, () => { - if (cachedTimestamps.length > 0 && echartsInstance) { + if (cachedTargetSessions.length > 0 && echartsInstance) { initChart(); } + updateOverlapChart(); }); + (async () => { + excludeHoursEnabled.value = await configRepository.getBool('VRCX_overlapExcludeEnabled', false); + excludeStartHour.value = await configRepository.getString('VRCX_overlapExcludeStart', '1'); + excludeEndHour.value = await configRepository.getString('VRCX_overlapExcludeEnd', '6'); + })(); + onBeforeUnmount(() => { disposeChart(); }); @@ -151,42 +271,30 @@ echartsInstance.dispose(); echartsInstance = null; } + if (overlapResizeObserver) { + overlapResizeObserver.disconnect(); + overlapResizeObserver = null; + } + if (overlapEchartsInstance) { + overlapEchartsInstance.dispose(); + overlapEchartsInstance = null; + } } - function getFilteredTimestamps() { - if (selectedPeriod.value === 'all') return cachedTimestamps; + function getFilteredSessions() { + if (selectedPeriod.value === 'all') return cachedTargetSessions; const days = parseInt(selectedPeriod.value, 10); - const cutoff = dayjs().subtract(days, 'day'); - return cachedTimestamps.filter((ts) => dayjs(ts).isAfter(cutoff)); + const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000; + return filterSessionsByPeriod(cachedTargetSessions, cutoffMs); } - /** - * @param {string[]} timestamps - * @returns {{ data: number[][], maxVal: number, peakText: string }} + * Compute peak day/time from a 7×24 grid + * @param grid + * @returns {{peakDay: string, peakTime: string}} */ - function aggregateHeatmapData(timestamps) { - const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); - - for (const ts of timestamps) { - const d = dayjs(ts); - const dayOfWeek = d.day(); // 0=Sun, 1=Mon, ..., 6=Sat - const hour = d.hour(); - grid[dayOfWeek][hour]++; - } - - const data = []; - let maxVal = 0; - for (let day = 0; day < 7; day++) { - for (let hour = 0; hour < 24; hour++) { - const count = grid[day][hour]; - const displayDay = day === 0 ? 6 : day - 1; - data.push([hour, displayDay, count]); - if (count > maxVal) maxVal = count; - } - } - - // Peak day: sum each day across all hours + function computePeaks(grid) { + // Peak day let peakDayResult = ''; const daySums = new Array(7).fill(0); for (let day = 0; day < 7; day++) { @@ -206,7 +314,7 @@ peakDayResult = dayLabels.value[maxDayIdx]; } - // Peak time: sum each hour across all days, find contiguous peak range + // Peak time range let peakTimeResult = ''; const hourSums = new Array(24).fill(0); for (let hour = 0; hour < 24; hour++) { @@ -235,29 +343,42 @@ if (startHour === endHour) { peakTimeResult = `${String(startHour).padStart(2, '0')}:00`; } else { - peakTimeResult = `${String(startHour).padStart(2, '0')}:00–${String(endHour + 1).padStart(2, '0')}:00`; + peakTimeResult = `${String(startHour).padStart(2, '0')}:00\u2013${String(endHour + 1).padStart(2, '0')}:00`; } } - return { data, maxVal, peakDayResult, peakTimeResult }; + return { peakDayResult, peakTimeResult }; } function initChart() { if (!chartRef.value || !echartsInstance) return; - const filtered = getFilteredTimestamps(); - filteredEventCount.value = filtered.length; - totalOnlineEvents.value = filtered.length; + const filteredSessions = getFilteredSessions(); + // Use timestamps for event count display + const filteredTs = getFilteredTimestamps(); + filteredEventCount.value = filteredTs.length; + totalOnlineEvents.value = filteredTs.length; - if (filtered.length === 0) { + if (filteredSessions.length === 0) { peakDayText.value = ''; peakTimeText.value = ''; return; } - const { data, maxVal, peakDayResult, peakTimeResult } = aggregateHeatmapData(filtered); + + const { grid, maxVal } = aggregateSessionsToGrid(filteredSessions); + const { peakDayResult, peakTimeResult } = computePeaks(grid); peakDayText.value = peakDayResult; peakTimeText.value = peakTimeResult; + const data = []; + for (let day = 0; day < 7; day++) { + for (let hour = 0; hour < 24; hour++) { + const count = grid[day][hour]; + const displayDay = day === 0 ? 6 : day - 1; + data.push([hour, displayDay, count]); + } + } + const option = { tooltip: { position: 'top', @@ -352,10 +473,15 @@ const requestId = ++activeRequestId; isLoading.value = true; try { - const timestamps = await database.getOnlineFrequencyData(userId); + const [timestamps, events] = await Promise.all([ + database.getOnlineFrequencyData(userId), + database.getOnlineOfflineSessions(userId) + ]); if (requestId !== activeRequestId) return; if (userDialog.value.id !== userId) return; + cachedTimestamps = timestamps; + cachedTargetSessions = buildSessionsFromEvents(events); hasAnyData.value = timestamps.length > 0; totalOnlineEvents.value = timestamps.length; lastLoadedUserId = userId; @@ -363,8 +489,8 @@ await nextTick(); if (timestamps.length > 0) { - const filtered = getFilteredTimestamps(); - filteredEventCount.value = filtered.length; + const filteredTs = getFilteredTimestamps(); + filteredEventCount.value = filteredTs.length; await nextTick(); @@ -399,6 +525,18 @@ isLoading.value = false; } } + + // Load overlap data after main data (target sessions already cached) + if (hasAnyData.value) { + loadOverlapData(userId); + } + } + + function getFilteredTimestamps() { + if (selectedPeriod.value === 'all') return cachedTimestamps; + const days = parseInt(selectedPeriod.value, 10); + const cutoff = dayjs().subtract(days, 'day'); + return cachedTimestamps.filter((ts) => dayjs(ts).isAfter(cutoff)); } /** @@ -411,8 +549,250 @@ loadData(); } + let easterEggTimer = null; + function onChartRightClick() { toast(t('dialog.user.activity.easter_egg'), { position: 'bottom-center', icon: h(Tractor) }); + clearTimeout(easterEggTimer); + easterEggTimer = setTimeout(() => { + easterEggTimer = null; + }, 5000); + } + + function onOverlapChartRightClick() { + if (easterEggTimer) { + toast('You can\'t farm this.', { position: 'bottom-center', icon: h(Sprout) }); + } + } + + + async function loadOverlapData(userId) { + if (!userId) return; + + isOverlapLoading.value = true; + hasOverlapData.value = false; + try { + // Target sessions already cached from loadData, only fetch current user + const currentUserRows = await database.getCurrentUserOnlineSessions(); + + if (userDialog.value.id !== userId) return; + + const currentSessions = buildSessionsFromGamelog(currentUserRows); + + if (cachedTargetSessions.length === 0 || currentSessions.length === 0) { + hasOverlapData.value = false; + return; + } + + cachedCurrentSessions = currentSessions; + hasOverlapData.value = true; + + await nextTick(); + + if (!overlapEchartsInstance && overlapChartRef.value) { + overlapEchartsInstance = echarts.init( + overlapChartRef.value, + isDarkMode.value ? 'dark' : null, + { height: 240 } + ); + overlapResizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (overlapEchartsInstance) { + overlapEchartsInstance.resize({ + width: entry.contentRect.width + }); + } + } + }); + overlapResizeObserver.observe(overlapChartRef.value); + } + + updateOverlapChart(); + } catch (error) { + console.error('Error loading overlap data:', error); + hasOverlapData.value = false; + } finally { + isOverlapLoading.value = false; + } + } + + function updateOverlapChart() { + if (cachedTargetSessions.length === 0 || cachedCurrentSessions.length === 0) return; + + let targetSessions = cachedTargetSessions; + let currentSessions = cachedCurrentSessions; + + if (selectedPeriod.value !== 'all') { + const days = parseInt(selectedPeriod.value, 10); + const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000; + targetSessions = filterSessionsByPeriod(targetSessions, cutoffMs); + currentSessions = filterSessionsByPeriod(currentSessions, cutoffMs); + } + + if (targetSessions.length === 0 || currentSessions.length === 0) { + overlapPercent.value = 0; + bestOverlapTime.value = ''; + return; + } + + const result = calculateOverlapGrid(currentSessions, targetSessions); + + // Apply hour exclusion to the grid + if (excludeHoursEnabled.value) { + const start = parseInt(excludeStartHour.value, 10); + const end = parseInt(excludeEndHour.value, 10); + applyHourExclusion(result.grid, start, end); + // Recalculate maxVal after exclusion + result.maxVal = 0; + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + if (result.grid[d][h] > result.maxVal) result.maxVal = result.grid[d][h]; + } + } + // Recalculate overlap percent excluding those hours + const totalGrid = result.grid.flat().reduce((a, b) => a + b, 0); + if (totalGrid === 0) { + overlapPercent.value = 0; + bestOverlapTime.value = ''; + return; + } + } + + overlapPercent.value = result.overlapPercent; + bestOverlapTime.value = findBestOverlapTime(result.grid, dayLabels.value); + + if (result.overlapPercent > 0 || result.maxVal > 0) { + initOverlapChart(result); + } + } + + /** + * Zero out hours in the grid that fall within the exclusion range. + * Supports wrapping (e.g. 23 to 5 means 23,0,1,2,3,4). + * @param grid + * @param startHour + * @param endHour + * @returns {void} + */ + function applyHourExclusion(grid, startHour, endHour) { + for (let d = 0; d < 7; d++) { + if (startHour <= endHour) { + for (let h = startHour; h < endHour; h++) { + grid[d][h] = 0; + } + } else { + for (let h = startHour; h < 24; h++) { + grid[d][h] = 0; + } + for (let h = 0; h < endHour; h++) { + grid[d][h] = 0; + } + } + } + } + + function onExcludeToggle(value) { + excludeHoursEnabled.value = value; + configRepository.setBool('VRCX_overlapExcludeEnabled', value); + updateOverlapChart(); + } + + function onExcludeRangeChange() { + configRepository.setString('VRCX_overlapExcludeStart', excludeStartHour.value); + configRepository.setString('VRCX_overlapExcludeEnd', excludeEndHour.value); + updateOverlapChart(); + } + + function initOverlapChart(result) { + if (!overlapEchartsInstance) return; + + const data = []; + for (let day = 0; day < 7; day++) { + for (let hour = 0; hour < 24; hour++) { + const count = result.grid[day][hour]; + const displayDay = day === 0 ? 6 : day - 1; + data.push([hour, displayDay, count]); + } + } + + const option = { + tooltip: { + position: 'top', + formatter: (params) => { + const [hour, dayIdx, count] = params.data; + const dayName = displayDayLabels.value[dayIdx]; + return `${dayName} ${hourLabels[hour]}
${count} ${t('dialog.user.activity.overlap.times_overlap')}`; + } + }, + grid: { + top: 6, + left: 42, + right: 16, + bottom: 32 + }, + xAxis: { + type: 'category', + data: hourLabels, + splitArea: { show: false }, + axisLabel: { + interval: 2, + fontSize: 10 + }, + axisTick: { show: false } + }, + yAxis: { + type: 'category', + data: displayDayLabels.value, + splitArea: { show: false }, + axisLabel: { + fontSize: 11 + }, + axisTick: { show: false } + }, + visualMap: { + min: 0, + max: Math.max(result.maxVal, 1), + calculable: false, + show: false, + inRange: { + color: isDarkMode.value + ? [ + 'hsl(220, 15%, 12%)', + 'hsl(260, 30%, 25%)', + 'hsl(260, 50%, 42%)', + 'hsl(260, 65%, 55%)', + 'hsl(260, 70%, 65%)' + ] + : [ + 'hsl(210, 30%, 95%)', + 'hsl(260, 35%, 82%)', + 'hsl(260, 48%, 62%)', + 'hsl(260, 55%, 48%)', + 'hsl(260, 60%, 38%)' + ] + } + }, + series: [ + { + type: 'heatmap', + data, + emphasis: { + itemStyle: { + shadowBlur: 6, + shadowColor: 'rgba(0, 0, 0, 0.3)' + } + }, + itemStyle: { + borderWidth: 1.5, + borderColor: isDarkMode.value ? 'hsl(220, 15%, 8%)' : 'hsl(0, 0%, 100%)', + borderRadius: 2 + } + } + ], + backgroundColor: 'transparent' + }; + + overlapEchartsInstance.setOption(option, { notMerge: true }); } defineExpose({ loadOnlineFrequency }); diff --git a/src/localization/en.json b/src/localization/en.json index f725c000..9416b98b 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -1433,7 +1433,14 @@ "sat": "Sat", "sun": "Sun" }, - "easter_egg": "Did you farm your green squares today?" + "easter_egg": "Did you farm your green squares today?", + "overlap": { + "header": "Online Overlap", + "peak_overlap": "Peak overlap:", + "exclude_hours": "Exclude hours", + "no_data": "Not enough data to calculate overlap", + "times_overlap": "times overlapping" + } }, "note_memo": { "header": "Edit Note And Memo", diff --git a/src/services/database/feed.js b/src/services/database/feed.js index 496a5c6e..20fbdc0c 100644 --- a/src/services/database/feed.js +++ b/src/services/database/feed.js @@ -606,6 +606,23 @@ const feed = { return data; }, + /** + * Get Online and Offline events for a user to build sessions + * @param {string} userId + * @returns {Promise>} + */ + async getOnlineOfflineSessions(userId) { + const data = []; + await sqliteService.execute( + (dbRow) => { + data.push({ created_at: dbRow[0], type: dbRow[1] }); + }, + `SELECT created_at, type FROM ${dbVars.userPrefix}_feed_online_offline WHERE user_id = @userId AND (type = 'Online' OR type = 'Offline') ORDER BY created_at`, + { '@userId': userId } + ); + return data; + }, + /** * @param {number} days - Number of days to look back * @param {number} limit - Max number of worlds to return diff --git a/src/services/database/gameLog.js b/src/services/database/gameLog.js index 4006fb99..03a4ddf0 100644 --- a/src/services/database/gameLog.js +++ b/src/services/database/gameLog.js @@ -1372,6 +1372,23 @@ const gameLog = { return instances; }, + /** + * Get current user's online sessions from gamelog_location + * Each row has created_at (leave time) and time (duration in ms) + * Session start = created_at - time, Session end = created_at + * @returns {Promise>} + */ + async getCurrentUserOnlineSessions() { + const data = []; + await sqliteService.execute( + (dbRow) => { + data.push({ created_at: dbRow[0], time: dbRow[1] || 0 }); + }, + `SELECT created_at, time FROM gamelog_location ORDER BY created_at` + ); + return data; + }, + async getUserIdFromDisplayName(displayName) { var userId = ''; await sqliteService.execute( diff --git a/src/shared/utils/__tests__/overlapCalculator.test.js b/src/shared/utils/__tests__/overlapCalculator.test.js new file mode 100644 index 00000000..546f99d1 --- /dev/null +++ b/src/shared/utils/__tests__/overlapCalculator.test.js @@ -0,0 +1,340 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; +import { + buildSessionsFromEvents, + buildSessionsFromGamelog, + filterSessionsByPeriod, + calculateOverlapGrid, + aggregateSessionsToGrid, + findBestOverlapTime +} from '../overlapCalculator.js'; + +// Helper: create a UTC date string +function utc(dateStr) { + return new Date(dateStr).toISOString(); +} + +// Helper: create a timestamp from UTC string +function ts(dateStr) { + return new Date(dateStr).getTime(); +} + +describe('buildSessionsFromEvents', () => { + test('returns empty array for empty input', () => { + expect(buildSessionsFromEvents([])).toEqual([]); + }); + + test('builds a single session from Online→Offline pair', () => { + const events = [ + { created_at: utc('2025-01-06T10:00:00Z'), type: 'Online' }, + { created_at: utc('2025-01-06T12:00:00Z'), type: 'Offline' } + ]; + const result = buildSessionsFromEvents(events); + expect(result).toEqual([ + { start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') } + ]); + }); + + test('builds multiple sessions', () => { + const events = [ + { created_at: utc('2025-01-06T10:00:00Z'), type: 'Online' }, + { created_at: utc('2025-01-06T12:00:00Z'), type: 'Offline' }, + { created_at: utc('2025-01-06T14:00:00Z'), type: 'Online' }, + { created_at: utc('2025-01-06T16:00:00Z'), type: 'Offline' } + ]; + const result = buildSessionsFromEvents(events); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }); + expect(result[1]).toEqual({ start: ts('2025-01-06T14:00:00Z'), end: ts('2025-01-06T16:00:00Z') }); + }); + + test('handles consecutive Online events (closes previous session)', () => { + const events = [ + { created_at: utc('2025-01-06T10:00:00Z'), type: 'Online' }, + { created_at: utc('2025-01-06T12:00:00Z'), type: 'Online' }, + { created_at: utc('2025-01-06T14:00:00Z'), type: 'Offline' } + ]; + const result = buildSessionsFromEvents(events); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }); + expect(result[1]).toEqual({ start: ts('2025-01-06T12:00:00Z'), end: ts('2025-01-06T14:00:00Z') }); + }); + + test('ignores Offline without preceding Online', () => { + const events = [ + { created_at: utc('2025-01-06T10:00:00Z'), type: 'Offline' }, + { created_at: utc('2025-01-06T12:00:00Z'), type: 'Online' }, + { created_at: utc('2025-01-06T14:00:00Z'), type: 'Offline' } + ]; + const result = buildSessionsFromEvents(events); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ start: ts('2025-01-06T12:00:00Z'), end: ts('2025-01-06T14:00:00Z') }); + }); + + test('does not close session if no Offline follows', () => { + const events = [ + { created_at: utc('2025-01-06T10:00:00Z'), type: 'Online' } + ]; + const result = buildSessionsFromEvents(events); + expect(result).toEqual([]); + }); +}); + +describe('buildSessionsFromGamelog', () => { + test('returns empty array for empty input', () => { + expect(buildSessionsFromGamelog([])).toEqual([]); + }); + + test('builds session with known duration', () => { + const rows = [ + { created_at: utc('2025-01-06T10:00:00Z'), time: 7200000 } // 2h + ]; + const result = buildSessionsFromGamelog(rows); + expect(result).toEqual([ + { start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') } + ]); + }); + + test('estimates duration from next row when time=0', () => { + const rows = [ + { created_at: utc('2025-01-06T10:00:00Z'), time: 0 }, + { created_at: utc('2025-01-06T12:00:00Z'), time: 3600000 } + ]; + const result = buildSessionsFromGamelog(rows); + // First session: estimated end = next row start (12:00) + // Second session: 12:00 + 1h = 13:00 + // Gap between them is 0ms, so they merge + expect(result).toHaveLength(1); + expect(result[0].start).toBe(ts('2025-01-06T10:00:00Z')); + expect(result[0].end).toBe(ts('2025-01-06T13:00:00Z')); + }); + + test('caps duration at 24h for last row with time=0', () => { + const now = Date.now(); + const longAgo = new Date(now - 48 * 3600000).toISOString(); // 48h ago + const rows = [ + { created_at: longAgo, time: 0 } + ]; + const result = buildSessionsFromGamelog(rows); + expect(result).toHaveLength(1); + const duration = result[0].end - result[0].start; + expect(duration).toBe(24 * 3600000); + }); + + test('merges adjacent sessions within default gap (5min)', () => { + const rows = [ + { created_at: utc('2025-01-06T10:00:00Z'), time: 3600000 }, // 10:00-11:00 + { created_at: utc('2025-01-06T11:03:00Z'), time: 3600000 } // 11:03-12:03 (3min gap) + ]; + const result = buildSessionsFromGamelog(rows); + expect(result).toHaveLength(1); + expect(result[0].start).toBe(ts('2025-01-06T10:00:00Z')); + expect(result[0].end).toBe(ts('2025-01-06T12:03:00Z')); + }); + + test('does not merge sessions with gap exceeding mergeGapMs', () => { + const rows = [ + { created_at: utc('2025-01-06T10:00:00Z'), time: 3600000 }, // 10:00-11:00 + { created_at: utc('2025-01-06T12:00:00Z'), time: 3600000 } // 12:00-13:00 (1h gap) + ]; + const result = buildSessionsFromGamelog(rows); + expect(result).toHaveLength(2); + }); + + test('respects custom mergeGapMs', () => { + const rows = [ + { created_at: utc('2025-01-06T10:00:00Z'), time: 3600000 }, // 10:00-11:00 + { created_at: utc('2025-01-06T11:03:00Z'), time: 3600000 } // 11:03-12:03 + ]; + // Custom gap: 1 minute → should NOT merge (3min gap > 1min threshold) + const result = buildSessionsFromGamelog(rows, 60000); + expect(result).toHaveLength(2); + }); +}); + +describe('filterSessionsByPeriod', () => { + const sessions = [ + { start: ts('2025-01-01T00:00:00Z'), end: ts('2025-01-01T02:00:00Z') }, + { start: ts('2025-01-10T00:00:00Z'), end: ts('2025-01-10T02:00:00Z') }, + { start: ts('2025-01-20T00:00:00Z'), end: ts('2025-01-20T02:00:00Z') } + ]; + + test('returns all sessions when cutoff is before all', () => { + const cutoff = ts('2024-12-01T00:00:00Z'); + const result = filterSessionsByPeriod(sessions, cutoff); + expect(result).toHaveLength(3); + }); + + test('filters out sessions ending before cutoff', () => { + const cutoff = ts('2025-01-05T00:00:00Z'); + const result = filterSessionsByPeriod(sessions, cutoff); + expect(result).toHaveLength(2); + expect(result[0].start).toBe(ts('2025-01-10T00:00:00Z')); + }); + + test('clamps session start to cutoff when session spans cutoff', () => { + const cutoff = ts('2025-01-10T01:00:00Z'); // mid-session + const result = filterSessionsByPeriod(sessions, cutoff); + expect(result).toHaveLength(2); + // First result should have start clamped to cutoff + expect(result[0].start).toBe(cutoff); + expect(result[0].end).toBe(ts('2025-01-10T02:00:00Z')); + }); + + test('returns empty for all sessions before cutoff', () => { + const cutoff = ts('2025-02-01T00:00:00Z'); + const result = filterSessionsByPeriod(sessions, cutoff); + expect(result).toHaveLength(0); + }); +}); + +describe('calculateOverlapGrid', () => { + test('returns zero overlap for non-overlapping sessions', () => { + const sessionsA = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const sessionsB = [{ start: ts('2025-01-06T14:00:00Z'), end: ts('2025-01-06T16:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, sessionsB); + expect(result.overlapPercent).toBe(0); + expect(result.totalOverlapMs).toBe(0); + expect(result.maxVal).toBe(0); + }); + + test('calculates full overlap for identical sessions', () => { + const sessionsA = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const sessionsB = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, sessionsB); + expect(result.overlapPercent).toBe(100); + expect(result.totalOverlapMs).toBe(2 * 3600000); + }); + + test('calculates partial overlap', () => { + // A: 10:00-14:00 (4h), B: 12:00-16:00 (4h) + // Overlap: 12:00-14:00 (2h) = 50% + const sessionsA = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T14:00:00Z') }]; + const sessionsB = [{ start: ts('2025-01-06T12:00:00Z'), end: ts('2025-01-06T16:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, sessionsB); + expect(result.overlapPercent).toBe(50); + expect(result.totalOverlapMs).toBe(2 * 3600000); + }); + + test('returns correct grid dimensions', () => { + const sessionsA = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const sessionsB = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, sessionsB); + expect(result.grid).toHaveLength(7); + for (const row of result.grid) { + expect(row).toHaveLength(24); + } + }); + + test('populates grid at correct day/hour slots', () => { + // 2025-01-06 is Monday, getDay()=1 + const sessionsA = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const sessionsB = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, sessionsB); + // day=1 (Monday), hours 10 and 11 should have value + // Note: getDay() returns local day, getHours() returns local hour + // Using UTC dates, so the actual slot depends on timezone + expect(result.maxVal).toBeGreaterThan(0); + }); + + test('handles empty sessionsA', () => { + const sessionsB = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const result = calculateOverlapGrid([], sessionsB); + expect(result.overlapPercent).toBe(0); + expect(result.totalOverlapMs).toBe(0); + }); + + test('handles empty sessionsB', () => { + const sessionsA = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T12:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, []); + expect(result.overlapPercent).toBe(0); + expect(result.totalOverlapMs).toBe(0); + }); + + test('overlap percent is based on the shorter online time', () => { + // A: 2h online, B: 4h online, overlap: 2h → 2/2 = 100% + const sessionsA = [{ start: ts('2025-01-06T12:00:00Z'), end: ts('2025-01-06T14:00:00Z') }]; + const sessionsB = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T14:00:00Z') }]; + const result = calculateOverlapGrid(sessionsA, sessionsB); + expect(result.overlapPercent).toBe(100); + expect(result.totalUserAMs).toBe(2 * 3600000); + expect(result.totalUserBMs).toBe(4 * 3600000); + }); +}); + +describe('aggregateSessionsToGrid', () => { + test('returns empty grid for no sessions', () => { + const result = aggregateSessionsToGrid([]); + expect(result.maxVal).toBe(0); + expect(result.grid).toHaveLength(7); + for (const row of result.grid) { + expect(row.every((v) => v === 0)).toBe(true); + } + }); + + test('increments grid for session hours', () => { + // A 3-hour session should increment 3 hour slots + const sessions = [{ start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T13:00:00Z') }]; + const result = aggregateSessionsToGrid(sessions); + const total = result.grid.flat().reduce((a, b) => a + b, 0); + expect(total).toBe(3); + expect(result.maxVal).toBe(1); + }); + + test('stacks multiple sessions on same hour', () => { + // Two sessions on the same day/hour + const sessions = [ + { start: ts('2025-01-06T10:00:00Z'), end: ts('2025-01-06T11:00:00Z') }, + { start: ts('2025-01-13T10:00:00Z'), end: ts('2025-01-13T11:00:00Z') } // Same weekday, 1 week later + ]; + const result = aggregateSessionsToGrid(sessions); + expect(result.maxVal).toBe(2); + }); +}); + +describe('findBestOverlapTime', () => { + const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + test('returns empty string for all-zero grid', () => { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + expect(findBestOverlapTime(grid, dayLabels)).toBe(''); + }); + + test('returns single hour when only one slot has data', () => { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + grid[1][14] = 5; // Monday 14:00 + const result = findBestOverlapTime(grid, dayLabels); + expect(result).toBe('Mon, 14:00'); + }); + + test('returns time range for adjacent high hours', () => { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + // Saturday (6) has strong activity at 20, 21, 22 + grid[6][20] = 10; + grid[6][21] = 10; + grid[6][22] = 10; + const result = findBestOverlapTime(grid, dayLabels); + expect(result).toContain('Sat'); + expect(result).toContain('20:00'); + expect(result).toContain('23:00'); + }); + + test('picks the day with highest overlap in peak hours', () => { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + // Mon(1) and Fri(5) both have activity at hour 20, but Fri is stronger + grid[1][20] = 3; + grid[5][20] = 8; + const result = findBestOverlapTime(grid, dayLabels); + expect(result).toContain('Fri'); + }); + + test('handles activity spread across all days equally', () => { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + for (let d = 0; d < 7; d++) { + grid[d][15] = 5; + } + const result = findBestOverlapTime(grid, dayLabels); + // Should pick Sunday (index 0) since it's first with equal values + expect(result).toContain('Sun'); + expect(result).toContain('15:00'); + }); +}); diff --git a/src/shared/utils/overlapCalculator.js b/src/shared/utils/overlapCalculator.js new file mode 100644 index 00000000..0fcda5f7 --- /dev/null +++ b/src/shared/utils/overlapCalculator.js @@ -0,0 +1,257 @@ +/** + * Builds online sessions from Online/Offline events. + * @param {Array<{created_at: string, type: string}>} events - Sorted by created_at + * @returns {Array<{start: number, end: number}>} Sessions as Unix timestamps (ms) + */ +export function buildSessionsFromEvents(events) { + const sessions = []; + let currentStart = null; + + for (const event of events) { + const ts = new Date(event.created_at).getTime(); + if (event.type === 'Online') { + 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; + } + } + return sessions; +} + +/** + * Builds online sessions from gamelog_location rows. + * Each row: created_at = enter time, time = duration in ms (updated on leave). + * If time = 0, the user may still be in this instance or it wasn't updated. + * For the last row with time=0, we estimate end = next row's start or now. + * Merges adjacent sessions within mergeGapMs. + * @param {Array<{created_at: string, time: number}>} rows + * @param {number} [mergeGapMs] - Merge gap threshold (default 5 min) + * @returns {Array<{start: number, end: number}>} + */ +export function buildSessionsFromGamelog(rows, mergeGapMs = 5 * 60 * 1000) { + if (rows.length === 0) return []; + + const rawSessions = []; + for (let i = 0; i < rows.length; i++) { + const start = new Date(rows[i].created_at).getTime(); + let duration = rows[i].time || 0; + + if (duration === 0) { + // time not yet updated: estimate end as next row's start, or now for the last row + if (i < rows.length - 1) { + duration = new Date(rows[i + 1].created_at).getTime() - start; + } else { + // Last row, user may still be online - use current time + duration = Date.now() - start; + } + // Cap at 24h to avoid unreasonable durations from stale data + duration = Math.min(duration, 24 * 60 * 60 * 1000); + } + + if (duration > 0) { + rawSessions.push({ start, end: start + duration }); + } + } + + if (rawSessions.length === 0) return []; + + rawSessions.sort((a, b) => a.start - b.start); + + const merged = [{ ...rawSessions[0] }]; + for (let i = 1; i < rawSessions.length; i++) { + const last = merged[merged.length - 1]; + const curr = rawSessions[i]; + if (curr.start <= last.end + mergeGapMs) { + last.end = Math.max(last.end, curr.end); + } else { + merged.push({ ...curr }); + } + } + return merged; +} + +/** + * Computes intersection intervals between two sorted, non-overlapping session arrays. + * @param {Array<{start: number, end: number}>} sessionsA + * @param {Array<{start: number, end: number}>} sessionsB + * @returns {Array<{start: number, end: number}>} + */ +function computeIntersections(sessionsA, sessionsB) { + const result = []; + let i = 0; + let j = 0; + while (i < sessionsA.length && j < sessionsB.length) { + const a = sessionsA[i]; + const b = sessionsB[j]; + const start = Math.max(a.start, b.start); + const end = Math.min(a.end, b.end); + if (start < end) { + result.push({ start, end }); + } + if (a.end < b.end) { + i++; + } else { + j++; + } + } + return result; +} + +/** + * Increments a 7×24 grid for each hour-slot covered by the given time range. + * @param {number[][]} grid - 7×24 array (dayOfWeek × hour) + * @param {number} startMs + * @param {number} endMs + */ +function incrementGrid(grid, startMs, endMs) { + // Walk hour by hour from start to end + const ONE_HOUR = 3600000; + let cursor = startMs; + + while (cursor < endMs) { + const d = new Date(cursor); + const day = d.getDay(); // 0=Sun + const hour = d.getHours(); + grid[day][hour]++; + + // Move to next hour boundary + const nextHour = new Date(cursor); + nextHour.setMinutes(0, 0, 0); + nextHour.setTime(nextHour.getTime() + ONE_HOUR); + cursor = nextHour.getTime(); + } +} + +/** + * Filters sessions to only include those overlapping with the given time range. + * @param {Array<{start: number, end: number}>} sessions + * @param {number} cutoffMs - Only include sessions that end after this timestamp + * @returns {Array<{start: number, end: number}>} + */ +export function filterSessionsByPeriod(sessions, cutoffMs) { + return sessions + .filter((s) => s.end > cutoffMs) + .map((s) => ({ + start: Math.max(s.start, cutoffMs), + end: s.end + })); +} + +/** + * Calculates overlap grid and statistics between two users' sessions. + * @param {Array<{start: number, end: number}>} sessionsA - Current user + * @param {Array<{start: number, end: number}>} sessionsB - Target user + * @returns {{ + * grid: number[][], + * maxVal: number, + * totalOverlapMs: number, + * totalUserAMs: number, + * totalUserBMs: number, + * overlapPercent: number + * }} + */ +export function calculateOverlapGrid(sessionsA, sessionsB) { + const overlapSessions = computeIntersections(sessionsA, sessionsB); + + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + for (const session of overlapSessions) { + incrementGrid(grid, session.start, session.end); + } + + const totalOverlapMs = overlapSessions.reduce((sum, s) => sum + (s.end - s.start), 0); + const totalUserAMs = sessionsA.reduce((sum, s) => sum + (s.end - s.start), 0); + const totalUserBMs = sessionsB.reduce((sum, s) => sum + (s.end - s.start), 0); + const minOnline = Math.min(totalUserAMs, totalUserBMs); + const overlapPercent = minOnline > 0 ? Math.round((totalOverlapMs / minOnline) * 100) : 0; + + let maxVal = 0; + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + if (grid[d][h] > maxVal) maxVal = grid[d][h]; + } + } + + return { grid, maxVal, totalOverlapMs, totalUserAMs, totalUserBMs, overlapPercent }; +} + +/** + * Aggregates sessions into a 7×24 grid (dayOfWeek × hour). + * For each session, increments all hour slots it covers. + * @param {Array<{start: number, end: number}>} sessions + * @returns {{ grid: number[][], maxVal: number }} + */ +export function aggregateSessionsToGrid(sessions) { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + for (const session of sessions) { + incrementGrid(grid, session.start, session.end); + } + let maxVal = 0; + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + if (grid[d][h] > maxVal) maxVal = grid[d][h]; + } + } + return { grid, maxVal }; +} + +/** + * Finds the best time to meet based on the overlap grid. + * @param {number[][]} grid - 7×24 (dayOfWeek × hour) + * @param {string[]} dayLabels - ['Sun', 'Mon', ..., 'Sat'] + * @returns {string} e.g. "Sat, 20:00–23:00" or empty string + */ +export function findBestOverlapTime(grid, dayLabels) { + const hourSums = new Array(24).fill(0); + for (let h = 0; h < 24; h++) { + for (let d = 0; d < 7; d++) { + hourSums[h] += grid[d][h]; + } + } + + let maxHourSum = 0; + let maxHourIdx = 0; + for (let h = 0; h < 24; h++) { + if (hourSums[h] > maxHourSum) { + maxHourSum = hourSums[h]; + maxHourIdx = h; + } + } + if (maxHourSum === 0) return ''; + + const threshold = maxHourSum * 0.6; + let startHour = maxHourIdx; + let endHour = maxHourIdx; + while (startHour > 0 && hourSums[startHour - 1] >= threshold) { + startHour--; + } + while (endHour < 23 && hourSums[endHour + 1] >= threshold) { + endHour++; + } + + const daySums = new Array(7).fill(0); + for (let d = 0; d < 7; d++) { + for (let h = startHour; h <= endHour; h++) { + daySums[d] += grid[d][h]; + } + } + let maxDaySum = 0; + let maxDayIdx = 0; + for (let d = 0; d < 7; d++) { + if (daySums[d] > maxDaySum) { + maxDaySum = daySums[d]; + maxDayIdx = d; + } + } + + const dayName = dayLabels[maxDayIdx]; + const timeRange = + startHour === endHour + ? `${String(startHour).padStart(2, '0')}:00` + : `${String(startHour).padStart(2, '0')}:00\u2013${String(endHour + 1).padStart(2, '0')}:00`; + + return `${dayName}, ${timeRange}`; +}