mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 06:56:04 +02:00
feat: Add online overlap visualization in user activity tab
This commit is contained in:
@@ -53,6 +53,80 @@
|
|||||||
style="width: 100%; height: 240px"
|
style="width: 100%; height: 240px"
|
||||||
@contextmenu.prevent="onChartRightClick">
|
@contextmenu.prevent="onChartRightClick">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Online Overlap Section -->
|
||||||
|
<div v-if="hasAnyData" class="mt-4 border-t border-border pt-3">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium">{{ t('dialog.user.activity.overlap.header') }}</span>
|
||||||
|
<Spinner v-if="isOverlapLoading" class="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div v-if="hasOverlapData" class="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<Switch
|
||||||
|
:model-value="excludeHoursEnabled"
|
||||||
|
class="scale-75"
|
||||||
|
@update:model-value="onExcludeToggle" />
|
||||||
|
<span class="text-sm text-muted-foreground whitespace-nowrap">{{ t('dialog.user.activity.overlap.exclude_hours') }}</span>
|
||||||
|
<Select v-model="excludeStartHour" @update:model-value="onExcludeRangeChange">
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
class="w-[78px] h-6 text-xs px-2"
|
||||||
|
@click.stop>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="h in 24" :key="h - 1" :value="String(h - 1)">
|
||||||
|
{{ String(h - 1).padStart(2, '0') }}:00
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span class="text-xs text-muted-foreground">–</span>
|
||||||
|
<Select v-model="excludeEndHour" @update:model-value="onExcludeRangeChange">
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
class="w-[78px] h-6 text-xs px-2"
|
||||||
|
@click.stop>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="h in 24" :key="h - 1" :value="String(h - 1)">
|
||||||
|
{{ String(h - 1).padStart(2, '0') }}:00
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isOverlapLoading && hasOverlapData" class="flex flex-col gap-1 mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium" :class="overlapPercent > 0 ? 'text-accent-foreground' : 'text-muted-foreground'">
|
||||||
|
{{ overlapPercent }}%
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-500"
|
||||||
|
:style="{
|
||||||
|
width: `${overlapPercent}%`,
|
||||||
|
backgroundColor: isDarkMode ? 'hsl(260, 60%, 55%)' : 'hsl(260, 55%, 50%)'
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bestOverlapTime" class="text-sm">
|
||||||
|
<span class="text-muted-foreground">{{ t('dialog.user.activity.overlap.peak_overlap') }}</span>
|
||||||
|
<span class="font-medium ml-1">{{ bestOverlapTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hasOverlapData"
|
||||||
|
ref="overlapChartRef"
|
||||||
|
style="width: 100%; height: 240px"
|
||||||
|
@contextmenu.prevent="onOverlapChartRightClick" />
|
||||||
|
|
||||||
|
<div v-if="!isOverlapLoading && !hasOverlapData && hasAnyData" class="text-sm text-muted-foreground py-2">
|
||||||
|
{{ t('dialog.user.activity.overlap.no_data') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -61,7 +135,8 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DataTableEmpty } from '@/components/ui/data-table';
|
import { DataTableEmpty } from '@/components/ui/data-table';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 { Spinner } from '@/components/ui/spinner';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
@@ -71,7 +146,16 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { database } from '../../../services/database';
|
import { database } from '../../../services/database';
|
||||||
|
import configRepository from '../../../services/config';
|
||||||
import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
|
import { useAppearanceSettingsStore, useUserStore } from '../../../stores';
|
||||||
|
import {
|
||||||
|
buildSessionsFromEvents,
|
||||||
|
buildSessionsFromGamelog,
|
||||||
|
calculateOverlapGrid,
|
||||||
|
filterSessionsByPeriod,
|
||||||
|
findBestOverlapTime,
|
||||||
|
aggregateSessionsToGrid
|
||||||
|
} from '../../../shared/utils/overlapCalculator';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { userDialog } = storeToRefs(useUserStore());
|
const { userDialog } = storeToRefs(useUserStore());
|
||||||
@@ -86,10 +170,25 @@
|
|||||||
const selectedPeriod = ref('all');
|
const selectedPeriod = ref('all');
|
||||||
const filteredEventCount = ref(0);
|
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 echartsInstance = null;
|
||||||
let resizeObserver = null;
|
let resizeObserver = null;
|
||||||
let lastLoadedUserId = '';
|
let lastLoadedUserId = '';
|
||||||
|
|
||||||
|
let overlapEchartsInstance = null;
|
||||||
|
let overlapResizeObserver = null;
|
||||||
|
let cachedTargetSessions = [];
|
||||||
|
let cachedCurrentSessions = [];
|
||||||
|
|
||||||
const dayLabels = computed(() => [
|
const dayLabels = computed(() => [
|
||||||
t('dialog.user.activity.days.sun'),
|
t('dialog.user.activity.days.sun'),
|
||||||
t('dialog.user.activity.days.mon'),
|
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(() => isDarkMode.value, rebuildChart);
|
||||||
watch(locale, rebuildChart);
|
watch(locale, rebuildChart);
|
||||||
watch(selectedPeriod, () => {
|
watch(selectedPeriod, () => {
|
||||||
if (cachedTimestamps.length > 0 && echartsInstance) {
|
if (cachedTargetSessions.length > 0 && echartsInstance) {
|
||||||
initChart();
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
disposeChart();
|
disposeChart();
|
||||||
});
|
});
|
||||||
@@ -151,42 +271,30 @@
|
|||||||
echartsInstance.dispose();
|
echartsInstance.dispose();
|
||||||
echartsInstance = null;
|
echartsInstance = null;
|
||||||
}
|
}
|
||||||
|
if (overlapResizeObserver) {
|
||||||
|
overlapResizeObserver.disconnect();
|
||||||
|
overlapResizeObserver = null;
|
||||||
|
}
|
||||||
|
if (overlapEchartsInstance) {
|
||||||
|
overlapEchartsInstance.dispose();
|
||||||
|
overlapEchartsInstance = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilteredTimestamps() {
|
function getFilteredSessions() {
|
||||||
if (selectedPeriod.value === 'all') return cachedTimestamps;
|
if (selectedPeriod.value === 'all') return cachedTargetSessions;
|
||||||
const days = parseInt(selectedPeriod.value, 10);
|
const days = parseInt(selectedPeriod.value, 10);
|
||||||
const cutoff = dayjs().subtract(days, 'day');
|
const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
return cachedTimestamps.filter((ts) => dayjs(ts).isAfter(cutoff));
|
return filterSessionsByPeriod(cachedTargetSessions, cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string[]} timestamps
|
* Compute peak day/time from a 7×24 grid
|
||||||
* @returns {{ data: number[][], maxVal: number, peakText: string }}
|
* @param grid
|
||||||
|
* @returns {{peakDay: string, peakTime: string}}
|
||||||
*/
|
*/
|
||||||
function aggregateHeatmapData(timestamps) {
|
function computePeaks(grid) {
|
||||||
const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
|
// Peak day
|
||||||
|
|
||||||
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
|
|
||||||
let peakDayResult = '';
|
let peakDayResult = '';
|
||||||
const daySums = new Array(7).fill(0);
|
const daySums = new Array(7).fill(0);
|
||||||
for (let day = 0; day < 7; day++) {
|
for (let day = 0; day < 7; day++) {
|
||||||
@@ -206,7 +314,7 @@
|
|||||||
peakDayResult = dayLabels.value[maxDayIdx];
|
peakDayResult = dayLabels.value[maxDayIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peak time: sum each hour across all days, find contiguous peak range
|
// Peak time range
|
||||||
let peakTimeResult = '';
|
let peakTimeResult = '';
|
||||||
const hourSums = new Array(24).fill(0);
|
const hourSums = new Array(24).fill(0);
|
||||||
for (let hour = 0; hour < 24; hour++) {
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
@@ -235,29 +343,42 @@
|
|||||||
if (startHour === endHour) {
|
if (startHour === endHour) {
|
||||||
peakTimeResult = `${String(startHour).padStart(2, '0')}:00`;
|
peakTimeResult = `${String(startHour).padStart(2, '0')}:00`;
|
||||||
} else {
|
} 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() {
|
function initChart() {
|
||||||
if (!chartRef.value || !echartsInstance) return;
|
if (!chartRef.value || !echartsInstance) return;
|
||||||
|
|
||||||
const filtered = getFilteredTimestamps();
|
const filteredSessions = getFilteredSessions();
|
||||||
filteredEventCount.value = filtered.length;
|
// Use timestamps for event count display
|
||||||
totalOnlineEvents.value = filtered.length;
|
const filteredTs = getFilteredTimestamps();
|
||||||
|
filteredEventCount.value = filteredTs.length;
|
||||||
|
totalOnlineEvents.value = filteredTs.length;
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filteredSessions.length === 0) {
|
||||||
peakDayText.value = '';
|
peakDayText.value = '';
|
||||||
peakTimeText.value = '';
|
peakTimeText.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { data, maxVal, peakDayResult, peakTimeResult } = aggregateHeatmapData(filtered);
|
|
||||||
|
const { grid, maxVal } = aggregateSessionsToGrid(filteredSessions);
|
||||||
|
const { peakDayResult, peakTimeResult } = computePeaks(grid);
|
||||||
peakDayText.value = peakDayResult;
|
peakDayText.value = peakDayResult;
|
||||||
peakTimeText.value = peakTimeResult;
|
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 = {
|
const option = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
position: 'top',
|
position: 'top',
|
||||||
@@ -352,10 +473,15 @@
|
|||||||
const requestId = ++activeRequestId;
|
const requestId = ++activeRequestId;
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const timestamps = await database.getOnlineFrequencyData(userId);
|
const [timestamps, events] = await Promise.all([
|
||||||
|
database.getOnlineFrequencyData(userId),
|
||||||
|
database.getOnlineOfflineSessions(userId)
|
||||||
|
]);
|
||||||
if (requestId !== activeRequestId) return;
|
if (requestId !== activeRequestId) return;
|
||||||
if (userDialog.value.id !== userId) return;
|
if (userDialog.value.id !== userId) return;
|
||||||
|
|
||||||
cachedTimestamps = timestamps;
|
cachedTimestamps = timestamps;
|
||||||
|
cachedTargetSessions = buildSessionsFromEvents(events);
|
||||||
hasAnyData.value = timestamps.length > 0;
|
hasAnyData.value = timestamps.length > 0;
|
||||||
totalOnlineEvents.value = timestamps.length;
|
totalOnlineEvents.value = timestamps.length;
|
||||||
lastLoadedUserId = userId;
|
lastLoadedUserId = userId;
|
||||||
@@ -363,8 +489,8 @@
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
if (timestamps.length > 0) {
|
if (timestamps.length > 0) {
|
||||||
const filtered = getFilteredTimestamps();
|
const filteredTs = getFilteredTimestamps();
|
||||||
filteredEventCount.value = filtered.length;
|
filteredEventCount.value = filteredTs.length;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
@@ -399,6 +525,18 @@
|
|||||||
isLoading.value = false;
|
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();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let easterEggTimer = null;
|
||||||
|
|
||||||
function onChartRightClick() {
|
function onChartRightClick() {
|
||||||
toast(t('dialog.user.activity.easter_egg'), { position: 'bottom-center', icon: h(Tractor) });
|
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]}<br/><b>${count}</b> ${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 });
|
defineExpose({ loadOnlineFrequency });
|
||||||
|
|||||||
@@ -1433,7 +1433,14 @@
|
|||||||
"sat": "Sat",
|
"sat": "Sat",
|
||||||
"sun": "Sun"
|
"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": {
|
"note_memo": {
|
||||||
"header": "Edit Note And Memo",
|
"header": "Edit Note And Memo",
|
||||||
|
|||||||
@@ -606,6 +606,23 @@ const feed = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Online and Offline events for a user to build sessions
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Promise<Array<{created_at: string, type: string}>>}
|
||||||
|
*/
|
||||||
|
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} days - Number of days to look back
|
||||||
* @param {number} limit - Max number of worlds to return
|
* @param {number} limit - Max number of worlds to return
|
||||||
|
|||||||
@@ -1372,6 +1372,23 @@ const gameLog = {
|
|||||||
return instances;
|
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<Array<{created_at: string, time: number}>>}
|
||||||
|
*/
|
||||||
|
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) {
|
async getUserIdFromDisplayName(displayName) {
|
||||||
var userId = '';
|
var userId = '';
|
||||||
await sqliteService.execute(
|
await sqliteService.execute(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user