feat(web/m7-365): dayBucket + groupByDay utilities
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user