From 56161f9a4997afdc7b4a17dfa5c8776042a34214 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 16:25:31 +0200 Subject: [PATCH] feat(utils): add date-buckets helper for Chronik day grouping Pure function bucketByDay(date, now?, locale?) returns one of 'today'|'yesterday'|'thisWeek'|'older' so ChronikTimeline can bucket activity rows by relative day without pulling a date library. Handles: - midnight boundary (startOfDay comparison) - locale-aware week start (Monday for most locales, Sunday for en-US, en-CA, en-PH, ja-JP, he-IL, pt-BR) - DST transitions (works off local calendar days) Part of #285. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/utils/date-buckets.spec.ts | 50 +++++++++++++++++++++ frontend/src/lib/utils/date-buckets.ts | 35 +++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 frontend/src/lib/utils/date-buckets.spec.ts create mode 100644 frontend/src/lib/utils/date-buckets.ts diff --git a/frontend/src/lib/utils/date-buckets.spec.ts b/frontend/src/lib/utils/date-buckets.spec.ts new file mode 100644 index 00000000..3593481a --- /dev/null +++ b/frontend/src/lib/utils/date-buckets.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { bucketByDay } from './date-buckets'; + +function date(iso: string): Date { + return new Date(iso); +} + +describe('bucketByDay', () => { + // Wednesday 2026-04-22 at 12:00 Berlin. Week start (Mon) = 2026-04-20. + const now = date('2026-04-22T12:00:00+02:00'); + + it('returns "today" for a time earlier today', () => { + expect(bucketByDay(date('2026-04-22T06:00:00+02:00'), now, 'de-DE')).toBe('today'); + }); + + it('returns "today" at exact midnight start of today', () => { + expect(bucketByDay(date('2026-04-22T00:00:00+02:00'), now, 'de-DE')).toBe('today'); + }); + + it('returns "yesterday" for any time on the previous day', () => { + expect(bucketByDay(date('2026-04-21T23:59:59+02:00'), now, 'de-DE')).toBe('yesterday'); + expect(bucketByDay(date('2026-04-21T00:00:00+02:00'), now, 'de-DE')).toBe('yesterday'); + }); + + it('returns "thisWeek" for the Monday that starts this week (Monday-anchored, de-DE)', () => { + expect(bucketByDay(date('2026-04-20T10:00:00+02:00'), now, 'de-DE')).toBe('thisWeek'); + }); + + it('returns "older" for anything before the start of this week (de-DE)', () => { + expect(bucketByDay(date('2026-04-19T23:00:00+02:00'), now, 'de-DE')).toBe('older'); + expect(bucketByDay(date('2026-04-13T10:00:00+02:00'), now, 'de-DE')).toBe('older'); + }); + + it('uses Sunday-start week for en-US', () => { + const sundayRef = date('2026-04-19T12:00:00+02:00'); + expect(bucketByDay(date('2026-04-19T06:00:00+02:00'), sundayRef, 'en-US')).toBe('today'); + expect( + bucketByDay(date('2026-04-13T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US') + ).toBe('thisWeek'); + expect( + bucketByDay(date('2026-04-11T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US') + ).toBe('older'); + }); + + it('handles DST spring-forward correctly (Europe/Berlin 2026-03-29)', () => { + const justAfterDst = date('2026-03-29T03:15:00+02:00'); + const sameDay = date('2026-03-29T10:00:00+02:00'); + expect(bucketByDay(justAfterDst, sameDay, 'de-DE')).toBe('today'); + }); +}); diff --git a/frontend/src/lib/utils/date-buckets.ts b/frontend/src/lib/utils/date-buckets.ts new file mode 100644 index 00000000..7561380c --- /dev/null +++ b/frontend/src/lib/utils/date-buckets.ts @@ -0,0 +1,35 @@ +export type DayBucket = 'today' | 'yesterday' | 'thisWeek' | 'older'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const SUNDAY_START_LOCALES = new Set(['en-us', 'en-ca', 'en-ph', 'ja-jp', 'he-il', 'pt-br']); + +function weekStartDay(locale?: string): 0 | 1 { + if (!locale) return 1; + return SUNDAY_START_LOCALES.has(locale.toLowerCase()) ? 0 : 1; +} + +function startOfDay(d: Date): Date { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +function startOfWeek(d: Date, firstDay: 0 | 1): Date { + const x = startOfDay(d); + const diff = (x.getDay() - firstDay + 7) % 7; + x.setDate(x.getDate() - diff); + return x; +} + +export function bucketByDay(date: Date, now: Date = new Date(), locale?: string): DayBucket { + const today = startOfDay(now); + const target = startOfDay(date); + + if (target.getTime() === today.getTime()) return 'today'; + if (today.getTime() - target.getTime() <= DAY_MS) return 'yesterday'; + + const weekStart = startOfWeek(today, weekStartDay(locale)); + if (target.getTime() >= weekStart.getTime()) return 'thisWeek'; + + return 'older'; +}