feat(web/m7-365): dayBucket + groupByDay utilities

This commit is contained in:
2026-05-04 06:18:18 -04:00
parent 46066c4d08
commit 4dee5922d9
2 changed files with 149 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { dayBucket, groupByDay } from './dayGroup';
import type { HistoryEvent } from '$lib/api/history';
import type { TrackRef } from '$lib/api/types';
const NOW = new Date('2026-05-04T14:00:00Z');
const stubTrack: TrackRef = {
id: 'track-x',
title: 'Song X',
album_id: 'al-x',
album_title: 'Album X',
artist_id: 'ar-x',
artist_name: 'Artist X',
duration_sec: 200,
stream_url: '/stream/x'
};
const mkEvent = (id: string, playedAt: string): HistoryEvent => ({
id,
played_at: playedAt,
track: stubTrack
});
describe('dayBucket', () => {
it('5 minutes ago → Today', () => {
expect(dayBucket(new Date('2026-05-04T13:55:00Z'), NOW)).toBe('Today');
});
it('today at 00:01 → Today (when now is later same day)', () => {
expect(dayBucket(new Date('2026-05-04T00:01:00Z'), NOW)).toBe('Today');
});
it('yesterday at 23:59 → Yesterday', () => {
expect(dayBucket(new Date('2026-05-03T23:59:00Z'), NOW)).toBe('Yesterday');
});
it('6 days ago at 12:00 → This week', () => {
expect(dayBucket(new Date('2026-04-28T12:00:00Z'), NOW)).toBe('This week');
});
it('7 days ago → absolute date (current year, no year suffix)', () => {
const result = dayBucket(new Date('2026-04-27T12:00:00Z'), NOW);
// Expect format like "Apr 27" (no year suffix); regex avoids exact match
// because Intl.DateTimeFormat output may vary slightly across runtimes.
expect(result).toMatch(/^[A-Za-z]+ \d+$/);
});
it('different calendar year → absolute date with year', () => {
const result = dayBucket(new Date('2025-12-25T12:00:00Z'), NOW);
expect(result).toMatch(/2025/);
});
});
describe('groupByDay', () => {
it('returns empty array for empty input', () => {
expect(groupByDay([], NOW)).toEqual([]);
});
it('emits one section per bucket transition, preserves order', () => {
const events = [
mkEvent('e1', '2026-05-04T13:00:00Z'),
mkEvent('e2', '2026-05-04T11:00:00Z'),
mkEvent('e3', '2026-05-03T20:00:00Z'),
mkEvent('e4', '2026-04-28T10:00:00Z')
];
const groups = groupByDay(events, NOW);
expect(groups.map((g) => g.label)).toEqual(['Today', 'Yesterday', 'This week']);
expect(groups[0].events.map((e) => e.id)).toEqual(['e1', 'e2']);
expect(groups[1].events.map((e) => e.id)).toEqual(['e3']);
expect(groups[2].events.map((e) => e.id)).toEqual(['e4']);
});
it('does not collapse events across same bucket if input order changes', () => {
// Defensive: walker is stateful per-iteration; out-of-order input
// would emit duplicate buckets. Real input is always sorted, but
// pin the contract.
const events = [
mkEvent('e1', '2026-05-04T13:00:00Z'), // Today
mkEvent('e2', '2026-05-03T20:00:00Z'), // Yesterday
mkEvent('e3', '2026-05-04T11:00:00Z') // Today (out of order)
];
const groups = groupByDay(events, NOW);
expect(groups.map((g) => g.label)).toEqual(['Today', 'Yesterday', 'Today']);
expect(groups[0].events.map((e) => e.id)).toEqual(['e1']);
expect(groups[2].events.map((e) => e.id)).toEqual(['e3']);
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { HistoryEvent } from '$lib/api/history';
export type DayGroup = {
label: string;
events: HistoryEvent[];
};
const MONTHS = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
function startOfDay(d: Date): Date {
const out = new Date(d);
out.setHours(0, 0, 0, 0);
return out;
}
function daysBetween(later: Date, earlier: Date): number {
const ms = startOfDay(later).getTime() - startOfDay(earlier).getTime();
return Math.round(ms / (1000 * 60 * 60 * 24));
}
// dayBucket categorizes a played_at timestamp relative to `now`. Buckets:
// Today — same calendar day
// Yesterday — exactly 1 day before
// This week — 2-6 days before
// <Mon DD> — 7+ days before, current year
// <Mon DD, YYYY> — 7+ days before, different year
// All comparisons use the browser-local timezone (Date methods).
export function dayBucket(playedAt: Date, now: Date): string {
const delta = daysBetween(now, playedAt);
if (delta <= 0) return 'Today';
if (delta === 1) return 'Yesterday';
if (delta < 7) return 'This week';
const month = MONTHS[playedAt.getMonth()];
const day = playedAt.getDate();
if (playedAt.getFullYear() === now.getFullYear()) {
return `${month} ${day}`;
}
return `${month} ${day}, ${playedAt.getFullYear()}`;
}
// groupByDay walks an array of history events (assumed sorted newest-first)
// and emits a section per bucket transition. Out-of-order input emits
// duplicate buckets — the walker doesn't dedupe.
export function groupByDay(events: HistoryEvent[], now: Date): DayGroup[] {
const groups: DayGroup[] = [];
let currentLabel: string | null = null;
for (const event of events) {
const playedAt = new Date(event.played_at);
const label = dayBucket(playedAt, now);
if (label !== currentLabel) {
groups.push({ label, events: [event] });
currentLabel = label;
} else {
groups[groups.length - 1].events.push(event);
}
}
return groups;
}