feat(relativeTime): add relativeYearsDe helper for historical letter dates

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-23 14:36:43 +02:00
committed by marcel
parent 70d813ee70
commit a52d481a8e
2 changed files with 48 additions and 1 deletions

View File

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

View File

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