Timeline: shared precision-aware date-label helper (#778) #824
@@ -3,7 +3,7 @@ name: SDD Gate
|
||||
# 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
|
||||
# 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.
|
||||
#
|
||||
# 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`)
|
||||
# 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:
|
||||
pull_request:
|
||||
|
||||
@@ -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
|
||||
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
|
||||
- **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).
|
||||
- **Feature-local decisions** that are only meaningful within one in-flight feature →
|
||||
beside that feature's spec, e.g.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Version:** v1.0.0
|
||||
**Status:** Ratified
|
||||
**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
|
||||
> 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:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -35,5 +35,11 @@
|
||||
| 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-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. -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 →
|
||||
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
|
||||
[`.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
|
||||
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
|
||||
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
|
||||
duplicate or contradict it.
|
||||
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# ADR-041 — Adopt Spec-Driven Development (SDD)
|
||||
# ADR-042 — Adopt Spec-Driven Development (SDD)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Issue:** SDD integration (docs/sdd-integration branch)
|
||||
|
||||
> This is the "ADR-000" the SDD scaffold refers to, numbered 041 to fit the existing archive
|
||||
> sequence rather than starting a parallel one. See [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
|
||||
> This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
|
||||
> sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
|
||||
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
|
||||
|
||||
## Context
|
||||
|
||||
@@ -158,6 +158,7 @@ export default defineConfig(
|
||||
{ type: 'ocr', pattern: 'src/lib/ocr/**' },
|
||||
{ type: 'activity', pattern: 'src/lib/activity/**' },
|
||||
{ type: 'conversation', pattern: 'src/lib/conversation/**' },
|
||||
{ type: 'timeline', pattern: 'src/lib/timeline/**' },
|
||||
{ type: 'shared', pattern: 'src/lib/shared/**' },
|
||||
{ type: 'routes', pattern: 'src/routes/**' }
|
||||
]
|
||||
@@ -198,6 +199,7 @@ export default defineConfig(
|
||||
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
||||
{ from: { type: 'notification' }, 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: 'routes' },
|
||||
|
||||
@@ -4,6 +4,13 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
/**
|
||||
* Precision of a document's date — mirrors the backend {@code DatePrecision} enum
|
||||
* 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';
|
||||
|
||||
|
||||
65
frontend/src/lib/timeline/dateLabel.spec.ts
Normal file
65
frontend/src/lib/timeline/dateLabel.spec.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/timeline/dateLabel.ts
Normal file
30
frontend/src/lib/timeline/dateLabel.ts
Normal 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());
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export default defineConfig({
|
||||
'src/lib/shared/server/**',
|
||||
'src/lib/shared/discussion/**',
|
||||
'src/lib/document/**',
|
||||
'src/lib/timeline/**',
|
||||
'src/hooks.server.ts'
|
||||
],
|
||||
exclude: [
|
||||
|
||||
Reference in New Issue
Block a user