From a52d481a8e24430f57839614ec2bf8dff532f38a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 23 Apr 2026 14:36:43 +0200 Subject: [PATCH] feat(relativeTime): add relativeYearsDe helper for historical letter dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The correspondence timeline labels each row with its distance from today ("vor 86 Jahren"). Uses calendar-field math so the anniversary day flips exactly — an ms-based 365.25d average misses by a day on leap years. Invalid / future dates return "" so the caller can hide the label rather than print "vor 0 Jahren". Refs #305 Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/relativeTime.spec.ts | 30 ++++++++++++++++++++++++++- frontend/src/lib/relativeTime.ts | 19 +++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/relativeTime.spec.ts b/frontend/src/lib/relativeTime.spec.ts index 1855d07d..2a39f8ac 100644 --- a/frontend/src/lib/relativeTime.spec.ts +++ b/frontend/src/lib/relativeTime.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { relativeTimeDe } from './relativeTime'; +import { relativeTimeDe, relativeYearsDe } from './relativeTime'; const NOW = new Date('2026-04-20T12:00:00Z'); @@ -39,3 +39,31 @@ describe('relativeTimeDe', () => { expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i); }); }); + +describe('relativeYearsDe', () => { + it('returns singular "vor 1 Jahr" for exactly one whole year ago', () => { + const from = new Date('2025-04-20T12:00:00Z'); + expect(relativeYearsDe(from, NOW)).toBe('vor 1 Jahr'); + }); + + it('returns plural "vor N Jahren" for more than one year', () => { + const from = new Date('1940-04-20T12:00:00Z'); + expect(relativeYearsDe(from, NOW)).toBe('vor 86 Jahren'); + }); + + it('floors a partial year down (eleven months ago = 0 years)', () => { + const from = new Date('2025-06-01T00:00:00Z'); + // We show "vor weniger als 1 Jahr" rather than rounding up to 1. + expect(relativeYearsDe(from, NOW)).toBe('vor weniger als 1 Jahr'); + }); + + it('returns empty string when the input Date is invalid', () => { + const invalid = new Date('not-a-real-date'); + expect(relativeYearsDe(invalid, NOW)).toBe(''); + }); + + it('returns empty string for future dates', () => { + const future = new Date('2030-01-01T00:00:00Z'); + expect(relativeYearsDe(future, NOW)).toBe(''); + }); +}); diff --git a/frontend/src/lib/relativeTime.ts b/frontend/src/lib/relativeTime.ts index f2e45a7e..ef6f80d7 100644 --- a/frontend/src/lib/relativeTime.ts +++ b/frontend/src/lib/relativeTime.ts @@ -9,3 +9,22 @@ export function relativeTimeDe(from: Date, now: Date = new Date()): string { if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) }); return m.comment_time_days({ count: Math.round(minutes / 1440) }); } + +// "vor N Jahren" for a historical letter date relative to now. Computed from +// calendar fields (not a constant ms-per-year) so that a letter from exactly +// one year ago reports "vor 1 Jahr" rather than falling on the wrong side of +// a leap-year rounding. Returns "" for invalid or future dates — the caller +// should then hide the relative-time label rather than render a misleading +// "vor 0 Jahren". +export function relativeYearsDe(from: Date, now: Date = new Date()): string { + if (Number.isNaN(from.getTime()) || Number.isNaN(now.getTime())) return ''; + if (from.getTime() > now.getTime()) return ''; + let years = now.getUTCFullYear() - from.getUTCFullYear(); + const beforeAnniversary = + now.getUTCMonth() < from.getUTCMonth() || + (now.getUTCMonth() === from.getUTCMonth() && now.getUTCDate() < from.getUTCDate()); + if (beforeAnniversary) years -= 1; + if (years < 1) return 'vor weniger als 1 Jahr'; + if (years === 1) return 'vor 1 Jahr'; + return `vor ${years} Jahren`; +}