Timeline: shared precision-aware date-label helper (#778) #824

Merged
marcel merged 6 commits from feat/778-timeline-date-label into main 2026-06-13 14:04:56 +02:00
11 changed files with 122 additions and 10 deletions

View File

@@ -3,7 +3,7 @@ name: SDD Gate
# Spec-Driven Development quality gate. Runs on PRs. # Spec-Driven Development quality gate. Runs on PRs.
# #
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed # This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
# spec.md (see ADR-041). So CI cannot lint the spec text itself — instead it validates the SDD # spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution. # artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
# #
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the # The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
@@ -11,7 +11,7 @@ name: SDD Gate
# #
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`) # TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
# once SDD adoption has settled — target: after the first 5 features have shipped through # once SDD adoption has settled — target: after the first 5 features have shipped through
# the workflow. Tracked in ADR-041. # the workflow. Tracked in ADR-042.
on: on:
pull_request: pull_request:

View File

@@ -10,7 +10,7 @@ This project already keeps a mature, permanent ADR archive at
next free `NNN` (verify against the directory on disk — parallel worktrees make next free `NNN` (verify against the directory on disk — parallel worktrees make
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md). issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
- **The decision to adopt SDD itself** → - **The decision to adopt SDD itself** →
[`docs/adr/041-sdd-adoption.md`](../../docs/adr/041-sdd-adoption.md) (this is the [`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence). "ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
- **Feature-local decisions** that are only meaningful within one in-flight feature → - **Feature-local decisions** that are only meaningful within one in-flight feature →
beside that feature's spec, e.g. beside that feature's spec, e.g.

View File

@@ -3,7 +3,7 @@
**Version:** v1.0.0 **Version:** v1.0.0
**Status:** Ratified **Status:** Ratified
**Date:** 2026-06-13 **Date:** 2026-06-13
**Adoption ADR:** [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md) **Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is > The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
> bound by this document. Rules here are deliberately few and absolute — guidance and > bound by this document. Rules here are deliberately few and absolute — guidance and
@@ -73,7 +73,7 @@
When this constitution changes, the author MUST, in the same PR: When this constitution changes, the author MUST, in the same PR:
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/041-sdd-adoption.md](../docs/adr/041-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change). 1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists. 2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
3. Update any `.specify/templates/*` section that quotes a changed rule. 3. Update any `.specify/templates/*` section that quotes a changed rule.
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists. 4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.

View File

@@ -35,5 +35,11 @@
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned | | REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned | | REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned | | REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
| REQ-003 | `UNKNOWN``null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. --> <!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->

View File

@@ -3,7 +3,7 @@
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform, How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR → machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
multi-persona review → red/green TDD). It does not replace any of that — see multi-persona review → red/green TDD). It does not replace any of that — see
[ADR-041](./docs/adr/041-sdd-adoption.md) for the why. [ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and - **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation). [`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
@@ -179,7 +179,7 @@ issue body for you via the Gitea API.)
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
in ADR-041's revision log (or a superseding ADR for MAJOR). in ADR-042's revision log (or a superseding ADR for MAJOR).
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never - **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
duplicate or contradict it. duplicate or contradict it.
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free - **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free

View File

@@ -1,11 +1,12 @@
# ADR-041 — Adopt Spec-Driven Development (SDD) # ADR-042 — Adopt Spec-Driven Development (SDD)
**Status:** Accepted **Status:** Accepted
**Date:** 2026-06-13 **Date:** 2026-06-13
**Issue:** SDD integration (docs/sdd-integration branch) **Issue:** SDD integration (docs/sdd-integration branch)
> This is the "ADR-000" the SDD scaffold refers to, numbered 041 to fit the existing archive > This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
> sequence rather than starting a parallel one. See [`.specify/adrs/README.md`](../../.specify/adrs/README.md). > sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
## Context ## Context

View File

@@ -158,6 +158,7 @@ export default defineConfig(
{ type: 'ocr', pattern: 'src/lib/ocr/**' }, { type: 'ocr', pattern: 'src/lib/ocr/**' },
{ type: 'activity', pattern: 'src/lib/activity/**' }, { type: 'activity', pattern: 'src/lib/activity/**' },
{ type: 'conversation', pattern: 'src/lib/conversation/**' }, { type: 'conversation', pattern: 'src/lib/conversation/**' },
{ type: 'timeline', pattern: 'src/lib/timeline/**' },
{ type: 'shared', pattern: 'src/lib/shared/**' }, { type: 'shared', pattern: 'src/lib/shared/**' },
{ type: 'routes', pattern: 'src/routes/**' } { type: 'routes', pattern: 'src/routes/**' }
] ]
@@ -198,6 +199,7 @@ export default defineConfig(
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
{ {
from: { type: 'routes' }, from: { type: 'routes' },

View File

@@ -4,6 +4,13 @@ import { m } from '$lib/paraglide/messages.js';
/** /**
* Precision of a document's date — mirrors the backend {@code DatePrecision} enum * Precision of a document's date — mirrors the backend {@code DatePrecision} enum
* and the import normalizer's seven values verbatim. * and the import normalizer's seven values verbatim.
*
* DRIFT RISK: this is a hand-maintained mirror of the Java {@code DatePrecision}
* enum, NOT an OpenAPI-generated type. It must be updated manually whenever the
* Java enum changes, and must NOT be migrated to the generated API type — the
* generated enum is request/response-shaped, while this drives the shared
* client-side formatter (used by both documents and the timeline façade). Keep
* the two in lockstep by hand.
*/ */
export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APPROX' | 'UNKNOWN'; export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APPROX' | 'UNKNOWN';

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatDocumentDate } from '$lib/shared/utils/documentDate';
import { timelineDateLabel } from './dateLabel';
// The mocked path MUST include the `.js` suffix to match the import in
// dateLabel.ts — a specifier mismatch silently skips the mock and the helper
// would resolve the real locale. We partially mock via importOriginal so only
// getLocale is stubbed: the shared formatter still pulls real Paraglide message
// exports (season words, range prefixes) off the same runtime module.
vi.mock('$lib/paraglide/runtime.js', async (importOriginal) => ({
...(await importOriginal<typeof import('$lib/paraglide/runtime.js')>()),
getLocale: vi.fn(() => 'de')
}));
describe('timelineDateLabel', () => {
beforeEach(() => {
// Default every test to German; locale-specific tests override below.
vi.mocked(getLocale).mockReturnValue('de');
});
it('renders a DAY date localized in German (REQ-001/REQ-002)', () => {
const label = timelineDateLabel('1916-07-28', 'DAY');
expect(label).toContain('28');
expect(label).toContain('Juli');
});
it('renders a DAY date localized in English (REQ-001/REQ-002)', () => {
vi.mocked(getLocale).mockReturnValue('en');
const label = timelineDateLabel('1916-07-28', 'DAY');
expect(label).toContain('July');
});
it('renders a SEASON date with the German season word (REQ-001/REQ-002)', () => {
const label = timelineDateLabel('1916-04-01', 'SEASON');
expect(label).toContain('Frühling');
});
it('returns null for UNKNOWN precision even with a date (REQ-003)', () => {
expect(timelineDateLabel('1916-07-28', 'UNKNOWN')).toBeNull();
});
it('returns null for UNKNOWN precision without a date (REQ-003)', () => {
expect(timelineDateLabel(null, 'UNKNOWN')).toBeNull();
});
it('returns null for APPROX with a null eventDate, without calling the formatter (REQ-004)', () => {
expect(timelineDateLabel(null, 'APPROX')).toBeNull();
});
it('returns null for DAY with an empty-string eventDate (REQ-004)', () => {
expect(timelineDateLabel('', 'DAY')).toBeNull();
});
it('delegates a same-year RANGE to formatDocumentDate (REQ-001/REQ-002)', () => {
const expected = formatDocumentDate('1914-01-01', 'RANGE', '1914-11-11', null, 'de');
expect(timelineDateLabel('1914-01-01', 'RANGE', '1914-11-11')).toBe(expected);
});
it('treats undefined eventDateEnd identically to null for RANGE (REQ-004)', () => {
expect(timelineDateLabel('1914-01-01', 'RANGE', undefined)).toBe(
timelineDateLabel('1914-01-01', 'RANGE', null)
);
});
});

View File

@@ -0,0 +1,30 @@
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
/**
* Renders a timeline event's date by delegating to the shared
* {@link formatDocumentDate} — so a timeline chip reads identically to the same
* date on a document, in whichever locale is active. This module is a thin
* façade: it owns NO precision or rendering logic (REQ-005), only the two
* timeline-specific decisions below.
*
* @param eventDate the event's anchor day (`YYYY-MM-DD`). `null`, `undefined`
* and `''` are equivalent — all mean "undated" and yield `null` WITHOUT
* calling the formatter (REQ-004).
* @param precision the event's precision metadata; `'UNKNOWN'` yields `null`
* (no chip) even when a date is present (REQ-003).
* @param eventDateEnd the RANGE end day; `undefined` and `null` are equivalent
* and both mean an open-ended range.
* @returns the localized label, or `null` for an UNKNOWN/undated event.
*/
export function timelineDateLabel(
eventDate: string | null | undefined,
precision: DatePrecision,
eventDateEnd?: string | null
): string | null {
if (precision === 'UNKNOWN' || !eventDate) return null;
// raw is always null for timeline events — there is no verbatim spreadsheet
// cell to interpolate; season words derive from the structured anchor month
// (never from untrusted text). See documentDate.ts for the raw contract.
return formatDocumentDate(eventDate, precision, eventDateEnd ?? null, null, getLocale());
}

View File

@@ -72,6 +72,7 @@ export default defineConfig({
'src/lib/shared/server/**', 'src/lib/shared/server/**',
'src/lib/shared/discussion/**', 'src/lib/shared/discussion/**',
'src/lib/document/**', 'src/lib/document/**',
'src/lib/timeline/**',
'src/hooks.server.ts' 'src/hooks.server.ts'
], ],
exclude: [ exclude: [