Timeline: shared precision-aware date-label helper (#778) #824
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. -->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
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/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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user