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) <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-20 16:25:31 +02:00
committed by marcel
parent 5fc39b0371
commit 56161f9a49
2 changed files with 85 additions and 0 deletions

View File

@@ -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');
});
});

View File

@@ -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';
}