Commit Graph

417 Commits

Author SHA1 Message Date
mAi
247e9005db Merge: t-paliad-248 Slice A — symmetric date-range picker + filter-bar wiring (m/paliad#79)
# Conflicts:
#	frontend/src/client/filter-bar/axes.ts
2026-05-25 15:51:36 +02:00
mAi
bcfde73815 feat(inbox): t-paliad-249 Slice A frontend — inbox dispatch + UI axes (m/paliad#80)
The /inbox surface drops "Genehmigungen" framing in favour of "Inbox"
and renders the unified feed.

- shape-list.ts: factor renderApprovalRow out of renderApprovalList so
  it can be reused alongside renderProjectEventInboxRow inside the new
  renderInboxList (row_action="inbox"). Project_event rows show a
  compact stream layout with an Öffnen link pointing at the right
  project tab (deadlines / appointments / notes).
- filter-bar gets two new axes: unread_only (binary chip cluster) +
  inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine
  / +Fristen). Both round-trip via url-codec; inbox_focus translates
  to (sources, project_event.event_types, approval_request.entity_types)
  at the bar's resolve step (applyInboxFocusOverlay).
- FilterSpec gains a top-level unread_only flag; the bar writes it
  when the user toggles the chip; the server overlays the cursor.
- /inbox header: new "Alles als gelesen markieren" button POSTs
  /api/inbox/mark-all-seen with up_to=<newest visible row> for
  race-safety against a second tab.
- INBOX_AXES adds project + project_event_kind as advanced override
  chips so power users can still narrow per kind.
- i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen
  / inbox.action.open / inbox.empty.feed / views.bar.unread_only.*  /
  views.bar.inbox_focus.* (DE + EN).
- url-codec round-trip tests for the two new axes.
2026-05-25 15:49:54 +02:00
mAi
31d78526cf feat(date-range-picker): t-paliad-248 — symmetric picker + filter-bar wiring
Slice A complete. Builds on the additive backend constants (commit
34e3d71) by shipping the user-facing surface.

# Pure helpers (no DOM)

frontend/src/client/date-range-picker-pure.ts (190 LoC) — TimeSpec
shape, ALL_HORIZONS / PAST_HORIZONS / NEXT_HORIZONS registries,
horizonBounds (mirrors view_service.go), isValidHorizon, isValidISODate
(strict — rejects 2026-02-30 etc.), validateCustomRange, parseURL /
serializeURL (canonical ?horizon=...&horizon_from=...&horizon_to=...
with default-omission), isDefault.

frontend/src/client/date-range-picker-pure.test.ts (38 bun tests,
118 expect calls): registries, horizon bounds for all 14 values,
ISO-date validity rejects calendar-impossible dates, validateCustomRange
on every error branch, parseURL fallback to default, serializeURL
default-omission + key-override + custom-bounds, full round-trip.

# DOM mount

frontend/src/client/date-range-picker.ts (290 LoC) — mountDateRangePicker
returns {element, getValue, setValue, close, destroy}. Trigger button
in a .multi-anchor wrapper, popover panel reusing .multi-panel
positioning. Symmetric chip row: past fan (right-aligned) | ALLES
centre (target glyph U+2316) | next fan. 'Anpassen' chip toggles an
inline date-pair editor with Apply / Cancel + a live validation
message that surfaces only the meaningful 'inverted range' error
during typing (empty/format errors are visible via the disabled
Apply button). Outside-click + Esc close the popover, focus returns
to the trigger. setValue lets the host sync from URL changes.

# Filter-bar wiring

frontend/src/client/filter-bar/axes.ts:renderTimeAxis — the disabled
'Anpassen' stub (t-paliad-163 Phase 2 placeholder) is gone; the axis
mounts the picker instead. New default presets surface 6 chips +
ALLES centre + Anpassen, plus the per-surface timePresets override
filters down to whatever subset the surface declares. 'any' still
maps to BarState.time = undefined to keep the canonical URL short
and preserve the existing 'no overlay' semantics.

frontend/src/client/filter-bar/types.ts — TimeOverlay.horizon union
extended with next_1d / next_14d / next_all / past_1d / past_14d /
past_all.

frontend/src/client/filter-bar/url-codec.ts — parseHorizon accepts
the six new values; existing 9 values continue to round-trip.

frontend/src/client/filter-bar/url-codec.test.ts — round-trip
iteration extended to all 14 horizons.

frontend/src/client/views/types.ts — TimeHorizon TS mirror extended.

frontend/src/client/projects-detail.ts — horizonBounds covers the
six new values (open-ended for next_all/past_all so the upstream
filter treats nil bounds as 'no narrowing in that direction').

# i18n + retired legacy keys

frontend/src/client/i18n.ts — 30 new keys per language (date_range.*
namespace for the picker + 6 missing views.horizon.* labels for
existing dynamic-key composition in views.ts:317). Legacy
views.bar.time.* keys (10 per language) retired with a one-line
breadcrumb comment pointing at the date_range.* namespace.

frontend/src/i18n-keys.ts — regenerated by build.ts.

# CSS

frontend/src/styles/global.css — date-range-* class block (256 LoC).
Trigger button, popover panel, past/centre/next groups, custom-range
editor, mobile stack at <540px. Reuses --color-accent /
--color-accent-light / --color-bg-lime-tint / --color-border /
--color-text + .agenda-chip / .agenda-chip-active for chip styling
so every active state lights up with the same lime accent as every
other paliad filter chip — no new tokens, no fresh dark-mode
contrast risk (t-paliad-150 / fritz lesson held).

# Surfaces lit up by this single change

- /projects/:id Verlauf (filter-bar consumer)
- /views runtime
- /views/:id Custom-Views editor
- /inbox InboxFilterBar

All four pick up the picker on their next page load. Per-surface
presets (timePresets MountOpt) preserved exactly; Verlauf still
shows the past-only subset, /inbox the forward-leaning subset etc.
The custom chip that's been disabled-with-coming_soon since
t-paliad-163 now works.

# Tests + build hygiene

- go build ./... clean
- go test ./internal/services/ clean (filter_spec + new bounds test)
- bun test passes (150 tests, 8 files, 377 expect calls)
- bun run build clean (2848 i18n keys, data-i18n scan clean)

# What's NOT in this slice

- /agenda chip-row migration (Slice B).
- /admin/audit-log + /projects/:id/chart migration (Slice C).
- upckommentar-style range slicer for custom mode (Slice D, separate
  task).
2026-05-25 15:47:51 +02:00
mAi
51fca9383f Merge: t-paliad-246 — Backup Mode Slice A (on-demand admin org export, local disk, .zip bundle, mig 123) (m/paliad#77) 2026-05-25 15:29:48 +02:00
mAi
99c9d89daa feat(backups): t-paliad-246 — Backup Mode Slice A (on-demand admin org export)
m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async
export) into a new "Backup Mode" surface gated by adminGate.

m's calls (all 4 material picks per design §2):
- Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only)
- Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved
- paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally
- Scheduler (Slice B): nightly 03:00 UTC, env-tunable

Wiring:
- mig 123 adds paliad.backups catalog table (kind/status/storage_uri/
  size/row_counts/warnings/error/deleted_at + admin-only RLS).
- ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets
  + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for
  snapshot consistency (design §3.3).
- writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext
  so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx
  (org snapshot path) work.
- BackupRunner orchestrates: catalog INSERT → audit INSERT
  (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch
  catalog + audit on success/failure.
- ArtifactStore interface + LocalDiskStore impl (defense-in-depth key
  validation + URI-outside-dir guard).
- Sentinel actor for scheduled runs: actor_email='system@paliad',
  actor_id=NULL — no phantom user in paliad.users.
- Admin handlers POST /api/admin/backups/run + GET list/get/download
  behind adminGate(users, …); /admin/backups page + sidebar entry +
  bilingual i18n keys.
- BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return
  503 otherwise (same shape as requireDB).

Tests: 8 pure-function tests cover registry shape (no dups, paliadin
absent both as sheet name and SQL substring, ref__* sheets unscoped,
every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key
rejection, URI-traversal rejection, mkdir on construction).

go build ./... + go test ./internal/... clean. bun run build clean.

Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish)
are separate follow-ups per head's instruction.
2026-05-25 15:28:37 +02:00
mAi
7e66da8def mAi: #92 - t-paliad-261 — submission-draft autosave focus + click-variable-in-preview jump
Two related editor polish fixes.

(A) Autosave-refresh focus preservation
  paintVariables() replaces every input via innerHTML, blowing away
  the focused-input reference and dropping the cursor mid-edit. Fix:
  capture the active variable input's data-var key + selectionStart/
  End/Direction before the repaint, restore on the new element after
  (by data-var lookup + setSelectionRange). Cursor stays put across
  autosave, rename, and reset cycles. Works for <input> and
  <textarea> via the shared selectionRange contract.

(B) Click variable in preview → jump to sidebar input
  Go renderer wraps every substituted placeholder value in the HTML
  preview with <span class="draft-var" data-var="key">…</span>.
  Implemented via a valueWrapperFn plumbed through
  substituteInDocumentXML → substituteInTextNodes /
  substituteAcrossRuns → replacePlaceholders. RenderHTML passes
  htmlPreviewWrapper which marks values with three PUA sentinels
  (U+E100/U+E101/U+E102) that emitTextWithDraftVars converts to the
  span pair inside docXMLToHTML. Missing-marker text is wrapped too
  so a clicked [KEIN WERT: foo] jumps to the empty field.

  Render() (.docx export) passes nil for wrap → output is byte-
  identical to pre-261. New test
  TestRender_DocxOutputUnchangedByPreviewWrap asserts the .docx never
  carries draft-var/data-var markup or PUA sentinels.

  Client wireDraftVars() adds .draft-var--has-input only to spans
  whose key resolves to a sidebar input — derived variables (e.g.
  today.iso) stay non-clickable. Click handler:
    scrollIntoView(smooth, center) → focus + select after 50ms →
    1.2s lime flash on the row.
  Keyboard accessible (Enter / Space) with role=button + aria-label.

CSS adds a subtle lime tint to every .draft-var so the user sees
what was substituted; --has-input layers cursor: pointer + brighter
hover background. Flash animation respects prefers-reduced-motion
via a steps(1, end) fallback.

Tests: TestRenderHTML_ExtractsParagraphsAndFormatting updated to
assert the new span wrap. New tests for missing-marker wrap +
.docx-path-untouched. Go + frontend builds clean.
2026-05-25 15:12:10 +02:00
mAi
ef21e43375 Merge: t-paliad-260 — submission-draft mobile layout (m/paliad#91) 2026-05-25 14:59:53 +02:00
mAi
4cb99fb627 mAi: #91 - t-paliad-260 — submission-draft mobile layout: drop sticky on sidebar at ≤900px
Approach A: stack vertically. At single-column widths the variable
editor was sticky + max-height: calc(100vh - 2rem), so it stayed
pinned at the top of the viewport while the user scrolled down to
read the preview, visually overlaying the preview pane.

Add a media-query override that switches the sidebar to position:
static, max-height: none, overflow-y: visible at the same ≤900px
breakpoint where the grid already collapses to one column. The
sidebar now reflows above the preview, takes its natural height,
and scrolls away as the user moves down — no overlay, no
horizontal scroll. Desktop (≥901px) layout unchanged: sidebar
keeps its sticky behavior side-by-side with the preview.

Verified at 375 / 414 / 768 / 1280 px in Playwright on the
populated editor body — same renderer serves both URL shapes
(/submissions/draft/{id} and
/projects/{id}/submissions/{code}/draft/{id}).
2026-05-25 14:58:21 +02:00
mAi
452ccdf127 Merge: t-paliad-258 — Deadline form Auto/Custom rule field + canonical rule-label display (m/paliad#89) 2026-05-25 14:56:18 +02:00
mAi
045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00
mAi
538c2d2da9 Merge: t-paliad-257 — Verfahrensablauf user-perspective column axis (Unsere Seite / Gericht / Gegnerseite) (m/paliad#88) 2026-05-25 14:34:38 +02:00
mAi
a9a9adbd2a mAi: #88 - Verfahrensablauf: column axis reframed to user-perspective
Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.

Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.

Changes:

- frontend/src/client/views/verfahrensablauf-core.ts:
  • ColumnsRow fields proactive/reactive → ours/opponent.
  • renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
    labels — no more variant-by-side label keys.
  • bucketDeadlinesIntoColumns routes the user's party into `ours`
    when opts.side ∈ {"defendant"}; default (null) keeps the legacy
    "we are claimant" fallback so claimant-on-left layout survives.

- verfahrensablauf-core.test.ts: rewritten expectations on the new
  ours/opponent fields. Added two new tests pinning the WE-on-left
  semantics and the side+appellant interaction (side=defendant +
  appellant=claimant → "both" collapses into opponent).

- fristenrechner.ts: wires currentPerspective into renderColumnsBody
  as `side` so the columns honour the chip-strip perspective.
  Without this, a defendant-perspective user would see claimant
  filings under the "Unsere Seite" header — the old code didn't
  need the wire-up because the labels weren't perspective-aware.

- i18n.ts: replaces deadlines.col.proactive(.defendant) +
  deadlines.col.reactive(.claimant) with deadlines.col.ours +
  deadlines.col.opponent ("Unsere Seite"/"Client Side",
  "Gegnerseite"/"Opponent Side"). Court key unchanged.

- i18n-keys.ts: regenerated key union.

- global.css: .fr-col-proactive/.fr-col-reactive renamed to
  .fr-col-ours/.fr-col-opponent.

Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
  has no project context, and /tools/fristenrechner already does this
  via applyOurSidePredefine().

Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
2026-05-25 14:32:57 +02:00
mAi
f24a90b722 Merge: t-paliad-252 — Approval withdraw warning modal + edit-instead path (m/paliad#83) 2026-05-25 14:26:20 +02:00
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
50cd80a4a6 Merge: t-paliad-255 — kill /events horizontal scroll on mobile (m/paliad#86) 2026-05-25 14:10:07 +02:00
mAi
716f6d7ece fix(events): t-paliad-255 — kill /events horizontal scroll on mobile
A native <select> sizes itself to the widest <option> text. With long
project titles in the matters filter, the select grew wider than the
viewport and the /events page scrolled horizontally on mobile.

The existing 480px media query forced .entity-select to width:100% on
phones, but the 481-1000px range (tablet portrait + landscape phones)
had no constraint at all and inherited the intrinsic select width.

Fix: cap .filter-group and .entity-select at max-width:100% with
min-width:0 so the cell can shrink to fit its flex container at every
viewport. Desktop layout is preserved — normal-length options still
sit in one row across the page; only pathological content (a single
title wider than the row) wraps onto its own line.

Approach: A — let the trigger respect its container at every width.

Verified: zero horizontal scroll at 320 / 375 / 414 / 768 px with a
realistic 130-character project title injected into the matters
selector. Desktop (1280px) keeps all four filter-groups in one row.
2026-05-25 14:08:44 +02:00
mAi
1bf62c78e3 Merge: t-paliad-251 — Deadline form overhaul (m/paliad#82) 2026-05-25 14:05:06 +02:00
mAi
8caaf6a631 mAi: #82 - deadline form overhaul: type-modal filter chips, type→rule autofill, Auto mode, Standardtitel
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.

Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
  (UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
  any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
  the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.

Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
  client-side: given a chosen event_type X, candidate rules are
  those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
  proceeding_type_id, (2) jurisdiction match on the rule's
  proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
  by court (jurisdiction grouping with optgroup), alphabetical.
  Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
  payload — no new endpoint.

Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
  surfaces whenever the Rule was derived from the chosen Type.
  Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
  "Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
  existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
  fill only replaces its own previous suggestion, never a manual
  pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
  auto-derived from the type — the "vorgegeben durch Regel" copy
  reads backwards in that case; show picker + Auto badge instead.

Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
  and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
    head =
      1. event_type label (if exactly one Typ chip is set)
      2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
      3. proceeding type name from project (create form only)
      4. fallback: t("deadlines.field.title.default_fallback")
    suffix = " — <project.reference>" when ref is set and not
             already in head.
  Examples:
    Klageerwiderung — C-UPC-0042       (type known)
    RoP.023 — Klageerwiderung — REF    (rule known, no type)
    UPC — Verletzungsverfahren — REF   (only proceeding type)
    Neue Frist — REF                   (fallback)
- Click REPLACES current title; no destructive confirmation
  because the user invoked it explicitly. Focus moves into the
  title input afterwards so the user can fine-tune.

Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
  schema, no service, no migration.

Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
  Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
  + show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
  badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
  .form-hint--auto, .form-hint-badge, .form-field-label-row,
  .btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
2026-05-25 14:03:04 +02:00
mAi
228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00
mAi
02255c4234 mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:

A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
   (Kläger/Beklagter/Beide) and an appellant selector. The side selector
   swaps which column labels which user-side; the appellant selector
   collapses party='both' rules into the appellant's column (no mirror)
   so role-swap proceedings (Appeal, etc.) stop showing every row
   twice in the timeline. Both selectors are URL-driven (?side= +
   ?appellant=) and re-render without a backend round-trip.

   The appellant row hides itself for proceedings without an appellant
   axis (first-instance Inf/Rev/Opp) via a small allowlist.

B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
   / "Appealable Decision" instead of falling back to the proceeding
   name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
   trigger_event_label_{de,en} column on paliad.proceeding_types (mig
   121); the frontend prefers it over the proceedingName fallback that
   fires when no rule has IsRootEvent=true. No new deadline rules, no
   slug changes (hard rule from the issue).

C. Parameter contract for the column projection is unified in
   bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
   helper extracted from renderColumnsBody so the routing behaviour
   stays unit-testable without a DOM. Tests cover the default mirror,
   appellant-collapse for both sides, side-swap of column ownership,
   the combined case, and row alignment by dueDate.

Verification

- go build ./...                        clean
- go test ./...                         all green
- bun run build (frontend)              clean
- bun test (frontend/src)               110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
  carries the curated trigger label pair.

Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
2026-05-25 13:57:38 +02:00
mAi
898348a64a Merge: t-paliad-245 — Daten Exportieren demoted into Verwaltung tab (m/paliad#76) 2026-05-25 13:34:53 +02:00
mAi
1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00
mAi
5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00
mAi
a911a2d0ee feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:

- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
  and rewrites the four RLS policies to gate purely on user_id when
  project_id IS NULL, otherwise on paliad.can_see_project. Down
  refuses to run if project-less rows exist (safer than silent
  data corruption).

- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
  layer skips project/parties/deadline lookups when nil and exposes
  DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
  ListAllForUser LEFT JOINs paliad.projects so project-less drafts
  surface in the global index next to project-scoped ones.

- New HTTP surface:
    GET  /submissions/new                 (picker page)
    GET  /submissions/draft/{draft_id}    (editor for any draft)
    GET  /api/submissions/catalog         (catalog without project)
    POST /api/submission-drafts           (project-less or attached)
    GET/PATCH/DELETE /api/submission-drafts/{draft_id}
    POST /api/submission-drafts/{draft_id}/export
  Existing /api/projects/{id}/submissions/... routes remain bit-
  identical so the project-scoped flow keeps working unchanged.

- Frontend: /submissions/new lists the full cross-proceeding catalog
  grouped by proceeding, filterable by text + chip. Each row offers
  "Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
  with autocomplete over visible projects). /submissions index gains
  a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
  the picker. The editor renders a banner + "Projekt zuweisen"
  action when project_id is null; assigning persists project_id and
  redirects to the project-scoped URL.

Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
2026-05-23 02:19:55 +02:00
mAi
8e195cb497 feat(submissions): t-paliad-242 — Schriftsätze tab shows full catalog grouped by proceeding
Per m's 2026-05-23 ask: from any project, surface every available
template/generator instead of just the project's own proceeding.

Backend (GET /api/projects/{id}/submissions):
- drop the proceeding_type_id filter; JOIN deadline_rules with
  proceeding_types to return every active+published filing rule
  across every active proceeding
- response gains proceeding_code, proceeding_name, proceeding_name_en
  per row plus project_proceeding_code at the top so the frontend
  can pin the project's own group
- has_template now reflects "per-submission .docx wired in
  submissionTemplateRegistry"; the editor still falls back to the
  universal HL Patents Style for everything else (t-paliad-238)
- can_see_project gate unchanged; rules are static reference data
- sorted by (proceeding_code, submission_code)

Frontend:
- client/submissions.ts renders a grouped table: project's own
  proceeding pinned to the top with a lime border + "(dieses
  Projekt)" suffix, every other proceeding alphabetised below
- "Generieren" + "Bearbeiten" buttons stay on every row (editor
  handles missing variables via [KEIN WERT: …])
- "universell"/"universal" badge surfaces for rules without a
  per-submission template — informational, not blocking
- soften the no_proceeding hint so the catalog still renders below
- entity-table-group-header CSS, including --own modifier and a
  read-only override so group rows don't pretend to be clickable

Verified: 103 filing rules across 19 proceedings surface (de.inf.lg,
upc.inf.cfi, epa.opp.opd, etc.). go build + go vet + go test
./internal/... + bun run build clean.
2026-05-23 01:55:32 +02:00
mAi
436c1b41bb feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index
Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.

Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.

Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
2026-05-23 01:29:56 +02:00
mAi
2c5f85b802 Merge: t-paliad-238 Slice A — dedicated Submissions draft editor + merge engine 2026-05-23 00:06:50 +02:00
mAi
d3aade5aac feat(submissions): t-paliad-238 Slice A — dedicated draft editor page
Adds the dedicated Submissions/Schriftsätze editor at
/projects/{id}/submissions/{code}/draft (and …/draft/{draft_id}) per
docs/design-submission-page-2026-05-22.md.

Lawyer picks (or creates) a named draft, edits placeholder variables
in a sticky sidebar, sees a read-only HTML preview of the merged
document body, and exports a .docx with project state + lawyer
overrides resolved. Drafts persist in paliad.submission_drafts
keyed on (project_id, submission_code, user_id, name) with RLS via
can_see_project; updates and deletes additionally gated on owner-only
(Q-E4 owner-scoped pick, m-confirmed).

Resurrected from git history per the design's "no rewrite" plan:
  SubmissionVarsService    ← commit 1765d5e (Slice 2 with patent_number_upc)
  SubmissionRenderer       ← commit 8ea3509 (in-house merge engine — the
                             lukasjarosch/go-docx library refuses sibling
                             placeholders in one run, which patent submissions
                             use routinely)
  ConvertDotmToDocx        ← existing format-only convert (kept; reused as
                             pre-pass so .dotm inputs strip macros before
                             merge)

New code:
  paliad.submission_drafts  migration 119 (idempotent — DROP POLICY IF EXISTS
                            + CREATE; CREATE OR REPLACE for the shared trigger
                            function). Applied to live DB.
  SubmissionDraftService    CRUD + autosave-friendly Update + Export/RenderPreview
                            entry points
  RenderHTML method         new on the renderer; walks the same merged
                            document.xml as Render but emits HTML for the
                            preview pane (Q-E3 server-side pick)
  7 API handlers            list / create / get / patch / delete / preview / export
  2 page routes             /draft and /draft/{draft_id}
  submission-draft.tsx      stand-alone editor page (header / sidebar /
                            preview / export button); served via
                            dist/submission-draft.html
  submission-draft.ts       client bundle — autosave (500ms debounce),
                            draft switcher, rename, delete, export with
                            blob download

Tab integration: existing /projects/{id}/#tab-submissions rows get
[Bearbeiten] alongside the existing [Generieren] one-click format-only
path — additive, no removal.

Slice A template: universal HL Patents Style .dotm (same path
t-paliad-230 uses). resolveSubmissionTemplate carries the
submission_code parameter so Slice B's TemplateRegistry wiring (per-
code .docx fallback chain) is a one-function swap.

Audit trail: paliad.system_audit_log row per export
(event_type='submission.exported') + paliad.project_events row
(event_type='submission_exported', timeline_kind='custom_milestone')
so the export surfaces on the project's Verlauf / SmartTimeline. No
paliad.documents write (Q-E2 inventor pick, head-ratified).

Tests: TestRender_* / TestPlaceholderRegex_* / TestRenderHTML_* +
TestLegalSourcePretty / TestOurSide* / TestPatentNumberUPC — all
green. go build / go vet / go test ./internal/... / bun run build all
clean.

Migration slot taken: 119.
2026-05-23 00:06:08 +02:00
mAi
c6a5416611 feat(projects): t-paliad-239 — add Checklist button on project Checklists tab
The project-detail Checklists tab now exposes an "Add Checklist"
button that opens a template picker modal. Picking a template POSTs
to /api/checklists/{slug}/instances with the current project_id and
the template title as the instance name; the table refreshes and a
transient success banner confirms the add. Reuses the catalog cache
across the tab renderer and modal so the second open doesn't refetch.

Closes the UX cul-de-sac in the previous empty-state copy that told
users to leave the page and create instances on the Vorlagen-Seite.
2026-05-22 23:56:14 +02:00
mAi
3ff1b23238 fix(timeline): t-paliad-237 — anchor lookup must traverse linked proceedings
On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.

The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.

Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).

Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
2026-05-22 23:43:15 +02:00
mAi
903225b593 Merge: tesla — dashboard widget size clamp on read + write 2026-05-22 15:53:59 +02:00
mAi
4cd2f05d33 fix(dashboard): t-paliad-238 — hidden widgets render at proper size in edit mode
Symptom (m, 2026-05-22): "super slim columns which I can move but not
resize - and they seem greyed out." Hidden widgets in edit mode were
rendering as 1×1 slivers because applyLayout left their inline grid-
column empty — placeWidgets skipped non-visible entries entirely, so
CSS Grid auto-flowed them into the next free cell at 1/12th width.
The greyed-out + no-resize-handle parts were correct UX signalling
that the widget is hidden; the slim rendering was the bug.

Fix:
- placeWidgets() gains a {includeHidden} option. When true, a second
  pass places hidden widgets after the visible pass — collision-aware
  + cursor-aware so the hidden tray stacks below the active layout
  without ever displacing a visible widget. applyLayout() passes
  includeHidden:true in edit mode.
- materializePositions() keeps the default (hidden widgets retain
  their stored coordinates so un-hiding restores them in place).

Server-side recovery (belt-and-braces):
- SanitizeForRead now also clamps each widget's W/H/X against the
  catalog Min/Max + grid bounds on load. Stale rows with W below MinW
  (or above MaxW, or X+W overflowing the grid) heal on the next
  /api/me/dashboard-layout GET and the cleaned spec is persisted
  back. W=0 stays 0 (auto/default sentinel — the placer expands it).
- The validator stays strict on write; the read-path sanitiser only
  exists to recover users who got into a bad state under the old
  rules.

Tests:
- bun: 4 new cases in dashboard-grid.test.ts pin includeHidden
  behaviour (hidden skipped by default, two-pass ordering, multi-
  hidden, no-overlap invariant).
- go: 7 sub-tests in dashboard_layout_spec_test.go cover each
  SanitizeForRead clamp (MinW, MaxW, grid-width, MaxH, X+W overflow,
  W=0 sentinel, negative X) plus a round-trip Validate guarantee.
2026-05-22 15:53:19 +02:00
mAi
83b00d13fe feat(projects): demote "Projekt archivieren" into Edit modal danger zone (t-paliad-236)
Archive is a rare, deliberate action — it doesn't deserve real estate next
to navigation / common actions on the project view. Move it from the
prominent entity-detail-footer button into the bottom of the Edit Project
modal as a tertiary btn-link-danger inside a visually separated
.modal-danger-zone (top-bordered section, right-aligned).

The visibility wiring (project-delete-wrap, admin-gated in renderHeader)
and click handler (project-delete-btn → delete-modal confirm flow) keep
the same DOM ids, so the existing confirm-modal and POST behaviour are
unchanged. Backend (/api/projects/{id} status=archived) untouched.
2026-05-22 15:51:34 +02:00
mAi
cdd27d674e feat(paliadin): stream + honest late-recovery (t-paliad-235)
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.

Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:

1. Switch the aichat backend to /chat/turn/stream
   - new AichatPaliadinService.RunTurnStream relays incremental chunks
   - SSE parser handles default `data:` frames (chunk/meta/done/error)
     and named `event: heartbeat` frames per the upstream contract
   - no more 130 s hard ceiling — stream stays open as long as data or
     heartbeats flow; silenceTimeout (90 s) catches a true upstream
     stall instead

2. Proof-of-life thinking events
   - handler emits `event: thinking` every 5 s while the upstream is
     silent (synthesised locally) AND relays aichat's `heartbeat`
     events as thinking pings
   - frontend renders a lime-dot pulse + monospace counter inside the
     assistant bubble — the user can SEE the chat is still working

3. Honest disconnect copy + real late-recovery
   - new dispatching endpoint GET /api/paliadin/turns/{id}/recover
   - aichat backend: asks aichat via GET /chat/conversations and
     /chat/conversations/{id}/turns whether the turn actually finished
   - legacy backend: falls through to the local row read (janitor)
   - frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
     while the recovery polls; on confirmed "lost" swaps to
     "Antwort konnte nicht zugestellt werden — bitte erneut stellen"
   - migration 118 adds aichat_conversation_id to paliadin_turns so
     the recovery has a fast path when the done frame arrived before
     the drop

Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.

13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.

go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
2026-05-22 15:17:24 +02:00
mAi
af073f87da fix(print): default to portrait, opt-in landscape for wide surfaces (t-paliad-233)
The smart-timeline-chart block in global.css declared @page { size: A4
landscape } inside @media print. @page rules are global even when nested
in selectors, so this leaked landscape onto every printed surface in
paliad — not just the chart.

Switch to named-page strategy:

- Default @page { size: A4 portrait; margin: 1.5cm 1.2cm }
- @page paliad-landscape { size: A4 landscape; margin: 1.5cm }
- @media print: body.<surface> { page: paliad-landscape } opts surfaces
  that need width into landscape via per-page body classes

Landscape opt-ins:
- body.page-kostenrechner — wide fee-tier tables
- body.page-projects-chart — horizontal Smart Timeline chart
- body.events-view-calendar — /events Kalender tab (month grid)
- body.views-shape-active-calendar / -timeline — Custom Views shapes
- body.verfahrensablauf-view-timeline — horizontal procedure timeline

Body classes:
- kostenrechner.tsx, projects-chart.tsx, verfahrensablauf.tsx now set
  page-<slug> on body
- verfahrensablauf.ts toggles verfahrensablauf-view-(timeline|columns)
  in initViewToggle
- views.ts toggles views-shape-active-<shape> in setActiveShape (mirrors
  the existing events.ts events-view-* pattern)

General print polish in the universal block (the catch-all at the bottom
of global.css):
- Hide .fab / .fab-button / .edit-mode-handle / .paliadin-widget /
  [data-print-hide] in print
- thead { display: table-header-group } so headers repeat across pages
- tr/th/td page-break-inside: avoid so rows don't split mid-cell
- h1-h6 page-break-after: avoid, orphans/widows: 3 for p/h*/li
- print-color-adjust: exact on brand-coloured headers + status pills
- a[href^="http"]::after content: " (" attr(href) ")" prints external
  URLs after their link text (opt-out via data-print-url="hide")
- body font-size: 11pt for print readability

Verified via Playwright on static dist build that:
- Default surfaces (dashboard, projects, fristenrechner, agenda, admin)
  match no page: rule → portrait
- kostenrechner, projects-chart match the landscape rule
- verfahrensablauf-view-columns → portrait, -view-timeline → landscape
- views-shape-active-list/-cards → portrait, -calendar/-timeline →
  landscape
- /events default (events-view-cards) → portrait, calendar toggle →
  landscape

go build ./... + go test ./internal/... + bun test (99 pass) + bun
run build all clean.
2026-05-21 22:01:46 +02:00
mAi
da8389b6e3 feat(projects): t-paliad-232 Verfahrenstyp picker + Schriftsätze CTA
Two-part fix from m's 2026-05-21 finding that the Schriftsätze tab
told users "Bitte zuerst einen Verfahrenstyp setzen" while the
project form had no field to set it. The `proceeding_type_id`
column was already on `paliad.projects` and accepted by the API.

  Part 1 — Verfahrenstyp picker on the case-fields block

    * frontend/src/components/ProjectFormFields.tsx — new optional
      <select id="project-proceeding-type-id"> rendered between
      Aktenzeichen and Mandantenrolle inside the type=case block.
      First option is "(nicht gesetzt)" / "(unset)".
    * frontend/src/client/project-form.ts — shared
      loadProceedingTypes() + populateProceedingTypeSelect()
      helpers. Options sorted by `code` (de.* → dpma.* → epa.* →
      upc.*). readPayload sends `proceeding_type_id` only when the
      user picked a value; prefillForm restores the saved id via
      dataset.preselect to survive the async populate race.
    * frontend/src/client/projects-new.ts — kicks off populate on
      DOMContentLoaded.
    * frontend/src/client/projects-detail.ts — edit-modal preload
      now awaits populate; the local loadProceedingTypes duplicate
      (used by the counterclaim modal) is replaced by the shared
      helper so both surfaces hit the same cache.

  Part 2 — Actionable empty-state on the Schriftsätze tab

    * frontend/src/projects-detail.tsx — the static <p> empty-state
      becomes a div with a "Projekt bearbeiten" button.
    * frontend/src/client/projects-detail.ts — openEditModal now
      accepts an optional focusFieldID; the new
      #project-submissions-edit-cta click handler calls it with
      "project-proceeding-type-id" so the picker is scrolled into
      view and focused right after the modal opens.

  i18n: new keys projects.field.proceeding_type{,.unset,.hint} and
  projects.detail.submissions.empty.no_proceeding.cta; reworded
  no_proceeding copy to match the new "edit the project" CTA.

  Backend already validates via validateProceedingTypeCategory
  (mig 087/088 fristenrechner-category guard). Added
  TestProjectService_CaseProceedingTypePicker exercising both the
  happy and reject paths through a `case`-typed Create.

Manual test path: open any case project → Edit → the Verfahrenstyp
picker shows below Aktenzeichen → save → the Schriftsätze tab now
lists the submission codes. Clicking the empty-state CTA jumps
straight to the picker.
2026-05-21 15:45:19 +02:00
mAi
7967839f78 Merge: t-paliad-230 — submission generator format-only convert (.dotm → .docx) 2026-05-21 15:26:31 +02:00
mAi
d86cac0b53 feat(submissions): t-paliad-230 format-only .dotm→.docx convert
m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.

Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:

* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
  format converter that strips word/vbaProject.bin, word/vbaData.xml,
  word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
  [Content_Types].xml (demotes the macro/template main type to plain
  docx, drops the .bin Default Extension and the macro Overrides), and
  rewrites word/_rels/document.xml.rels to drop the vbaProject +
  keyMapCustomizations relationships. Idempotent on a plain .docx.
  archive/zip + regex stdlib only — no new third-party dependencies.

* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
  /generate fetches the cached HL Patents Style .dotm (via a new
  fetchHLPatentsStyleBytes accessor on files.go that shares the same
  cache as /files/{slug}), converts, writes one paliad.system_audit_log
  row (event_type='submission.generated', metadata={submission_code,
  rule_name, filename}), and streams the .docx as an attachment. GET
  /api/projects/{id}/submissions still lists filing rules but
  has_template is unconditionally true (one universal template).

* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
  .docx, with Umlauts ASCII-folded and slashes → underscores.

Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.

Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):

  unzip -tq /tmp/hl-patents-style.converted.docx   → No errors
  python3 -c "import zipfile, xml.etree.ElementTree as ET; \
              z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
              [ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
  uv run --with python-docx python3 -c "import docx; \
              d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
              print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
              → 236 paragraphs, 168 styles, 1 section

All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.

go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
2026-05-21 15:23:24 +02:00
mAi
9f339747e5 feat(team): mailto: selection on project-detail Team tab (t-paliad-231)
Non-admins can now select team members directly on the project detail
Team tab and open a mailto: link in their local mail client with every
selected member queued in the To: line. No server call, no audit row —
the existing /admin/team server-SMTP broadcast (t-paliad-147) stays
admin-only and untouched.

Behaviour:
- Checkbox column on every team-body row (direct + ancestor-inherited).
  Rows for users without an email render a disabled checkbox so the
  column geometry stays uniform.
- Tri-state master checkbox in the header row toggles every visible,
  email-bearing row.
- Single "Mail an Auswahl" button above the table, disabled while the
  selection is empty. When one or more rows are selected the label
  picks up "(N)" and the title attribute spells out the count.
- Click composes mailto:a@x,b@y via the existing buildMailtoHref
  helper from broadcast.ts (RFC 6068 comma join + encodeURIComponent
  per address) and sets window.location.href. Pure client side.
- Selection is pruned to currently-rendered, email-bearing user_ids
  on every renderTeam call so removed members or members who lose
  their email drop out automatically.
2026-05-21 15:17:52 +02:00
mAi
d555d5f679 fix(dashboard): preserve query string on /deadlines → /events redirect
m's 2026-05-21 14:20 report: dashboard "Diese Woche" card linked to
/deadlines?status=this_week but the 301 to /events?type=deadline dropped
the query string, landing on the default Pending filter instead of the
This-Week bucket.

Two-part fix:

1. handleDeadlinesListRedirect now appends r.URL.RawQuery to the
   target so any filter (status, project_id, event_type, …) survives
   the redirect. Regression test pins all three shapes (no query,
   single param, multi param).

2. Dashboard summary cards point at the canonical
   /events?type=deadline&status=… URL directly — saves the 301 bounce
   and matches the URL the events page itself reads on load.

The five card values (overdue/today/this_week/next_week/later) are all
in STATUS_OPTIONS_DEADLINE in frontend/src/client/events.ts, so the
events page filter chip picks them up natively.
2026-05-21 14:23:04 +02:00
mAi
92d0340d74 fix(dashboard): t-paliad-228 — collision-aware widget placement (m/paliad#70)
After m/paliad#69's edit-mode overhaul, widgets visually overlapped on
mixed-size rows: a 12-col + 6-col swap, an auto-flow widget landing on
an explicit blocker, or a resize-grow into a sibling all produced
layouts that ignored colspan footprints when computing occupancy.

Extracts placement math from dashboard.ts into a pure ./dashboard-grid
module and adds an occupancy bitmap. Every visible widget is placed
once; explicit-position collisions are resolved by searching downward
from the requested row for the first w×h block that fits, preferring
the requested column. Resize-grow + drag-drop swap now reliably
produce no-overlap layouts because the placer cleans up after them.

x+w > GRID_COLUMNS is clamped in the placer instead of rendered as an
overflow — matches the validator's hard rule on the wire.

Adds 14 dashboard-grid.test.ts regressions covering the mixed-width
swap, resize-grow shifting siblings, multi-row widgets, and the
overflow clamp. Pure tests — no DOM.
2026-05-21 10:48:10 +02:00
mAi
f8245a06a6 fix(dashboard): t-paliad-227 — rebuild edit mode on a single 12-col grid (m/paliad#69)
Three issues from Slice B were entangled in the same root cause:

1. **Drag/drop reorder only swapped the first two same-size widgets.**
   Widgets lived in two parents (.container + .dashboard-columns); the
   old applyLayout used parent.appendChild per widget which physically
   moved every .container widget to the END of .container — past the
   .dashboard-columns row, edit-footer, and save-toast. Only the two
   columns inside .dashboard-columns swapped visibly because they
   shared a parent. Cross-row drags appeared to silently no-op.

2. **No resize affordance** — the design's per-widget sizing existed
   only on paper.

3. **Per-widget options were thin** — count + horizon dropdowns only.

This change rebuilds the whole layout primitive on a single 12-column
CSS grid:

Backend (internal/services/):
- DashboardWidgetRef gains x/y/w/h grid coordinates. Validator clamps
  against catalog MinW/MaxW/MinH/MaxH and rejects x+w > 12.
- WidgetDef gains DefaultW/H + MinW/MaxW/MinH/MaxH for the resize clamps.
- WidgetSettingsSchema gains Views ([{id,label_de,label_en}]), CountMax,
  HorizonMax. Validator accepts free-form ints inside [1,CountMax] in
  addition to dropdown presets, plus view-id against schema.
- WidgetCatalog wires views for upcoming-deadlines/-appointments (list,
  calendar), inline-agenda (timeline, list), recent-activity (full,
  compact), plus default sizes per widget.
- FactoryDefaultLayout greedy-packs visible widgets onto the grid,
  tracking row-max height so taller previous neighbours never overlap.

Frontend:
- dashboard.tsx: every widget moved into a single .dashboard-grid
  wrapper; matter-summary converted to a CollapsibleSection so it
  participates in the grid like everything else.
- applyLayout rewritten — never moves DOM nodes; writes inline
  grid-column / grid-row from computed placements. computePlacements
  trusts explicit positions and auto-flows the rest with the same
  rowMaxH-aware packer the backend uses.
- reorderViaDnd swaps (x, y) instead of array order; layout re-sorted
  by (y, x) so the persisted array matches visual order.
- Resize handles in edit mode: bottom-right pointer-drag, cellW/cellH
  derived from live grid metrics, snaps to grid + clamps to schema,
  autosaves on pointerup. Native HTML5 DnD suppressed during resize.
- afterLayoutMutation now materialises every visible widget's
  (x,y,w,h) so the spec stays self-describing — no mixed
  explicit/auto-flow on next render.
- Gear popover expanded: view segmented control, custom count/horizon
  numeric inputs alongside preset dropdowns, size (W/H) + position
  (X/Y) spinners. Every visible widget gets a gear in edit mode.
- View-aware renderers:
  - upcoming-deadlines / -appointments: list (default) or mini-month
    calendar with item dots.
  - inline-agenda: timeline (default) or flat list.
  - recent-activity: full (default) or compact (one-line per row).

CSS:
- .dashboard-grid (12 cols, dense auto-flow); collapses to single
  stack on narrow viewports.
- .dashboard-widget__resize handle (bottom-right diagonal stripes).
- .dashboard-widget__view-group segmented control.
- .dashboard-cal-* mini-calendar.
- .dashboard-activity-list--compact one-line variant.
- Grid items get card chrome via .dashboard-grid > .dashboard-section.

Tests:
- New: AcceptsCustomCountWithinMax, AcceptsValidView,
  RejectsUnknownView, RejectsViewOnNoViewWidget, GridPosition,
  GridSizeOutsideClamps, NoOverlap (greedy packer regression),
  AssignsPositions.
- Updated: BadSettings now asserts a value above CountMax (free-form
  values inside [1,CountMax] are valid; presets stay valid too).

Backwards-compatible: a stored layout without x/y/w/h still loads — the
client's auto-flow placer puts widgets into a clean single column until
the user customises. The first drag / resize / settings tweak
materialises all positions so subsequent renders are deterministic.
2026-05-21 09:54:23 +02:00
mAi
6b565be830 feat(dashboard): t-paliad-219 Slice C — catalog expansion + firm-wide admin default
Three additions on top of Slice B's edit-mode chrome.

**Catalog expansion (2 new widgets, default-hidden — opt-in via picker):**

- pinned-projects: surfaces a list of the user's pinned matters via the
  pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New
  DashboardService.loadPinnedProjects joins paliad.user_pinned_projects
  to paliad.projects under the standard visibility predicate, preserves
  pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects
  []PinnedProjectRef grows DashboardData; SetPinService wired
  post-construction to mirror the SetApprovalService pattern.

- quick-actions: pure UI affordance with three buttons linking to the
  existing /projects/new, /deadlines/new, /appointments/new routes. No
  backend payload, no settings schema.

Both default-hidden — m's brief asked for "high-value adds"; injecting
new widgets into every user's dashboard unannounced would be loud.
Factory test relaxed: visibility now matches catalog.DefaultVisible
instead of the previous "all-visible" invariant.

**Firm-wide admin default (mig 117 + new service + 4 endpoints):**

- paliad.firm_dashboard_default: single-row table (id smallint PK CHECK
  id=1) with layout_json + updated_by + updated_at. RLS: SELECT
  authenticated, no INSERT/UPDATE policy (writes go through the
  service-role connection behind the adminGate).
- FirmDashboardDefaultService Get/Set/Clear. Validates against the
  catalog on Set so an admin can't seed an invalid layout.
- DashboardLayoutService.SetFirmDefaultService wires in the firm
  source. Both GetOrSeed and ResetToDefault now prefer the firm
  default over the code-resident FactoryDefaultLayout when one is set.
  Nil-safe — empty firm row falls back to the factory layout, transient
  DB errors fall back too (a blip can't strand a user without a
  dashboard).
- HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin-
  gated). POST /api/me/dashboard-layout/promote: admin convenience —
  reads the admin's own current layout and stashes it as the firm
  default (saves the JSON-editor step; admins edit via /dashboard's
  normal editor, then click Promote).

**Frontend (Slice B's edit-mode footer grew an admin button):**

- "Als Firmen-Standard speichern" button in the edit footer; hidden via
  CSS-inline until syncPromoteButtonVisibility unhides for
  global_admin. Confirm() → POST /promote → toast.
- The existing "Auf Standard zurücksetzen" copy stays the same — the
  semantics now "firm default if set, else factory", which is the
  desired surface: users see one canonical "Standard" link.

i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*,
dashboard.edit.promote*). i18n-keys.ts regenerated by build.

m/paliad#46.

go build ./... clean; go vet ./... clean
go test ./internal/... clean (Slice C catalog test + factory-default
   test relaxation; FirmDashboardDefault round-trip tests gated on
   TEST_DATABASE_URL)
Migration 117 dry-run: PASS (other dry-run failures are pre-existing
   local-DB collisions on origin/main; mig 117 itself clean)
bun run build clean: dashboard.html carries new section markup + admin
   button; dashboard.js bundles renderPinnedProjects + promote handler
   + all new i18n keys
2026-05-20 19:15:32 +02:00
mAi
4bf0a719b0 feat(dashboard): t-paliad-219 Slice B — edit mode + drag/drop + autosave
Adds the user-facing dashboard customization UI on top of Slice A's
backend (already shipped). Off by default — view-mode DOM and behavior
are byte-identical to the factory render.

Anpassen toggle in the dashboard header flips body.dashboard-editing.
When on, every [data-widget-key] grows a chrome strip with drag handle,
↑/↓ keyboard reorder buttons, hide/show button, and ⚙ gear for widgets
with a settings schema. An edit footer below the activity widget
surfaces "+ Widget hinzufügen" and "Auf Standard zurücksetzen".

Drag-and-drop uses native HTML5 DnD (dragstart / dragover / drop) on
the widget element itself. ↑/↓ buttons are the keyboard + touch
fallback. Hide flips Visible:false in the layout draft; re-showing via
the picker either un-hides in place or appends to the end if the
widget was never added.

Picker modal uses the unified openModal() helper (t-paliad-217). Each
catalog entry shows title + description + active/hidden/absent pill;
tapping an inactive entry mutates the layout and the list re-renders
in place so the user can multi-add.

Gear popover anchors absolutely inside the widget. Per-widget knobs
follow the catalog's WidgetSettingsSchema: count {1,3,5,10,20} for
list widgets, horizon_days {7,14,30,60} for upcoming-deadlines/-appoint-
ments, horizon-only {14,30,60} for inline-agenda, count {1,3,5,10} for
inbox. Selecting a value scheduleSave()s; close on outside-click / Esc.

Autosave: every layout mutation → snapshot rollback target +
400ms-debounced PUT /api/me/dashboard-layout. Success flashes a
"Gespeichert" toast (1.5s); failure rolls back, re-renders, and shows
"Speichern fehlgeschlagen". Reset link → confirm() → POST /api/me/
dashboard-layout/reset, replacing currentLayout with the factory
default returned by the service.

Mobile (≤32rem): toggle becomes full-width tappable, drag handle
hides in favor of ↑/↓ buttons (touch DnD is unreliable), picker uses
the existing modal full-screen breakpoint, toast spans the row.

Frontend-only — Slice A already shipped GET/PUT/POST /api/me/dashboard-
layout, GET /api/dashboard-widget-catalog, and the three-blob shell
hydration (data, layout, catalog). The client reads __PALIAD_DASHBOARD
_CATALOG__ inline; fetch fallback on hydration miss.

i18n: 23 new keys × 2 langs (DE + EN) for the toggle, picker, gear,
toast, and reset confirm. The i18n-keys.ts regenerates on every build.

m/paliad#46.

go build ./... clean
go vet ./... clean
go test ./internal/... clean (24 dashboard-layout/widget-catalog unit tests pass)
go test ./cmd/server/ -run TestBootSmoke: SKIPS without TEST_DATABASE_URL
   (CI's clean test DB runs the boot-smoke gate)
bun run build clean: dashboard.html still carries the three placeholder
   tokens; dashboard.js bundles the edit-mode code + i18n keys
2026-05-20 18:42:41 +02:00
mAi
e56cb3b210 feat(checklists): t-paliad-225 Slice C frontend — Geteilte Vorlagen tab + outdated-template badge
m/paliad#61 Slice C frontend pass.

Discovery (Geteilte Vorlagen):
- New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene
  Instanzen". Filters the merged catalog response to authored entries
  not owned by the caller (firm-visible OR globally-promoted OR
  share-recipient). Tab state round-trips via ?tab=gallery.
- Regime filter pills (UPC / DE / EPA / OTHER) operate independently
  from the main Vorlagen tab.
- Cards show regime badge, item count, author line, visibility chip.
- Self-filter relies on /api/me email match — loadMe() fires once on
  page boot and is idempotent.

Versioning UI on /checklists/instances/{id}:
- "Vorlage aktualisiert" badge appears when the instance's
  template_version is known AND lags the live template version (only
  for authored templates; static templates never bump). Shows "v{from}
  → v{to}" delta.
- "Änderungen anzeigen" button opens a diff modal that compares the
  instance's template_snapshot against the live template body.
  Item-level grouping by (section title, item label). Surfaces added /
  removed / changed items with localised section labels. Empty state
  when only metadata changed.

i18n: 13 new keys per language (DE + EN) under
checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other,
and checklisten.instance.{outdated,diff}.*. Total 2666 keys.

Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test
+ TestBootSmoke ./cmd/server/ all green.
2026-05-20 15:50:38 +02:00
mAi
a93277a072 feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page
m/paliad#61 Slice B frontend pass.

Detail page (/checklists/{slug}) gains:
- Provenance line ("Erstellt von <author>") for authored templates,
  populated from the catalog response's owner_display_name.
- Owner action buttons: Bearbeiten (links to
  /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen,
  Löschen. Reveal driven by /api/me email match against the catalog
  response's owner_email.
- global_admin action buttons: "Als Firmen-Vorlage hinterlegen"
  (promote) when visibility != 'global'; "Aus Katalog entfernen"
  (demote) when visibility == 'global'. Reveal driven by /api/me
  global_role.

Share modal:
- Single modal with a kind-picker (Kollege / Office / Dezernat /
  Projekt) and a matching select per kind — sections toggle on the
  active kind.
- Recipient pickers populated from /api/users, /api/partner-units,
  /api/projects (loaded in parallel on open). Office options use the
  canonical 8-key set from internal/offices.
- Existing grants surface in a list under the form with per-row
  Entfernen buttons; Revoke confirms before DELETE.
- Errors surface inline (recipient-required, generic share failure).

i18n: 32 new keys per language (DE+EN) under checklisten.share.*
and checklisten.detail.promote/demote/delete.*. Total 2653 keys.

Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all
green; bun run build clean.
2026-05-20 15:38:43 +02:00
mAi
794617cbfd hotfix(checklists): disambiguate /checklists/{slug}/edit → /checklists/templates/{slug}/edit (production-down route conflict)
Go ServeMux refused to register patterns 'GET /checklists/{slug}/edit' (from
dirac's Slice A merge b418705) and 'GET /checklists/instances/{id}' (existing)
because both match '/checklists/instances/edit'. Container crash-looped on
boot since 13:32 UTC; paliad.de returned 404 from Traefik because no app was
listening.

Renaming the new template-edit route to /checklists/templates/{slug}/edit
disambiguates — '/templates/...' is a literal segment so the {slug} is now
strictly under a fixed prefix that can't collide with 'instances'.

Touches:
- internal/handlers/handlers.go:257 — route pattern
- frontend/src/client/checklists.ts:290 — Bearbeiten link
- frontend/src/client/checklists-author.ts:52 — URL parser regex
- frontend/src/checklists-author.tsx — doc comment

go build + bun run build clean.
2026-05-20 15:34:00 +02:00
mAi
7a1fd81d23 feat(checklists): t-paliad-225 Slice A frontend — Meine Vorlagen + authoring wizard
m/paliad#61 Slice A frontend pass.

Pages:
- /checklists gets a third tab "Meine Vorlagen" between Vorlagen and
  Vorhandene Instanzen — lists owned authored templates with regime
  badge, visibility chip, Bearbeiten / Löschen actions, "Neue Vorlage"
  CTA. Tab state round-trips via ?tab=mine.
- /checklists/new and /checklists/{slug}/edit serve a shared bundle
  (checklists-author.html). Client reads location.pathname to decide
  create vs edit mode; edit mode prefills from /api/checklists/templates/mine.

Wizard:
- Metadata form (title, description, regime UPC/DE/EPA/OTHER, court,
  reference, deadline, language de/en, visibility private/firm).
- Repeating section + item editor — add/remove sections, add/remove
  items per section, label + optional note + optional rule per item.
- Single-language authoring (lang column on paliad.checklists). The
  catalog read layer mirrors the title/description onto both DE and EN
  sides so the existing bilingual frontend renders without a special
  case for authored entries.
- Save POSTs (create) or PATCHes (edit) the template; visibility flip
  on edit goes through its own endpoint so the audit row captures the
  transition.

Merged catalog:
- /api/checklists now returns the merged list (static + DB visible);
  the Summary shape gained origin / visibility / owner_email /
  owner_display_name fields.

i18n: 55 new keys per language (110 total) under
checklisten.tab.mine.*, checklisten.mine.*, checklisten.author.*,
checklisten.detail.* (Bearbeiten/Löschen labels for Slice B). i18n
codegen total: 2621 keys.

Build hygiene: bun run build clean, go build clean, go vet clean,
go test ./internal/... + ./cmd/server/ all green.
2026-05-20 15:24:07 +02:00
mAi
0f98d2cd39 refactor(calendar): t-paliad-224 — retire standalone calendar pages + prune dead code
Delete the four orphan files behind /deadlines/calendar +
/appointments/calendar:
- frontend/src/{deadlines,appointments}-calendar.tsx
- frontend/src/client/{deadlines,appointments}-calendar.ts
The standalone pages were unreachable from the UI since t-paliad-110
(Sidebar/BottomNav point at /events?type=…); their only role was as
bookmark targets.

Handlers in internal/handlers/{deadlines,appointments}_pages.go now
301-redirect to /events?type=…&view=calendar so bookmarks still
work. Route registrations in handlers.go remain unchanged — the
gate + redirect pair gives us the same URL surface with one canonical
renderer.

build.ts: drop the renderDeadlinesCalendar / renderAppointmentsCalendar
imports + entry-point bundle paths + dist HTML writes.

frontend/src/client/paliadin-context.ts: drop the two route-key
matches for the standalone URLs (the client never sees those
pathnames any more — 301 fires server-side).

Dead CSS pruned in frontend/src/styles/global.css (~180 lines):
- .frist-calendar, .frist-cal-{controls,month-label,grid,cell,…}
  block (lines 7464-7613 pre-refactor)
- @media (max-width: 700px) { .frist-cal-cell { min-height: 64px; } }
- .termin-cal-legend{,-item}
- .frist-cal-popup-time
- .frist-cal-dot.events-cal-dot-appointment

All verified by grep across frontend/ + internal/ to have no
non-calendar consumers before deletion.

Dead i18n keys removed (DE + EN + i18n-keys.ts union type):
- deadlines.kalender.{title,heading,subtitle,list,today,empty}
- appointments.kalender.{title,heading,subtitle,list,empty}
- deadlines.list.calendar, appointments.list.calendar (button labels
  on the deleted standalone routes)
- events.calendar.empty (replaced by cal.day.no_entries inside
  mountCalendar's day view)

Per head decisions §11 Q1 + Q8 (drop standalone pages as 301s; drop
dead i18n now).

Tests: go build ./... clean; go test ./internal/... 9 packages pass;
cd frontend && bun run build clean (2535 i18n keys); bun test
frontend/src/client/{calendar,views}/ all 73/73 pass.
2026-05-20 15:23:28 +02:00
mAi
d0f732d0ec refactor(events): t-paliad-224 — fold Kalender tab into mountCalendar()
The /events Kalender view now mounts the canonical mountCalendar()
module from frontend/src/client/calendar/ — same renderer Custom
Views uses for shape=calendar. Drops the events-page-specific
month-grid + popup code path entirely.

What replaces what
- renderCalendar() / openCalPopup() / calDotClass / fmtMonthYear /
  isoDate / itemDateISO and the calYear/calMonth module state →
  one mountCalendar() handle (lazy, urlState=true).
- events-cal-prev / events-cal-next / events-cal-today buttons →
  toolbar in mountCalendar (includes its own 'Heute' button).
- modal popup on cell click → drill-down to day view (matches
  /views; head decision §11 Q2).
- @media min-height shrink on .frist-cal-cell → views-calendar-*
  responsive surface (CSS unchanged from /views).

Behavioural deltas vs pre-refactor
- /events Kalender now persists view+anchor in ?cal_view + ?cal_date
  (head decision §11 Q3) — refresh / share-link safe.
- Pills are kind-coded (deadline / appointment) rather than urgency-
  coded; matches /views (head decision §11 Q4 — drop subtype dot
  colouring, file as follow-up).
- Empty-month message gone; the per-day no-entries state from the
  day-view replaces it (head decision §11 Q8 — drop dead i18n).

Adapter: toCalendarItem() preserves the pre-refactor bucketing rule
— deadlines bucket on due_date, appointments on start_at, both fall
back to event_date.

events.tsx: 31-line calendar subtree (toolbar + grid + modal +
empty hint) reduces to a single host div. mountCalendar fills it
when the user picks Kalender.
2026-05-20 15:23:28 +02:00