Migration 052 fixes six concept↔leaf mismaps in the v3 seed and adds
three proactive entry leaves under spaetere-schriftsaetze.
1. cms-eingang.gericht.hinweisbeschluss — drop the response-to-
preliminary-opinion | DE_INF row. DE_INF (LG) has no
Hinweisbeschluss; the concept lives only in DE_NULL via PatG §83.
2. cms-eingang.gegenseite.upc-inf.klageschrift — drop the notice-of-
defence-intention | UPC_INF row. UPC has no such rule in the corpus;
R.23 reaction is captured by statement-of-defence directly.
3. UPC R.221 cost-appeal sequence (m's Q5): three leaves now surface
BOTH application-for-leave-to-appeal | UPC_COST_APPEAL (sort 100,
R.221.1, 15 days) AND notice-of-appeal | UPC_APP (sort 200,
conditional on leave granted, R.220.1). Replaces the wrong notice-of-
appeal | UPC_COST_APPEAL row that was silently dropping pills.
4. ich-moechte-einreichen.berufung.upc-coa-orders — replace the buggy
application-for-leave-to-appeal | UPC_APP_ORDERS (no rule for that
combo) with request-for-discretionary-review | UPC_APP_ORDERS
(R.220.3).
5. cms-eingang.gericht.anordnung — narrow request-for-discretionary-
review NULL → UPC_APP_ORDERS. R.220.3 review applies specifically
to the Anordnungen / 15-day track.
6a. reply-to-cross-appeal coverage: add UPC_APP rows under upc-{inf,
rev}.berufungsschrift so the reply leaf is reachable when the
opponent files an Anschlussberufung.
6b. New leaves under ich-moechte-einreichen.spaetere-schriftsaetze for
proactive entry: r116-eingaben (EPA R.116 final submissions),
anschlussberufung-upc (R.237), reply-to-cross-appeal-upc (R.238).
NO `RAISE EXCEPTION` coverage gate (m's Q7) — last night's outage was
caused by exactly that pattern in migration 049. Replaced with a Go-
side test in event_category_coverage_test.go that asserts every
category='submission' concept is reachable from at least one leaf
(except the prosecution-only exempt list: filing, request-for-
examination, approval-and-translation). Skipped without
TEST_DATABASE_URL; CI gates on it.
bescheid-mit-frist mapping deferred per m's Q4. Will land separately.
Migration verified via supabase MCP dry-run + ROLLBACK on the live
youpc DB; end-state matches design §3.2-§3.4.
The v3 B1 decision tree filter collapsed each leaf's
(concept_id, proceeding_type_code) tuple list down to a flat concept_id
slice in EventCategoryService.ConceptIDsForSlug, dropping the per-leaf
proceeding constraint. The search service then loaded pills by
concept_id only, so picking a UPC-specific leaf still surfaced DE/EPA/
DPMA pills for any shared concept (Klageerwiderung, Replik, Duplik,
Berufungsschrift). m's repro: choosing CMS-Eingang → Gegenseite →
UPC Verletzung leaked national submissions.
Confirmed via DB: at least 25 leaves were over-broad pre-fix.
Fix carries the tuple set end-to-end via a new subtreeFilter type with
parallel uuid[] / text[] arrays. The matview SQL now uses
unnest($cids, $procs) AS t(cid, pcode) to match each row against the
allowed tuples — a junction row with NULL proc encodes "any proc for
this concept" (used by cross-cutting concepts like Wiedereinsetzung).
EventCategoryService gains AllOutcomes() for browse-all so the root
view also respects junction tuples. allMappedConceptIDs is gone.
Tests: added 5 v4 subtests under TestDeadlineSearch covering m's
repro slug, multi-tuple narrowing, trigger-pill cross-cutting,
forum AND-narrowing, plus an invariant regression gate that walks
every leaf with non-NULL proc and asserts no pill leaks. Skipped
when TEST_DATABASE_URL is unset; existing v3 assertions unchanged.
No schema change. No migration. Ships independently of Phases B/C.
Five m's-bookmark fixes on top of the B1 surface change:
1. Sort proceeding pills inside concept cards by real-world frequency.
New paliad.proceeding_types.display_order column (m's spec values:
UPC_INF=10, DE_INF=20, UPC_REV=30, ..., UPC_PI=920, ...). Default
999 for unmapped legacy codes. Search service surfaces it through
the deadline_search matview (rebuilt to add the column) and uses
it as primary key in pillSortKey, replacing the jurisdiction-rank.
2. Name standardisation: -klage → -verfahren on the proceeding-types
that describe a multi-step process. Specifically:
UPC_REV Nichtigkeitsklage → Nichtigkeitsverfahren
UPC_APP Berufung → Berufungsverfahren
DE_INF Verletzungsklage (LG) → Verletzungsverfahren (LG)
DE_INF_OLG, DE_NULL_BGH, DPMA_OPP, DPMA_BPATG_BESCHWERDE,
UPC_COST_APPEAL, UPC_APP_ORDERS, DPMA_BGH_RB, DE_INF_BGH —
same -verfahren standardisation.
3. legal_source for rev.defence × UPC_REV: was NULL, leaking the
internal local_code 'rev.defence' to the UI. Set to UPC.RoP.49.1
(Defence to Application for Revocation, R.49.1).
4. Frontend renderPill no longer falls back to rule_local_code when
legal_source is missing — the source span just collapses, so no
internal slug ever shows up as a "citation".
5. Quick-pick chips refactored to a slug-based array (QUICK_CHIPS) in
fristenrechner.tsx, single source of truth for both fork-shortcut
and B2-search-bar rows. Each chip carries data-chip-name-de /
data-chip-name-en; relabelChips() rewrites visible text per active
language. Dropped the duplicate "Statement of Defence" chip (same
concept as "Klageerwiderung"). Each chip now maps to one concept
slug — Klageerwiderung→statement-of-defence, Berufung→notice-of-
appeal, Einspruch→opposition, Replik→reply-to-defence,
Beschwerde→nichtzulassungsbeschwerde, Schadensbemessung→
application-for-determination-of-damages, Wiedereinsetzung→
wiedereinsetzung.
Migration 051 uses RAISE WARNING (not EXCEPTION) on coverage gates
per the 049 outage lesson — partial-migration recovery beats whole-
transaction failure. Matview rebuild stays inside the transaction;
RefreshSearchView() on next boot is a cheap no-op.
Pathway B B1 mode previously rendered an empty result area on every
state — the runB1Search() output target was #fristen-search-results,
which lives inside the B2 panel. When B2 is hidden (B1 active), the
results were written into a hidden subtree and never seen.
Changes:
- TSX: add #fristen-b1-results inside #fristen-b1-panel, below the
cascade button row.
- frontend/fristenrechner.ts: extract renderSearchResultsInto() and
wirePillClicks(); runB1Search now writes to fristen-b1-results,
fetches /api/.../search?browse=all when no slug is picked yet (full
landscape on entry), and applies CSS-driven loading dim with a seq
guard against out-of-order responses. Hoisted loadAndRenderB1() so
showBMode("tree") can trigger the tree load on Pathway B entry
(radio.checked = true does not fire change events).
- backend: SearchOptions.BrowseAll, allMappedConceptIDs() returning
the union of every concept reachable from any leaf via
paliad.event_category_concepts, lifted limit ceiling for browse
modes (default 200, max 500). Handler exposes ?browse=all.
- CSS: shared loading-state styling for fristen-b1-results.
Backend layer for the v3 decision tree:
- internal/services/event_category_service.go (NEW)
- Tree(): nested tree of all active event_categories for the
Pathway B / B1 cascade UI. Uses single SELECT + in-memory
parent-child stitching; corpus is small (≤100 nodes).
- ConceptsForSlug(): recursive CTE walks descendants of a slug and
joins event_category_concepts to return the candidate concept
outcomes (with optional proceeding_type_code narrowing).
- ConceptIDsForSlug(): convenience reduction for
`WHERE concept_id = ANY(...)` queries against the existing
deadline_search matview.
- ProceedingCodesForSlug(): per-leaf proceeding-code narrowing for
Phase D's forum filter intersection.
- internal/handlers/fristenrechner_event_categories.go (NEW)
- GET /api/tools/fristenrechner/event-categories returning the
nested tree as JSON. Frontend will ETag-cache via localStorage.
- Wired EventCategory into handlers.Services + dbServices + main.go.
The existing /api/tools/fristenrechner/search handler stays
unchanged in this commit; Phase D will add ?event_category_slug=
and ?forum= query params on top.
Build + vet clean.
Closes the search half of the unified Fristenrechner. Phase D (concept-card
UI on /tools/fristenrechner) follows in a subsequent shift.
Migration 047:
- Seed the missing `wiedereinsetzung` concept and re-point the four
Wiedereinsetzung trigger_events (200..203) at it. PR-7 referenced
the slug `re-establishment-of-rights` but never seeded the concept,
so the four cross-cutting triggers were dropping out of any concept-
JOINing query. Per m's slug rule (Q1: shared cross-cutting concepts
use DE slug because German term dominates HLC vocabulary).
- Create paliad.deadline_search materialised view: UNION ALL of
(deadline_rules joined to deadline_concepts) and (trigger_events
joined to deadline_concepts via slug). Trigram GIN indexes on
legal_source / concept_name_de / concept_name_en / rule_name_de /
rule_name_en / rule_code; gin (concept_aliases) for array
containment; UNIQUE INDEX on a synthetic row_key so refresh can
run CONCURRENTLY.
Refresh strategy: data only mutates via migration files at server
startup, so no AFTER triggers and no pg_cron — main.go calls
services.RefreshSearchView right after db.ApplyMigrations. CONCURRENTLY
keeps reads online and stays well under 100 ms at < 1k rows.
Service `internal/services/deadline_search_service.go`:
- Two-query pipeline per request: (1) rank concept_ids by
GREATEST(similarity()) across name / aliases / legal_source / rule_code
plus a 0.2 alias-hit boost; (2) load all matview rows for the top-N
concepts and assemble per-pill JSON.
- normalizeQuery strips legal-prefix noise (`§`, `Art.`, `Section`,
`Rule `) so users typing `§ 82` find DE.PatG.82.1 even though the
structured legal_source column doesn't carry the prefix.
- FormatLegalSourceDisplay renders structured codes back to the
pleading form HLC users expect:
UPC.RoP.23.1 → "UPC RoP R.23(1)"
DE.PatG.82.1 → "PatG §82(1)"
EU.EPÜ.108 → "EPÜ Art.108"
EU.EPC-R.79.1 → "EPC R.79(1)"
EU.RPBA.12.1.c → "RPBA Art.12(1)(c)"
- Drill URLs route per kind: rule pills → ?proc=…&focus=…, trigger
pills → ?mode=event&triggerId=…
Handler `GET /api/tools/fristenrechner/search?q=&party=&proc=&source=&limit=`:
- Returns the JSON shape from design §6.1 (cards-with-pills).
- 503 with friendly DE message when DATABASE_URL is unset, mirroring
the other Fristenrechner endpoints.
- Empty q returns an empty cards array (browse surface is Phase D).
Tests:
- Pure-Go: TestFormatLegalSourceDisplay (12 cases across all known
prefixes) + TestNormalizeQuery (8 cases).
- Integration (skipped without TEST_DATABASE_URL): golden table
pinning the design's binding queries — Klageerwiderung returns the
statement-of-defence card with UPC.RoP.23.1, DE.ZPO.276.1,
DE.PatG.82.1, EU.EPC-R.79.1, DE.PatG.59.3 pills; "RoP 23" returns
the same card; "§ 82" → normalized "82" → BPatG hit; Wiedereinsetzung
returns one card with exactly 4 trigger pills (ids 200..203);
party / source filters narrow as expected; limit cap honoured.
- SQL semantics validated against live data via supabase MCP using a
CTE-inlined matview definition with the slug fix simulated; results
match the golden table.
Per design doc `docs/plans/unified-fristenrechner.md` §4.6 (matview
shape) + §6 (search ranking + API).
Closes m's primary complaint: today's `with_ccr` flag on UPC_INF only
swaps the Replik / Duplik durations. Per UPC RoP R.29 the with-CCR flow
ALSO adds 5–7 new submissions across the claimant / defendant exchange.
Same gap on UPC_REV: Application to amend (R.49.2.a → R.55 = R.32 m.m.)
and Counterclaim for infringement (R.49.2.b → R.50, R.56 cycle) were
entirely missing.
UPC_INF gets a nested `with_amend` flag under `with_ccr` (R.30 amend
is only available with a CCR). UPC_REV gets two parallel independent
flags `with_amend` + `with_cci`; both can be on. Citations verified
against data.laws_contents (youpcdb, UPCRoP).
Migration 041 (waved INSERTs because each subsequent rule references
the prior wave's parent_id):
- Wave 0: 11 new concept rows (counterclaim-for-revocation,
defence-to-counterclaim-for-revocation, defence-to-application-to-amend,
reply-to-defence-to-counterclaim-for-revocation,
reply-to-defence-to-application-to-amend,
rejoinder-on-reply-to-defence-to-ccr, rejoinder-on-reply-to-amend,
counterclaim-for-infringement, defence-to-counterclaim-for-infringement,
reply-to-defence-to-counterclaim-for-infringement,
rejoinder-on-counterclaim-for-infringement). counterclaim-for-revocation
also seeded for the search bar even though its rule lives implicitly
in inf.sod (the with_ccr flag captures it).
- UPC_INF + UPC_REV sequence_orders renumbered to leave gaps (10/20/30…)
so new cross-flow rows interleave chronologically with the backbone.
- 7 new UPC_INF rules: inf.def_to_ccr (R.29.a), inf.app_to_amend (R.30.1),
inf.def_to_amend (R.32.1), inf.reply_def_ccr (R.29.d),
inf.reply_def_amd (R.32.3), inf.rejoin_reply_ccr (R.29.e),
inf.rejoin_amd (R.32.3).
- 8 new UPC_REV rules: rev.app_to_amend (R.49.2.a), rev.def_to_amend
(R.43.3), rev.reply_def_amd (R.32.3 m.m.), rev.rejoin_amd (R.32.3 m.m.),
rev.cc_inf (R.49.2.b), rev.def_cci (R.56.1), rev.reply_def_cci (R.56.3),
rev.rejoin_cci (R.56.4).
Calculator (services/fristenrechner.go):
- Zero-duration rules now split into 4 buckets, not 2:
1. parent=nil + non-court → IsRootEvent (existing)
2. parent=nil + court → IsCourtSet (existing, e.g. inf.oral when stand-alone)
3. parent set + court → IsCourtSet (existing, waypoints)
4. parent set + non-court → "filed-with-parent" — inherit parent's
date. NEW. Used by rev.app_to_amend / rev.cc_inf which per
R.49(2) are filed AS PART OF the Defence to revocation.
- AnchorOverrides on a zero-duration rule short-circuits to the user's
date, propagating downstream as before.
Frontend:
- New checkboxes inf-amend-flag (UPC_INF, nested under ccr-flag),
rev-amend-flag, rev-cci-flag (UPC_REV). Visibility per proceeding
type; inf-amend disabled until ccr is on (R.30 dependency).
- Three new i18n keys (DE+EN). Small CSS for nested-checkbox indent
and disabled-state colour.
Live-verified via curl on paliad.de against tester@hlc.de:
UPC_INF + with_ccr+with_amend, trigger 2026-05-04 → all 7 new rules
render at correct dates (R.29.a 2mo, R.30.1 2mo, R.32.1 2mo from
app_to_amend, R.29.d 2mo from def_to_ccr, R.32.3 1mo, R.29.e 1mo,
R.32.3 1mo).
UPC_REV + with_amend+with_cci → rev.app_to_amend / rev.cc_inf show
rev.defence's date (filed-with-parent), R.43.3 2mo / R.56.1 2mo /
R.32.3 + R.56.3 1mo / R.32.3 + R.56.4 1mo all line up.
PR-1 of the Unified Fristenrechner. Purely additive: new search-grouping
layer + per-rule date override capability. No coverage changes yet
(those land in PR-2 = Phase B1 UPC counterclaim cross-flows).
Migrations:
- 037: paliad.deadline_concepts (id, slug, name_de/en, aliases text[],
party, category, sort_order). Trigram + GIN indexes for the search bar.
- 038: deadline_rules.concept_id (uuid FK), legal_source (text);
event_deadlines.legal_source; trigger_events.concept_id (text slug,
soft-link — youpc imports keep their bigint PK).
- 039: deadline_rules.condition_flag text → text[] (USING ARRAY[old]).
Semantic: rule renders iff every element is in CalcOptions.Flags.
Single-element arrays preserve the legacy with_ccr swap exactly.
- 040: seed 30 concept rows + backfill all 74 fristenrechner deadline_rules
with concept_id; backfill legal_source from existing rule_code
(e.g. 'RoP.023' → 'UPC.RoP.23.1', '§ 276 ZPO' → 'DE.ZPO.276.1',
'Art. 108 EPÜ' → 'EU.EPÜ.108', 'R. 79(1) EPÜ' → 'EU.EPC-R.79.1').
Calculator (services/fristenrechner.go):
- ConditionFlag is now pq.StringArray (matches text[] schema). New
allFlagsSet() helper gates rule rendering; rules with multi-element
flags require ALL of them set (prep for Phase B1 with_amend ∧ with_cci).
- CalcOptions.AnchorOverrides map[string]string (rule_code → YYYY-MM-DD).
The tree-walk consults overrideDates[parent.code] before reading the
computed-date map; lets a downstream rule re-anchor on a user-set date.
- IsCourtSet rows that get an override stop being placeholder and emit
the user's date as a real anchor (so downstream cost_app etc. compute
off it). New IsOverridden flag in UIDeadline so the UI can highlight
user-edited rows.
- LegalSource surfaced on UIDeadline for future search-card display.
UI (frontend/src/client/fristenrechner.ts + global.css + i18n):
- Each timeline / column rule date is click-to-edit. Click → inline
date input → blur or Enter → POST with anchorOverrides → re-render.
Empty value clears the override. Escape cancels. Root-event rows
(the trigger anchor) stay non-editable — that's the trigger-date input.
- Override map cleared on proceeding switch / reset; persists across
trigger-date / flag toggle changes within the same proceeding.
- New CSS: subtle hover underline on .frist-date-edit; lime border on
.timeline-date--overridden + .frist-date-edit-input.
- New i18n key deadlines.date.edit.hint (DE + EN).
Handler (handlers/fristenrechner.go):
- POST body gains optional anchorOverrides map<string,string>; passed
through to CalcOptions.
Tests:
- TestAllFlagsSet covers single-flag legacy semantic, two-flag AND
semantic, empty-required unconditional, extra-flags-no-effect.
- Existing TestIsCourtDeterminedRule unchanged.
Phase A ships standalone — Phase B1 (UPC counterclaim cross-flows) and
Phase C/D (search backend + concept-card UI) follow.
Redefines the "Nur persönliche" filter on /events from "appointment with
NULL project_id" to "items where created_by = me", applied uniformly to
deadlines and appointments.
Before: client-side filter dropped every deadline row because the type
guard was `x.type === "appointment"`. m saw zero deadlines under "Nur
persönliche" even though he created plenty.
After:
- /api/events?personal_only=true (and /api/events/summary?personal_only=true)
narrow BOTH rails to f.created_by / t.created_by = current user.
ProjectID is ignored when personal_only is set (the two are
contradictory).
- DeadlineService.ListFilter and AppointmentService.AppointmentListFilter
gain CreatedBy *uuid.UUID — composes with existing visibility (AND), so
a row created on a team the user has since left still won't leak.
- Frontend drops the client-side filter; sends personal_only=true when
projectFilter === PERSONAL. URL ?personal_only=true also accepted on
initial load (bookmark-friendly alias for ?project_id=__personal__).
Personal option now shows for type=Fristen too — applies uniformly.
- 3 new live subtests covering personal_only across type=deadline /
appointment / all, with mixed-creator + multi-project + null-project
fixtures.
The /events Project filter dropdown was sorted by `updated_at DESC`, so a
recently-touched Case appeared above its parent Client and cousins
interleaved unrelated branches — m's report (2026-05-04): "Siemens cases
come directly after 'mandant vs Gegner' and are not under 'Siemens-AG'".
Backend: switch ProjectService.List to ORDER BY p.path so every
descendant immediately follows its ancestor — the same ordering BuildTree
produces. Both callers (handleListProjects, searchProjects) gain a
stable, hierarchical default that matches user expectation.
Frontend: add project-indent.ts shared helper and apply NBSP indent
prefix in every <select> picker fed by /api/projects: events filter,
/deadlines/new, /appointments/new, checklist new-instance modal,
Fristenrechner save modal. NBSP avoids browser whitespace collapse
inside <option> labels. Multi-parent repetition is out of scope (data
model has singular parent_id today).
Tests: project_list_order_test pins the path-order contract against a
seeded mixed-recency tree.
Selecting a Client in the project filter now returns rows attached to
that Client AND every Litigation / Patent / Case below it (and so on
down the tree). Previously the filter was exact-match: picking a Client
hid every item in the subtree, which was the opposite of what users
expect when they pick a parent in a hierarchical picker.
The descendant set comes from paliad.projects.path - every project's
path always contains its own id and every ancestor's id, so any project
whose path includes the filter UUID is either that project or a
descendant. Pattern matches the existing visibility predicate (which
walks the path UPWARD for inheritance); the new helper just inverts the
direction.
Filter sites updated:
- DeadlineService.ListVisibleForUser (/deadlines, /events)
- DeadlineService.SummaryCounts (deadline summary cards)
- AppointmentService.ListVisibleForUser (/appointments, /events)
- EventService.deadlineBuckets (/events deadline rail)
- EventService.appointmentBuckets (/events appointment rail)
ListForProject (deadline/appointment/checklist/note) is unchanged - it
fetches items for ONE specific project on the project detail page, not
a filter.
Visibility predicate (paliad.can_see_project) untouched - that walks
upward and is a different concern.
Until now, /events hid the Status dropdown when Type=Termine. The
date-bucket filters (Heute, Diese Woche, Nächste Woche, Später) only
worked on the deadline rail — m wanted them on appointments too, even
without a "completed" dimension.
Frontend (events.ts):
- New populateStatusFilter() rebuilds the Status <select> options based
on currentType: deadlines get the full 8-option set, appointments
narrow to 5 (Alle + 4 buckets). The "completed/pending/overdue"
options drop because they have no appointment analogue.
- applyTypeVisibility() no longer hides the Status filter for
appointments; it calls the populator instead. The populator runs on
type-chip click and on language change so labels translate live.
- When switching type while a now-invalid status is selected (e.g.
Termine + status=completed via URL), the populator falls back to the
per-type default (deadline → pending, appointment → all) and updates
URL params.
- syncURLParams + isFilterPristine + initFilters use a per-type default
so the appointment view treats `all` as pristine and stays out of the
URL until the user picks a bucket.
- loadList always sends `status` to /api/events; backend already
applies bucket-aware appointment filtering via
bucketAppointmentWindow().
events.tsx:
- The static <option> list collapses to a single placeholder; the
populator owns the option set at hydration.
i18n:
- New `events.filter.status.all` ("Alle"/"All") for the appointment-only
case — `deadlines.filter.all` says "Alle (offen & erledigt)" which is
wrong for appointments (they don't have a completed/pending state).
Backend (event_service_test.go):
- Three new live subtests confirming type=appointment + status=today
narrows to today's appointments, status=later narrows to far-future,
and status=completed collapses the appointment rail (defensive vs.
URL-hacking — the dropdown excludes that value for appointments).
Per UPC AC decision 2023-05-26, the UPC has summer + winter judicial
vacations but the Court continues to operate during them — they do NOT
extend procedural deadlines. paliad's HolidayService was treating every
paliad.holidays row as a non-working day, including vacation entries, so
a deadline landing on Tue 2026-08-04 (a regular working Tuesday) was
incorrectly shifted to Mon 2026-08-31 by walking the entire summer-
vacation run.
Fix: gate IsNonWorkingDay on Holiday.IsClosure (true for public_holiday
and closure types, false for vacation). IsHoliday still returns the row
regardless of type — UI surfaces that want to flag "this date is inside
UPC vacation" can still ask. paliad.holidays data is unchanged: the UPC
vacation rows stay as informational metadata.
The Kind="vacation" branch of AdjustForNonWorkingDaysWithReason is now
unreachable in practice (every vacation entry is IsClosure=false, so the
walk loop never enters with a vacation as the cause). Left in place as
defensive code for any future vacation type that should shift.
Tests: replaced TestAdjustForNonWorkingDaysWithReason_Vacation (asserted
the old wrong behaviour) with TestVacationDoesNotShiftDeadlines covering
m's reproduction (Tue 2026-08-04 → no shift), winter-vacation no-shift
(Mon 2026-12-28), Christmas/Neujahr regression (still shift correctly,
and walk through informational vacation entries to land on the next
real working day), and a Karfreitag regression to lock public-holiday
shifts.
The current "Wochenende/Feiertag" / "weekend/holiday" label hides the cause
of long shifts — m's reproduction had a deadline jump from 4.8.2026 to
31.8.2026 (+27 calendar days) across UPC Summer Vacation, and the UI made
it look like a bug. The math was correct; the explanation was lying.
Backend:
- AdjustForNonWorkingDaysWithReason returns an AdjustmentReason alongside
the adjusted date. Walks the same 60-iter loop, classifies the dominant
cause (vacation > public_holiday > weekend), collects every named
holiday hit, and for vacations scans outward to report the contiguous
block boundary (27.7.–28.8., not the 25 individual rows).
- AdjustForNonWorkingDays now wraps the new method, preserving its
3-tuple signature for existing callers (deadline_calculator,
event_deadline_service).
- UIDeadline gains an AdjustmentReason field; FristenrechnerService
populates it on every shifted deadline.
- Date fields serialise as YYYY-MM-DD strings (HolidayDTO + string
vacation span) — the Fristenrechner client already speaks that format.
Frontend:
- AdjustmentReason → human-readable phrase via renderAdjustmentReason:
vacation → "{vacation_name} ({span})"
public_holiday → "Feiertag ({first_holiday_name})" / "{name} holiday"
weekend → "Wochenende" / localised weekday
- Surrounding format becomes "Verschoben wegen X: A → B" (DE) or
"Shifted (X): A → B" (EN). Falls back to the legacy reason string
when the backend hasn't sent a structured reason.
- Vacation names render verbatim from paliad.holidays — no hardcoded
i18n mapping for individual closures (those rotate via the seed, not
via i18n.ts).
Tests cover the three Kind paths plus the no-shift case; UPC vacation
test injects the migration-010 seed into the cache so the assertion
runs without a live DB.
Out of scope (raised in conversation, deferred):
- Whether "UPC Summer Vacation" / "UPC Winter Vacation" are the right
names for the seeded rows, and whether the winter block belongs in
paliad.holidays at all (m flagged this as BS while reviewing the
task — needs a data-side decision before renaming/removing).
- holidays.country isn't filtered by proceeding-type jurisdiction, so
UPC vacation currently shifts EP_GRANT / EPA_APP / German national
deadlines too. Bigger fix; flagged for a follow-up issue.
Two adjacent i18n leaks in /tools/fristenrechner "Was kommt nach…" mode,
same pattern as t-paliad-112's deadline_rules fix but on event_deadlines:
A) title_de empty for all 70 rows. The DTO already falls back to title
silently, so DE locale rendered English titles ("Statement of Defence",
"Decision of the EPO", …). Backfilled via mig 035 with UPC RoP DE
terminology that matches the trigger_events.name_de translations from
mig 033, so the picker label and the deadline row read the same.
B) notes column carries English text on rows 50, 52, 70 (DE-named column
was DE-only in spec, but seeds slipped EN strings through). Mig 036
adds a parallel notes_en column following the t-112 mig 032 pattern,
copies the existing English into notes_en, and replaces notes with
proper DE for those three rows.
Render path:
- service: select notes_en, plumb through EventDeadlineResult.NotesEN
- frontend: getLang() === "en" ? (notesEN || notes) : notes (mirrors the
proceeding-tree timeline branch already in fristenrechner.ts)
Three correctness bugs from the t-paliad-101 QA sweep, fixed together since
they all change displayed/saved numbers users rely on.
B1 — Kostenrechner UPC GESAMTKOSTEN double-count
ComputeUPCInstance was setting InstanceTotal = effectiveCourtFee +
recoverableCeiling. The R.152 recoverable-cost cap is the OPPOSING
side's worst-case loss-of-suit liability, not the user's own cost —
folding it into GESAMTKOSTEN inflated the UPC total under a label
that means "your outlay," and the DE LG/OLG/BGH branches don't add
any opponent estimate. Drop it from InstanceTotal; the ceiling
still surfaces as its own RecoverableCeiling line item.
Live pre-fix on paliad.de (Streitwert 100k, UPC 1. Instanz only):
instanceTotal = 52600 = 14600 court fee + 38000 R.152 ceiling
Post-fix:
instanceTotal = 14600 (court fee only); RecoverableCeiling stays 38000
B3 — Court-determined Termine emit trigger date as a real-looking date
Zwischenverfahren / Mündliche Verhandlung / Entscheidung all live in
paliad.deadline_rules with duration_value=0 and parent_id=NULL, so
Calculate() classified them as IsRootEvent and emitted the trigger
date as their own DueDate. Worse, RoP.151 "Antrag auf Kostenentscheidung"
parents off inf.decision and chained 1 month off the placeholder ->
bogus deadline that the UI rendered as real.
Fix: classify a zero-duration rule as IsCourtSet (not IsRootEvent)
when primary_party = 'court' or event_type ∈ {hearing, decision,
order}. Track court-set rule IDs and propagate IsCourtSet downstream
to any rule whose parent is court-set, so RoP.151 also surfaces as
court-set rather than a fabricated date. Save-modal already greys
out IsCourtSet rows so the "Gerichtsbestimmte Termine ohne Datum
werden übersprungen" footnote becomes truthful again.
Live pre-fix on paliad.de (UPC_INF, trigger 2026-04-29):
Zwischenverfahren / Oral / Entscheidung -> dueDate 2026-04-29
Antrag auf Kostenentscheidung -> 2026-05-29 (bogus, +1mo from trigger)
B6 — Fristenrechner save flow stored rule code in TITLE
Frontend was concatenating "RoP.023 — Klageerwiderung" into the
title because deadlines had nowhere else to put the citation, and
the /deadlines REGEL column ended up showing "—". Add migration 032
with a paliad.deadlines.rule_code text column, plumb it through
CreateDeadlineInput / insertTx, drop the now-redundant r.code AS
rule_code JOIN alias on the list query (the deadline owns its
citation), and render f.rule_code on the project-detail deadlines
table + /deadlines events list + deadline-detail page.
Build, vet, and tests all clean. New unit test
TestIsCourtDeterminedRule pins the B3 discriminator across the
event_type / primary_party combinations seen in migrations 012 + 031.
Repro creds: tester@hlc.de
Three i18n bugs from the t-paliad-101 QA sweep, fixed together:
B2 — Fristenrechner deadline notes leaked German into the EN locale.
Migration 032 adds paliad.deadline_rules.deadline_notes_en (TEXT NULL)
and backfills English translations for all 30 rules that carry a
deadline_notes value (UPC RoP / EPC / ZPO terminology). The frontend
prefers _en when locale=EN and falls back to deadline_notes (DE) when
the column is NULL, so future seeds without an EN translation render
in DE rather than empty. UIDeadline DTO gains notesEN. The bulk
"Als Frist(en) speichern" CTA now stores the locale-matched note text
so EN users get an EN note alongside the EN title.
B8 — trigger-event picker labels were English-only when DE locale was
active (102 rows, name_de defaulted to '' in 028, frontend already had
the locale switch but no data). Migration 033 backfills name_de for
all 102 trigger events using standard German UPC RoP terminology
(Klageschrift, Klageerwiderung, Replik, Duplik, Nichtigkeitswiderklage,
Verletzungswiderklage, Berufungsschrift/-begründung, Anschlussberufung,
Schutzschrift, Beweissicherung, etc.).
S3 — frontend/src/client/checklists-instance.ts:154 had a hardcoded
"Project" label in both branches of the locale ternary; the DE branch
now reads "Projekt", matching the surrounding meta-item labels' pattern
(Court / Authority → Gericht / Behörde, Reference → Rechtsgrundlage).
PR-4 of the Fristen+Termine unification, closing out t-paliad-110.
Fristen rail (was 5 cards):
- Erledigt card removed (status=completed stays reachable via the EventsPage
filter dropdown — no card on the rail per the new model)
- Später card added (pending deadlines past Mon-week-after, click filters
to /deadlines?status=later)
- 4+1 final shape: Überfällig (conditional alarm) · Heute · Diese Woche ·
Nächste Woche · Später
Termine rail (new): 3 cards — Heute · Diese Woche · Später. No Überfällig
(past appointments aren't urgent), no Nächste Woche (low-value distinction
for appointments per the design rationale). Cards click through to
/appointments?status=… so users land in the matching EventsPage view.
Backend (DashboardService.loadSummary):
- DeadlineSummary.CompletedThisWeek dropped, .Later added
- AppointmentSummary added (Today / ThisWeek / Later)
- One CTE-based query computes both rails alongside MatterSummary; bucket
cutoffs share computeDeadlineBucketBounds with /api/events/summary +
/api/deadlines/summary so all three surfaces stay in lockstep
Frontend:
- dashboard.tsx: Erledigt card removed, Später card + Termine section added
- client/dashboard.ts: types updated, renderAppointmentSummary added
- 4 new i18n keys (DE+EN): dashboard.summary.later +
dashboard.appointment_summary.heading
- CSS: .dashboard-card-later (muted blue) + 3 .dashboard-card-appt-* rules
reusing the existing --bucket-* tokens
go build/vet/test ./... clean. bun run build clean (1396 keys).
PR-2 of the Fristen+Termine unification. Pure additive change — the existing
deadlines.tsx + appointments.tsx pages stay live; this PR introduces the new
events.tsx shell + client/events.ts runtime that PR-3 will mount onto the
two routes.
Frontend (new):
- frontend/src/events.tsx — shared shell with the 3-chip type toggle
(Fristen / Termine / Beides), the 5-card summary row (Überfällig
conditional + 4 universal cards), the union filter row, and the unified
table that renders a discriminated row per type. Two header CTAs ("Neue
Frist" + "Neuer Termin") collapse to the relevant one in single-type mode.
- frontend/src/client/events.ts — runtime. Reads window.__PALIAD_EVENTS__
(PR-3 will inject defaultType from the Go handler), derives the rest from
?type query param. Card click sets status filter; the events endpoint
takes care of bucket-aware appointment-side date windowing so both rails
stay in sync in Beides mode. Hide-on-uniform pattern applied per column
(rule, event_type, location, appointment_type, status, row-type chip).
- frontend/build.ts — emits events-deadlines.html + events-appointments.html
from one renderEvents(currentPath) so each output gets the right Sidebar
highlight; client/events.ts bundle added.
- 16 i18n keys (DE+EN): events.toggle.*, events.summary.later,
events.col.*, events.row.type.*, events.empty.*, events.unavailable plus
the new deadlines.summary.later / deadlines.filter.later pair for the
Später bucket.
- CSS: --bucket-later (#1d4ed8 light / #60a5fa dark) for the Später card,
matching events-table--hide-* column hiders, .events-row-type-chip
styling, .event-type-chip-row spacing.
Backend tweaks (small):
- DeadlineFilterLater (`later`): pending deadlines past Mon-week-after.
Click-target for the Später card.
- EventService.ListVisibleForUser now derives an appointment-side date
window from a bucket-style status (today/this_week/next_week/later) so
card clicks filter both rails consistently. Overdue/Completed exclude
appointments entirely (no appointment analogue).
- pickLater / pickEarlier helpers intersect the bucket-derived window with
any caller-supplied from/to.
go build/vet/test ./... clean. bun run build clean (1394 keys, IIFE prologue
guard passes).
PR-1 of the Fristen+Termine unification (t-paliad-110). Backend layer
only — no frontend changes; the existing /deadlines and /appointments
pages still render the type-specific UIs.
EventService delegates to DeadlineService + AppointmentService for the
actual reads (no duplicate visibility logic, no duplicate event_type
hydration), then projects both into the discriminated EventListItem
union and merges/sorts by event_date asc. The handler exposes:
GET /api/events?type=deadline|appointment|all&status=…&project_id=…
&event_type=…&type_filter=…&from=…&to=…
GET /api/events/summary?type=…&project_id=…
Bucket model (per t-paliad-110 spec, supersedes t-106):
- four universal cards: Heute · Diese Woche · Nächste Woche · Später
- Überfällig is deadline-only, conditional, alarm-styled when > 0
- Erledigt drops from the card row; stays available as a filter option
- appointments have no completed_at — past appointments aren't bucketed
The deadline-side cutoffs reuse computeDeadlineBucketBounds so
/api/events/summary and /api/deadlines/summary can never disagree.
Existing /api/deadlines and /api/appointments stay untouched —
calendars, project-detail panes, and CalDAV consumers still call them
directly.
Both surfaces now show the same buckets with the same labels and the same
cutoffs: Überfällig (conditional, alarming) · Heute · Diese Woche ·
Nächste Woche · Erledigt. Single-source bucket math via
computeDeadlineBucketBounds — Heute = today, Diese Woche = tomorrow
through the upcoming Sunday inclusive, Nächste Woche = next Monday
through next Sunday inclusive, all disjoint. Items past next Sunday
are visible only via "All open"/"Upcoming" filters; the Überfällig
card stays hidden when count == 0 and switches to a saturated red
pulse + bold white text when count > 0.
Filter dropdown on /deadlines gains today / next_week entries; old
"upcoming" filter still works as a back-compat alias for everything
pending past this Sunday so legacy bookmarks don't 4xx.
Tests: 8 deterministic table cases for the bucket pivots (every
weekday + a 21-day disjointness walk).
Extends the t-paliad-097 metadata pattern from checklist_* events to the
remaining audit families. Project Verlauf and Dashboard activity feed now
deep-link each event to its originating entity:
- deadline_{created,updated,completed,reopened} → /deadlines/{id}
- appointment_{created,updated} → /appointments/{id}
- note_created → /appointments/{id} | /deadlines/{id} | /projects/{id}
(most-specific parent — notes have no standalone page)
Backend (Go):
- deadline_service.go / appointment_service.go: switch single-entity
mutation events from insertProjectEvent to insertProjectEventWithMeta
carrying {"deadline_id"|"appointment_id": uuid}.
- note_service.go:insertWithAudit: derive metadata from noteParent so
the audit row records {note_id, deadline_id|appointment_id|project_id}.
Frontend (TS):
- projects-detail.ts: extract eventDetailHref(); wrapEventTitleLink
delegates to it. Comment block lists every wired event family.
- dashboard.ts:activityHref: same routing rules as the project Verlauf.
- global.css: .entity-event becomes position:relative; the
.entity-event-link::before pseudo expands the link's hit area to the
full card so a click anywhere on the row navigates (matches what m
expected from "die Karte ist verlinkt"). Hover lifts border + shadow.
Excluded by design (mirrors checklist_deleted exclusion):
- *_deleted events — entity is gone.
- deadlines_imported — bulk event with no single deadline_id; would
need an aggregate target the product doesn't have today.
Pre-metadata rows stay non-clickable (no backfill — same precedent as
t-paliad-097).
Two related checklist UX gaps:
1. Checklist events in a project's Verlauf tab were unclickable — and
nothing in the project_events row carried the originating instance ID.
Add an `insertProjectEventWithMeta` helper, write
{"checklist_instance_id": <uuid>} as project_events.metadata for
checklist_created / _renamed / _linked / _unlinked / _reset (skipped
for _deleted — instance is gone). Surface metadata on
/api/projects/{id}/events and on dashboard recent_activity. The
Verlauf renderer wraps the title in <a href="/checklists/instances/{id}">
when metadata.checklist_instance_id is present, and the dashboard's
activity feed deep-links the project ref to the instance directly for
checklist_* events. Existing rows (metadata `{}`) stay non-clickable —
no migration backfill needed.
2. /checklists previously demanded a template pick before any existing
instance was reachable. Add a tab nav (Vorlagen / Vorhandene Instanzen)
using the existing entity-tab pattern. New endpoint
GET /api/checklist-instances and ChecklistInstanceService.ListAllVisible
return every visible instance across templates + projects, joined with
project ref/title and sorted by created_at DESC. Rows show template,
instance name (linked), project link (or "Persönlich"), progress bar,
and created date. URL state (?tab=instances) keeps the active tab
shareable. EN + DE i18n covered for tab labels and column headers.
Also adds event.title.checklist_* localizations for the Verlauf header
that translateEvent looks up.
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.
Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
global.css
Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
Q6 of t-paliad-088 left firm-wide event_type creation open to any user; this
ships the moderation surface admins use to dedupe and clean up the resulting
drift.
Service layer (internal/services/event_type_service.go):
- ListAllForAdmin(filter) — firm-wide rows with usage_count and
author_display_name, optionally including archived (single query, scalar
subquery + LEFT JOIN paliad.users). Sorted live-first, then category +
label_de.
- ListPrivatePendingPromotion — every private non-archived row across all
users, sorted by usage_count DESC.
- ArchiveBulk(ids) — UPDATE archived_at=now() WHERE is_firm_wide AND NULL.
- Promote(id) — flip is_firm_wide=true; surfaces ErrEventTypeSlugTaken on
collision so the admin can merge instead.
- Restore(id) — flip archived_at back to NULL; same slug-collision surface.
- MergeIDs(winner, losers) — tx-scoped INSERT … SELECT … ON CONFLICT
redirect of deadline_event_types from losers → winner, then DELETE on the
loser junction rows, then archive the losers. Refuses if the winner is
archived or private. Junction PK does the dedup.
- requireAdmin gate runs at every method (defence-in-depth on top of the
handler-level RequireAdminFunc).
Handlers (internal/handlers/admin_event_types.go):
- GET /api/admin/event-types[?include_archived=1]
- GET /api/admin/event-types/private
- POST /api/admin/event-types/archive { ids:[…] }
- POST /api/admin/event-types/merge { winner_id, loser_ids:[…] }
- POST /api/admin/event-types/{id}/promote
- POST /api/admin/event-types/{id}/restore
- GET /admin/event-types page shell.
All wrapped behind auth.RequireAdminFunc at registration time.
Frontend:
- New /admin/event-types SPA (admin-event-types.tsx + client/admin-event-types.ts):
search, "Archivierte anzeigen" toggle, per-row archive/restore, bulk
archive, merge modal (winner picker defaults to highest-usage row),
separate table for private types pending promotion.
- Sidebar entry under Verwaltung; admin landing card.
- ~50 i18n keys DE+EN under admin.event_types.* + nav.admin.event_types.
- CSS for archived badge, merge option list, bulk-actions bar.
Out of scope (deferred): public "merge request" workflow for non-admins.
F-3 from t-paliad-074 architecture audit. NoteService used to call
ProjectService.GetByID and AppointmentService.GetByID just for the
visibility bit (~6 cross-service full-row reads in note_service.go).
Each was a full SELECT on the parent row when only a boolean was needed.
Add CanSee(ctx, userID, id) (bool, error) on the two parent services:
single EXISTS round-trip, no projection. Personal Appointments stay
visible only to their creator; project-anchored Appointments inherit
the project's visibility predicate (global_admin shortcut + team-walk).
NoteService gains two private helpers — requireProjectVisible and
requireAppointmentVisible — that wrap CanSee + ErrNotVisible. All
visibility-only sites in note_service.go (ListForProject /
ListForDeadline / ListForAppointment / ListForProjectEvent /
CreateForProject / CreateForDeadline / requireVisible) now go through
the helpers.
CreateForAppointment keeps appointment.GetByID — it legitimately needs
the appointment's project_id for the audit-event row.
DeadlineService.CanSee was not added: note_service never reaches into
the deadline service for visibility (it does its own SELECT project_id
FROM paliad.deadlines and gates via the project predicate).
Test: cansee_test.go covers the gate level for both new methods —
admin sees everything (global_admin shortcut), team member sees their
team's, non-member sees nothing, missing IDs are invisible to all,
personal appointments are private to creator.
Migration 030 adds paliad.event_types and paliad.deadline_event_types
junction. ~43 firm-wide seeds biased toward submissions (25 UPC
submissions + 8 UPC decisions/orders/hearings + 5 EPO + 4 DPMA/DE + 1
cross-jurisdiction). UPC-seeded rows carry a loose trigger_event_id
column (no FK constraint per Q2: event_types leads, trigger_events
follows). RLS policies are defense-in-depth — primary enforcement is
in the Go service layer. Per Q6, any authenticated user can create
firm-wide types; admins moderate via the soft-delete archive lever.
EventTypeService: List (firm-wide ∪ own-private), GetByID, Create
(slug auto-derived, supports diacritics → ASCII), Update (author OR
admin-on-firm-wide), SuggestSimilar (powers the duplicate-warning in
the add modal), AttachToDeadlineTx + ValidateForUser + ListForDeadlines
for the junction.
DeadlineService gains an EventTypeService dependency and now:
- accepts event_type_ids on Create / Update / CreateBulk
- attaches them in the same transaction as the deadline insert
- hydrates EventTypeIDs on every Get / List / ListForProject
- supports the multi-select Typ filter via ListFilter.EventTypeIDs +
IncludeUntyped (UNION semantics within types, AND-intersected with
Status/Project)
AgendaService gets the same Typ filter on its deadline side;
appointments are unaffected.
API:
- GET /api/event-types?category=&jurisdiction=
- GET /api/event-types/suggest?q=
- POST /api/event-types
- PATCH /api/event-types/{id} (set archive=true to hide)
- GET /api/deadlines?event_type=<uuid>,<uuid>,none
- GET /api/agenda?event_type=<uuid>,<uuid>,none
- POST/PATCH /api/deadlines accept event_type_ids: [uuid]
go build / go vet / go test ./... clean.
Frontend (picker + custom-add modal + multi-select filter) follows in
PR-2. Admin moderation panel deferred to t-paliad-089 follow-up.
Implements the four audit recommendations from §6.1 of
docs/audit-fristenrechner-completeness-2026-04-30.md plus a holiday-
adjustment cap fix surfaced by PR-2's smoke test.
(1) UPC_INF CCR-conditional rejoinder
Public Fristenrechner now flips inf.reply (RoP.029.b → RoP.029.a) and
inf.rejoin (1mo / RoP.029.c → 2mo / RoP.029.d) when the user ticks
"Mit Widerklage auf Nichtigkeit." Implemented via a new
`condition_flag` column on paliad.deadline_rules: when the rule names
a flag and the API request's flags array contains it, the calculator
substitutes alt_duration_value/unit and alt_rule_code. Independent of
the existing `condition_rule_id` mechanism (which references a real
rule in the same proceeding tree — only useful for matter-attached
trees that already seed the CCR rule).
(2) UPC_APP / internal APP grounds anchoring
`app.grounds` is now anchored on the trigger date (the appealed
decision) with a 4-month duration, not chained 2mo after `app.notice`.
Per RoP 220.1 the legal rule is "4 months from notification of the
decision," independent of when the notice itself was filed. The chain
only happened to give the right answer when both legs landed on a
working day; under holiday rollover (e.g. notice deadline pushed to
Monday) the grounds deadline drifted off the 4mo legal target.
(3) EP_GRANT publish anchor on priority date
New `anchor_alt` column on paliad.deadline_rules. ep_grant.publish
carries `anchor_alt='priority_date'`. The Fristenrechner UI surfaces
an optional "Prioritätstag" input (visible only when EP_GRANT is
selected) that, when populated, anchors the publish-A1 calculation on
the priority date instead of the filing. Falls back to filing date
when the priority field is empty (the case for purely-EP applications
with no foreign priority claim).
(4) Rule-code format normalisation
Migration 029 normalises 'RoP 23' → 'RoP.023', 'RoP 29b' / 'RoP.029b'
→ 'RoP.029.b', 'RoP 220.1' → 'RoP.220.1', etc. across deadline_rules.
Matches the canonical youpc format already used by the PR-1 imported
event-deadline rule codes.
(+) AdjustForNonWorkingDays cap bumped 30 → 60
Surfaced by the PR-2 smoke test: SoD on 2026-04-30 (3mo from trigger)
landed on Sat 2026-08-29 instead of Mon 2026-08-31. The 30-iteration
safety bound on AdjustForNonWorkingDays cannot walk past the 33-day
UPC summer vacation plus flanking weekends. Bumped to 60. Pure-Go
one-liner, locked by a follow-up production smoke (real
paliad.holidays seed has the UPC vacation).
Schema (migration 029): two new nullable text columns on
paliad.deadline_rules — `condition_flag` and `anchor_alt`. Both ignored
by every existing rule; only the rows updated above carry values.
Models: DeadlineRule gains ConditionFlag + AnchorAlt (nilable strings).
Service: FristenrechnerService.Calculate now takes a CalcOptions struct
(PriorityDateStr, Flags). API handler accepts optional priorityDate and
flags fields on POST /api/tools/fristenrechner.
Frontend: TSX surfaces the priority-date row + CCR checkbox conditionally
on selectedType (only EP_GRANT / UPC_INF respectively). Client TS reads
them and threads through the API call. New i18n keys for both DE+EN.
Migration 029 dry-run validated on prod Supabase (BEGIN/ROLLBACK):
schema + UPDATEs apply cleanly, rule states match expected post-fix
shape. Tests + go build/vet + bun build all clean.
Adds the second Fristenrechner mode (mirrored from youpc.org's deadline
calc): pick a UPC trigger event + date, see all deadlines that flow
from it. Coexists with the existing course-of-proceedings timeline mode
via a tab toggle on /tools/fristenrechner.
Backend:
- internal/services/event_deadline_service.go — EventDeadlineService.
ListTriggerEvents (alphabetical), Calculate (resolves all deadlines
flowing from a trigger). Routes through HolidayService for weekend +
holiday rollover. Honours the new working_days unit. Resolves
composite rules (alt_* + combine_op) by computing both legs and
picking max/min. Used by R.198/R.213 ("31d OR 20wd, whichever is
longer") imported in PR-1.
- internal/services/event_deadline_service_test.go — covers
addWorkingDays (forward, backward, zero, holiday-skip), composite
rule semantics, before-timing.
- internal/handlers/fristenrechner.go — two new endpoints:
GET /api/tools/trigger-events, POST /api/tools/event-deadlines.
- handlers.Services / dbServices: new EventDeadline / eventDeadline
field; wired in cmd/server/main.go from the same HolidayService.
Frontend:
- frontend/src/fristenrechner.tsx — tab strip + second wizard panel
(3 steps: trigger picker → date → flat result list).
- frontend/src/client/fristenrechner.ts — initEventMode wiring,
typeahead filter over the 102 trigger events, Calculate flow,
bilingual rendering, composite-rule labels, lang-change refresh.
- frontend/src/client/i18n.ts — 27 new keys (DE+EN) under
deadlines.mode.* and deadlines.event.* (incl. units, timing).
- frontend/src/styles/global.css — fristen-mode-tabs, mode-panel,
event-list, event-result-row visual style.
Working-day arithmetic detail: the new addWorkingDays helper steps
one day at a time and skips runs of non-working days (Sat/Sun + DE
federal + UPC vacations seeded via paliad.holidays). Day-zero is the
caller's job — addWorkingDays(0) returns the input unchanged so
callers can decide whether to roll forward via AdjustForNonWorkingDays.
Composite-rule resolution: when a row carries alt_duration_value +
alt_duration_unit + combine_op, Calculate computes both legs,
picks max/min, and surfaces a compositeNote like
"max(31 days, 20 working_days) → working_days leg" so the UI can
explain which leg won.
PR-3 will land Tier 1 bug fixes from the audit (CCR adaptive,
UPC_APP grounds anchoring, EP_GRANT priority, rule-code normalisation).
F-2 from t-paliad-074 audit. The inlined visibility predicate had drifted
back into 6 hot-path SQL sites despite the central helper extracted in
t-paliad-058. Consolidating now so future visibility changes (e.g.
Chinese-wall in design v2 §8) only need one edit.
**Sites converted (6):**
- dashboard_service.go:158, 214, 244, 274
- agenda_service.go:138, 204
All six replace `$N = 'global_admin' OR EXISTS (path-walk)` with the
existing `visibilityPredicatePositional("p", 1)` helper. The helper
resolves global_admin via EXISTS on paliad.users — the role string no
longer flows through positional args, removing one foot-gun (typo'd
literal mismatched against bound role) entirely. Equivalence verified
on the live youpc DB:
tester@hlc.de (global_admin, 1 team membership):
old predicate count = 11 new predicate count = 11
standard user (no team):
old predicate count = 0 new predicate count = 0
**No new helper variant added.** The audit suggested
`visibilityPredicateLateral`, but the existing positional helper drops
into the dashboard/agenda WHERE clauses unchanged — adding a redundant
variant would be technical debt. dashboard/agenda do not use LATERAL
JOIN; they use plain WHERE EXISTS in (sub-)SELECT context, which is
already what visibilityPredicatePositional emits.
**Other 4 sites flagged by audit — left intentionally:**
- reminder_service.go:312, 325 are role-restricted (`pt.role = 'lead'`)
membership checks, NOT visibility predicates. Adding a global_admin
shortcut to the lead branch would over-include rows: every global
admin would receive every project's lead-targeted reminder, even with
the `own.escalation_contact_id` override that exists precisely to
avoid that. global_admin already has its own dedicated branch in the
query (`$3 = TRUE AND own.escalation_contact_id IS NULL` at line 328).
- deadline_service.go:422 (`assertCanAdminProject`) is role-restricted
(`pt.role IN ('admin', 'lead')`) and already short-circuits global_admin
at the Go level before the SQL runs (line 413). Both halves correct;
no change needed.
- team_service.go:162 (`IsEffectiveMember`) was dead code with no callers
in the entire repo. "Is this user a structural team member?" and
"can this user see this project?" are different questions; adding a
global_admin shortcut would have conflated them. Deleted instead.
**Test:** new TestVisibilityPredicate_DashboardAgendaForGlobalAdmin in
visibility_test.go seeds a project + deadline + appointment + activity
event with project_teams empty, then asserts a global_admin sees all
four on /dashboard and /agenda while a standard user sees none. Skips
when TEST_DATABASE_URL is unset (matching the existing live-DB tests).
**Pre-existing finding (separate concern):** the live-DB test gate is
currently blocked locally by a stale `public.paliad_schema_migrations`
(version=2, dirty=t) left over from before the schema-pinned tracker
landed. Authoritative `paliad.paliad_schema_migrations` is at version
27, dirty=f. Out of scope for this task; should be filed as cleanup.
The suggestion + feedback handlers wrote to legacy public-schema tables
(`patholo_link_suggestions`, `patholo_link_feedback`) via Supabase PostgREST.
The patHoLo→Paliad rebrand moved those tables into the paliad schema as
`paliad.link_suggestions` / `paliad.link_feedback` — PostgREST is not
configured to expose paliad on the youpc Supabase, so all three callsites
500'd in prod.
Replace the PostgREST integration with a new LinkService backed by the same
sqlx pool every other paliad service uses. Schema-qualified table names
work directly via DATABASE_URL, the inconsistent supabaseInsert/Count
helpers go away, and the suggestion/feedback handlers now use requireDB
for clean 503s when the pool isn't wired.
handleSuggestionCount keeps its tolerant 0-on-error behaviour so the admin
badge never blocks page render. When DATABASE_URL is unset the count
endpoint returns 0 instead of 503 — knowledge-platform-only deployments
still serve the Link Hub page.
Flagged in t-paliad-074 (F-12).
Migration 027 renamed paliad.departments → paliad.partner_units and
paliad.department_members → paliad.partner_unit_members but two queries in
AdminDeleteUser were missed by the rename sweep, so admin off-boarding
500'd in prod. Update both DELETE/UPDATE statements and the surrounding
comments to match the current schema.
Flagged by ada in t-paliad-074 (F-1).
Pre-fix `time.NewTicker(time.Hour)` fired every hour from container start,
so a Dokploy redeploy at 13:27:50 produced ticks at HH:27:50 forever —
drifting the user-visible arrival of a 09:00-Berlin digest anywhere in the
09:xx hour, and entirely losing the slot when redeploys happened to land
during the slot's hour (m saw this on 2026-04-29).
Replace the simple ticker with a recompute-per-iteration sleep to the next
HH:00:00 boundary using nextTopOfHour(now). The recompute self-corrects
any clock skew or RunOnce duration rather than accumulating drift.
Add runStartupCatchUp: on boot, fire any user/slot whose hour has already
passed today but has no log row yet. The slot_date dedup makes this safe
(re-firing a logged slot is a no-op). Without this a single mistimed
redeploy still loses a day for affected users.
Tests: TestNextTopOfHour (boundary math, including the 13:27:50 signature
and sub-second offsets), TestNextTopOfHour_AlwaysLandsOnBoundary (fuzz
across an hour of offsets), TestNextTopOfHour_StableAfterRunOnce (confirms
the next fire is HH+1:00 after a fire at HH:00, not HH:00+delay1),
TestSlotPastDueToday (catch-up filter table), and a live-DB
TestRunStartupCatchUp_RecoversMissedMorningSlot covering the redeploy-at-
11:50-Berlin scenario plus dedup on a second startup the same day.
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.
Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
language file when no DB row, Save validates parse + structural
invariants and writes a version, Reset deletes the active row, Restore
copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
back to the embedded default if the active row is malformed at parse
time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
text/template strings stored in the (key, lang) row. Default subjects
ship with a {{/* keep this phrasing */}} comment pointing at the
reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
+ .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
RequireAdminFunc(users) admin middleware, same shape as /admin/team.
Frontend:
- /admin/email-templates list page — three cards (one per template),
each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
textarea + variable docs + actions on the left, sandboxed iframe
preview + version log on the right. 500 ms debounced live preview;
save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.
Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.
Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean
Design: docs/design-email-templates-2026-04-29.md.
Backend rename (frontend lands in next commit):
- Migration 026: rename paliad.departments → paliad.partner_units,
paliad.department_members → paliad.partner_unit_members, junction FK
department_id → partner_unit_id, plus all constraints/indexes/policies.
Pre-drop seed re-runs migration 019's logic to capture any users.dezernat
drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table
with RLS (any-authenticated read, global_admin write).
- models.User.Dezernat dropped. Department / DepartmentMember →
PartnerUnit / PartnerUnitMember.
- DepartmentService → PartnerUnitService (file renamed via git mv to
preserve blame). Every mutation now opens a tx and emits a
partner_unit_events row in the same tx (created/updated/deleted/
member_added/member_removed). Update emits before/after snapshots;
Delete emits BEFORE the cascade so the FK still resolves, then
ON DELETE SET NULL keeps the historical row.
- /api/departments/* → /api/partner-units/*. Handlers renamed.
- New /admin/partner-units page handler stub.
- AuditService UNIONs the new partner_unit_events source as a 4th
branch; handler accepts AuditSourcePartnerUnitEvents.
- user_service: drop dezernat from CreateUserInput / UpdateProfileInput
/ AdminCreateInput / AdminUpdateInput. CreateUserInput gains
PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and
the membership row + audit event are inserted in the same tx.
- Settings tab aliases drop dezernat/department.
- Legacy /dezernate and /departments now redirect to
/admin/partner-units (admins only see it; non-admins land on the
forbidden bounce).
go build / vet / test compile clean.
Replaces the "Geplant: Audit-Log" placeholder on /admin with a working
viewer that unions paliad.project_events + caldav_sync_log + reminder_log
into a single keyset-paginated timeline.
- AuditService.ListEntries (internal/services/audit_service.go) does one
UNION ALL across the three sources, projecting each into a unified
AuditEntry shape and ordering by (timestamp, id) DESC. Cursor is
(BeforeTS, BeforeID) — matches the project-event Verlauf pattern. ILIKE
search escapes %/_ so "100%" doesn't act as a wildcard.
- GET /api/audit-log (internal/handlers/audit.go) accepts
source/from/to/q/before_ts/before_id/limit, validates the cursor halves
are paired, and returns { entries, next_cursor }. Both API and the
GET /admin/audit-log SPA shell are wrapped in auth.RequireAdminFunc, so
non-admins get 403 (API) / 302 (browser) via the same gate /admin/team
uses.
- Frontend (admin-audit-log.tsx + client/admin-audit-log.ts) renders the
table with source dropdown, range presets (24h / 7d / 30d / custom /
all), free-text search (debounced 250ms), and "Weitere laden" cursor
pagination. project_events rows reuse translateEvent (t-paliad-067 PR-1)
for DE/EN narrative parity with the dashboard activity feed; caldav and
reminder rows have their own per-event-type i18n keys.
- /admin landing card moved from PLANNED to AVAILABLE; sidebar admin
group gains a third entry.
$2 was the offset, used only in the morning dateCond. Evening's query
referenced $1, $3, $4 — $2 was passed but unused, and Postgres can't
infer the type of an unreferenced parameter ('could not determine data
type of parameter $2', 42P18).
Inline offset directly into the morning dateCond as a literal '%d days'
(safe — it's clamped to ≥1 above). New positional layout:
$1 = today
$2 = userid
$3 = is_global_admin
Three rounds of SQL fights for one query. Adding integration coverage
to TestRunSlotForUser is a follow-up — it currently skips when
TEST_DATABASE_URL is unset, which is why none of these reached prod via
CI.
Previous fix replaced $arg with :arg per sqlx.Named convention but the
query body contains PostgreSQL `::TYPE` cast operators (`::uuid[]`,
`::date`, `::interval`). sqlx.Named eats the second `:` thinking it's
a named-arg prefix → 'syntax error at or near ":"'.
Switching to positional $1..$4 sidesteps sqlx.Named entirely. Args
passed directly to db.SelectContext.
Order: $1=today, $2=offset, $3=userid, $4=is_global_admin.
Same root cause as 1652436 — the $/literal token form was the right
intuition; the proper fix is positional, not :name.
fetchSlotDeadlines built the query with $today_arg / $userid_arg /
$offset_arg / $is_global_admin_arg placeholders. sqlx.Named only
recognises :name (colon prefix). Postgres got the literal $arg and
rejected with 'syntax error at or near "$"' on every tick — that's
why no reminder_log row has been written since the t-paliad-064 deploy
yesterday.
Changed all four placeholders to :name. The integration test that would
have caught this (TestRunSlotForUser) skips when TEST_DATABASE_URL is
unset, so CI never hit the live SQL.
Today's morning slot is permanently lost (hourly-tick design issue
tracked in t-paliad-069). Deploying now while Berlin hour=16 should
fire m's evening slot immediately on container startup via the
Start()→RunOnce() call.
Per docs/audit-polish-2-2026-04-29.md PR-1. Single concern: text rendered
to a German narrative that was still English or raw-keyed.
- F-04 deadlines-new.ts now references the existing fristen.field.akte.*
keys (the SSR template already used them) instead of the non-existent
fristen.field.project.* keys, so the picker no longer renders the raw
i18n key.
- F-07 + F-21 dashboard activity log + project Verlauf:
• i18n.ts gains the missing dashboard.action.short.project_type_changed
plus a parallel event.title.* key set (full noun-phrase form for
Verlauf, complementing the dashboard's verb form) and
event.description.* templates with {title}/{count}/{parent}
placeholders.
• New translateEvent(eventType, title, description) helper localizes a
stored project_events row for display; parses both new value-only
descriptions and legacy English+DE-mix shapes ("Deadline „Foo"
geändert", "Type case → litigation", "Note zu deadline hinzugefügt").
Wired into dashboard.ts and projects-detail.ts renderers.
• Go services now write descriptions as value-only payloads (the title,
the count, the parent slug, or "old → new") so future rows are
locale-clean. Affected services: deadline_service.go (5 sites),
appointment_service.go (3 sites), note_service.go (1 site),
project_service.go (2 sites: status_changed, project_type_changed).
• Translation covers historical project_events rows too — the
legacy-format parsers in translateEventDescription strip the English
"Type"/"Status" prefix and pull the quoted title out of "Deadline
„Foo" geändert" so DE/EN renders correctly without DB migration.
• Renamed dashboard.action.short.project_* DE labels from "...Akte" to
"...Projekt" to match the project-rename direction.
- F-10 deadlines list REGEL column now resolves rule_name/rule_name_en
via a JOIN-side alias on deadline_service.ListWithProjects (added
RuleName/RuleNameEN to DeadlineWithProject). New ruleDisplay() helper
prefers the localized rule name and falls back to em-dash; never
renders the raw rule_code slug ("inf.rejoin").
- F-12 fristen.col.akte and termine.col.akte DE values flip "Akte" →
"Projekt"; matching SSR placeholder text on deadlines.tsx and
appointments.tsx column headers (EN already said "Matter").
- F-29 the checklists empty-state hint on /projects/{id}/checklists is
split into prefix/link/suffix spans so the <a href="/checklists"> stays
intact after applyTranslations() runs (the previous single-string i18n
value collapsed the anchor on first paint).
- F-35 projekte.subtitle DE flips "Fälle" → "Verfahren" (matches the
actual type taxonomy: Mandant/Streitsache/Patent/Verfahren/Projekt).
Same fix on projekte.empty.hint. EN keeps "cases" since EN labels the
case type as "case".
- F-46 dashboard.greeting.prefix EN flips "Good day" → "Hello".
Verified
- go build ./... + go vet ./... + go test ./... all green.
- bun run build clean.
- Dashboard activity widget + project Verlauf renderer verified by
reading the translated paths; live smoke pending deploy.
Exposes paliad.users.escalation_contact_id (added in migration 025) via
the Benachrichtigungen tab so users can route DRINGEND/overdue
escalation to a specific colleague instead of the global_admins
fallback.
Service:
- UpdateProfileInput.EscalationContactID *string (empty = clear, matches
Dezernat tri-state pattern). Server-side validation rejects self-
pointer (also enforced by CHECK in migration 025) and unknown UUIDs.
Reminder read path:
- digestRow now carries owner.escalation_contact_id and the audience
predicate adds the override. visibleForCategory's "global admin"
branch suppresses when an override is set, so escalation does not
fan out to the whole admin team. Test table extended with override
cases (escalation contact sees overdue / DRINGEND, admin suppressed).
UI / client:
- New "Eskalations-Kontakt" section under Benachrichtigungen with a
select populated from /api/users (excluding self, sorted by name).
First option is the default-fallback marker; selecting it clears.
- savePrefs PATCHes escalation_contact_id alongside the existing
reminder fields.
i18n: einstellungen.prefs.escalation.{heading,hint,default_option}
in DE + EN.
docs/project-status.md: flips the open follow-up to "shipped".