mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat: optimize stats dashboard data and components
This commit is contained in:
@@ -52,25 +52,88 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
|
||||
lookupHitRate: 0.8,
|
||||
},
|
||||
];
|
||||
const overview: OverviewData = {
|
||||
sessions,
|
||||
rollups,
|
||||
hints: {
|
||||
totalSessions: 1,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
totalActiveMin: 50,
|
||||
activeDays: 2,
|
||||
totalCards: 9,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
assert.equal(summary.todayCards, 2);
|
||||
assert.equal(summary.totalTrackedCards, 9);
|
||||
assert.equal(summary.episodesToday, 2);
|
||||
assert.equal(summary.activeAnimeCount, 3);
|
||||
assert.equal(summary.averageSessionMinutes, 50);
|
||||
assert.equal(summary.allTimeHours, 1);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
});
|
||||
|
||||
test('buildOverviewSummary prefers lifetime totals from hints when provided', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const today = Math.floor(now / 86_400_000);
|
||||
const overview: OverviewData = {
|
||||
sessions,
|
||||
rollups,
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 2,
|
||||
canonicalTitle: 'B',
|
||||
videoId: 2,
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
startedAtMs: now - 60_000,
|
||||
endedAtMs: now,
|
||||
totalWatchedMs: 60_000,
|
||||
activeWatchedMs: 60_000,
|
||||
linesSeen: 10,
|
||||
wordsSeen: 10,
|
||||
tokensSeen: 10,
|
||||
cardsMined: 10,
|
||||
lookupCount: 1,
|
||||
lookupHits: 1,
|
||||
},
|
||||
],
|
||||
rollups: [
|
||||
{
|
||||
rollupDayOrMonth: today,
|
||||
videoId: 2,
|
||||
totalSessions: 1,
|
||||
totalActiveMin: 1,
|
||||
totalLinesSeen: 10,
|
||||
totalWordsSeen: 10,
|
||||
totalTokensSeen: 10,
|
||||
totalCards: 10,
|
||||
cardsPerHour: 600,
|
||||
wordsPerMin: 10,
|
||||
lookupHitRate: 1,
|
||||
},
|
||||
],
|
||||
hints: {
|
||||
totalSessions: 1,
|
||||
totalSessions: 999,
|
||||
activeSessions: 0,
|
||||
episodesToday: 2,
|
||||
activeAnimeCount: 3,
|
||||
totalEpisodesWatched: 5,
|
||||
totalAnimeCompleted: 1,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalActiveMin: 120,
|
||||
activeDays: 40,
|
||||
totalCards: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
assert.equal(summary.todayCards, 2);
|
||||
assert.equal(summary.totalTrackedCards, 2);
|
||||
assert.equal(summary.episodesToday, 2);
|
||||
assert.equal(summary.activeAnimeCount, 3);
|
||||
assert.equal(summary.averageSessionMinutes, 50);
|
||||
assert.equal(summary.totalTrackedCards, 5);
|
||||
assert.equal(summary.totalSessions, 999);
|
||||
assert.equal(summary.allTimeHours, 2);
|
||||
assert.equal(summary.activeDays, 40);
|
||||
});
|
||||
|
||||
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
StreakCalendarDay,
|
||||
VocabularyEntry,
|
||||
} from '../types/stats';
|
||||
import { epochDayToDate, localDayFromMs } from './formatters';
|
||||
import { epochDayToDate, epochMsFromDbTimestamp, localDayFromMs } from './formatters';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
@@ -47,6 +47,10 @@ export interface VocabularySummary {
|
||||
recentDiscoveries: VocabularyEntry[];
|
||||
}
|
||||
|
||||
function normalizeDbTimestampSeconds(ts: number): number {
|
||||
return Math.floor(epochMsFromDbTimestamp(ts) / 1000);
|
||||
}
|
||||
|
||||
function makeRollupLabel(value: number): string {
|
||||
if (value > 100_000) {
|
||||
const year = Math.floor(value / 100);
|
||||
@@ -135,6 +139,8 @@ export function buildOverviewSummary(
|
||||
|
||||
const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined);
|
||||
const rollupCards = sumBy(aggregated, (row) => row.cards);
|
||||
const lifetimeCards = overview.hints.totalCards ?? Math.max(sessionCards, rollupCards);
|
||||
const totalActiveMin = overview.hints.totalActiveMin ?? sumBy(aggregated, (row) => row.activeMin);
|
||||
|
||||
let streakDays = 0;
|
||||
const streakStart = daysWithActivity.has(today) ? today : today - 1;
|
||||
@@ -155,8 +161,8 @@ export function buildOverviewSummary(
|
||||
sumBy(todaySessions, (session) => session.cardsMined),
|
||||
),
|
||||
streakDays,
|
||||
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60),
|
||||
totalTrackedCards: Math.max(sessionCards, rollupCards),
|
||||
allTimeHours: Math.max(0, Math.round(totalActiveMin / 60)),
|
||||
totalTrackedCards: lifetimeCards,
|
||||
episodesToday: overview.hints.episodesToday ?? 0,
|
||||
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
|
||||
totalEpisodesWatched: overview.hints.totalEpisodesWatched ?? 0,
|
||||
@@ -170,7 +176,7 @@ export function buildOverviewSummary(
|
||||
)
|
||||
: 0,
|
||||
totalSessions: overview.hints.totalSessions,
|
||||
activeDays: daysWithActivity.size,
|
||||
activeDays: overview.hints.activeDays ?? daysWithActivity.size,
|
||||
recentWatchTime: aggregated
|
||||
.slice(-14)
|
||||
.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
@@ -202,14 +208,18 @@ export function buildVocabularySummary(
|
||||
const byDay = new Map<number, number>();
|
||||
|
||||
for (const word of words) {
|
||||
const day = Math.floor(word.firstSeen / 86_400);
|
||||
const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen);
|
||||
const day = Math.floor(firstSeenSec / 86_400);
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
uniqueWords: words.length,
|
||||
uniqueKanji: kanji.length,
|
||||
newThisWeek: words.filter((word) => word.firstSeen >= weekAgoSec).length,
|
||||
newThisWeek: words.filter((word) => {
|
||||
const firstSeenSec = normalizeDbTimestampSeconds(word.firstSeen);
|
||||
return firstSeenSec >= weekAgoSec;
|
||||
}).length,
|
||||
topWords: [...words]
|
||||
.sort((left, right) => right.frequency - left.frequency)
|
||||
.slice(0, 12)
|
||||
@@ -222,7 +232,11 @@ export function buildVocabularySummary(
|
||||
value: count,
|
||||
})),
|
||||
recentDiscoveries: [...words]
|
||||
.sort((left, right) => right.firstSeen - left.firstSeen)
|
||||
.sort((left, right) => {
|
||||
const leftFirst = normalizeDbTimestampSeconds(left.firstSeen);
|
||||
const rightFirst = normalizeDbTimestampSeconds(right.firstSeen);
|
||||
return rightFirst - leftFirst;
|
||||
})
|
||||
.slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { formatRelativeDate } from './formatters';
|
||||
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
|
||||
|
||||
test('formatRelativeDate: future timestamps return "just now"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now');
|
||||
@@ -27,12 +27,28 @@ test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 23 hours ago returns "23h ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 23 * 3_600_000), '23h ago');
|
||||
test('formatRelativeDate: same calendar day can return "23h ago"', () => {
|
||||
const realNow = Date.now;
|
||||
const now = new Date(2026, 2, 16, 23, 30, 0).getTime();
|
||||
const sameDayMorning = new Date(2026, 2, 16, 0, 30, 0).getTime();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
assert.equal(formatRelativeDate(sameDayMorning), '23h ago');
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 36 hours ago returns "Yesterday"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 36 * 3_600_000), 'Yesterday');
|
||||
test('formatRelativeDate: two calendar days ago returns "2d ago"', () => {
|
||||
const realNow = Date.now;
|
||||
const now = new Date(2026, 2, 16, 12, 0, 0).getTime();
|
||||
const twoDaysAgo = new Date(2026, 2, 14, 0, 0, 0).getTime();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
assert.equal(formatRelativeDate(twoDaysAgo), '2d ago');
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
|
||||
@@ -43,3 +59,43 @@ test('formatRelativeDate: 10 days ago returns locale date string', () => {
|
||||
const ts = Date.now() - 10 * 86_400_000;
|
||||
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
|
||||
});
|
||||
|
||||
test('formatRelativeDate: prior calendar day under 24h returns "Yesterday"', () => {
|
||||
const realNow = Date.now;
|
||||
const now = new Date(2026, 2, 16, 0, 30, 0).getTime();
|
||||
const previousDayLate = new Date(2026, 2, 15, 23, 45, 0).getTime();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
assert.equal(formatRelativeDate(previousDayLate), 'Yesterday');
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('epochMsFromDbTimestamp converts seconds to ms', () => {
|
||||
assert.equal(epochMsFromDbTimestamp(1_700_000_000), 1_700_000_000_000);
|
||||
});
|
||||
|
||||
test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
|
||||
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
|
||||
});
|
||||
|
||||
test('formatSessionDayLabel formats today and yesterday', () => {
|
||||
const now = Date.now();
|
||||
const oneDayMs = 24 * 60 * 60_000;
|
||||
assert.equal(formatSessionDayLabel(now), 'Today');
|
||||
assert.equal(formatSessionDayLabel(now - oneDayMs), 'Yesterday');
|
||||
});
|
||||
|
||||
test('formatSessionDayLabel includes year for past-year dates', () => {
|
||||
const now = new Date();
|
||||
const sameDayLastYear = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()).getTime();
|
||||
const label = formatSessionDayLabel(sameDayLastYear);
|
||||
const year = new Date(sameDayLastYear).getFullYear();
|
||||
assert.ok(label.includes(String(year)));
|
||||
const withoutYear = new Date(sameDayLastYear).toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
assert.notEqual(label, withoutYear);
|
||||
});
|
||||
|
||||
@@ -18,14 +18,22 @@ export function formatPercent(ratio: number | null): string {
|
||||
export function formatRelativeDate(ms: number): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - ms;
|
||||
if (diffMs < 60_000) return 'just now';
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffDays < 2) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffMs <= 0) return 'just now';
|
||||
|
||||
const nowDay = localDayFromMs(now);
|
||||
const sessionDay = localDayFromMs(ms);
|
||||
const dayDiff = nowDay - sessionDay;
|
||||
|
||||
if (dayDiff <= 0) {
|
||||
if (diffMs < 60_000) return 'just now';
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
return `${diffHours}h ago`;
|
||||
}
|
||||
|
||||
if (dayDiff === 1) return 'Yesterday';
|
||||
if (dayDiff < 7) return `${dayDiff}d ago`;
|
||||
return new Date(ms).toLocaleDateString();
|
||||
}
|
||||
|
||||
@@ -42,3 +50,26 @@ export function localDayFromMs(ms: number): number {
|
||||
export function todayLocalDay(): number {
|
||||
return localDayFromMs(Date.now());
|
||||
}
|
||||
|
||||
// Immersion tracker stores word/kanji first_seen/last_seen as epoch seconds.
|
||||
// Older fixtures or callers may still pass ms, so normalize defensively.
|
||||
export function epochMsFromDbTimestamp(ts: number): number {
|
||||
if (!Number.isFinite(ts)) return 0;
|
||||
return ts < 10_000_000_000 ? Math.round(ts * 1000) : Math.round(ts);
|
||||
}
|
||||
|
||||
export function formatSessionDayLabel(sessionStartedAtMs: number): string {
|
||||
const today = todayLocalDay();
|
||||
const day = localDayFromMs(sessionStartedAtMs);
|
||||
|
||||
if (day === today) return 'Today';
|
||||
if (day === today - 1) return 'Yesterday';
|
||||
|
||||
const date = new Date(sessionStartedAtMs);
|
||||
const includeYear = date.getFullYear() !== new Date().getFullYear();
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
...(includeYear ? { year: 'numeric' } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user