From 4dee5922d92788c53193dafd99630dfa10971945 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 4 May 2026 06:18:18 -0400 Subject: [PATCH] feat(web/m7-365): dayBucket + groupByDay utilities --- web/src/lib/utils/dayGroup.test.ts | 88 ++++++++++++++++++++++++++++++ web/src/lib/utils/dayGroup.ts | 61 +++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 web/src/lib/utils/dayGroup.test.ts create mode 100644 web/src/lib/utils/dayGroup.ts diff --git a/web/src/lib/utils/dayGroup.test.ts b/web/src/lib/utils/dayGroup.test.ts new file mode 100644 index 00000000..f7cb03d6 --- /dev/null +++ b/web/src/lib/utils/dayGroup.test.ts @@ -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']); + }); +}); diff --git a/web/src/lib/utils/dayGroup.ts b/web/src/lib/utils/dayGroup.ts new file mode 100644 index 00000000..a4b02f0a --- /dev/null +++ b/web/src/lib/utils/dayGroup.ts @@ -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 +// — 7+ days before, current year +// — 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; +}