addRelationship and updateRelationship each inlined the same self-check,
reverse-PARENT_OF cycle check, saveAndFlush→DUPLICATE conflict mapping, and
family-membership flagging. Extract them into requireNotSelf,
requireNoReverseParent, persistOrConflict, and flagFamilyMembership so the
shared invariants live once and each public method reads as its own clear
create-vs-mutate sequence. Behaviour-preserving: RelationshipServiceTest (22)
and RelationshipServiceIntegrationTest (10) stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PersonService and RelationshipService each carried a verbatim copy of the
date⇔precision coherence check and the null→UNKNOWN precision normalizer.
Hoist both into a single document.DatePrecisionValidation util so the rule
that the V76/V78 CHECK constraints mirror has one source of truth. Each
service keeps its own order check (BIRTH_AFTER_DEATH vs
INVALID_RELATIONSHIP_DATES), which is the only genuinely domain-specific part.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerate api.ts for the LocalDate+DatePrecision RelationshipDTO /
RelationshipUpsertRequest and the new PUT, then migrate every caller:
- RelationshipDateField (mirrors PersonLifeDateField: DAY/MONTH/YEAR, 44px
targets, labelled, semantic dark-mode tokens, relation_* i18n keys).
- AddRelationshipForm is now upsert-capable: an optional `relationship` prop
pre-fills type, person, both dates+precision and notes; posts to
?/updateRelationship (else ?/addRelationship); the submit control disables and
shows a progress spinner while a request is in flight (REQ-019); notes textarea
(<=2000).
- RelationshipChip gains an accessible Edit affordance (canWrite + onEdit);
StammbaumCard wires it, formats the date range via formatRelationshipDateRange,
and sorts by fromDate. PersonRelationshipsCard (read view) shows the date range
and notes; no dates -> no date line.
- persons/[id]/edit/+page.server.ts: updateRelationship action (PUT) + the
addRelationship action reshaped to date+precision+notes (empty date omits
precision for coherence).
- Genealogy callers fixed for the dropped year fields: familyForest spouse-order
and StammbaumConnectors ended-edge dashing now key off fromDate/toDate.
- i18n relation_* form keys in de/en/es.
REQ-004, REQ-014, REQ-015, REQ-016, REQ-019
Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plain-text "from – to" range formatter for relationship dates, delegating all
precision rendering to the shared formatDocumentDate (zero new precision logic).
Lives in $lib/person next to personLifeDates and reuses the existing
person → shared boundary. From-only renders just the start (no trailing dash),
no dates renders nothing.
REQ-015
Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PUT /api/persons/{id}/relationships/{relId} (@RequirePermission WRITE_ALL)
updates a relationship's type, related person, dates and notes in place,
preserving the original createdAt. The update re-runs every create invariant —
self-relation (VALIDATION_ERROR), date coherence/order, reverse PARENT_OF
(CIRCULAR_RELATIONSHIP) and the (person, relatedPerson, type) unique constraint
via saveAndFlush (DUPLICATE_RELATIONSHIP) — and re-flags both endpoints as
family members when edited into a family type. The directed orientation is
preserved per viewpoint, so a PARENT_OF edge stays parent->child whether edited
from either person's page.
Ownership mismatch now returns 404 RELATIONSHIP_NOT_FOUND (shared loadOwned
helper) for both PUT and DELETE — anti-enumeration, replacing DELETE's former
403. No @Version: last-write-wins, matching person edit (single-writer archive).
REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-012, REQ-013, REQ-018
Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace person_relationships.from_year/to_year (Integer) with from_date/
to_date (LocalDate) + NOT-NULL from_date_precision/to_date_precision
(DatePrecision, default UNKNOWN), mirroring the Person life-date pattern
(ADR-039 / V76). V78 backfills existing years as YYYY-01-01 at YEAR precision,
adds five named CHECK constraints (coherence both ends, from<=to, precision
value sets) and drops the year columns — verified by RelationshipMigrationTest
on a real Postgres 16 container.
validateRelationshipDates replaces validateYears: coherence (date <=> non-
UNKNOWN precision) -> INVALID_DATE_PRECISION, order (toDate before fromDate) ->
INVALID_RELATIONSHIP_DATES. The derived Zeitstrahl Heirat event now sources the
SPOUSE_OF.from_date at its stored precision, so a DAY-precision wedding surfaces
the exact day instead of just the year. RelationshipDTO and the shared
create/update request (renamed CreateRelationshipRequest ->
RelationshipUpsertRequest) carry the date+precision fields.
REQ-001, REQ-002, REQ-003, REQ-010, REQ-011, REQ-014, REQ-017
Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New 400 error code for a relationship whose toDate precedes its fromDate,
registered in all four sites at once (ErrorCode.java, errors.ts,
getErrorMessage, messages/{de,en,es}.json) per constitution §3.6. Thrown by
the relationship date validation introduced in the following commit.
Refs #837
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two cleanups flagged in review, both behaviour-preserving:
- collapse the {#if color}/{:else} square-marker branches (identical but for the
neutral fill) into one element via class:bg-ink-3={!color}; squareStyle is
already empty when color is null, so no var(--c-tag-) leaks into the neutral
chip.
- drop the redundant `truncate` class from the name span — the inline
overflow/ellipsis trio (kept so it applies before the stylesheet loads,
REQ-008a) already expresses exactly what `truncate` would.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveRoot silently falls back to returning the tag itself when no null-parent
ancestor surfaces — an orphaned parent_id or a chain deeper than the
findAncestorIds CTE depth guard. The chip then renders a non-root tag as if it
were the theme, with no trace. Log a warning (UUIDs only, per REQ-014) before
the fallback so the anomaly is diagnosable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mapDocument re-ran the alphabetical min() scan over the letter's tag set to
look up its already-resolved root, duplicating the work resolveLetterRootTags
had just done and leaving two independent definitions of "primary tag" that
could silently diverge. Key the resolved-root map by document id and compute
the primary tag exactly once per letter; drop the redundant resolvePrimaryRoot
helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add one row per requirement for the zeitstrahl-tag-chips feature, each mapped
to its implementation file(s) and the test(s) that prove it, Status=Done.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc comment described escaping by naming the raw-HTML directive literally,
which trips the lib/timeline grep gate that forbids that token. Reword it the
way LetterCard already does — behavior unchanged.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LetterCard now renders a TagChip beneath the sender→receiver/date line
whenever the entry carries a rootTagName, mapping rootTagColor to the chip
(neutral when null). Because the chip lives on LetterCard it shows up wherever
a LetterCard does — the global timeline and the expanded YearLetterStrip — with
no per-surface special-casing; a tagless letter shows no chip. A long name
truncates inline so the card never overflows at 320px.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagChip renders a letter's primary root tag as a small rounded pill — a
decorative aria-hidden colored square (var(--c-tag-{token}), neutral when the
color is null) plus the escaped tag name, prefixed by the sr-only theme label
so color is never the only cue. Truncation is set inline so a long name
ellipsizes without forcing the card into horizontal scroll, and the full name
stays reachable via the chip title. Timeline-local by design — lib/timeline may
not import lib/tag (eslint boundary).
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
timeline_tag_chip_label (de "Thema" / en "Topic" / es "Tema") is the sr-only
prefix the /zeitstrahl letter tag chip reads out so color is never the only
cue. Pinned per locale in messages.spec.ts; the tag name itself is rendered as
data, never translated.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
openapi-typescript pickup of TimelineEntryDTO.rootTagId/rootTagName/
rootTagColor (all optional), so the SvelteKit timeline can read the new
letter chip fields. Regenerated from the live dev spec; only the additive
fields differ from the committed baseline.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TimelineEntryDTO gains three nullable letter-only fields — rootTagId,
rootTagName, rootTagColor (token) — assembled in-transaction in TimelineService
(ADR-036): id + name + token only, never a serialized Tag entity. A letter's
primary tag is the root ancestor of its alphabetically-first assigned tag
(#827 Resolved Decision 3); roots are resolved through TagService in one
batched pass over the distinct primary tags (no per-letter N+1). The fields are
null for non-letter entries, untagged letters, and (color only) a colorless
root, so they are deliberately not @Schema(requiredMode = REQUIRED).
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TagService.resolveRootTags(tags) maps each tag to its root ancestor as a
RootTag (id, name, color token), keyed by the input tag id. A root maps to
itself; a child is walked to the parentless ancestor via the existing
recursive-CTE findAncestorIds — one CTE per distinct non-root tag (memoized),
plus a single batched findAllById — so a timeline of many letters sharing few
tags costs O(distinct tags) queries, never O(letters). The color is read from
the resolved root's stored token (null when the root has none).
This is the shared enrichment the /zeitstrahl tag chip (#835) and, later, the
Thema buckets (#827) both consume. Unit-tested in TagServiceTest; the
DB-dependent ancestry walk is pinned against real Postgres in
TagServiceIntegrationTest.
Refs #835
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine offset (0.5rem phone / 50% desktop) was hard-coded in both
TimelineView's .timeline-axis::before and YearBand's .year-node/.letter-dot,
kept in sync only by a comment. Declare --spine-x once on .timeline-axis
and have the markers consume it by inheritance, so a change to the spine
position moves the markers with it. Add a test that the year-node tracks
the token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The aria-hidden glyph + sr-only label markup was hand-copied in LetterCard
and YearLetterStrip. Extract a small GlyphLabel component and use it at
both sites so the accessibility idiom has a single owner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flatMap intermediate array plus two filter passes with one
walk over the year bands and the undated bucket. Same counts, no
throwaway allocation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "· historisch" register was emitted in all three date branches, with
the dateless branch dropping the leading separator. Render the span pill
or date as a conditional prefix, then a single trailing "· historisch"
span — one render site, consistent separator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A count of one rendered "1 Briefe" / "1 Ereignisse". Add _singular
companion keys (de/en/es) and select them when the count is exactly one,
following the project's _singular/_plural convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A timeline with content in only one category rendered a misleading
"0 Briefe" / "0 Ereignisse" segment. Only push a count segment when its
count is greater than zero; the grouping label and (when present) the
range are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The provenance token (abgeleitet/kuratiert) was nested inside the
{#if dateLabel} block, so an undated or UNKNOWN-precision event — e.g.
one in the undated bucket — rendered no provenance at all. Compose the
subtitle as an optional "{date} · " prefix in front of the always-present
provenance instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The page is already bg-canvas, so the frame's border was the only thing
making it visible; per review it reads cleaner without it. Keep the padded
bg-canvas surface; the timeline sits on the page without a frame line.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The absolutely-positioned spine ::before painted above the in-flow centered
content (density strips, event pills), drawing the line through them. Give
.timeline-axis a stacking context and the spine z-index:-1 so the line is
always background; cards, pills, strips, dots and badges ride on top.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The node marker carried a higher stacking order than the (non-positioned)
badge, so on the centered desktop axis the navy node painted over the white
year digits. Make the badge positioned with the higher z-index; the node now
sits behind the centered pill (which is itself the axis interruption) and
shows only to the badge's left on phone. Guarded by a z-index assertion.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All sixteen visual-fidelity requirements have a green test (142 timeline +
zeitstrahl + messages tests pass); record the implementation files and the
test that proves each.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pass the full PageData shape (layout auth fields + timeline) through a
pageData() helper so the route spec is svelte-check-clean; the component
still only reads data.timeline.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spine now runs mint → navy → slate, matching the spec's life-thread,
using --palette-mint / --palette-navy / --c-tag-slate (no --palette-slate
token exists). Semantic tokens only — no raw hex.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The year badge now centers on the axis at ≥1024px and hugs the left spine
below that (sticky top:4rem preserved), with a navy node marker so it
visibly interrupts the spine. Each letter row gains a connector dot (white
fill, mint ring) on the spine: centered between card and axis on desktop,
on the left spine clear of the indented card on phone. Spine geometry is
commented to track TimelineView's spine so the markers can't silently desync.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timeline now sits inside a bordered, rounded bg-canvas sheet. Below the
heading a sub-line composes the year range, the letter and event counts
(from timelineMeta), and the static "Gruppierung: Datum" — joined by " · "
so the range drops out when there are no year bands and the whole line is
absent for an empty timeline. Semantic tokens only; AA-legible text-xs.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pure timelineMeta() returns the year range (first/last band, null when
there are no bands) and the letter/event totals across all year bands plus
the undated bucket — the single place these counts are computed.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Ohne Datum" section now renders inside a dashed-bordered surface box
whose heading reads "Ohne Datum · {count}", matching the spec's .undated
treatment. The kind/type dispatch (events as pills/bands, letters as cards)
is unchanged; the section stays absent when there are no undated entries.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dense-year strip count now carries the ✉ glyph (aria-hidden + sr-only
"Brief"), and beneath the sparkline a "Monats-Dichte" caption sits between
the two endpoint month labels (Jan/Dez {year}) at the ≥10px micro-axis
floor, localized via the shared month formatter. The ≥44px keyboard-
focusable "Briefe anzeigen" expand toggle is preserved unchanged.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A HISTORICAL band's subtitle now carries the visible "historisch" register
inline as plain text: "{date} · historisch", or — for a RANGE — after the
existing 1914–1918 span pill (whose Zeitraum aria-label is unchanged). The
descriptor is a text node, never a second pill. Every WorldBand is
historical, so the suffix also trails an undated band on its own.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A present LetterCard title now reads "✉ {title}" with an aria-hidden glyph
and an sr-only "Brief" label rendered as sibling nodes — never interpolated
into the escaped user title, which keeps its own pre-line span for
multi-line OCR text. No title → no glyph, no label (the row still shows
sender → receiver and the date). An XSS regression pins the no-{@html}
contract: an HTML-bearing title renders verbatim as text.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The derived/curated pill subtitle now reads "{date} · abgeleitet" or
"{date} · kuratiert", keyed off entry.derived so a reader sees both the
date and whether the event was derived from Person data or curated. Only
the single provenance token ships; the spec sheet's "· persönlich" /
"· SEASON" annotations stay out (already covered by the ★ glyph + mint
border and not production UI).
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven new timeline_* keys feeding the upcoming chrome: the header meta
line (grouping label + events count), the event-pill provenance token,
the ✉ sr-only label, the world-band "historisch" suffix, and the strip
density caption — present in de/en/es with matching key sets, pinned by a
new parity test.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trace the visual-fidelity requirements end to end before implementation,
matching the timeline-feature convention of landing RTM rows in the first
commit of the branch. Status: Planned; flipped to Done as each task lands.
Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The submitted type comes from EventTypeSelect's own hidden input; EventForm's
`type` $state was only read to seed `value=` and the onchange reassignment
had no downstream reader. Inline the seed expression and pass markDirty
directly, removing the second source of truth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A RANGE event with a blank end date passed validateEventForm and reached
the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end
must not be before start" — wrong for a missing end date, and shown only as
a top-of-form alert. Validate it before the API call and surface a dedicated
event_editor_end_date_required message on the end-date field via a new
DatePrecisionField endDateError prop (defaults '', so the document form is
unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
preservedFormFields echoed back only title/type/description/pickers, and
EventForm seeded dateIso/precision/endDateIso solely from `event` (undefined
on /new). So a no-JS validation-error reload silently dropped the entire
When-section the curator had entered, while every other field survived.
Echo eventDate/precision/eventDateEnd in the fail payload and seed the date
controls from `form` ahead of `event`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
titleError/dateError short-circuited on the server fail payload
(`form?.titleError ?? …`, `form?.dateError ?? ''`), so after a fail(400)
the red border and message stuck until the next submit even once the user
typed a valid value. Derive both from the current field value instead: the
server error still seeds the message, but a non-empty title/date clears it
immediately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The delete <form> combined onsubmit={confirmDelete} with use:enhance.
SvelteKit's enhance ignores event.defaultPrevented, so the DELETE fired on
the bare click — before the dialog was answered — and the post-DELETE
redirect ran regardless of the user's choice. Reading getConfirmService()
lazily inside the handler also threw (Svelte context is init-only), so the
dialog never appeared even with <ConfirmDialog> mounted.
Capture confirm at init and run the gate inside the enhance submit phase,
calling cancel() on "no". Clear dirty in the result callback so the
beforeNavigate guard no longer prompts "unsaved changes" on the post-delete
redirect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The EventForm onsubmit guard called e.preventDefault() on a blank title,
but use:enhance ignores defaultPrevented (forms.js only bails on cancel()),
so a blank-title Save still fired a network POST. In a component unit test
the resulting update() -> applyAction() dereferenced an undefined root
($set on undefined), surfacing as an unhandled rejection.
Move the guard into the enhance submit phase and call cancel() so the POST
is actually stopped; the server still owns the authoritative fail(400).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-2 dirty-guard used an $effect that both read and wrote its `dirtyArmed`
$state, so the self-write re-triggered the effect and `dirty` flipped true on
mount — the beforeNavigate confirm then fired on every navigation away from an
untouched form (caught by the round-3 clean-agent review + the Svelte autofixer,
which flags assigning state inside $effect).
Replace it with the component's existing idiomatic pattern: DatePrecisionField,
PersonMultiSelect, and DocumentMultiSelect each gain an optional `onchange`
callback fired on a real user edit, and EventForm passes `markDirty` to all
three. Now date/precision/end-date and picker add/remove mark the form dirty
exactly like title/type/description already did — no effect, no mount-timing
trap. The new props are optional, so the other consumers (WhoWhenSection, the
document forms) are unaffected. Svelte autofixer: clean.
Addresses PR #832 review (round-3 clean-agent concern).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>