Compare commits
102 Commits
mai/knuth/
...
mai/hermes
| Author | SHA1 | Date | |
|---|---|---|---|
| d4df81e374 | |||
| f4dee97493 | |||
| 7aed8e4ec5 | |||
| b429dabf9e | |||
| d3c28009de | |||
| 8be7af7cd6 | |||
| d52995a4d6 | |||
| f0c343c638 | |||
| f11390d18b | |||
| aa2f4aacc6 | |||
| 3d985ef0c2 | |||
| f72e8a7b85 | |||
| 013facb9db | |||
| ff503ffc43 | |||
| 05f7ea2af5 | |||
| df2a1275cb | |||
| 3700d68c68 | |||
| e0c8401482 | |||
| 247e9005db | |||
| e68b800d52 | |||
| bcfde73815 | |||
| 4ead2d08c1 | |||
| 31d78526cf | |||
| a8e2bd8350 | |||
| 8c94dccf83 | |||
| 90f5dd4b1b | |||
| 34e3d7188e | |||
| 24f3baf61f | |||
| 0f2f3e3ea1 | |||
| 2683c5f9cf | |||
| 51fca9383f | |||
| 99c9d89daa | |||
| 7bc6fdb18a | |||
| 94a9e7e5fb | |||
| f55648944c | |||
| 7e66da8def | |||
| ef21e43375 | |||
| 4cb99fb627 | |||
| 452ccdf127 | |||
| 045accc6d9 | |||
| e6b61b4d2e | |||
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a | |||
| f24a90b722 | |||
| 55bfe439f2 | |||
| 0ac26fe0ee | |||
| 72b64140e9 | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| c6a5416611 | |||
| d590be4bb7 | |||
| f7374a67cd | |||
| 3ff1b23238 | |||
| a88269c7c1 | |||
| 3d85ce5444 | |||
| 903225b593 | |||
| 4cd2f05d33 | |||
| b4f5af7f70 | |||
| 83b00d13fe | |||
| 34372ca4c8 | |||
| 65308651dd | |||
| d088de95eb | |||
| becf4f0ce3 | |||
| 924dbd9768 | |||
| 6c40823038 | |||
| 007ebc2794 | |||
| cdd27d674e | |||
| 28de2e56d0 | |||
| af073f87da | |||
| f22e918048 | |||
| 79d98cfeb8 | |||
| 19d95d6f5b | |||
| 17d149c09e | |||
| 7c7030c5bf | |||
| da8389b6e3 | |||
| 7967839f78 | |||
| 69f45893a3 | |||
| 9f339747e5 |
@@ -151,6 +151,14 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
partySvc := services.NewPartyService(pool, projectSvc)
|
||||
// t-paliad-238 — dedicated submission draft editor. The variable
|
||||
// bag service is shared between the renderer (export) and the
|
||||
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
||||
// (commits 3677c81 + 1765d5e + 8ea3509).
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -160,7 +168,8 @@ func main() {
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -211,6 +220,23 @@ func main() {
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
|
||||
// directory). Without it the /admin/backups handlers return 503
|
||||
// in the same shape as Paliadin's gate. The directory is created
|
||||
// (0700) on first use; a malformed path fails fast at boot so
|
||||
// misconfig surfaces before the server starts taking traffic.
|
||||
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
|
||||
store, err := services.NewLocalDiskStore(exportDir)
|
||||
if err != nil {
|
||||
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
|
||||
}
|
||||
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
|
||||
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
|
||||
} else {
|
||||
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
|
||||
@@ -41,5 +42,14 @@ services:
|
||||
- AICHAT_URL=${AICHAT_URL:-}
|
||||
- AICHAT_TOKEN=${AICHAT_TOKEN:-}
|
||||
- AICHAT_PERSONA=${AICHAT_PERSONA:-paliadin}
|
||||
# Backup Mode (m/paliad#77 Slice A). Local-disk export target; the
|
||||
# paliad_exports named volume below persists it across container
|
||||
# restarts. Unset → /admin/backups returns 503 (BackupService gate).
|
||||
- PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
volumes:
|
||||
- paliad_exports:/var/lib/paliad/exports
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
paliad_exports:
|
||||
|
||||
856
docs/design-date-range-picker-2026-05-25.md
Normal file
856
docs/design-date-range-picker-2026-05-25.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# Symmetric date-range picker — design
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Task:** t-paliad-248 (Gitea m/paliad#79)
|
||||
**Inventor:** atlas
|
||||
**Branch:** `mai/atlas/inventor-symmetric-date`
|
||||
**Status:** READ-ONLY design. Awaiting head's go/no-go before coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today paliad has **three independent date-range schemes** scattered across surfaces:
|
||||
|
||||
1. **`/agenda`** — future-only chip row [7|14|30|90 Tage], state `rangeDays`.
|
||||
2. **`/admin/audit-log`** — past-only `<select>` [24h|7d|30d|custom|all] + manual `<input type="date">` pair.
|
||||
3. **`/projects/:id/chart`** — symmetric `RangePreset` [1y|2y|all|custom] + manual date pair.
|
||||
|
||||
…plus a **fourth, unified `TimeHorizon` contract** (`internal/services/filter_spec.go`, mirrored in `frontend/src/client/views/types.ts`) that's used by the filter-bar, Verlauf, Custom Views, and InboxFilterBar — but its "Anpassen" custom-range chip is still stubbed (`filter-bar/axes.ts:105-112`, marked Phase 2, disabled, "coming soon" tooltip).
|
||||
|
||||
The fix is **not** "build a fourth scheme." The fix is to **finish the TimeHorizon contract** (add `past_14d`, `next_14d`, `past_all`, `next_all`), build **one reusable `<DateRangePicker>`** that emits a `TimeSpec`, then migrate the three legacy affordances to it.
|
||||
|
||||
**Layout (m's brief, locked):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ [Zeitraum: Nächste 30 Tage ▾] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓ click to open
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Vergangenheit (ALLE) Zukunft │
|
||||
│ [Ganze Vergangenheit] [⌖ ALLE] [Ganze Zukunft] │
|
||||
│ [90 T] [30 T] [14 T] [7 T] [7 T] [14 T] [30 T] [90 T] │
|
||||
│ │
|
||||
│ ── oder benutzerdefiniert ── │
|
||||
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Slice plan:**
|
||||
|
||||
- **Slice A** — `<DateRangePicker>` component + 4 new horizon constants (`past_14d`, `next_14d`, `past_all`, `next_all`). Wired onto filter-bar `time` axis first (lights up Verlauf + InboxFilterBar + views simultaneously by replacing the stubbed Phase-2 chip).
|
||||
- **Slice B** — `/agenda` migrates (highest-traffic standalone consumer).
|
||||
- **Slice C** — `/admin/audit-log` + `/projects/:id/chart` migrate. Each surface picks the preset subset it cares about.
|
||||
- **Slice D** *(optional, later)* — upckommentar-style two-handle slicer replaces the inline date-pair for the "custom" mode.
|
||||
|
||||
**Hard rules honoured:**
|
||||
|
||||
- No new top-level table or migration in Slice A — purely additive enum values + Go switch arms.
|
||||
- No new dependency in Slice A — slicer is deferred (it's a non-trivial port from Svelte to paliad's plain TSX renderer).
|
||||
- Backward-compatible URL shape — each surface keeps its current short-alias parser (e.g. `?range=30` → `horizon=next_30d`) and additionally accepts the canonical `?horizon=…&from=…&to=…`.
|
||||
|
||||
---
|
||||
|
||||
## §1 Current state — every date-range affordance
|
||||
|
||||
Cataloguing **every** place a paliad user picks a past/future window, with file:line refs.
|
||||
|
||||
### 1.1 `/agenda` — future-only chip row
|
||||
|
||||
`frontend/src/agenda.tsx:64-67`:
|
||||
|
||||
```tsx
|
||||
<button className="agenda-chip" data-range="7" >7 Tage</button>
|
||||
<button className="agenda-chip" data-range="14" >14 Tage</button>
|
||||
<button className="agenda-chip" data-range="30" >30 Tage</button>
|
||||
<button className="agenda-chip" data-range="90" >90 Tage</button>
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/agenda.ts:80-104`:
|
||||
|
||||
- `state.rangeDays ∈ {7,14,30,90}` (set `VALID_RANGES`). Default `30`.
|
||||
- URL: `?range=30&types=…&event_type=…`.
|
||||
- Fetch: `GET /api/agenda?from=<today>&to=<today+rangeDays-1>&types=…`.
|
||||
- **Future-only by construction** — m's complaint applies precisely here. No "past 7 days" affordance, no "all" affordance.
|
||||
|
||||
### 1.2 `/admin/audit-log` — past-only `<select>` + manual date pair
|
||||
|
||||
`frontend/src/admin-audit-log.tsx:50-65`:
|
||||
|
||||
```tsx
|
||||
<select id="audit-range">
|
||||
<option value="24h">Letzte 24h</option>
|
||||
<option value="7d" selected>Letzte 7 Tage</option>
|
||||
<option value="30d">Letzte 30 Tage</option>
|
||||
<option value="custom">Benutzerdefiniert</option>
|
||||
<option value="all">Alles</option>
|
||||
</select>
|
||||
<!-- custom toggles a date-pair: -->
|
||||
<input type="date" id="audit-from" />
|
||||
<input type="date" id="audit-to" />
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/admin-audit-log.ts:135-174`:
|
||||
|
||||
- `rangePresetToFrom(preset)` converts `"24h" | "7d" | "30d"` → `Date`. `"custom"` reads `from`/`to` inputs. `"all"` clears both bounds.
|
||||
- URL: `?source=…&range=7d&q=…&from=…&to=…&limit=…&before_ts=…&before_id=…` (cursor-paged).
|
||||
- **Past-only by construction.** No future-projection — this is an audit log, looking forward makes no sense.
|
||||
|
||||
### 1.3 `/projects/:id/chart` — symmetric `RangePreset`
|
||||
|
||||
`frontend/src/client/views/types.ts:77-79`:
|
||||
|
||||
```ts
|
||||
range_preset?: "1y" | "2y" | "all" | "custom";
|
||||
range_from?: string;
|
||||
range_to?: string;
|
||||
```
|
||||
|
||||
UI `frontend/src/projects-chart.tsx:78-82`:
|
||||
|
||||
```tsx
|
||||
<input type="date" id="projects-chart-range-from" />
|
||||
<input type="date" id="projects-chart-range-to" />
|
||||
```
|
||||
|
||||
State machine `frontend/src/client/projects-chart.ts:73-118`:
|
||||
|
||||
- `rangeFromURL()` → `{preset, from?, to?}` with default `"1y"`.
|
||||
- "1y" = `today-1y..today+1y`, "2y" = `today-2y..today+2y`, "all" derived from loaded events, "custom" = read inputs.
|
||||
- URL: `?range=1y&from=YYYY-MM-DD&to=YYYY-MM-DD`.
|
||||
- **Symmetric around today** by construction — this is a chart, not a filter; the user is panning a viewport, not picking a fan.
|
||||
|
||||
### 1.4 `views-editor.tsx` (Custom Views config form)
|
||||
|
||||
`frontend/src/views-editor.tsx:102-109`:
|
||||
|
||||
```tsx
|
||||
<select id="editor-time-horizon">
|
||||
<option value="next_7d">Nächste 7 Tage</option>
|
||||
<option value="next_30d">Nächste 30 Tage</option>
|
||||
<option value="next_90d">Nächste 90 Tage</option>
|
||||
<option value="past_30d">Letzte 30 Tage</option>
|
||||
<option value="past_90d">Letzte 90 Tage</option>
|
||||
<option value="any">Beliebig</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
- Mixes past + future, but only 5 horizons exposed (no 14d, no past_7d, no all).
|
||||
- Persists into `paliad.user_views.filter_spec` (JSON column) as a `TimeSpec`.
|
||||
- **This is the closest existing affordance to m's symmetric fan**, but rendered as a plain `<select>` and incomplete.
|
||||
|
||||
### 1.5 Filter-bar `time` axis (riemann's t-paliad-163 Phase 1)
|
||||
|
||||
`frontend/src/client/filter-bar/axes.ts:65-115`:
|
||||
|
||||
- Renders a chip cluster: `[next_7d, next_30d, next_90d, past_30d, any]` (default presets, line 77-79).
|
||||
- **"Anpassen" chip is disabled** with `coming_soon` tooltip (line 108-112). This is the documented Phase 2 substrate.
|
||||
- Surfaces declaring axis `time` thread their own preset list via `RenderAxisOpts.timePresets` — e.g. Verlauf overrides to `["past_7d","past_30d","past_90d","any"]` (`frontend/src/client/projects-detail.ts:2310`).
|
||||
|
||||
Consumers:
|
||||
- `/projects/:id` Verlauf (`projects-detail.ts:2296` initial state, 2310 preset override).
|
||||
- `/views` and `/views/:id` (Custom Views runtime).
|
||||
- `/inbox` (`InboxFilterBar` flow — t-paliad-138/139 derived inbox).
|
||||
|
||||
### 1.6 `horizonBounds()` — the materializer
|
||||
|
||||
`frontend/src/client/projects-detail.ts:393-406` mirrors the Go-side `computeViewSpecBounds()` (`internal/services/view_service.go:156-187`):
|
||||
|
||||
```ts
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
default: return {};
|
||||
```
|
||||
|
||||
(Backend equivalent: `internal/services/view_service.go:160-186`.)
|
||||
|
||||
### 1.7 Single-date inputs (NOT date-range — listed for completeness)
|
||||
|
||||
These are out of scope but mentioned so the audit is exhaustive:
|
||||
|
||||
- `verfahrensablauf.tsx:174` — `#trigger-date` (calculator anchor).
|
||||
- `fristenrechner.tsx:496,504,616` — `#trigger-date`, `#priority-date`, `#event-date` (calculator).
|
||||
- `admin-rules-edit.tsx:265` — `#preview-trigger-date`.
|
||||
- `deadlines-detail.tsx:82` — `#deadline-due-edit` (inline-edit).
|
||||
- `deadlines-new.tsx:116` — `#deadline-due` (form).
|
||||
- `appointments-new.tsx`, `appointments-detail.tsx` — `start_at`/`end_at`.
|
||||
- `projects-detail.tsx:181` — `#smart-timeline-milestone-date` (add-milestone modal).
|
||||
- `components/ProjectFormFields.tsx:134,138` — `#project-filing-date`, `#project-grant-date`.
|
||||
|
||||
### 1.8 Summary matrix
|
||||
|
||||
| Surface | Direction | Presets | Custom | URL contract | Default |
|
||||
|---|---|---|---|---|---|
|
||||
| `/agenda` | Future | 7\|14\|30\|90 | — | `?range=N` | 30d |
|
||||
| `/admin/audit-log` | Past | 24h\|7d\|30d\|all + custom | date pair | `?range=…&from=…&to=…` | 7d |
|
||||
| `/projects/:id/chart` | Symmetric ±N | 1y\|2y\|all + custom | date pair | `?range=…&from=…&to=…` | 1y |
|
||||
| `/views/:id` editor | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|past_90d\|any | — | persisted JSON | next_30d |
|
||||
| Filter-bar `time` axis | Past+Future mix | next_7d\|next_30d\|next_90d\|past_30d\|any | **stubbed** | persisted + `?…__time_from=` | per surface |
|
||||
| Verlauf | Past + any | past_7d\|past_30d\|past_90d\|any | **stubbed** | URL | past_30d |
|
||||
| InboxFilterBar | Mix | filter-bar default | **stubbed** | URL | per surface |
|
||||
|
||||
Three of seven surfaces have **incomplete** custom-range affordances. None of the seven exposes the full symmetric fan m wants.
|
||||
|
||||
---
|
||||
|
||||
## §2 upckommentar slicer pattern
|
||||
|
||||
Verified by reading source at `/home/m/dev/web/upc-kommentar/src/lib/`:
|
||||
|
||||
- **`DateRangeSlider.svelte`** (component, 448 lines).
|
||||
- **`date-range-slider-pure.ts`** (pure-math helpers, 487 lines, fully unit-tested).
|
||||
- **`InboxFilterBar.svelte`** (host).
|
||||
|
||||
### 2.1 What it is
|
||||
|
||||
A **two-handle range slider** that wraps `svelte-range-slider-pips` (npm: `svelte-range-slider-pips@4`). The slider's rail is the upckommentar floor (`2023-01-01`) to today, and the two handles define `dateFrom` and `dateTo`. Step is **1 day** regardless of zoom.
|
||||
|
||||
Public contract (DateRangeSlider.svelte:57-82):
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
minISO: string; // axis lower bound, default 2023-01-01
|
||||
maxISO: string; // axis upper bound, today
|
||||
fromISO: string | null; // current From (null = parked at min)
|
||||
toISO: string | null; // current To (null = parked at max)
|
||||
onChange: (from, to) => void; // emits on every slider change
|
||||
testid?: string;
|
||||
axisWidthPx?: number; // test override for jsdom
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Anchor rail + granularity
|
||||
|
||||
Below the slider rail is a **custom-rendered anchor rail** (the lib's own pips are hidden via `pips={false}` because they're evenly-spaced approximations — issue #42 in upckommentar). Anchor day-numbers come from `pipAnchorsFor(granularity, minDay, maxDay)`:
|
||||
|
||||
- **year:** every Jan 1 in range.
|
||||
- **month:** every 1st-of-month.
|
||||
- **day:** every Monday.
|
||||
|
||||
Edges (`minDay`, `maxDay`) are always anchors so the user can park at the slider's extremes.
|
||||
|
||||
Granularity has **+/- zoom buttons** in the top-right of the slider (`year → month → day`), with each level showing more anchors.
|
||||
|
||||
### 2.3 Click-to-snap (left half / right half)
|
||||
|
||||
`DateRangeSlider.svelte:219-240` + pure helper `endOfPeriodDay()`:
|
||||
|
||||
- **Left half of an anchor label** → snap closest handle to **start** of period (the anchor day itself, e.g. Jan 1).
|
||||
- **Right half of the same label** → snap to **end** of period (Dec 31 for year, last-of-month for month, Sunday for day).
|
||||
- Keyboard activation falls back to left-half (start-of-period) deterministically.
|
||||
|
||||
### 2.4 Label thinning + two-row alternation
|
||||
|
||||
`pipLabelStrideFor()` + `pipLabelRow()` (pure helpers):
|
||||
|
||||
- Measures rail width via `ResizeObserver`.
|
||||
- Computes a stride — only every Nth label is rendered.
|
||||
- Adjacent rendered labels alternate row 0 / row 1 (~1.1em offset down) so they can sit closer horizontally without colliding.
|
||||
|
||||
### 2.5 Handle behaviour
|
||||
|
||||
- `range=true` draws a colored bar between handles.
|
||||
- `draggy=true` lets the user drag the **bar itself** to shift the window without changing its width.
|
||||
- `pushy=true` — handles push each other when crossed.
|
||||
- `float=true` — tooltip floats above the dragged handle showing `DD.MM.YYYY`.
|
||||
|
||||
### 2.6 URL contract on host
|
||||
|
||||
`InboxFilterBar.svelte` debounces `onChange` at 250ms, then writes:
|
||||
|
||||
```
|
||||
?date_from=2024-03-15&date_to=2024-09-30
|
||||
```
|
||||
|
||||
When a handle is parked at min/max, that bound is **omitted** from the URL (`valuesToFromTo()` in the pure module). So `?date_from=2024-03-15` alone means "from March 15 onwards, no upper bound."
|
||||
|
||||
### 2.7 What's worth borrowing for paliad
|
||||
|
||||
| Element | Borrow? | Why |
|
||||
|---|---|---|
|
||||
| Two-handle drag | **Yes — but defer to Slice D** | Excellent fine-tune UX. Non-trivial to port without `svelte-range-slider-pips` (or a Svelte ↔ TSX adapter). |
|
||||
| Anchor rail with click-to-snap | Yes (in Slice D) | Year/month/Monday anchors are the right granularities. |
|
||||
| Label thinning + two-row alternation | Yes (in Slice D) | Makes the rail readable at any width. |
|
||||
| Granularity + zoom +/- | Yes (in Slice D) | Single most useful interaction; users don't drag pixel-precise. |
|
||||
| Epoch-day pure math | Yes — verbatim | The `date-range-slider-pure.ts` module is well-tested and dependency-free. Port to TS in paliad's pure-helper layer. |
|
||||
| `null` = parked at edge | Yes — already aligned | TimeHorizon's `past_all` / `next_all` map cleanly to "one bound parked at infinity." |
|
||||
| The library `svelte-range-slider-pips` itself | **No** | Adds a Svelte dependency to a non-Svelte project. Slice D would build a tiny equivalent on top of `<input type="range">` × 2 + CSS — or vendor the lib's pure parts. |
|
||||
|
||||
### 2.8 What does NOT apply to paliad
|
||||
|
||||
- **Floor at 2023-01-01.** upckommentar starts at the UPC's first day. paliad has decade-old patents and future-projecting deadlines; the axis must extend in both directions. We use `today ± 5 years` as the default visible range with `past_all` / `next_all` chips to escape it.
|
||||
- **Single granularity locked per session.** upckommentar's UI shows one of year/month/day at a time. paliad's typical use ("next 30 days for the deadline list") doesn't benefit from a zoom; the chips ARE the granularity. Slicer in Slice D only opens when the user picks "Anpassen" — at which point the zoom UI makes sense.
|
||||
|
||||
---
|
||||
|
||||
## §3 Component design — `<DateRangePicker>`
|
||||
|
||||
### 3.1 Public API
|
||||
|
||||
```ts
|
||||
type TimeHorizonExt =
|
||||
| "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "custom";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
// Current state. The component is fully controlled.
|
||||
value: TimeSpec;
|
||||
onChange: (next: TimeSpec) => void;
|
||||
|
||||
// Per-surface preset filter — omit a chip by leaving it out of the array.
|
||||
// Default: all symmetric chips + "any" + "custom".
|
||||
presets?: TimeHorizonExt[];
|
||||
|
||||
// Closed-state button label override. Defaults to the i18n key for value.horizon
|
||||
// (e.g. "Letzte 30 Tage"). Override for surfaces that want a heading prefix
|
||||
// like "Zeitraum: Letzte 30 Tage".
|
||||
labelPrefix?: string;
|
||||
|
||||
// i18n strings consumed via the i18n.ts dictionary. No props for individual labels.
|
||||
// Localisation flows through existing data-i18n attributes.
|
||||
|
||||
// Surface tag — used to derive a stable testid and URL-param namespace if
|
||||
// the host wires URL serialization through helpers we provide (see §4).
|
||||
surface: string; // e.g. "agenda" | "audit-log" | "filter-bar"
|
||||
|
||||
// Mode — popover (default) or modal (rare).
|
||||
mode?: "popover" | "modal";
|
||||
|
||||
// Anchor / placement for popover mode. Defaults to "below".
|
||||
placement?: "below" | "above" | "right";
|
||||
}
|
||||
```
|
||||
|
||||
`TimeSpec` mirrors the existing shape (`internal/services/filter_spec.go:107-112`), extended with the 4 new horizon values:
|
||||
|
||||
```ts
|
||||
interface TimeSpec {
|
||||
horizon: TimeHorizonExt;
|
||||
field?: "auto" | "created_at";
|
||||
from?: string; // ISO YYYY-MM-DD; set only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 States
|
||||
|
||||
The component is a small state machine:
|
||||
|
||||
```
|
||||
closed ────[click button]────► open
|
||||
▲ │
|
||||
└──[click outside / Esc]───────┘
|
||||
│
|
||||
open ───[click chip]──── closed (commit immediately)
|
||||
│
|
||||
open ───[click "Anpassen"]► custom-editor
|
||||
│
|
||||
custom-editor ─[Anwenden]► closed (commit)
|
||||
custom-editor ─[Esc]─────► open
|
||||
```
|
||||
|
||||
- **closed** — single button with current selection label and a chevron `▾`. No outline/highlight unless the value is not the default for this surface.
|
||||
- **open** — popover anchored below the button (or below-then-flip-up on viewport-bottom). Contains the symmetric chip row + ALL center + "Anpassen" sub-section.
|
||||
- **custom-editor** — replaces the "Anpassen" link with two `<input type="date">` + "Anwenden" / "Abbrechen" buttons. (In Slice D this becomes the slicer.)
|
||||
|
||||
### 3.3 Symmetric chip layout
|
||||
|
||||
The popover body — full ASCII sketch:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ╭ Vergangenheit ────────╮ ╭ ALLES ╮ ╭ Zukunft ───────────╮ │
|
||||
│ │ [Ganze Vergangenheit] │ │ [⌖] │ │ [Ganze Zukunft] │ │
|
||||
│ │ [Letzte 90 Tage] │ │ │ │ [Nächste 7 Tage] │ │
|
||||
│ │ [Letzte 30 Tage] │ │ │ │ [Nächste 14 Tage] │ │
|
||||
│ │ [Letzte 14 Tage] │ │ │ │ [Nächste 30 Tage] │ │
|
||||
│ │ [Letzte 7 Tage] │ │ │ │ [Nächste 90 Tage] │ │
|
||||
│ ╰───────────────────────╯ ╰───────╯ ╰────────────────────╯ │
|
||||
│ │
|
||||
│ ── Anpassen ────────────────────────────────────────── │
|
||||
│ Von [____.____.____] Bis [____.____.____] [Anwenden] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Visual cues:
|
||||
|
||||
- The currently-selected chip gets the **lime accent** (`--color-bg-lime-tint` background, `--color-text` text, `--color-accent` border) — matches existing `.agenda-chip-active` so we don't introduce a new active state.
|
||||
- The "ALLES" center button is **larger** than the fan chips (44px tall vs. 32px), drawn with a target-style glyph `⌖` (or `∞` — see Q3.B). Inventor pick: `⌖` plus the word "ALLES" beneath. Larger so it reads as "the no-filter affordance," not as one chip among many.
|
||||
- The two fans are visually **mirrored** — past on the left, future on the right. Both have a "Ganze …" terminal chip at the outer edge (left-most for past_all, right-most for next_all) and decreasing-magnitude chips fanning toward the center. The ordering matches the human intuition: "left = back in time, right = forward in time."
|
||||
- On viewports < 480px the popover stacks vertically (past fan above, ALL middle, future fan below). On viewports < 360px the popover becomes a modal-feeling slide-up sheet (existing inbox modal CSS pattern reusable).
|
||||
|
||||
### 3.4 Sketch of the closed button states
|
||||
|
||||
```
|
||||
default: ┌─Zeitraum: Nächste 30 Tage ▾─┐
|
||||
custom: ┌─Zeitraum: 15.03.2026 – 30.04.2026 ▾─┐
|
||||
any: ┌─Zeitraum: Alles ▾─┐
|
||||
past_all: ┌─Zeitraum: Ganze Vergangenheit ▾─┐
|
||||
hover/open: same + outline + bg-accent-tint
|
||||
```
|
||||
|
||||
When the value is **not** the surface default, an additional small `●` dot appears between "Zeitraum:" and the value — the existing universal "filter is non-default" indicator used by the filter-bar.
|
||||
|
||||
### 3.5 Keyboard
|
||||
|
||||
- `Tab` lands on the button. `Enter`/`Space` opens the popover.
|
||||
- `Esc` from open state closes it. `Esc` from custom-editor returns to chip view (one level back).
|
||||
- Chips are focusable buttons in the natural left-to-right reading order: past_all → past_90 → past_30 → past_14 → past_7 → any (center) → next_7 → next_14 → next_30 → next_90 → next_all.
|
||||
- The custom date inputs are `<input type="date" lang="de">` — gets the OS-native picker on macOS / iOS / Android / Windows. No new custom calendar widget.
|
||||
|
||||
### 3.6 Accessibility
|
||||
|
||||
- The button has `aria-haspopup="dialog"` and `aria-expanded` toggled on open/close.
|
||||
- The popover has `role="dialog"` with `aria-label` = `t("date_range.dialog.label")` ("Zeitraum wählen" / "Choose date range").
|
||||
- Chips are `<button>` with `aria-pressed="true"` on the active one.
|
||||
- The two fan groups have `role="group"` + `aria-label="Vergangenheit"` / `aria-label="Zukunft"`.
|
||||
|
||||
### 3.7 Module layout
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ └── DateRangePicker.tsx ← TSX shell (markup only)
|
||||
├── client/
|
||||
│ ├── date-range-picker.ts ← mount() + state machine + DOM event wiring
|
||||
│ └── date-range-picker-pure.ts ← horizon-bounds math, label resolver, parse/serialize
|
||||
└── styles/
|
||||
└── global.css ← .date-range-* classes
|
||||
```
|
||||
|
||||
`-pure.ts` is the headless module — fully testable under `bun test`. The boot client in `-picker.ts` consumes it, mirroring the pattern used by `shape-timeline-chart.ts` + `shape-timeline-chart.test.ts` (see memory: t-paliad-173 / gauss).
|
||||
|
||||
Pure module exports (preliminary):
|
||||
|
||||
```ts
|
||||
export function horizonBounds(h: TimeHorizonExt, now: Date): { from?: Date; to?: Date }
|
||||
export function labelForHorizon(h: TimeHorizonExt, lang: "de"|"en"): string
|
||||
export function labelForCustom(from: string, to: string, lang: "de"|"en"): string
|
||||
export function parseURL(params: URLSearchParams): TimeSpec
|
||||
export function serializeURL(spec: TimeSpec, defaults: Partial<TimeSpec>): URLSearchParams
|
||||
export function isDefault(spec: TimeSpec, default_: TimeSpec): boolean
|
||||
```
|
||||
|
||||
### 3.8 Go-side additions
|
||||
|
||||
`internal/services/filter_spec.go`:
|
||||
|
||||
```go
|
||||
// Add four new constants alongside the existing TimeHorizon block.
|
||||
HorizonNext14d TimeHorizon = "next_14d"
|
||||
HorizonPast14d TimeHorizon = "past_14d"
|
||||
HorizonNextAll TimeHorizon = "next_all"
|
||||
HorizonPastAll TimeHorizon = "past_all"
|
||||
```
|
||||
|
||||
`internal/services/view_service.go:computeViewSpecBounds()`:
|
||||
|
||||
```go
|
||||
case HorizonNext14d:
|
||||
bounds.from = &startOfDay; t := startOfDay.AddDate(0, 0, 14); bounds.to = &t
|
||||
case HorizonPast14d:
|
||||
f := startOfDay.AddDate(0, 0, -14); bounds.from = &f; bounds.to = &startOfTomorrow
|
||||
case HorizonNextAll:
|
||||
bounds.from = &startOfDay
|
||||
// bounds.to left nil → "no upper bound"
|
||||
case HorizonPastAll:
|
||||
bounds.to = &startOfTomorrow
|
||||
// bounds.from left nil
|
||||
```
|
||||
|
||||
`HorizonNextAll` and `HorizonPastAll` are **one-sided unbounded** — distinct from existing `HorizonAll` (bidirectional unbounded) and `HorizonAny` (no filter at all, same effect as `HorizonAll` for view-spec runtime but different in intent).
|
||||
|
||||
`filter_spec.go:validate()` (line 280-292) gains the two new past/next constants in the switch.
|
||||
|
||||
### 3.9 i18n keys
|
||||
|
||||
Two-language matrix (DE primary, EN secondary):
|
||||
|
||||
```
|
||||
date_range.button.label "Zeitraum" / "Time range"
|
||||
date_range.button.label.custom "Von … bis …" / "From … to …"
|
||||
date_range.horizon.next_7d "Nächste 7 Tage" / "Next 7 days"
|
||||
date_range.horizon.next_14d "Nächste 14 Tage" / "Next 14 days"
|
||||
date_range.horizon.next_30d "Nächste 30 Tage" / "Next 30 days"
|
||||
date_range.horizon.next_90d "Nächste 90 Tage" / "Next 90 days"
|
||||
date_range.horizon.next_all "Ganze Zukunft" / "All future"
|
||||
date_range.horizon.past_7d "Letzte 7 Tage" / "Last 7 days"
|
||||
date_range.horizon.past_14d "Letzte 14 Tage" / "Last 14 days"
|
||||
date_range.horizon.past_30d "Letzte 30 Tage" / "Last 30 days"
|
||||
date_range.horizon.past_90d "Letzte 90 Tage" / "Last 90 days"
|
||||
date_range.horizon.past_all "Ganze Vergangenheit" / "All past"
|
||||
date_range.horizon.any "Alles" / "All"
|
||||
date_range.horizon.custom "Benutzerdefiniert" / "Custom"
|
||||
date_range.dialog.label "Zeitraum wählen" / "Choose date range"
|
||||
date_range.fan.past.label "Vergangenheit" / "Past"
|
||||
date_range.fan.future.label "Zukunft" / "Future"
|
||||
date_range.center.label "Alles" / "All"
|
||||
date_range.custom.from "Von" / "From"
|
||||
date_range.custom.to "Bis" / "To"
|
||||
date_range.custom.apply "Anwenden" / "Apply"
|
||||
date_range.custom.cancel "Abbrechen" / "Cancel"
|
||||
date_range.custom.invalid "Bis-Datum muss nach Von-Datum liegen." / "End date must be after start date."
|
||||
```
|
||||
|
||||
Total: 21 keys × 2 langs = 42 new entries in `i18n.ts`. Existing per-surface keys (`agenda.range.7`, `admin.audit.range.24h`, `views.bar.time.next_30d` etc.) stay until each surface migrates, then get retired.
|
||||
|
||||
---
|
||||
|
||||
## §4 URL / form serialization contract
|
||||
|
||||
### 4.1 Canonical URL shape
|
||||
|
||||
The picker writes (and reads) **canonical** params on the host's URL:
|
||||
|
||||
```
|
||||
?horizon=next_30d
|
||||
?horizon=past_all
|
||||
?horizon=any ← omitted if it matches the surface default
|
||||
?horizon=custom&from=2026-03-15&to=2026-04-30
|
||||
```
|
||||
|
||||
The host page's URL-init code (`bootDateRangePicker(surface, opts)`) calls `parseURL(searchParams)` to derive the initial `TimeSpec`, then calls `serializeURL(spec, defaults)` on every change. Params equal to the surface default are **omitted** so the canonical URL stays short and dedupable — matches the existing `writeParamToURL` pattern in `projects-chart.ts:144-154`.
|
||||
|
||||
### 4.2 Backwards-compat aliases
|
||||
|
||||
Each migrating surface keeps its existing alias parser for the transition window:
|
||||
|
||||
| Surface | Legacy URL | Canonical URL | Adapter |
|
||||
|---|---|---|---|
|
||||
| `/agenda` | `?range=30` | `?horizon=next_30d` | `range=N → horizon=next_${N}d` if `N ∈ {7,14,30,90}`, else `next_all` for `N>90`. Read both, write canonical. |
|
||||
| `/admin/audit-log` | `?range=7d` | `?horizon=past_7d` | `range=24h → horizon=past_1d` (new, see Q5) or kept as `past_7d` fallback. `range=all → horizon=any`. |
|
||||
| `/projects/:id/chart` | `?range=1y` | `?range=1y` (kept) | **NOT migrated to TimeHorizon** — projects-chart is symmetric-around-today. It uses DateRangePicker only for its **custom**-mode UI (the date-pair → slicer in Slice D). The 1y/2y/all presets stay surface-specific. |
|
||||
|
||||
The Go side is unaffected by aliasing — handlers receive whatever shape they always have, and the URL alias adapter lives entirely client-side per surface. **No backend route signature changes** in Slice A.
|
||||
|
||||
### 4.3 Custom Views (persisted JSON)
|
||||
|
||||
`paliad.user_views.filter_spec` is a JSON column. The TimeSpec extension is additive (new enum values, no shape change). Existing rows continue to validate. Migration not needed.
|
||||
|
||||
### 4.4 Form fields (Custom Views editor)
|
||||
|
||||
`views-editor.tsx:102-109` migrates from `<select>` to the picker. The form submits the same FormData shape (just one extra key for custom from/to — already plumbed via TimeSpec.from / TimeSpec.to). The Go-side `parseViewForm()` (TBD by coder) gains 4 new acceptable horizon values; existing test cases continue to pass.
|
||||
|
||||
---
|
||||
|
||||
## §5 Migration plan
|
||||
|
||||
### Slice A — substrate + filter-bar `time` axis
|
||||
|
||||
**Backend** (single migration not needed — additive constants only):
|
||||
|
||||
- `internal/services/filter_spec.go` — 4 new `TimeHorizon` constants + validate switch arms.
|
||||
- `internal/services/view_service.go` — `computeViewSpecBounds()` 4 new switch cases.
|
||||
- Pure unit tests for each new horizon (zero DB).
|
||||
|
||||
**Frontend**:
|
||||
|
||||
- New `frontend/src/components/DateRangePicker.tsx` + boot client + pure module.
|
||||
- New i18n keys (42 entries).
|
||||
- `frontend/src/client/filter-bar/axes.ts:renderTimeAxis()` — replace the disabled "Anpassen" stub with the picker. The chip cluster either becomes the picker's open-state (preferred) OR the chips stay flat and the picker only opens on "Anpassen" click (fallback if popover-in-bar is visually noisy). **Inventor pick (R): chips stay flat in the bar; "Anpassen" chip becomes the picker trigger. Picker emits TimeSpec back into the bar's state, same patch path.**
|
||||
|
||||
**Surfaces lit up automatically**: Verlauf (`/projects/:id`), Custom Views (`/views`, `/views/:id`), InboxFilterBar (`/inbox`).
|
||||
|
||||
**LoC estimate**: ~600 LoC (pure: 180 / boot: 180 / TSX: 100 / CSS: 80 / Go: 30 / tests: 240). Tests-first per `docs/design-paliad-test-strategy-2026-05-19.md`.
|
||||
|
||||
### Slice B — `/agenda`
|
||||
|
||||
- `agenda.tsx:51-69` — replace chip rows with `<DateRangePicker surface="agenda" presets={["next_7d","next_14d","next_30d","next_90d","next_all","custom"]} />`.
|
||||
- `client/agenda.ts:85-104` — replace `wireControls()` chip wiring with picker subscription.
|
||||
- URL alias adapter — accept `?range=N` for back-compat, emit `?horizon=…`.
|
||||
|
||||
**LoC**: ~80 LoC delta, mostly deletion.
|
||||
|
||||
### Slice C — `/admin/audit-log` + `/projects/:id/chart`
|
||||
|
||||
- `admin-audit-log.tsx:50-65` — replace `<select>` + date-pair with `<DateRangePicker surface="audit-log" presets={["past_7d","past_14d","past_30d","past_90d","past_all","custom"]} />`.
|
||||
- `projects-chart.tsx:75-83` — **wrap** the existing 1y/2y/all presets in a custom-prop variant (a sibling component `<SymmetricRangePicker>` that shares the picker's popover scaffolding but emits the surface-specific `range_preset`). Or — if the head/m prefers — fold 1y/2y/all into TimeHorizon as `sym_1y` / `sym_2y` / `sym_all`. **Inventor pick (R): sibling component**, because symmetric-around-today is conceptually different from past/future fan. See §8 Q1.
|
||||
|
||||
**LoC**: ~120 LoC for audit-log, ~80 LoC for projects-chart wrap.
|
||||
|
||||
### Slice D *(optional, separate task)* — slicer
|
||||
|
||||
- Add `<DateRangeSlicer>` for the custom-editor sub-pane. Built on `<input type="range">` × 2 with a custom anchor rail above, ported from `date-range-slider-pure.ts`.
|
||||
- Replaces inline date-pair when `horizon === "custom"` and `surface ∈ {agenda, audit-log, filter-bar}`. Projects-chart keeps inline date-pair OR also uses slicer — its choice.
|
||||
- No new dependency.
|
||||
- ~400 LoC including pure helpers + DOM scaffolding + tests.
|
||||
|
||||
### Per-slice rollout
|
||||
|
||||
| Slice | Risk | Surfaces affected | Coder profile |
|
||||
|---|---|---|---|
|
||||
| A | Low — additive only | 4 (filter-bar + 3 consumers) | Pattern-fluent Sonnet |
|
||||
| B | Low | 1 | Same coder |
|
||||
| C | Medium (projects-chart sibling) | 2 | Same coder |
|
||||
| D | Medium (new slicer) | 0 (additive on top of A) | Separate task |
|
||||
|
||||
---
|
||||
|
||||
## §6 Visual decisions
|
||||
|
||||
### 6.1 Chip labels
|
||||
|
||||
Final labels — bilingual (DE first):
|
||||
|
||||
| Chip | DE | EN |
|
||||
|---|---|---|
|
||||
| past_all | Ganze Vergangenheit | All past |
|
||||
| past_90d | Letzte 90 Tage | Last 90 days |
|
||||
| past_30d | Letzte 30 Tage | Last 30 days |
|
||||
| past_14d | Letzte 14 Tage | Last 14 days |
|
||||
| past_7d | Letzte 7 Tage | Last 7 days |
|
||||
| any (center) | Alles | All |
|
||||
| next_7d | Nächste 7 Tage | Next 7 days |
|
||||
| next_14d | Nächste 14 Tage | Next 14 days |
|
||||
| next_30d | Nächste 30 Tage | Next 30 days |
|
||||
| next_90d | Nächste 90 Tage | Next 90 days |
|
||||
| next_all | Ganze Zukunft | All future |
|
||||
| custom | Anpassen | Customize |
|
||||
|
||||
Rationale on "Anpassen" vs "Benutzerdefiniert":
|
||||
- "Anpassen" matches existing `views.bar.time.custom` key value in `i18n.ts`.
|
||||
- "Benutzerdefiniert" is used in admin-audit-log's dropdown — verbose, but more accurate.
|
||||
- (R): **Anpassen** (consistent with filter-bar; six chars vs. eighteen).
|
||||
|
||||
### 6.2 Accent / active state
|
||||
|
||||
Reuse the existing **lime accent** chip-active state (`--color-bg-lime-tint` background, `--color-accent` border, `--color-text` text). This is the established affordance for the `agenda-chip-active` class — same visual reused, no new accent token.
|
||||
|
||||
### 6.3 The "ALLES" center button
|
||||
|
||||
A larger, target-glyph button — visually distinct from the fan chips so the user reads it as the "no time filter" exit, not as one chip among many:
|
||||
|
||||
```
|
||||
╭──────╮
|
||||
│ ⌖ │
|
||||
│ ALLES│
|
||||
╰──────╯
|
||||
```
|
||||
|
||||
(R) glyph: `⌖` (Unicode U+2316 POSITION INDICATOR). Alternatives considered: `∞` (too math-y), `⊕` (too connect-y), `▣` (too checkbox-y), no glyph (chip then looks like every other chip). See §8 Q3.B.
|
||||
|
||||
### 6.4 Custom-range entry
|
||||
|
||||
In Slice A: **inline date-pair below the chip rows**, with an "Anwenden" button that commits + closes the picker. Plain `<input type="date" lang="de">` — gets the OS-native picker.
|
||||
|
||||
In Slice D (later): same slot becomes the slicer. The chip rows remain; the slicer collapses under them so the user can switch back to a chip with one click.
|
||||
|
||||
### 6.5 Hover / focus
|
||||
|
||||
- Chip hover: existing `.agenda-chip:hover` (lighter background tint).
|
||||
- Chip focus-visible: 2px outline using `--color-accent`.
|
||||
- Button focus-visible: same.
|
||||
- Popover entry: 120ms fade-in via `transform: translateY(-4px) → 0` + opacity. Reduced-motion users (prefers-reduced-motion: reduce) get instant show.
|
||||
|
||||
### 6.6 Indication that the filter is non-default
|
||||
|
||||
The closed button shows a small `●` dot to the left of the label when the value is **not** the surface default. This matches the existing filter-bar non-default-indicator pattern (`frontend/src/client/filter-bar/index.ts` has a similar dot but on the whole bar; we adopt it per-control).
|
||||
|
||||
---
|
||||
|
||||
## §7 Edge cases
|
||||
|
||||
### 7.1 Timezones
|
||||
|
||||
All horizon math runs against **UTC `startOfDay`** of `new Date()` — same convention as `horizonBounds()` in `projects-detail.ts:393-406`. The user's browser may be in CEST in summer or CET in winter; the picker still treats "today" as a UTC date for filter purposes. The date-input localizes display (German locale → DD.MM.YYYY) but the underlying ISO is `YYYY-MM-DD` parsed as UTC midnight.
|
||||
|
||||
Practical impact: a user in CEST clicking "Letzte 7 Tage" at 01:30 local on 2026-06-15 sees `from=2026-06-07T00:00Z, to=2026-06-15T00:00Z` even though their local clock shows the 15th. This matches every other date-filter in paliad and avoids "the same row vanishes at 01:00 vs. 23:00" surprises. Document the convention in the pure module's header comment.
|
||||
|
||||
### 7.2 Far past truncation
|
||||
|
||||
`past_all` materialises to `from: nil`. The Go side (view_service.go) treats nil as "no lower bound" — the SQL `WHERE due_date >= ?` clause is omitted. No truncation needed.
|
||||
|
||||
For projects-chart's symmetric "all" mode, "all" still means **bounds derived from loaded events** (status quo) — the picker for projects-chart's surface uses the sibling `<SymmetricRangePicker>` which doesn't have `past_all`/`next_all` chips, only `1y/2y/all`.
|
||||
|
||||
### 7.3 Overlapping selections — past_7 + next_7 simultaneously?
|
||||
|
||||
The picker is **single-select** — one chip active at a time, OR custom mode. m's brief doesn't mention multi-select and the existing TimeSpec is single-valued. Multi-select would require a fundamental contract change. Don't.
|
||||
|
||||
If a user genuinely wants "last 7 days OR next 7 days," they use the custom-range with `from=today-7d`, `to=today+7d` — which is what `±1w` would mean. The fact that this is two chip-clicks vs. one isn't a real ergonomic loss.
|
||||
|
||||
### 7.4 Custom dates with from > to
|
||||
|
||||
Validate client-side: when both inputs are filled and `from > to`, the "Anwenden" button is disabled and a hint appears: "Bis-Datum muss nach Von-Datum liegen" (i18n key `date_range.custom.invalid`). The picker does **not** auto-swap.
|
||||
|
||||
### 7.5 Empty inputs in custom mode
|
||||
|
||||
If the user clicks "Anpassen" then clicks elsewhere before filling inputs, the picker reverts to whatever horizon was active before (state cached on entry to custom-editor). No "half-custom" state persists.
|
||||
|
||||
### 7.6 Surface-specific preset overrides
|
||||
|
||||
Each surface declares its own presets via the `presets` prop. The picker hides chips not in the array. The default surface preset (read from `defaults` prop, or hardcoded if absent) is what `serializeURL()` omits from the URL.
|
||||
|
||||
Important invariant: `defaults` must be a member of `presets`, OR be a special value like `any` that's always rendered. The component asserts this at boot and falls back to `any` if violated.
|
||||
|
||||
### 7.7 Bilingual labels mid-session
|
||||
|
||||
`labelForHorizon()` consults the live `i18n.ts` dictionary on every render, so a language toggle updates the picker immediately — including the closed-button label.
|
||||
|
||||
### 7.8 Embedded picker inside a filter bar
|
||||
|
||||
When the picker is mounted inside `filter-bar`, it should NOT use a full popover overlay — the filter bar already wraps controls. Instead the open-state's chip rows render **inline below the time chip cluster**, expanding the bar's height. This is `mode="inline"` (a third mode beyond popover/modal). Slice A picks this for filter-bar consumers; standalone surfaces (`/agenda`, `/admin/audit-log`) use popover mode.
|
||||
|
||||
### 7.9 What happens if a saved Custom View references `past_14d` before Slice A ships?
|
||||
|
||||
The JSON validator rejects it (`filter_spec.go:validate()` enum check). Saved views are migration-safe in one direction only — adding new enum values is fine; removing is not. Slice A adds, doesn't remove. No issue.
|
||||
|
||||
### 7.10 Race: URL change while picker is open
|
||||
|
||||
If the user has the picker open and a URL change happens via another control (e.g. they Cmd-Click a sidebar link), the picker is unmounted naturally with the page navigation. No state to preserve across navigations.
|
||||
|
||||
---
|
||||
|
||||
## §8 Open questions for m
|
||||
|
||||
Per task brief: **no AskUserQuestion**. Material picks escalated via `mai instruct head`; everything else defaults to (R) below. The head decides whether to forward to m or rule on the spot.
|
||||
|
||||
### Q1 [MATERIAL — escalate]: How to handle `/projects/:id/chart`?
|
||||
|
||||
The chart's range presets are **symmetric around today** (1y / 2y / all = ±1y / ±2y / all-data-bounds), conceptually different from past/future fans. Options:
|
||||
|
||||
- **(R) A — sibling component.** Keep a separate `<SymmetricRangePicker>` for the chart surface. Same popover scaffolding, different chip set. Chart's URL stays `?range=1y`. Doesn't add to TimeHorizon.
|
||||
- **B — fold into TimeHorizon.** Add `sym_1y`, `sym_2y`, `sym_all` constants. Picker prop selects which fan vs. symmetric. Saved views could then express "±1y" too.
|
||||
- **C — leave the chart as-is.** Don't migrate. Accept the visual inconsistency.
|
||||
|
||||
(R) **A.** Symmetric vs fan is a real semantic difference; one component trying to be both is muddier than two components sharing scaffolding. The chart isn't a "filter" — it's a viewport, and viewports legitimately want symmetric panning.
|
||||
|
||||
### Q2 [MATERIAL — escalate]: Modal vs popover for the standalone case?
|
||||
|
||||
m's brief says "mini modal." Options:
|
||||
|
||||
- **(R) A — popover always.** Anchored to the trigger button, click-outside dismiss. In-context, lightweight.
|
||||
- **B — modal for explicit "open date filter" intent.** Use a centered modal with scrim when the picker is the page's primary filter (e.g. `/admin/audit-log` where date is the most prominent control). Popover for embedded uses.
|
||||
- **C — modal everywhere.** Strong visual hierarchy, but interrupts the user.
|
||||
|
||||
(R) **A.** Modal feels heavy for what is conceptually a chip cluster. The "mini" qualifier in m's wording suggests popover, not full modal. If a surface specifically needs the modal weight, the `mode="modal"` prop is available — but no default surface picks it.
|
||||
|
||||
### Q3 [MATERIAL — escalate]: Slice priority — what migrates first?
|
||||
|
||||
- **(R) A — filter-bar `time` axis first** (Slice A). Lights up 4 surfaces simultaneously (Verlauf, InboxFilterBar, views runtime, Custom Views editor) by replacing the existing Phase-2 disabled stub.
|
||||
- **B — `/agenda` first** (per task brief default). Highest-traffic standalone surface, simplest migration.
|
||||
- **C — both A and B in parallel** (head splits between two coders).
|
||||
|
||||
(R) **A.** Filter-bar is the substrate everything else either uses or should use. Lighting it up first turns three downstream surfaces from "almost working" (the stubbed custom-range chip with "coming_soon" tooltip) to "fully working." Agenda then migrates as Slice B, on top of a proven component.
|
||||
|
||||
### Q3.B [DEFAULT — no escalation needed]: ALL center button glyph?
|
||||
|
||||
- **(R) `⌖`** (POSITION INDICATOR, U+2316). Implies "center / pin to here."
|
||||
- B `∞` (infinity). Mathy.
|
||||
- C `⊕` (circled plus). Looks like a button.
|
||||
- D No glyph, just "ALLES" in bold.
|
||||
|
||||
(R) `⌖`. If the head/m doesn't like the unicode lookup, D is the safe fallback.
|
||||
|
||||
### Q4 [DEFAULT — no escalation]: Custom-range entry in Slice A?
|
||||
|
||||
- **(R)** Inline `<input type="date">` pair, OS-native picker. Slice D adds the slicer.
|
||||
|
||||
### Q5 [DEFAULT — no escalation]: Past `24h` in audit-log?
|
||||
|
||||
audit-log currently has a `24h` preset; the picker would express this as `past_1d`. Options:
|
||||
|
||||
- **(R)** Map legacy `?range=24h` → `?horizon=past_1d`. Add a new `past_1d` constant.
|
||||
- B Drop `24h` — audit log defaults to `past_7d` like other surfaces. Users wanting "last 24h" use custom mode.
|
||||
|
||||
(R) Add `past_1d`. It's a one-line addition and audit-log users genuinely use "last 24h" for incident triage.
|
||||
|
||||
(Note: this means the picker actually has 5 past chips + 5 future chips + center + custom = 12 chips total, which fits comfortably in the popover.)
|
||||
|
||||
### Q6 [DEFAULT — no escalation]: Slice D (slicer) — separate task or fold in?
|
||||
|
||||
- **(R) Separate task.** Slice A-C are independently shippable. Slice D is meaningful design + ~400 LoC and shouldn't gate the main migration.
|
||||
|
||||
### Q7 [DEFAULT — no escalation]: Per-surface defaults?
|
||||
|
||||
Each migrating surface keeps its current default exactly:
|
||||
|
||||
- `/agenda` → `next_30d` (was 30).
|
||||
- `/admin/audit-log` → `past_7d` (was 7d).
|
||||
- `/projects/:id` Verlauf → `past_30d` (was past_30d in `projects-detail.ts:2310`).
|
||||
- `/views/:id` runtime → whatever the saved view has (no change).
|
||||
- `/inbox` (InboxFilterBar) → whatever filter-bar's surface defines.
|
||||
|
||||
### Q8 [DEFAULT — no escalation]: Should `past_14d` and `next_14d` retroactively appear in `views-editor.tsx`'s `<select>`?
|
||||
|
||||
(R) **Yes** — once Slice A ships, the `<select>` in `views-editor.tsx` is replaced by the picker (part of Slice A, as filter-bar consumers all flip in one commit). All 12 preset values become available for new Custom Views.
|
||||
|
||||
---
|
||||
|
||||
## §9 Implementer notes (for the coder shift, if approved)
|
||||
|
||||
### Lessons embedded
|
||||
|
||||
- **TimeSpec extension is additive only** — Go enum + TS union + i18n keys + horizonBounds switch. No DB migration, no contract break.
|
||||
- **Pure module is testable under `bun test`** — no DOM needed for horizon math, label resolution, URL serialization. Aim for 95%+ coverage of the pure module before touching the boot client.
|
||||
- **Reuse `.agenda-chip` styling** — adds no new tokens, no new dark-mode contrast risk (cf. memory t-paliad-150 / fritz — fritz lost 90 minutes to a `var(--token, #hex)` fallback bug because the token wasn't defined in dark mode).
|
||||
- **`mode="inline"` for filter-bar consumers** — the bar already wraps its own popover-like layout; nesting popovers gets visually noisy.
|
||||
- **Surface defaults must be members of `presets`** — assert at boot, fail loud in dev, fall back to `any` in prod.
|
||||
|
||||
### Recommended coder profile
|
||||
|
||||
Pattern-fluent Sonnet. Substrate is well-trodden (TimeSpec/TimeHorizon already lives, chip-cluster CSS exists, URL-codec pattern documented in `projects-chart.ts`). The novel piece is the popover scaffolding — paliad doesn't have a generic Popover primitive today; the picker builds its own DOM-anchored overlay. ~80 LoC of plain JS, no dependency.
|
||||
|
||||
### Build hygiene checklist
|
||||
|
||||
- `go build ./...` clean
|
||||
- `go vet ./...` clean
|
||||
- `go test ./...` clean (existing tests must continue passing — additive constants change zero behaviour)
|
||||
- `bun run build` clean (i18n scan: 21 new keys added, all `data-i18n` attributes present)
|
||||
- bun:test covers the pure module (horizon math, label resolver, URL parser/serializer)
|
||||
- Playwright smoke (manual, not gated): on `/inbox` the time axis "Anpassen" chip is now functional; custom-from/to date pair commits a usable filter.
|
||||
|
||||
### Out of scope for the coder
|
||||
|
||||
- Slicer (Slice D) — separate task.
|
||||
- Per-language adjustments beyond DE/EN (per task brief, out of scope).
|
||||
- Time-of-day picking — separate concern.
|
||||
- Recurring-event windows — events feed handles separately.
|
||||
- A generic Popover primitive — extract only if a second consumer appears in the same slice.
|
||||
|
||||
### Acceptance criteria for Slice A
|
||||
|
||||
1. New `<DateRangePicker>` mounts on filter-bar's `time` axis, replacing the disabled "Anpassen" chip.
|
||||
2. The 4 new horizon values (`past_14d`, `next_14d`, `past_all`, `next_all`) are accepted by Go's `TimeSpec.validate()` and produce correct `(from, to)` bounds in `computeViewSpecBounds()`.
|
||||
3. The 4 new horizons round-trip through saved Custom Views (`paliad.user_views.filter_spec` JSON).
|
||||
4. URL serialization is canonical (`?horizon=…&from=…&to=…`) and surface-default values are omitted.
|
||||
5. Verlauf (`/projects/:id`), `/views`, `/views/:id`, and `/inbox` continue to function with their existing presets unchanged — they pick up the new picker but don't switch their preset list yet.
|
||||
6. Pure-module unit tests cover: 12 horizons × bound calculation; URL parse / serialize round-trip; default-omission rule; custom-mode date validation.
|
||||
7. `bun run build` reports the new i18n keys (no missing-key warnings).
|
||||
8. No regression in `go test ./internal/services/...` (existing TimeSpec tests stay green).
|
||||
|
||||
---
|
||||
|
||||
## §10 Material picks summary — escalation message
|
||||
|
||||
To be sent via `mai instruct head` after this doc is pushed:
|
||||
|
||||
> Three material picks for m on date-range-picker design:
|
||||
>
|
||||
> 1. **`/projects/:id/chart` migration** — keep symmetric (1y/2y/all) presets as a sibling component, NOT fold into TimeHorizon. Chart is a viewport, not a filter.
|
||||
> 2. **Popover vs modal** — popover by default. Modal is a `mode` prop available per surface but no surface picks it in Slice A.
|
||||
> 3. **Slice A first migrates filter-bar time axis** (lights up Verlauf + InboxFilterBar + Views + Custom-Views-editor simultaneously by un-stubbing the existing "Anpassen" chip), not `/agenda` as the task brief defaulted. `/agenda` is Slice B.
|
||||
>
|
||||
> Everything else (chip labels, accent, glyph, custom-mode entry, surface defaults, past_1d for audit, slicer-as-Slice-D, 42 i18n keys) defaults per (R) in §8. Doc at `docs/design-date-range-picker-2026-05-25.md`.
|
||||
|
||||
---
|
||||
|
||||
*Verified premises (live, before designing):*
|
||||
|
||||
- `internal/services/filter_spec.go:107-126` — TimeHorizon enum at 9 values today.
|
||||
- `internal/services/view_service.go:156-187` — `computeViewSpecBounds()` switches on the same enum.
|
||||
- `frontend/src/client/views/types.ts:21-33` — TimeHorizon TS mirror; same 9 values.
|
||||
- `frontend/src/client/filter-bar/axes.ts:65-115` — chip cluster renderer; "Anpassen" stub at line 105-112 marked Phase 2, disabled, "coming_soon" tooltip.
|
||||
- `frontend/src/agenda.tsx:64-67` — chip row exact values `7|14|30|90`.
|
||||
- `frontend/src/admin-audit-log.tsx:50-65` — select exact values `24h|7d|30d|custom|all`.
|
||||
- `frontend/src/projects-chart.tsx:78-82` + `frontend/src/client/projects-chart.ts:73-118` — RangePreset `1y|2y|all|custom`, symmetric around today.
|
||||
- `frontend/src/views-editor.tsx:102-109` — select exact values `next_7d|next_30d|next_90d|past_30d|past_90d|any`.
|
||||
- `/home/m/dev/web/upc-kommentar/src/lib/components/DateRangeSlider.svelte` — 448 lines, wraps `svelte-range-slider-pips@4`, custom anchor rail above the lib's hidden pips, click-to-snap left/right halves, granularity year/month/day zoom.
|
||||
- `/home/m/dev/web/upc-kommentar/src/lib/modules/date-range-slider/date-range-slider-pure.ts` — 487 lines, fully testable pure helpers, dependency-free, portable to paliad's TS.
|
||||
|
||||
*Not verified live:* upckommentar.de in a browser (requires author auth; the source code IS the source of truth and was read end-to-end).
|
||||
848
docs/design-inbox-overhaul-2026-05-25.md
Normal file
848
docs/design-inbox-overhaul-2026-05-25.md
Normal file
@@ -0,0 +1,848 @@
|
||||
# Design: /inbox overhaul — project-events feed + filtering + list/cards/calendar toggles
|
||||
|
||||
**Task:** t-paliad-249
|
||||
**Gitea:** m/paliad#80
|
||||
**Author:** icarus (inventor)
|
||||
**Date:** 2026-05-25
|
||||
**Status:** LOCKED — head confirmed Q1=A with two refinements (2026-05-25), see §12.
|
||||
**Branch:** `mai/icarus/inventor-inbox-overhaul`
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
`/inbox` today is approval-requests only. m wants it to become the actual
|
||||
"what's new on my projects" surface — approval requests **plus** recent
|
||||
project_events on visible projects — with the same view-toggle paradigm
|
||||
as `/events` (list / cards / calendar) and a meaningful filter row.
|
||||
|
||||
The good news: the substrate already exists.
|
||||
|
||||
- `view_service.RunSpec` unions four sources (deadline, appointment,
|
||||
**project_event**, **approval_request**) into one ranked `[]ViewRow`.
|
||||
- `FilterSpec` has predicates for every axis we need
|
||||
(`ProjectEventPredicates.EventTypes`, `ApprovalRequestPredicates`).
|
||||
- `filter-bar` knows the axes we need: `time`, `project`,
|
||||
`approval_viewer_role`, `approval_status`, `approval_entity_type`,
|
||||
`project_event_kind`, plus `shape` / `sort` / `density`.
|
||||
- Shape renderers exist: `shape-list` (table + compact + approval), `shape-cards`
|
||||
(day-grouped), `shape-calendar` (thin adapter on `mountCalendar`).
|
||||
|
||||
So the work is **mostly re-mix**:
|
||||
|
||||
1. Extend `InboxSystemView` from `Sources=[ApprovalRequest]` to
|
||||
`Sources=[ApprovalRequest, ProjectEvent]`, default
|
||||
`Time.Horizon=Past30d`, and add a curated `project_event.event_types`
|
||||
default that filters out noise (approvals duplicate-suppression,
|
||||
checklist mutations, status churn).
|
||||
2. Extend `shape-list.ts` so `row_action="approve"` no longer assumes
|
||||
every row is an approval — rename it `"inbox"`, dispatch per
|
||||
`row.kind` (approval → existing approve-card layout; project_event →
|
||||
navigate-style stream row).
|
||||
3. Wire the existing view-axis selector (the chip cluster on `/events`)
|
||||
onto `/inbox`'s host, persisting selection via the filter-bar URL
|
||||
codec (axis `shape` already in `AxisKey`).
|
||||
4. Add a high-watermark read cursor (`paliad.users.inbox_seen_at`) +
|
||||
`POST /api/inbox/mark-all-seen` + extend `/api/inbox/count` to count
|
||||
unseen project_events too. Adds one new axis `unread_only` to the bar.
|
||||
|
||||
That's Slice A. Slice B layers cards + calendar toggles cleanly. Slice C
|
||||
is per-item dismissal — keep out of v1 unless the cursor proves not
|
||||
enough (m's pick Q3 is the cursor).
|
||||
|
||||
No new aggregation service, no new endpoint family — the inbox runs on
|
||||
`/api/views/inbox/run` like every other system view does today.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current `/inbox` state
|
||||
|
||||
**Routes (`internal/handlers/approvals.go`):**
|
||||
|
||||
| Path | Behaviour |
|
||||
|---------------------------------------|--------------------------------------------------------------|
|
||||
| `GET /inbox` | Serves `dist/inbox.html`, a thin shell. No SSR data. |
|
||||
| `GET /api/inbox/pending-mine` | Approval requests I can approve. |
|
||||
| `GET /api/inbox/mine` | Approval requests I submitted (all statuses by default). |
|
||||
| `GET /api/inbox/count` | `{count: N}` for the sidebar bell badge — `PendingCountForUser`. |
|
||||
| `GET /api/approval-requests/{id}` | Hydrate one request (used by suggest-changes modal). |
|
||||
| `POST /api/approval-requests/{id}/{action}` | `approve` / `reject` / `revoke` / `suggest-changes`. |
|
||||
|
||||
**Data path:** `frontend/src/client/inbox.ts` mounts the universal
|
||||
`FilterBar` over the inbox `SystemView` (slug `"inbox"`, sources
|
||||
`[approval_request]`, viewer_role `any_visible`, status `[pending]`).
|
||||
The bar fetches `/api/views/system`, hands the spec to itself, calls
|
||||
`/api/views/inbox/run?…`, and stamps rows via `shape-list.ts`'s
|
||||
`renderApprovalList(rows)` path (gated by `row_action="approve"`).
|
||||
|
||||
**Action wiring:** `wireApprovalActions(host)` listens on
|
||||
`.views-approval-action` clicks; on success it triggers
|
||||
`bar.refresh()` and `refreshInboxBadge()` (which pokes
|
||||
`/api/inbox/count`).
|
||||
|
||||
**Empty state + admin nudge:** when the result list is empty AND the
|
||||
caller is `global_admin` AND no `approval_policies` row exists firm-wide,
|
||||
the page shows a "configure policies" CTA. Otherwise the localized
|
||||
"no items" empty-state text.
|
||||
|
||||
**Sidebar bell:** `Sidebar.tsx:143` `navItem("/inbox", BELL_ICON, …)`
|
||||
plus `client/sidebar.ts:320–345`'s `initInboxBadge` which polls
|
||||
`/api/inbox/count` every 60s. Badge clamps to `"9+"`.
|
||||
|
||||
### What aggregates cleanly
|
||||
|
||||
The whole approval flow already plugs into `RunSpec`'s union pipeline.
|
||||
That's the win — extending sources from `[ApprovalRequest]` to
|
||||
`[ApprovalRequest, ProjectEvent]` is a `[]DataSource` literal edit in
|
||||
`InboxSystemView()` and the engine fans out per source, sorts, returns
|
||||
one `[]ViewRow`. The hard work (`runProjectEvents` + the
|
||||
visibility predicate + project metadata join) is already in
|
||||
`view_service.go:344–430`.
|
||||
|
||||
### What doesn't aggregate (yet)
|
||||
|
||||
- **Read state.** There is no `inbox_seen_at` on `paliad.users` (verified
|
||||
via information_schema). The bell badge counts pending **approval
|
||||
requests for the caller** only — it has no notion of "new project
|
||||
events since last visit". We have to add it.
|
||||
- **Mixed `row_action`.** `shape-list.ts`'s `renderApprovalList` assumes
|
||||
every row is an approval and unconditionally parses
|
||||
`row.detail` as an `ApprovalDetail`. Project_event rows in the same
|
||||
list would crash the parse. We need to branch per `row.kind` inside
|
||||
the inbox row stamper.
|
||||
- **`/inbox` shape toggle.** `client/inbox.ts` hardcodes `shape-list`;
|
||||
the `shape` axis is wired into `filter-bar/axes.ts` but `/inbox`'s
|
||||
`INBOX_AXES` deliberately omits it (because today the only meaningful
|
||||
shape was list). Adding it onto INBOX_AXES + a small dispatcher in
|
||||
`onResult` gives us cards + calendar for free.
|
||||
|
||||
Everything else (sidebar entry, /api/views machinery, FilterBar URL
|
||||
codec, RowAction validation) carries through unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 2. Event-type catalogue for inbox v1 (Q1)
|
||||
|
||||
This is the only design pick that requires a head/m signal. **Open
|
||||
question Q1 in §9 — defaulting to (A) until head answers.**
|
||||
|
||||
### (R) Recommendation (A): curated subset
|
||||
|
||||
Sources: `[approval_request, project_event]`.
|
||||
|
||||
**Approval requests:** all rows whose `viewer_role=any_visible` AND
|
||||
status ∈ {pending} by default; the existing chip cluster
|
||||
(approver_eligible / self_requested / any_visible) stays. Decided
|
||||
requests are filtered by the chip, not hidden by source-removal — so a
|
||||
user who wants to see "what got approved this week" toggles the status
|
||||
chip rather than the source.
|
||||
|
||||
**Project events:** filter by `event_type ∈ InboxProjectEventKinds`
|
||||
where InboxProjectEventKinds is a new sub-list of KnownProjectEventKinds:
|
||||
|
||||
| event_type | In inbox v1? | Reason |
|
||||
|-------------------------|--------------|---------------------------------------------------------------------|
|
||||
| `project_created` | no | The author already saw the page; not news to the team yet (the team grows post-creation). |
|
||||
| `project_archived` | **yes** | High-signal lifecycle event ("Akte XY wurde archiviert"). |
|
||||
| `project_reparented` | **yes** | Hierarchy moves matter to everyone with access. |
|
||||
| `project_type_changed` | **yes** | Same reason. |
|
||||
| `status_changed` | no | Currently too granular; surface in Verlauf, revisit if m disagrees. |
|
||||
| `deadline_created` | **yes** | New deadline on a project I can see — exactly the kind of event m named ("we should also display new events"). |
|
||||
| `deadline_completed` | **yes** | Likewise. |
|
||||
| `deadline_reopened` | **yes** | Likewise. |
|
||||
| `deadline_updated` | **yes** | Currently in DB (11 rows live) but not in KnownProjectEventKinds — add it. |
|
||||
| `deadline_deleted` | **yes** | Likewise — add to KnownProjectEventKinds. |
|
||||
| `deadlines_imported` | **yes** | Bulk-import event surfaces what got added. |
|
||||
| `appointment_created` | **yes** | |
|
||||
| `appointment_updated` | **yes** | |
|
||||
| `appointment_deleted` | **yes** | |
|
||||
| `note_created` | **yes** | A note is "someone said something about this project". High-signal; add to KnownProjectEventKinds. |
|
||||
| `our_side_changed` | **yes** | Party-side flip; high-signal, add to KnownProjectEventKinds. |
|
||||
| `member_role_changed` | no | Admin churn; would dominate active users' inbox. Revisit slice B. |
|
||||
| `*_approval_requested` | **no — de-duped** | The approval_request row itself carries the signal; the audit event is the same fact in a different table. Filtering it out avoids duplicate inbox entries. |
|
||||
| `*_approval_approved/rejected/revoked` | **no — de-duped** | Same reason. The approval_request row's status flip is what the user sees. |
|
||||
| `*_approval_changes_suggested` | **no — de-duped** | Same. |
|
||||
| `approval_decided` | no | This is the umbrella audit-only kind; superseded by the approval_request row. |
|
||||
| `checklist_*` | no | Low signal; checklists are surfaced on the project's checklist page. |
|
||||
|
||||
The de-dup pattern means: if a row exists in `approval_requests` for an
|
||||
entity, the corresponding `*_approval_*` project_event is **not** shown
|
||||
in the inbox — we trust the approval_request row.
|
||||
|
||||
### Alternative (B): everything in KnownProjectEventKinds + approvals
|
||||
|
||||
Simpler — no curated sub-list, no de-dup. Two drawbacks:
|
||||
|
||||
1. `*_approval_*` duplicates would render twice per request.
|
||||
2. `status_changed` and `member_role_changed` are admin churn; in firm
|
||||
tests both would dominate.
|
||||
|
||||
If head picks B, we need at minimum the `*_approval_*` de-dup; otherwise
|
||||
the inbox renders the same fact twice.
|
||||
|
||||
### Alternative (C): minimal — approvals + appointment_* + deadline_*
|
||||
|
||||
Tightest set. Drops notes + our_side_changed + project_*. Risk: m's
|
||||
brief literally says "new events that relate to one's projects" — notes
|
||||
and side changes ARE such events. C feels too narrow.
|
||||
|
||||
---
|
||||
|
||||
## 3. Read/unread model (Q3 → R: high-watermark cursor)
|
||||
|
||||
### (R) Decision: per-user high-watermark `inbox_seen_at`
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN inbox_seen_at timestamptz NULL;
|
||||
```
|
||||
|
||||
NULL means "never visited" → everything counts as unread. The high-water
|
||||
cursor advances exactly when the user POSTs to
|
||||
`/api/inbox/mark-all-seen` (UI affordance: a button in the inbox header
|
||||
+ implicit advance on page-mount, see Slice A wiring below).
|
||||
|
||||
### Why cursor, not per-item
|
||||
|
||||
m's recommendation: cursor. Mine matches: single column, no fan-out
|
||||
table, covers the common case ("I checked my inbox, mark everything
|
||||
read"). Per-item dismiss is Slice C — opt-in only if the cursor proves
|
||||
inadequate. The risk we're guarding against: a single high-value pending
|
||||
approval that's a week old gets buried by 80 fresh deadline_updated
|
||||
events; the user clears the badge and may now never look at the
|
||||
approval. Mitigation: **approval_requests with status=pending never
|
||||
fall behind the cursor** — they count toward the badge regardless of
|
||||
seen_at. This is a tiny conditional in the count query (Slice A).
|
||||
|
||||
### Cursor advance behaviour
|
||||
|
||||
- **Explicit:** "Alles als gelesen markieren" button in the inbox
|
||||
header. POSTs `/api/inbox/mark-all-seen`; server sets
|
||||
`inbox_seen_at = now()`.
|
||||
- **Implicit:** when the page mounts AND the bar surfaces at least one
|
||||
row that's newer than the current cursor, the *new* cursor is
|
||||
remembered locally as the timestamp of the **newest visible row**.
|
||||
We do **not** auto-advance the server cursor on mount — too easy to
|
||||
lose items behind a stray pageview. The "neu" highlight on rows
|
||||
newer than the saved cursor is the silent UX. Explicit click is the
|
||||
one and only path to clearing the badge.
|
||||
|
||||
### `unread_only` axis
|
||||
|
||||
New filter-bar axis (Slice A):
|
||||
|
||||
```ts
|
||||
// types.ts
|
||||
unread_only?: boolean;
|
||||
```
|
||||
|
||||
When `true`, the bar overlays a FilterSpec predicate:
|
||||
`row.event_date > inbox_seen_at` (substrate-side filter; for project_events
|
||||
that's `pe.created_at > $cursor`, for approval_requests that's
|
||||
`requested_at > $cursor` OR `status='pending'` per the carve-out above).
|
||||
|
||||
Default: **unread_only=true** for first paint (per Slice A — landing on
|
||||
the inbox shows you what's new). The "Alle" chip flips it off so the
|
||||
user can see history.
|
||||
|
||||
---
|
||||
|
||||
## 4. Filter contract
|
||||
|
||||
The bar surfaces these axes on `/inbox` (`INBOX_AXES` constant in
|
||||
`client/inbox.ts`):
|
||||
|
||||
| Axis | Why on /inbox | New? |
|
||||
|--------------------------|----------------------------------------------------------------------|------|
|
||||
| `time` | "Last 30 days" (default) with chip cluster + "Älter anzeigen" . | already |
|
||||
| `project` | Single-select autocomplete from visible projects. | already |
|
||||
| `approval_viewer_role` | "Zur Genehmigung" / "Eigene Anfragen" / "Alle sichtbaren". | already |
|
||||
| `approval_status` | pending / approved / rejected / revoked / changes_requested. | already |
|
||||
| `approval_entity_type` | Frist / Termin (chip pair). | already |
|
||||
| `project_event_kind` | Chip cluster over InboxProjectEventKinds. | already |
|
||||
| **`unread_only`** | Boolean toggle ("Nur ungelesen" / "Alle"); defaults to ungelesen. | **Slice A new axis** |
|
||||
| `shape` | list / cards / calendar. | already in `AxisKey`, not yet on `/inbox` |
|
||||
| `sort` | Newest first (default) / oldest first. | already |
|
||||
| `density` | comfortable / compact. | already |
|
||||
|
||||
**Default landing state** for a brand-new pageview:
|
||||
`?time=past_30d&unread_only=true&a_status=pending&shape=list&sort=date_desc`.
|
||||
|
||||
Bookmarks from older clients (e.g. the legacy `?tab=pending-mine`)
|
||||
still work because `client/inbox.ts:46–58` already applies the legacy
|
||||
tab → `a_role` redirect at hydration.
|
||||
|
||||
### Source-removal not exposed as an axis
|
||||
|
||||
Users do **not** see a "show approvals only / show events only" chip.
|
||||
The signal we want is "what's new across my projects"; splitting the
|
||||
two via the filter row is busywork. If they want approvals-only they
|
||||
chip-pick `project_event_kind` empty + status=any (or future axis pick
|
||||
`source=approval_request`). If feedback shows otherwise after Slice A
|
||||
ships, we add the axis in Slice B trivially (`Sources` is a
|
||||
spec.Sources literal flip).
|
||||
|
||||
---
|
||||
|
||||
## 5. View toggle implementation plan (Q5 → R: list / cards / calendar)
|
||||
|
||||
The pattern `/events` uses today (see `frontend/src/events.tsx:107–141`
|
||||
for the `<div className="events-view-selector">` block and
|
||||
`client/events.ts:617–650` for the `applyView` function):
|
||||
|
||||
- One chip cluster `data-event-view="cards|list|calendar"`.
|
||||
- Active class toggle.
|
||||
- Per-shape `display: none` on the table-wrap / cards-wrap / cal-wrap
|
||||
hosts.
|
||||
- For calendar, `mountCalendar()` constructs a month/week/day grid
|
||||
into a dedicated `events-calendar-wrap` host; the handle is destroyed
|
||||
on shape-leave so its URL state doesn't leak into the other shapes.
|
||||
|
||||
### Mapping onto /inbox
|
||||
|
||||
The cleanest path: **use `filter-bar`'s built-in `shape` axis instead of
|
||||
a per-page selector.** The axis already round-trips into the URL via
|
||||
`url-codec.ts` and serialises into `RenderSpec.Shape`. `client/inbox.ts`
|
||||
just needs:
|
||||
|
||||
1. Add `"shape"` to `INBOX_AXES`.
|
||||
2. Dispatch in the `onResult` callback by `effective.render.shape`:
|
||||
|
||||
```ts
|
||||
onResult: (result, effective) => {
|
||||
switch (effective.render.shape) {
|
||||
case "cards": return paintCards(result.rows, effective.render, ...);
|
||||
case "calendar": return paintCalendar(result.rows, ...);
|
||||
case "list":
|
||||
default: return paintList(result.rows, effective.render, ...);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. The renderers exist already: `renderCardsShape` in
|
||||
`views/shape-cards.ts`, `renderCalendarShape` in
|
||||
`views/shape-calendar.ts`, `renderListShape` in `views/shape-list.ts`.
|
||||
The only piece of new code is the per-shape host-clearing on switch
|
||||
(so we don't leak a stale shape's DOM into the new host).
|
||||
|
||||
### Calendar shape — items without dates
|
||||
|
||||
Calendar can only render rows with a calendar-mappable date. Today:
|
||||
|
||||
- **approval_request:** `requested_at` (timestamp). Maps fine, but
|
||||
shows up as a single point — rendering an approval-request on a month
|
||||
grid is semantically "you got asked on this day". OK for v1.
|
||||
- **project_event:** `created_at`. Same shape.
|
||||
- **deadline:** `due_date`. Already supported.
|
||||
- **appointment:** `start_at`. Already supported.
|
||||
|
||||
So every row in the inbox v1 has a calendar position. No
|
||||
need to filter rows on calendar-mount. **One caveat:** the calendar
|
||||
shape currently doesn't render action affordances (approve/reject) — it
|
||||
opens a detail dialog on click. Slice B accepts that: clicking an
|
||||
approval row on the calendar opens the inbox-list-style detail in a
|
||||
modal (re-using the existing per-row /api/approval-requests/{id}
|
||||
fetch). Out of scope for Slice A.
|
||||
|
||||
### Cards shape — day-grouped chronological cards
|
||||
|
||||
`shape-cards.ts` groups by day and renders one card per row, with
|
||||
title + meta + actor. The approval-card layout there is the standard
|
||||
card (no approve buttons — same caveat as calendar). For Slice B, we
|
||||
extend `shape-cards.ts` to detect `row.kind === "approval_request"
|
||||
&& row.detail.status === "pending"` and stamp the approve/reject button
|
||||
strip inline. The DOM template is the same as
|
||||
`shape-list.ts:renderApprovalRow`, so most of the work is hoisting that
|
||||
template into a shared util.
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend aggregation service (Q6 → R: reuse RunSpec)
|
||||
|
||||
**Decision: do not build a new aggregation service.** The
|
||||
substrate-level work is exactly two edits:
|
||||
|
||||
### 6.1 InboxSystemView (system_views.go:103–144)
|
||||
|
||||
```go
|
||||
func InboxSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox",
|
||||
Name: "Inbox",
|
||||
Filter: FilterSpec{
|
||||
Version: SpecVersion,
|
||||
Sources: []DataSource{
|
||||
SourceApprovalRequest,
|
||||
SourceProjectEvent,
|
||||
},
|
||||
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
||||
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "any_visible",
|
||||
Status: []string{"pending"}, // default; bar can override
|
||||
}},
|
||||
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
|
||||
EventTypes: InboxProjectEventKinds, // curated subset
|
||||
}},
|
||||
},
|
||||
},
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateDesc, // newest first — different from today's date_asc
|
||||
RowAction: RowActionInbox, // new — see §6.3
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Curated sub-list lives in `filter_spec.go` next to KnownProjectEventKinds:
|
||||
|
||||
```go
|
||||
var InboxProjectEventKinds = []string{
|
||||
"project_archived", "project_reparented", "project_type_changed",
|
||||
"deadline_created", "deadline_completed", "deadline_reopened",
|
||||
"deadline_updated", "deadline_deleted", "deadlines_imported",
|
||||
"appointment_created", "appointment_updated", "appointment_deleted",
|
||||
"note_created", "our_side_changed",
|
||||
}
|
||||
```
|
||||
|
||||
(With Q1 pick A locked. If head picks B, drop the InboxProjectEventKinds
|
||||
list and remove the `EventTypes` predicate. If head picks C, narrow the
|
||||
list to deadline_* + appointment_* only.)
|
||||
|
||||
KnownProjectEventKinds in `filter_spec.go:186` needs **additions** so
|
||||
`note_created`, `our_side_changed`, `deadline_updated`, `deadline_deleted`,
|
||||
`deadlines_imported` are valid filter values — without this the
|
||||
validator rejects the InboxSystemView spec. Migrate this list at the
|
||||
same time. (`event_categories` and similar grouping infra are already
|
||||
covered by `event_category_service.go` and won't move.)
|
||||
|
||||
### 6.2 Approval-duplicate suppression
|
||||
|
||||
In `view_service.runProjectEvents` (or in a tiny new predicate helper),
|
||||
skip `event_type LIKE '%_approval_%'` when source-set includes
|
||||
ApprovalRequest. This avoids the double-count described in Q1 §2.
|
||||
|
||||
Implementation: extend `allowedProjectEventKinds` (view_service.go:649) to
|
||||
auto-drop the `*_approval_*` strings when the same RunSpec already
|
||||
fans out the approval_request source. One conditional, six lines.
|
||||
|
||||
### 6.3 Mixed-row row_action
|
||||
|
||||
`shape-list.ts` today: `row_action="approve"` → calls
|
||||
`renderApprovalList(rows)` which assumes every row is an approval.
|
||||
Need a new value:
|
||||
|
||||
```go
|
||||
// render_spec.go
|
||||
const RowActionInbox ListRowAction = "inbox"
|
||||
```
|
||||
|
||||
And register it in `KnownRowActions`.
|
||||
|
||||
Frontend (`shape-list.ts`):
|
||||
|
||||
```ts
|
||||
if (rowAction === "inbox") {
|
||||
host.appendChild(renderInboxList(sorted));
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Where `renderInboxList(rows)`:
|
||||
|
||||
- approval_request rows → existing `renderApprovalRow(row)` template (the
|
||||
per-row factor-out from `renderApprovalList`).
|
||||
- project_event rows → a new `renderProjectEventRow(row)` template:
|
||||
timestamp + actor + title + project chip + optional "Öffnen" link
|
||||
to the underlying entity (deadline / appointment / note / project
|
||||
detail). Modelled on the Verlauf row in
|
||||
`client/projects-detail.ts:651–700` (`.entity-event` markup).
|
||||
|
||||
This makes the inbox stamping kind-aware. The
|
||||
existing `wireApprovalActions` continues to find buttons via class
|
||||
`.views-approval-action` and works unchanged.
|
||||
|
||||
### 6.4 Endpoints — what's new vs reused
|
||||
|
||||
| Path | Behaviour | Slice |
|
||||
|-------------------------------------|----------------------------------------------------------|-------|
|
||||
| `GET /api/views/inbox/run` | **Already exists** — fans the InboxSystemView spec. | A reuse |
|
||||
| `GET /api/inbox/count` | **Behaviour change:** count includes unread project_events on visible projects + pending approval_requests (the latter regardless of cursor). | A |
|
||||
| `POST /api/inbox/mark-all-seen` | New. Sets `users.inbox_seen_at = now()` for the caller. | A |
|
||||
| `GET /api/inbox/pending-mine` | **Keep** — backwards-compat for clients (sidebar bell may still use it). | unchanged |
|
||||
| `GET /api/inbox/mine` | **Keep** — used by the saved view `inbox-mine`. | unchanged |
|
||||
|
||||
The two `/api/inbox/{pending-mine,mine}` endpoints stay because they're
|
||||
narrower-than-RunSpec optimisations and used by the dashboard's
|
||||
`loadInboxSummary`. No reason to remove them.
|
||||
|
||||
### 6.5 InboxSummary on the dashboard (out of scope, but flag)
|
||||
|
||||
`DashboardData.InboxSummary` (dashboard_service.go:89) currently counts
|
||||
only pending approvals. If Slice C extends the badge count to include
|
||||
unread project_events, the dashboard widget also needs to swap
|
||||
`PendingCountForUser` for the new unified count — keep this as a small
|
||||
follow-up after Slice A ships and the cursor semantics are proven.
|
||||
|
||||
---
|
||||
|
||||
## 7. Slice plan
|
||||
|
||||
### Slice A — Project-event aggregation + read cursor + list view
|
||||
|
||||
**Goal:** /inbox shows pending approvals + curated project_events for
|
||||
visible projects in the last 30 days, with the new "Nur ungelesen"
|
||||
toggle. List view only.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. **Migration `NNN_inbox_seen_at.up.sql`:**
|
||||
`ALTER TABLE paliad.users ADD COLUMN inbox_seen_at timestamptz NULL;`
|
||||
2. **`filter_spec.go`:** extend `KnownProjectEventKinds` (add
|
||||
`note_created`, `our_side_changed`, `deadline_updated`,
|
||||
`deadline_deleted`, `deadlines_imported`). Add
|
||||
`InboxProjectEventKinds` (curated subset, Q1=A).
|
||||
3. **`system_views.go`:** rewrite `InboxSystemView` per §6.1 with
|
||||
both sources, `HorizonPast30d`, `SortDateDesc`,
|
||||
`RowAction=RowActionInbox`.
|
||||
4. **`render_spec.go`:** add `RowActionInbox`, register in
|
||||
`KnownRowActions`.
|
||||
5. **`view_service.go`:** in `runProjectEvents`, auto-drop
|
||||
`*_approval_*` event_types when ApprovalRequest is in
|
||||
`spec.Sources` (§6.2).
|
||||
6. **`approvals.go`:**
|
||||
- New handler `handleInboxMarkAllSeen` →
|
||||
`UPDATE paliad.users SET inbox_seen_at = now() WHERE id = $1`.
|
||||
- Modify `handleInboxCount` to return
|
||||
`pending_approvals_count + unread_project_events_count`. SQL
|
||||
in approval_service.go: one new method
|
||||
`UnseenInboxCountForUser(userID)` returning that union. Keep
|
||||
`PendingCountForUser` (dashboard still uses it).
|
||||
7. **`shape-list.ts`:** factor `renderApprovalRow(row)` out of
|
||||
`renderApprovalList`. Add `renderInboxList(rows)` that dispatches
|
||||
per `row.kind`. Wire `row_action="inbox"` to it.
|
||||
8. **`client/inbox.ts`:**
|
||||
- Add the `unread_only` axis to `INBOX_AXES` and wire to a FilterSpec
|
||||
overlay (sub-spec `Time.Horizon=Past30d` AND
|
||||
filter predicate "newer than cursor OR pending-approval").
|
||||
- Render "Alles als gelesen markieren" button in the page header
|
||||
(in `inbox.tsx`); on click POST `/api/inbox/mark-all-seen`,
|
||||
refresh bar + badge.
|
||||
- Listen for cursor update (server response) and refresh.
|
||||
9. **Sidebar badge (`client/sidebar.ts:initInboxBadge`):** unchanged code
|
||||
path, but the new server count includes project_events. Add no client
|
||||
changes for v1 — server returns the wider count.
|
||||
10. **i18n:** new keys —
|
||||
- `inbox.title.feed` ("Inbox") replaces "Genehmigungen" in the page
|
||||
header (since the page is now more than approvals).
|
||||
- `inbox.subtitle.feed` ("Neuigkeiten zu Ihren Projekten und offene
|
||||
Genehmigungen.").
|
||||
- `inbox.action.mark_all_seen` ("Alles als gelesen markieren").
|
||||
- `inbox.axis.unread_only.on/off`.
|
||||
- `inbox.empty.feed` ("Keine Neuigkeiten in den letzten 30 Tagen.").
|
||||
- `views.col.event_kind` (for the kind column in
|
||||
table-density list).
|
||||
- DE primary, EN secondary, both in `i18n.ts`.
|
||||
11. **Tests:** `system_views_test.go` covers the
|
||||
InboxSystemView spec shape; new test for the de-dup helper in
|
||||
view_service. `approval_service_test.go` adds tests for the new
|
||||
`UnseenInboxCountForUser` method. New
|
||||
`inbox_seen_at_test.go` covers the cursor migration + the POST
|
||||
handler.
|
||||
12. **Verify** the page renders for a sample user with both event types
|
||||
visible, "Nur ungelesen" toggles correctly, mark-all-seen clears the
|
||||
badge, the project-events deduplicate against approval requests.
|
||||
|
||||
### Slice B — Cards + calendar shape toggles
|
||||
|
||||
**Goal:** `?shape=cards` and `?shape=calendar` work on /inbox; users can
|
||||
switch via the bar's shape chip. Approval rows on cards/calendar are
|
||||
*read-only* (open detail modal on click; no inline approve/reject).
|
||||
|
||||
Tasks:
|
||||
|
||||
1. **`client/inbox.ts`:** add `"shape"` to `INBOX_AXES`. Add the
|
||||
per-shape host divs to `inbox.tsx` (one for cards, one for calendar)
|
||||
matching the `/events` pattern. Implement `onResult` dispatch.
|
||||
2. **`shape-cards.ts`:** when `row.kind==="approval_request"` AND
|
||||
`row.detail.status==="pending"`, stamp the approval row template
|
||||
inline. Hoist the template out of `shape-list.ts` if reuse pays.
|
||||
3. **`shape-calendar.ts`:** approval_request rows render as date-point
|
||||
chips; click opens a detail modal. The modal reuses the existing
|
||||
`approval-edit-modal` for suggest-changes when the user is the
|
||||
approver; otherwise a read-only summary.
|
||||
4. **CSS:** ensure `.entity-event` and `.views-approval-row` markup
|
||||
coexist on the cards view without z-index clashes; lightweight
|
||||
targeting via `.views-cards-list[data-surface="inbox"]`.
|
||||
5. **Tests:** shape toggle persistence via URL codec (already covered
|
||||
in `url-codec.test.ts`; add one inbox-surface case).
|
||||
|
||||
### Slice C — Badge upgrade + per-item dismiss (deferred)
|
||||
|
||||
**Goal:** sidebar badge reflects unified count; per-item dismiss for
|
||||
power-users.
|
||||
|
||||
Tasks:
|
||||
|
||||
1. **`paliad.inbox_dismissals` table** —
|
||||
`(user_id, source, row_id, dismissed_at)` PK `(user_id, source, row_id)`.
|
||||
"source" is `approval_request` / `project_event`; "row_id" is the
|
||||
row's UUID. New endpoint `POST /api/inbox/dismiss` body
|
||||
`{source, row_id}`. RunSpec for inbox subtracts dismissed rows.
|
||||
2. **`/api/inbox/count`:** subtract dismissed rows from the count.
|
||||
3. **Dashboard widget:** `DashboardData.InboxSummary` swaps to a new
|
||||
`UnifiedInboxSummary` that mirrors the page count. Backwards-compat
|
||||
JSON: keep old fields, add `total_count` and `top_unified`.
|
||||
4. **Empty-state:** "Alle Einträge gelesen — gut gemacht."
|
||||
5. **Optional `member_role_changed` etc.:** if Slice A surfaces that
|
||||
one of the excluded event_types is actually wanted, this slice opens
|
||||
up `InboxProjectEventKinds` accordingly.
|
||||
|
||||
### Why Slice A alone is shippable
|
||||
|
||||
Slice A delivers m's full ask except the cards/calendar views — which
|
||||
are aesthetic shape toggles, not data changes. Slice A gives:
|
||||
|
||||
- Inbox feed across approvals + project_events for visible projects
|
||||
- Project / type / time / read-state filters
|
||||
- Newest-first list with mark-all-seen
|
||||
- Sidebar badge reflects unified unread count (server-side)
|
||||
|
||||
Slice B + C are layer cake on top with no schema or substrate changes.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- **Push notifications.** Telegram / WhatsApp / email — different
|
||||
channel concerns, separate design.
|
||||
- **Cross-user inbox views.** No "admin sees others' inboxes" in v1.
|
||||
- **Pinning / starring items.** Not in m's ask. If feedback after Slice
|
||||
A wants it, opens its own design.
|
||||
- **Paliadin chat unread.** Not part of project_events; paliadin lives
|
||||
in its own pane. Slice C could surface a banner if asked.
|
||||
- **Replacement of the existing /api/inbox/{pending-mine,mine} endpoints.**
|
||||
They stay because the dashboard's `loadInboxSummary` uses them and
|
||||
no benefit to consolidating.
|
||||
- **Detail-page changes.** Clicking a project_event row in the inbox
|
||||
navigates to the existing entity detail page (deadline, appointment,
|
||||
note); we don't build a new "event detail" view.
|
||||
- **InboxSummary on the dashboard.** Out of Slice A. Slice C upgrades
|
||||
it; for now the widget keeps showing approval-only.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m
|
||||
|
||||
Defaulted to (R) per the inventor protocol — only **Q1** is escalated
|
||||
to head for explicit confirmation because it changes the
|
||||
inbox's surface area. Everything else falls to the recommended pick
|
||||
unless head/m flag otherwise.
|
||||
|
||||
**Q1 — Event-type catalogue (material pick, head answered):**
|
||||
**LOCKED = A** (curated subset with `*_approval_*` de-dup). Head added
|
||||
`member_role_changed` to the curated list with a Slice B narrowing
|
||||
follow-up + a coarser `inbox_focus` chip cluster on the bar. Full
|
||||
decision recorded in §12.
|
||||
|
||||
**Q2 — Time window:** (R) Past30d default + chip cluster
|
||||
(today / past_7d / past_30d / past_90d / any) + custom range via the
|
||||
existing time picker. Locked unless head overrides.
|
||||
|
||||
**Q3 — Read/unread model:** (R) High-watermark cursor
|
||||
(`users.inbox_seen_at`). Pending approval_requests carry forward even
|
||||
when older than the cursor — guards against burying a high-value
|
||||
approval. Per-item dismiss is Slice C, opt-in. Locked.
|
||||
|
||||
**Q4 — Filters surfaced on the bar:** (R) time / project /
|
||||
approval_viewer_role / approval_status / approval_entity_type /
|
||||
project_event_kind / unread_only / shape / sort / density. Locked
|
||||
unless head wants `source` (approvals-only vs events-only chip)
|
||||
added — defaulting to "not in v1".
|
||||
|
||||
**Q5 — View toggle parity with /events:** (R) list (default — newest
|
||||
first) / cards (day-grouped) / calendar (date-point). Wired via the
|
||||
filter-bar's existing `shape` axis, not a per-page selector. Locked.
|
||||
|
||||
**Q6 — Architecture:** (R) Reuse `view_service.RunSpec` with both
|
||||
sources in the InboxSystemView spec; no new aggregation service.
|
||||
Approval-event de-dup applied in `runProjectEvents`. Locked.
|
||||
|
||||
**Q7 — Notification badge:** (R) Yes — Slice A makes the existing
|
||||
`/api/inbox/count` return the unified unread count; sidebar badge
|
||||
client unchanged. Locked.
|
||||
|
||||
**Q8 — Acknowledgement flow:** (R) Approval rows keep
|
||||
approve/reject/revoke buttons inline (list shape only). project_event
|
||||
rows have no inline action — click row → navigate to the underlying
|
||||
entity. Cursor advance is via "Alles als gelesen markieren" only —
|
||||
no per-row mark-read in v1. Locked.
|
||||
|
||||
**Q9 — Empty-state copy:** (R) "Keine Neuigkeiten in den letzten 30
|
||||
Tagen." (DE primary) / "No updates in the last 30 days." (EN). The
|
||||
existing admin nudge for unseeded approval_policies stays untouched.
|
||||
Locked.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks + mitigations
|
||||
|
||||
- **Performance.** `runProjectEvents` reads up to LIMIT 500 rows per
|
||||
user-call; with two sources unioned + 30-day window + visibility
|
||||
predicate this should stay under 50ms on the live shape (project
|
||||
count ~100, events/day low double digits). If
|
||||
it doesn't, partial index hint: `paliad.project_events (created_at DESC)
|
||||
WHERE event_type IN (curated list)` — Slice A optional, add if
|
||||
EXPLAIN shows a seq scan in dev.
|
||||
- **De-dup correctness.** Suppressing `*_approval_*` events in the
|
||||
project_event source relies on the approval_request row being the
|
||||
authoritative signal. **Edge case:** a request gets revoked, then
|
||||
re-requested — both audit events exist. Both correspond to a single
|
||||
approval_request row at any moment (the latter via the partial-index
|
||||
upsert). De-dup stays valid.
|
||||
- **Cursor advance race.** If two browser tabs both POST mark-all-seen,
|
||||
the second wins (now() wins). Acceptable. If a user reads in tab A
|
||||
then clicks an item in tab B that was created between the two reads,
|
||||
tab A's "Alles als gelesen" advances past that newer item without
|
||||
the user seeing it. Mitigation: server-side, `mark-all-seen` accepts
|
||||
an optional `?up_to=<iso>` so the client can pin to the timestamp of
|
||||
the newest visible row. Slice A wires this.
|
||||
- **shape-list factor-out.** Pulling `renderApprovalRow` out of
|
||||
`renderApprovalList` risks regressions on the *current* /inbox. Cover
|
||||
with a snapshot/golden test on the approval row markup in Slice A
|
||||
before the dispatch change.
|
||||
- **Sidebar bell badge cap.** Current code clamps at "9+". Once we add
|
||||
project_events, the count can easily exceed 100. Keep the "9+" clamp
|
||||
for visual reasons — but make the page header show the *exact* count
|
||||
("123 neu") so the user knows what's behind it.
|
||||
- **Q1 fallback.** If head doesn't reply before Slice A coder shift
|
||||
starts, the (R) pick A locks. If head later picks B or C, the only
|
||||
change is the `InboxProjectEventKinds` list literal in
|
||||
`filter_spec.go` — no schema impact, no migration change. Cheap to
|
||||
flip.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build/test verify list (Slice A done-when)
|
||||
|
||||
1. `make build` clean.
|
||||
2. `go test ./...` passes; new tests cover:
|
||||
- InboxSystemView spec shape includes both sources + curated kinds.
|
||||
- `runProjectEvents` drops `*_approval_*` when ApprovalRequest is in spec.
|
||||
- `UnseenInboxCountForUser` returns expected count for cursor and pending-approval combinations.
|
||||
- POST `/api/inbox/mark-all-seen` updates the column.
|
||||
- URL codec round-trip for `unread_only` axis.
|
||||
3. Inbox loads at `/inbox` with project-event rows interleaved with
|
||||
approval rows in date-desc order.
|
||||
4. "Nur ungelesen" chip toggles between unread (with pending-approval
|
||||
carve-out) and full feed.
|
||||
5. "Alles als gelesen markieren" advances cursor; bar refreshes;
|
||||
badge clears (except for any still-pending approvals).
|
||||
6. Sidebar bell badge count is the unified number (approval + unread events).
|
||||
7. Existing approve/reject/revoke + suggest-changes flows on inbox
|
||||
rows still work unchanged.
|
||||
8. `?tab=mine` legacy redirect still hits the right state.
|
||||
9. Bilingual labels render (DE/EN toggle).
|
||||
|
||||
That's the doneness bar for Slice A.
|
||||
|
||||
---
|
||||
|
||||
## §12 — m's decisions (head 2026-05-25 11:30)
|
||||
|
||||
Head replied to the `mai instruct head` escalation; folded in below.
|
||||
|
||||
**Q1 (Event-type catalogue): A — locked.** Curated subset with
|
||||
`*_approval_*` de-dup. Tracks Verlauf, matches m's framing ("new events
|
||||
that relate to one's projects"), avoids double-counting approval audit
|
||||
events against the approval_request row.
|
||||
|
||||
Locked InboxProjectEventKinds:
|
||||
|
||||
- IN: `project_archived`, `project_reparented`, `project_type_changed`,
|
||||
`deadline_created`, `deadline_completed`, `deadline_reopened`,
|
||||
`deadline_updated`, `deadline_deleted`, `deadlines_imported`,
|
||||
`appointment_created`, `appointment_updated`, `appointment_deleted`,
|
||||
`note_created`, `our_side_changed`, **`member_role_changed`**
|
||||
(added by head — see refinement #1).
|
||||
- OUT (audit duplicates of approval_requests): every `*_approval_*` event.
|
||||
- OUT (too granular / authoring noise): `status_changed`,
|
||||
`project_created`, `checklist_*`.
|
||||
|
||||
**Refinement 1 — `member_role_changed` visibility predicate.**
|
||||
Head wants this kind included but narrowed: surface the row only when
|
||||
the role change applies to the **viewer themselves** or someone above
|
||||
them in the project tree (i.e. impacts the viewer's permissions / chain
|
||||
of command), not when it's a peer's role changing on a project the
|
||||
viewer happens to see.
|
||||
|
||||
- Slice A: include `member_role_changed` in
|
||||
`InboxProjectEventKinds` without the narrowing predicate. The row
|
||||
will appear for everyone who can see the project — over-surfacing but
|
||||
not wrong. This keeps Slice A's MVP scope tight.
|
||||
- Slice B: add a per-row narrowing filter on top of the inbox source
|
||||
(likely a small extension to `runProjectEvents` that, when
|
||||
`event_type='member_role_changed'`, inspects `metadata.affects_user_id`
|
||||
+ walks the project-membership predicate before emitting). The
|
||||
metadata shape is already written by the responsible handler; verify
|
||||
+ lock the filter in B.
|
||||
|
||||
Q2-Q9 all default to (R) per the inventor protocol.
|
||||
|
||||
**Refinement 2 — Filter chip copy.**
|
||||
For the visible chip cluster in the bar, head wants user-readable groupings,
|
||||
not raw event-kind names. The bar today exposes `project_event_kind`
|
||||
as one chip per kind (rendered via the
|
||||
`event.title.<kind>` i18n key). For the inbox surface, surface a
|
||||
**coarser grouping chip cluster** ahead of that:
|
||||
|
||||
- "Genehmigungen" — narrows to `Sources=[approval_request]` only.
|
||||
- "Genehmigungen + Termine" — adds appointment_* event_kinds + the
|
||||
approval_entity_type=appointment slice of approvals.
|
||||
- "Genehmigungen + Fristen" — adds deadline_* event_kinds + the
|
||||
approval_entity_type=deadline slice of approvals.
|
||||
- "Alles" — default; both sources, full curated kinds list.
|
||||
|
||||
Implementation: a new axis `inbox_focus` (Slice A, additive — replaces
|
||||
the lower-level `project_event_kind` chip's *default visibility* in the
|
||||
inbox UI; advanced users still see `project_event_kind` if they expand
|
||||
the bar). The four values map to FilterSpec overlays that tweak
|
||||
`Sources` + per-source `EventTypes`. Coder owns the exact chip-text
|
||||
final copy and the placement (probably first axis in `INBOX_AXES`).
|
||||
|
||||
The lower-level `project_event_kind` chip stays in `INBOX_AXES` as an
|
||||
advanced override for power users — when active, it overrides the
|
||||
`inbox_focus` chip's per-kind defaults.
|
||||
|
||||
---
|
||||
|
||||
### What changes for Slice A as a result
|
||||
|
||||
Doc deltas vs the draft text above:
|
||||
|
||||
1. **§2 / §6.1:** add `member_role_changed` to InboxProjectEventKinds.
|
||||
Note Slice B narrowing follow-up.
|
||||
2. **§4 / §5:** front of the bar gets a new `inbox_focus` axis
|
||||
(4 chips: Alles / Genehmigungen / +Termine / +Fristen). Default
|
||||
"Alles". `project_event_kind` stays available as an advanced chip,
|
||||
visible after the user expands the bar's overflow section.
|
||||
3. **§7 Slice A task list:** add task —
|
||||
"**12a.** New `inbox_focus` axis (`filter-bar/types.ts`,
|
||||
`axes.ts`). FilterSpec overlay translates the chip value to a
|
||||
`(Sources, ProjectEventPredicates.EventTypes, ApprovalRequestPredicates.EntityTypes)`
|
||||
triple. URL codec round-trips."
|
||||
4. **§11 Slice B done-when:** add — "`member_role_changed` narrowing
|
||||
predicate is in place; rows surface only when the change affects
|
||||
the viewer's permissions chain."
|
||||
|
||||
No schema changes from the head's adjustments. The `inbox_focus` axis
|
||||
is a pure UI/overlay primitive; nothing about the InboxSystemView spec
|
||||
schema moves.
|
||||
668
docs/design-submission-page-2026-05-22.md
Normal file
668
docs/design-submission-page-2026-05-22.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-22
|
||||
**Issue:** m/paliad (mai task t-paliad-238)
|
||||
**Branch:** `mai/cronus/inventor-dedicated`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
|
||||
|
||||
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
|
||||
|
||||
1. Picks (or creates) a named **draft** for one (project, submission_code).
|
||||
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
|
||||
3. Sees a read-only preview pane showing the merged document body as HTML.
|
||||
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
|
||||
|
||||
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
|
||||
|
||||
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
|
||||
|
||||
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
|
||||
|
||||
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-22)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
|
||||
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
|
||||
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123` — `Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
|
||||
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID` → `can_see_project`. |
|
||||
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
|
||||
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → `…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
|
||||
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
|
||||
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
|
||||
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
|
||||
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
|
||||
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
|
||||
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52` — `PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
|
||||
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-22)
|
||||
|
||||
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
|
||||
|
||||
| # | Question (from task brief) | Default adopted | Source |
|
||||
|---|---|---|---|
|
||||
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
|
||||
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
|
||||
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
|
||||
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
|
||||
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
|
||||
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
|
||||
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
|
||||
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export` → `.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
|
||||
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
|
||||
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
|
||||
|
||||
Inventor-defaulted (not in the (R) matrix; clear right answer):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
|
||||
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
|
||||
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
|
||||
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
|
||||
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
|
||||
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
|
||||
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
|
||||
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail (existing) — /projects/{id} with #tab-submissions │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Schriftsätze tab │ │
|
||||
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
|
||||
│ │ "Bearbeiten" deep-link │ "Generieren" = │
|
||||
│ │ │ existing one-click │
|
||||
│ ▼ │ format-only export │
|
||||
└───────────┼───────────────────────────────────────────┼──────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
|
||||
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
|
||||
│ │
|
||||
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
|
||||
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
|
||||
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
|
||||
│ │ ───────────────────────────────── │ │ overrides] │ │
|
||||
│ │ project.case_number │ │ │ │
|
||||
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
|
||||
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
|
||||
│ │ BMW AG [override?] │ │ durch … │ │
|
||||
│ │ deadline.due_date │ │ │ │
|
||||
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
|
||||
│ │ ... │ │ │ │
|
||||
│ │ │ │ [KEIN WERT: project.our_side] │ │
|
||||
│ │ [✎ Bearbeiten] inline │ │ │ │
|
||||
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
└──────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submission_drafts.go (NEW) │
|
||||
│ 1. Auth: ProjectService.GetByID → can_see_project │
|
||||
│ 2. Load draft row (RLS via project visibility) │
|
||||
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
|
||||
│ 4. Apply draft.overrides on top of bag │
|
||||
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
|
||||
│ (Slice A: skips registry, fetches the universal .dotm directly) │
|
||||
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
|
||||
│ 7. Audit: system_audit_log + project_events │
|
||||
│ 8. Stream .docx with Content-Disposition: attachment │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
|
||||
|
||||
---
|
||||
|
||||
## §4 Schema (`paliad.submission_drafts`)
|
||||
|
||||
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
|
||||
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
|
||||
-- empty value = "don't override, use bag"
|
||||
-- present key = "use this verbatim"
|
||||
|
||||
last_exported_at timestamptz, -- NULL until first export
|
||||
last_exported_sha text, -- template SHA at last export (audit aid)
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY submission_drafts_visible
|
||||
ON paliad.submission_drafts
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
|
||||
CREATE TRIGGER submission_drafts_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
```
|
||||
|
||||
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
|
||||
|
||||
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not** — `system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
|
||||
|
||||
### 4.1 RLS read-vs-write
|
||||
|
||||
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
|
||||
|
||||
### 4.2 Down migration
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
```
|
||||
|
||||
No data loss concern at design time — feature ships without legacy drafts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Service layer
|
||||
|
||||
### 5.1 Resurrect from git (no new code)
|
||||
|
||||
```
|
||||
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
|
||||
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
|
||||
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
|
||||
strip macros from the universal .dotm before the merge step runs.
|
||||
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
|
||||
NOT wired in Slice A (universal .dotm only); wired in Slice B.
|
||||
```
|
||||
|
||||
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
|
||||
|
||||
### 5.2 New service — `SubmissionDraftService`
|
||||
|
||||
```go
|
||||
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
|
||||
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
Overrides PlaceholderMap // jsonb → map[string]string
|
||||
LastExportedAt *time.Time
|
||||
LastExportedSHA *string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
|
||||
// Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
|
||||
|
||||
// Get returns a single draft by ID, gated on project visibility.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
|
||||
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
|
||||
|
||||
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
|
||||
|
||||
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
|
||||
```
|
||||
|
||||
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
|
||||
|
||||
### 5.3 Wiring
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (additions, no replacements)
|
||||
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
|
||||
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
|
||||
// Slice B only:
|
||||
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
|
||||
```
|
||||
|
||||
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
|
||||
|
||||
---
|
||||
|
||||
## §6 UI surface
|
||||
|
||||
### 6.1 Page layout
|
||||
|
||||
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
|
||||
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
|
||||
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
|
||||
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
|
||||
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
|
||||
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
|
||||
│ │ 80333 München │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ Variablen │ In der Sache │
|
||||
│ firm.name HLC │ │
|
||||
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
|
||||
│ project.court LG München I [✎] │ — Klägerin — │
|
||||
│ parties.claimant.name BMW AG [✎] │ │
|
||||
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
|
||||
│ parties.defendant.representative │ │
|
||||
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
|
||||
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
|
||||
│ rule.legal_source_pretty │ │
|
||||
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
|
||||
│ … │ │
|
||||
│ │ [KEIN WERT: project.our_side] │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ [Entwurf umbenennen] [Entwurf löschen] │ │
|
||||
└────────────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sidebar grouping (top-to-bottom, locale-aware labels):
|
||||
|
||||
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
|
||||
2. **Mandanten & Parteien** (parties.*)
|
||||
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
|
||||
4. **Frist** (deadline.* — due_date, computed_from)
|
||||
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
|
||||
|
||||
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
|
||||
|
||||
### 6.2 Preview pane
|
||||
|
||||
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
|
||||
|
||||
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
|
||||
|
||||
### 6.3 Routing + handlers
|
||||
|
||||
```
|
||||
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
|
||||
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
|
||||
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
|
||||
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
|
||||
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
|
||||
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
|
||||
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
|
||||
|
||||
### 6.4 Schriftsätze tab — additive change
|
||||
|
||||
```diff
|
||||
- // Each row: [Generieren ↓]
|
||||
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
|
||||
```
|
||||
|
||||
`[Bearbeiten ↗]` → `window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
|
||||
|
||||
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
|
||||
|
||||
---
|
||||
|
||||
## §7 Variable contract (v1 placeholder set)
|
||||
|
||||
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
|
||||
|
||||
```
|
||||
firm.name — branding.Name (HLC or FIRM_NAME override)
|
||||
firm.signature_block — empty in v1 (Phase 2 affordance)
|
||||
|
||||
today — 2026-05-22 (ISO, Europe/Berlin)
|
||||
today.iso — ISO short
|
||||
today.long_de — "22. Mai 2026"
|
||||
today.long_en — "22 May 2026"
|
||||
|
||||
user.display_name — paliad.users.display_name
|
||||
user.email — paliad.users.email
|
||||
user.office — paliad.users.office
|
||||
|
||||
project.title — paliad.projects.title
|
||||
project.reference — paliad.projects.reference
|
||||
project.case_number — paliad.projects.case_number
|
||||
project.court — paliad.projects.court
|
||||
project.patent_number — DE/inline form "EP 1 234 567 B1"
|
||||
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
|
||||
project.filing_date — ISO date
|
||||
project.grant_date — ISO date
|
||||
project.our_side — claimant | defendant
|
||||
project.our_side_de — "Klägerin" | "Beklagte"
|
||||
project.our_side_en — "Claimant" | "Defendant"
|
||||
project.instance_level — lg | olg | bgh | cfi | …
|
||||
project.client_number — paliad.projects.client_number
|
||||
project.matter_number — paliad.projects.matter_number
|
||||
project.proceeding.code — e.g. "de.inf.lg"
|
||||
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
|
||||
project.proceeding.name_de — explicit DE
|
||||
project.proceeding.name_en — explicit EN
|
||||
|
||||
parties.claimant.name — first paliad.parties row with role='claimant'
|
||||
parties.claimant.representative
|
||||
parties.defendant.name — first row with role='defendant'
|
||||
parties.defendant.representative
|
||||
parties.other.name — first non-claimant/defendant row
|
||||
parties.other.representative
|
||||
|
||||
rule.submission_code — "de.inf.lg.erwidg"
|
||||
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
|
||||
rule.name_de
|
||||
rule.name_en
|
||||
rule.legal_source — "DE.ZPO.276.1"
|
||||
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
|
||||
rule.primary_party — claimant | defendant | court | both
|
||||
rule.event_type — filing | hearing | decision
|
||||
|
||||
deadline.due_date — ISO of next pending deadline for this rule on this project
|
||||
deadline.due_date_long_de — "12. Juni 2026"
|
||||
deadline.due_date_long_en — "12 June 2026"
|
||||
deadline.original_due_date — ISO if extended
|
||||
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
|
||||
deadline.title — deadline.title
|
||||
deadline.source — "rule" | "manual" | …
|
||||
```
|
||||
|
||||
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
|
||||
|
||||
### 7.1 Override semantics
|
||||
|
||||
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
|
||||
|
||||
```go
|
||||
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
|
||||
for k, v := range draft.Overrides { // lawyer's edits
|
||||
if v == "" {
|
||||
delete(bag, k) // empty override means "force missing marker"
|
||||
} else {
|
||||
bag[k] = v // non-empty override replaces
|
||||
}
|
||||
}
|
||||
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
|
||||
```
|
||||
|
||||
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
|
||||
|
||||
---
|
||||
|
||||
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
|
||||
|
||||
### 8.1 Slice A — universal .dotm only
|
||||
|
||||
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
|
||||
|
||||
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
|
||||
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
|
||||
|
||||
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
|
||||
|
||||
### 8.2 Slice B — fallback-chain registry
|
||||
|
||||
Layout reproduced from the 2026-05-19 design §5.1:
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo, already proxied)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
|
||||
│ ├── de.inf.lg.klage.docx # Slice B addition
|
||||
│ ├── upc.inf.cfi.soc.docx # Slice B addition
|
||||
│ └── upc.inf.cfi.sod.docx # Slice B addition
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx
|
||||
│ ├── _skeleton.docx # ultra-generic fallback
|
||||
│ └── _universal.docx # the v1 Slice A "any code" template
|
||||
└── README.md # placeholder reference for template authors
|
||||
```
|
||||
|
||||
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
|
||||
|
||||
### 8.3 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go (resurrected from 3677c81)
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, "templates/_base/_skeleton.docx")
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
|
||||
|
||||
### 8.4 No template at all — Slice A vs Slice B
|
||||
|
||||
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
|
||||
|
||||
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
|
||||
|
||||
### 8.5 Template authoring task lands outside this design
|
||||
|
||||
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
|
||||
|
||||
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
|
||||
|
||||
---
|
||||
|
||||
## §9 Slice plan
|
||||
|
||||
### Slice A — schema + new page + variables-only export against universal .docx
|
||||
|
||||
Ships the editor end-to-end with one template.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
|
||||
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
|
||||
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
|
||||
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
|
||||
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
|
||||
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
|
||||
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
|
||||
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
|
||||
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
|
||||
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
|
||||
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
|
||||
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
|
||||
2. Sidebar renders ~30 placeholders, pre-filled from project state.
|
||||
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
|
||||
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
|
||||
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
|
||||
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
|
||||
7. `project_events` row appears on export and surfaces in Verlauf.
|
||||
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
|
||||
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
|
||||
|
||||
### Slice B — per-submission_code templates + fallback chain
|
||||
|
||||
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
|
||||
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
|
||||
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
|
||||
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
|
||||
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
|
||||
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
|
||||
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
|
||||
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
|
||||
|
||||
### Slice C — toggleable passages
|
||||
|
||||
Lawyer can include/exclude boilerplate sections before export.
|
||||
|
||||
| Deliverable | Notes |
|
||||
|---|---|
|
||||
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb` — `{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
|
||||
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
|
||||
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
|
||||
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
|
||||
|
||||
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
|
||||
|
||||
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
|
||||
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
|
||||
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
|
||||
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
|
||||
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
|
||||
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
|
||||
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
|
||||
- Citation insertion from the sources system. Waits for the sources surface m parked.
|
||||
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
|
||||
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
|
||||
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
|
||||
|
||||
---
|
||||
|
||||
## §11 Material picks escalated to head
|
||||
|
||||
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
|
||||
|
||||
### Q-E1 — Template authoring effort
|
||||
|
||||
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
|
||||
|
||||
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
|
||||
|
||||
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
|
||||
|
||||
### Q-E2 — `paliad.documents` row on export
|
||||
|
||||
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
|
||||
|
||||
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
|
||||
|
||||
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
|
||||
|
||||
### Q-E3 — Preview render — server or client?
|
||||
|
||||
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
|
||||
|
||||
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
|
||||
|
||||
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
|
||||
|
||||
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
|
||||
|
||||
### Q-E4 — Inter-user draft visibility
|
||||
|
||||
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
|
||||
|
||||
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
|
||||
|
||||
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
|
||||
|
||||
---
|
||||
|
||||
## §12 Implementation notes
|
||||
|
||||
For the coder, not for head.
|
||||
|
||||
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
|
||||
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
|
||||
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
|
||||
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
|
||||
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
|
||||
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
|
||||
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
|
||||
|
||||
---
|
||||
|
||||
## §13 Acceptance gate
|
||||
|
||||
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
|
||||
- Slices B and C are SEPARATE tasks — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
956
docs/research-deadlines-completeness-2026-05-25.md
Normal file
956
docs/research-deadlines-completeness-2026-05-25.md
Normal file
@@ -0,0 +1,956 @@
|
||||
# Bulletproof completeness audit — paliad.deadline_rules vs statutory sources
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-25
|
||||
**Task:** t-paliad-263 (m/paliad#94)
|
||||
**Mode:** read-only research, no DB writes
|
||||
**Branch:** `mai/curie/researcher-bulletproof`
|
||||
|
||||
Scope confirmed by head (paliad/head → paliad/curie, 2026-05-25 15:13):
|
||||
**UPC Rules of Procedure + EPC + PatG / ZPO / GebrMG**, plus UPC Agreement /
|
||||
Statute where they create time-limits. No HLC-internal checklists exist in
|
||||
the current head's working tree.
|
||||
|
||||
Companion / prior audits this report supersedes-and-extends:
|
||||
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie, t-paliad-084) — youpc-vs-paliad gap analysis.
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` (curie, t-paliad-159) — first UPC RoP gap list (52 rules / 2 duration bugs).
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` (pauli, t-paliad-157) — schema audit; the codes used here (`upc.inf.cfi`, `de.inf.lg`, …) reflect the post-mig-096 rename.
|
||||
|
||||
Migration baseline: migration ≤ `122_deadlines_custom_rule_text` (live as of 2026-05-25 14:00 UTC).
|
||||
|
||||
---
|
||||
|
||||
## §0. TL;DR
|
||||
|
||||
- **20 active fristenrechner proceeding_types** (live, `is_active=true`,
|
||||
`lifecycle_state='published'`) carry **132 active rules**. One extra
|
||||
`_archived_litigation` row holds 40 retired Pipeline-A rules from
|
||||
mig 093 — not surfaced anywhere, kept only for FK validity.
|
||||
|
||||
| Jurisdiction | Active types | Active rules | Statute-bound rules audited |
|
||||
|---|---:|---:|---:|
|
||||
| UPC (CFI + CoA) | 9 (incl. upc.ccr.cfi alias) | 67 | 67 |
|
||||
| EPA | 3 | 23 | 23 |
|
||||
| DPMA | 3 | 13 | 13 |
|
||||
| DE (LG/OLG/BGH/BPatG) | 5 | 29 | 29 |
|
||||
| **Total** | **20** | **132** | **132** |
|
||||
|
||||
- **5 high-impact bugs still live** that the prior May 8 audit
|
||||
surfaced (2) plus 3 new ones identified here.
|
||||
- 🔴 **`upc.rev.cfi.defence` 3 months, RoP.49.1 says 2 months.** Flagged
|
||||
May 8; still live. ★★★ — every UPC_REV defendant.
|
||||
- 🔴 **`upc.rev.cfi.rejoin` 2 months, RoP.52 says 1 month.** Flagged
|
||||
May 8; still live. ★★★ — every UPC_REV proceeding.
|
||||
- 🟠 **`upc.apl.merits.response` 2 months, RoP.235.1 says 3 months.**
|
||||
New finding (May 8 audit recorded the rule as "3 months / present-wrong
|
||||
rule_code only" — actually live data shows 2 months, so the audit
|
||||
sample mis-recorded the duration too). ★★★ — every UPC main-track
|
||||
appeal respondent.
|
||||
- 🟠 **`de.inf.lg.beruf_begr` chains parent = berufung (1mo) + 2mo = 3mo
|
||||
from urteil. ZPO §520(2) anchors the 2-month Begründungsfrist on
|
||||
service of urteil, not on filing of Berufung.** New finding.
|
||||
★★★ — every DE-first-instance appellant.
|
||||
- 🟠 **`de.inf.lg.replik` + `.duplik` have `parent_id=NULL` so they fire
|
||||
on the trigger date (Klageerhebung) — sequence-order says 30/40 but
|
||||
the compute engine reads parent_id first.** Reported as live UI bug
|
||||
by m via head (2026-05-25 13:13); confirmed by SQL. ★★★ — every
|
||||
DE-LG-Verletzung timeline.
|
||||
|
||||
- **5 rule-code / citation drift bugs still live** from the May 8 audit
|
||||
(`upc.apl.merits.notice`, `.grounds`, `.response`, `upc.rev.cfi.reply`,
|
||||
`.rejoin`) — durations may or may not be right, but the cited
|
||||
`legal_source` / `rule_code` points at the wrong rule. Pure
|
||||
cosmetic on `.notice`/`.grounds` (durations are right); load-bearing on
|
||||
`.rev.cfi.reply` / `.rejoin` because the cited rule is what tells
|
||||
the lawyer where to look the rule up.
|
||||
|
||||
- **4 DPMA / DE citation bugs** new in this audit, all citing PatG / ZPO
|
||||
sections that don't contain the cited deadline:
|
||||
- `de.null.bpatg.erwidg` cites `DE.PatG.82.1`; the 2-month Erwiderung
|
||||
is actually `§82(3)` (§82(1) is the 1-month Erklärungsfrist).
|
||||
- `dpma.opp.dpma.erwiderung` cites `DE.PatG.59.3`; §59(3) is about
|
||||
hearings, not a 4-month proprietor response. The 4-month figure is
|
||||
DPMA-internal practice, not statutory — should be court-set.
|
||||
- `dpma.appeal.bpatg.begruendung` cites `DE.PatG.75.1`; §75 is about
|
||||
*aufschiebende Wirkung* — there is no Begründungsfrist in PatG §73-§80
|
||||
for the BPatG-Beschwerde. The 1-month figure is also non-statutory.
|
||||
- `de.null.bgh.begruendung` cites `DE.PatG.111.1`; §111 is about the
|
||||
grounds-of-appeal *content* (Verletzung des Bundesrechts), not the
|
||||
Begründungsfrist. `de.null.bgh.erwiderung` cites `DE.PatG.111.3`;
|
||||
§111(3) doesn't exist in the deadline sense.
|
||||
|
||||
- **Wide UPC coverage gap inherited from May 8 audit, mostly un-closed:**
|
||||
~25 missing UPC RoP rules. Mig 095 (t-paliad-205) closed 4 of them
|
||||
(R.19 Preliminary Objection on UPC_INF and UPC_REV, R.220.1(a)
|
||||
merits-appeal spawn on both). The other ~21 (R.20.2, R.118.4,
|
||||
R.197.3, R.198, R.207.6.a, R.207.9, R.213, R.109.1/.4/.5, R.118.5,
|
||||
R.144, R.155, R.224.2(b), R.229.2, R.235.2, R.245.x, R.262.2,
|
||||
R.321.3, R.333.2, R.353, plus the DNI family R.63-R.69) are
|
||||
unchanged.
|
||||
|
||||
- **EPC gaps:** EPA opposition + Beschwerde modelled at the
|
||||
Article level only. Missing the entire Implementing Regulations
|
||||
family that drives day-to-day deadlines — R.71(3) approval period
|
||||
is half-modelled (the 4-month figure is there but the trigger
|
||||
anchor is broken: parent_id=NULL), R.79(1) proprietor response
|
||||
is modelled as a fixed 4-month period when it's actually
|
||||
court-set, R.116 oral-proceedings cut-off is modelled as
|
||||
duration-0/parent-NULL (works for some uses, not for others),
|
||||
R.121 / R.135 Weiterbehandlung is missing entirely (concept
|
||||
exists but no rule).
|
||||
|
||||
- **DE/DPMA gaps:** the entire Wiedereinsetzung family (PatG §123)
|
||||
is absent on the proceeding-tree side. `weiterbehandlung` and
|
||||
`wiedereinsetzung` concept slugs exist in the cascade (Pathway B)
|
||||
but no `paliad.deadline_rules` row computes them. Same for
|
||||
`versaeumnisurteil-einspruch` (ZPO §339 — 2 weeks).
|
||||
|
||||
- **15 ambiguities** that need m's judgement, not a coder's fix —
|
||||
mostly around court-set vs statutory periods (e.g. richterliche
|
||||
Fristen under ZPO §276(1) S.2, §283 Schriftsatznachreichung,
|
||||
EPC R.79(1), §59(3) PatG) and around the "whichever is
|
||||
longer / later" arithmetic primitives still missing
|
||||
(R.198 / R.213 / R.245.2).
|
||||
|
||||
- **Recommended fixes (§10) — total 41 items** prioritised in 4
|
||||
tiers. Tier 0 (5 hard duration bugs + 1 sequencing bug + 9
|
||||
citation/anchor bugs) should ship first. Tier 1 (12 rule-fill
|
||||
gaps, ★★★ / ★★) next. Tier 2 + 3 are coverage breadth that
|
||||
needs scoping by m (Wiedereinsetzung, R.198 working-day
|
||||
arithmetic, full Implementing Regulations port).
|
||||
|
||||
---
|
||||
|
||||
## §1. Methodology
|
||||
|
||||
For each of the 20 active proceeding_types I:
|
||||
|
||||
1. **Pulled the live rule set** via `mcp__supabase__execute_sql` against
|
||||
the youpc Postgres on 2026-05-25 14:00–15:00 UTC. Schema = `paliad`.
|
||||
Filter: `is_active = true AND lifecycle_state = 'published'`.
|
||||
2. **Enumerated the statutory deadlines** in the relevant code for the
|
||||
proceeding's scope.
|
||||
3. **Cross-referenced each statutory deadline against the live rule
|
||||
set** on (a) duration + unit, (b) anchor / parent, (c) party,
|
||||
(d) `rule_code` / `legal_source` citation, (e) sequencing.
|
||||
4. **Marked status**: `present-correct`, `present-wrong (duration)`,
|
||||
`present-wrong (citation)`, `present-wrong (anchor)`,
|
||||
`present-wrong (party)`, `partial`, `missing`, `n/a`.
|
||||
5. **Frequency tag** for prioritisation: ★★★ every case, ★★ common,
|
||||
★ specialist.
|
||||
|
||||
### 1.1 Sources
|
||||
|
||||
All citations carry a date stamp and a URL. Where the text was checked
|
||||
against more than one source, both are listed.
|
||||
|
||||
| Source | URL | Verified on | Used for |
|
||||
|---|---|---|---|
|
||||
| UPC Rules of Procedure (consolidated 18.05.2023, in force 2023-06-01) | https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf | 2026-05-25 | All UPC RoP citations |
|
||||
| UPC RoP verbatim text via `data.laws_contents` (youpc Postgres, law_type=`UPCRoP`, language=en) | youpc Supabase | 2026-05-25 | Cross-check on R.019.1, R.020.2, R.029.b/.c, R.049.1, R.051, R.051.p1, R.052, R.052.p1, R.220.1.a, R.224.1, R.224.1.a/.b, R.224.2, R.224.2.a/.b, R.235.1, R.235.2, R.237, R.238.1, R.238.2 |
|
||||
| European Patent Convention (EPC, 17th ed. 2020) — Articles | https://www.epo.org/en/legal/epc/2020/index.html (verbatim text per youpc `data.laws_contents`, law_type=`EPC`) | 2026-05-25 | EPC Articles 93, 99, 108, 112a, 116, 121, 123, 135 |
|
||||
| EPC Implementing Regulations — Rules (in force 2026 consolidated) | https://www.epo.org/en/legal/epc/2020/r71.html (and equivalents) | 2026-05-25 | EPC R.70(1), R.71(3), R.79(1)/(2), R.116(1), R.135 |
|
||||
| Patentgesetz (PatG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/patg/ | 2026-05-25 | §59, §73, §75, §82, §83, §99 ff., §100, §102, §110, §111 |
|
||||
| Zivilprozessordnung (ZPO) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/zpo/ | 2026-05-25 | §253, §276, §277, §283, §296a, §339, §517, §520, §521, §524, §544, §548, §551, §554 |
|
||||
| Gebrauchsmustergesetz (GebrMG) — gesetze-im-internet.de | https://www.gesetze-im-internet.de/gebrmg/ | 2026-05-25 | §17 (Löschung), §18 (Verfahren) — referenced only to confirm out-of-scope: no GebrMG-rooted proceeding_type exists in paliad today |
|
||||
|
||||
### 1.2 Conventions
|
||||
|
||||
- A **rule** here means a row in `paliad.deadline_rules`. paliad's local
|
||||
identifier is `submission_code` (post mig 098), e.g.
|
||||
`upc.rev.cfi.defence`.
|
||||
- A **statutory deadline** means an obligation derived directly from the
|
||||
text of a procedural code, with a fixed period.
|
||||
- "**Court-set**" / "richterliche Frist" means the statute authorises the
|
||||
court / DPMA / EPO to set the period — there is no fixed statutory
|
||||
duration. paliad models these with `is_court_set = true`
|
||||
(post mig ~079) or, legacy-style, `duration_value = 0`.
|
||||
- "**Anchoring**" refers to which event the period runs from. paliad
|
||||
models this via `parent_id` (chain anchor) or `anchor_alt` (e.g.
|
||||
`priority_date`); a NULL parent_id with non-zero duration means the
|
||||
deadline runs from the user-supplied trigger date.
|
||||
|
||||
### 1.3 Hard constraint: "no fabricated provisions"
|
||||
|
||||
Where I'm not 100% sure of a citation (because the youpc law DB only
|
||||
covers UPC + EPC, not PatG / ZPO, and my web-fetch coverage of
|
||||
PatG / ZPO is partial), I flag the finding as **"needs lawyer review"**
|
||||
in §9 rather than asserting a fix. Five PatG / ZPO findings carry that
|
||||
tag.
|
||||
|
||||
---
|
||||
|
||||
## §2. Current state inventory (per jurisdiction)
|
||||
|
||||
### 2.1 UPC
|
||||
|
||||
9 active types, 67 rules. `upc.ccr.cfi` is an alias proceeding that
|
||||
holds zero rules — it points at `upc.inf.cfi` rules under the
|
||||
`with_ccr` flag.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `upc.inf.cfi` | Verletzungsverfahren | 15 | RoP 19, 23, 25, 29.a-e, 30, 32, 151, 220.1(a) |
|
||||
| `upc.rev.cfi` | Nichtigkeitsverfahren | 17 | RoP 19, 32, 42, 43.3, 49.1, 49.2.a, 49.2.b, 51, 52, 56.1/3/4, 220.1(a) |
|
||||
| `upc.pi.cfi` | Einstweilige Maßnahmen | 4 | RoP 205, 207, 211 |
|
||||
| `upc.disc.cfi` | Bucheinsicht | 4 | RoP 141, 142.2, 142.3 |
|
||||
| `upc.dmgs.cfi` | Schadensbemessung | 4 | RoP 131.2, 137.2, 139 |
|
||||
| `upc.apl.merits` | Berufung | 8 | RoP 220.1, 224.1.a, 224.2.a, 235.1, 237, 238.1 |
|
||||
| `upc.apl.order` | Berufung gegen Anordnungen | 5 | RoP 220.1(c), 220.2, 220.3, 237, 238.2 |
|
||||
| `upc.apl.cost` | Berufung gegen Kostenentscheidung | 2 | RoP 221.1 |
|
||||
| `upc.ccr.cfi` | Widerklage auf Nichtigkeit (alias) | 0 | — |
|
||||
|
||||
### 2.2 EPA
|
||||
|
||||
3 active types, 23 rules.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `epa.grant.exa` | EP-Erteilung | 7 | EPC Art. 93, R.70(1), R.71(3) |
|
||||
| `epa.opp.opd` | EPA Einspruch | 8 | EPC Art. 99(1), 108, 116, 123; R.79(1), R.79(2), R.116(1) |
|
||||
| `epa.opp.boa` | EPA Beschwerde | 8 | EPC Art. 108, 112a; R.116(1); RPBA Art. 12 |
|
||||
|
||||
### 2.3 DPMA
|
||||
|
||||
3 active types, 13 rules.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `dpma.opp.dpma` | DPMA Einspruch | 4 | PatG §59(1), §59(3) |
|
||||
| `dpma.appeal.bpatg` | BPatG-Beschwerde | 5 | PatG §73(2), §74 ff. |
|
||||
| `dpma.appeal.bgh` | BGH-Rechtsbeschwerde | 4 | PatG §100, §102 |
|
||||
|
||||
### 2.4 DE (national patent / civil)
|
||||
|
||||
5 active types, 29 rules.
|
||||
|
||||
| Code | Name | Rule count | Audited against |
|
||||
|---|---|---:|---|
|
||||
| `de.inf.lg` | LG-Verletzungsklage | 8 | ZPO §253, §276, §283, §296a, §517, §520(2) |
|
||||
| `de.inf.olg` | OLG-Berufung Verletzung | 7 | ZPO §517, §520(2), §521(2), §524(2) |
|
||||
| `de.inf.bgh` | BGH-Revision Verletzung | 8 | ZPO §544, §548, §551, §554 |
|
||||
| `de.null.bpatg` | BPatG-Nichtigkeitsklage | 10 | PatG §81 ff., §82, §83 |
|
||||
| `de.null.bgh` | BGH-Nichtigkeitsberufung | 6 | PatG §110, §111 / ZPO ref via §117 PatG |
|
||||
|
||||
### 2.5 Cross-cutting: cascade vs proceeding-tree coverage
|
||||
|
||||
The cascade layer (`paliad.event_categories` + `…_concepts` +
|
||||
`paliad.deadline_concepts`) carries 56 concept "nouns" and ~153
|
||||
cascade-leaf → concept mappings. **9 concepts are orphans** (carry
|
||||
zero rules, so the cascade card dead-ends): `counterclaim-for-revocation`,
|
||||
`schriftsatznachreichung`, `versaeumnisurteil-einspruch`,
|
||||
`weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`,
|
||||
plus 3 more. Inventory and recommendations live in
|
||||
`docs/audit-fristen-logic-2026-05-13.md` §3.4 — this audit covers only
|
||||
the proceeding-tree side.
|
||||
|
||||
---
|
||||
|
||||
## §3. Findings — Missing rules (statute defines, paliad doesn't)
|
||||
|
||||
### 3.1 UPC RoP — 21 missing rules (out of ~25 flagged 2026-05-08, 4 closed by mig 095)
|
||||
|
||||
Notation: ★★★ every case, ★★ common, ★ specialist. Verbatim RoP text
|
||||
sampled from youpc `data.laws_contents` (law_type=`UPCRoP`, lang=en).
|
||||
|
||||
| RoP § | Period | Trigger | Freq | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **R.20.2** | 14 days | Service of Preliminary Objection | ★ | Reply to PO. Companion to R.19 (which mig 095 added). Without R.20.2 the PO branch is half-modelled. |
|
||||
| **R.118.4** | 2 months | Final decision on validity served | ★★ | Application for orders consequential on validity. Common after central-division revocation. |
|
||||
| **R.118.5** | n/a UPC | n/a | n/a | UPC has no Versäumnisurteil-Einspruch; closest is R.355 (review of contumacy). |
|
||||
| **R.144** | 0 (anchor) | Final decision on damages quantum | ★ | UPC_DAMAGES tree end-row missing. |
|
||||
| **R.155** | 1mo / 14d | Cost-decision opposition chain | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
|
||||
| **R.197.3** | 30 days | Saisie order served on respondent | ★ | Review application. Trigger event 65 exists; no rule attached. |
|
||||
| **R.198** | 31 calendar days **OR 20 working days, whichever is longer** | Saisie executed | ★ | Start proceedings on the merits. Blocked on `working_days` + `combine='max'` primitives (see §7 + §9). |
|
||||
| **R.207.6.a** | 14 days | Notification of deficiency in PI application | ★★ | Registry correction. |
|
||||
| **R.207.9** | 6 months | PI filed | ★ | Renewal of protective letter. |
|
||||
| **R.213** | 31 days OR 20 working days | PI granted | ★★ | Same arithmetic gap as R.198. |
|
||||
| **R.109.1** | 1 month **before** | Oral hearing date | ★★ | Simultaneous translation request. `timing='before'` schema supported but no rule populates it (see §7 cross-cutting). |
|
||||
| **R.109.4** | 2 weeks **before** | Oral hearing date | ★★ | Interpreter cost notification. `timing='before'`. |
|
||||
| **R.109.5** | 2 weeks after | Order of judge-rapporteur to lodge translations | ★★ | trigger event 113 exists; no rule. |
|
||||
| **R.224.2.b** | 15 days | Order under R.220.1(c) or decision under R.220.2/221.3 served | ★★ | Grounds-on-orders track. `upc.apl.order` has appeal-itself but no separate grounds row. Verified verbatim against `UPCRoP.224.2.b` (youpc DB). |
|
||||
| **R.229.2** | 14 days | Notification of appeal-deficiency | ★ | Registry correction in appeal context. |
|
||||
| **R.235.2** | 15 days | Statement of grounds (orders track) served | ★★ | Verified verbatim against `UPCRoP.235.2` (youpc DB): *"Within 15 days of service of grounds of appeal pursuant to Rule 224.2(b), any other party … may lodge a Statement of response"*. `upc.apl.order` has no standalone response row. |
|
||||
| **R.245.1** | 2 months | Final decision served | ★ | Application for rehearing. |
|
||||
| **R.245.2.a** | 2 months | Discovery of fundamental defect (or final decision service, whichever is later) | ★ | Outer cap 12mo. Needs multi-anchor + `max-of-two-anchors` arithmetic. |
|
||||
| **R.245.2.b** | 2 months | Discovery of criminal offence (or final decision service, whichever is later) | ★ | Same shape as 245.2.a. |
|
||||
| **R.262.2** | 14 days | Receipt of opposing party's confidentiality application | ★★ | Daily occurrence in HLC infringement work. Trigger event 25 exists; no rule. |
|
||||
| **R.320** | 2 months (cap 12 mo) | Wegfall des Hindernisses (Wiedereinsetzung) | ★★ | Cascade card exists (mig 063) but no proceeding-tree rule computes the deadline. Bridges proceedings → no obvious home in any one tree. |
|
||||
| **R.321.3** | 10 days | Preliminary objection referral to central division | ★ | |
|
||||
| **R.333.2** | 15 days | Case-management order served | ★★ | Review-of-CMO. Routine in busy LDs. |
|
||||
| **R.353** | 1 month | Decision / order delivered | ★ | Rectification application. |
|
||||
| **DNI: R.63 / R.67.1 / R.69.1 / R.69.2** | 0 / 2mo / 1mo / 1mo | DNI cascade | ★ | No UPC_DNI proceeding_type exists. Fringe at HLC (zero published filings in 2026-Q1 per May 8 audit). |
|
||||
| **Registry-correction family: R.16.3.a, R.27.2, R.89.2, R.253.2** | 14 days each | Various deficiency notifications | ★ | All same 14-day duration; different trigger codes. Most natural home is cascade not proceeding-tree (see audit-fristenrechner-completeness-2026-04-30.md §3.1). |
|
||||
|
||||
**Closed since May 8 audit (verified by SQL):**
|
||||
- ✅ R.19 Preliminary Objection on UPC_INF — `upc.inf.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095.
|
||||
- ✅ R.19 Preliminary Objection on UPC_REV — `upc.rev.cfi.prelim`, 1mo, RoP.019.1, flag-gated `with_po` — mig 095 (cites R.19 i.V.m. R.46).
|
||||
- ✅ R.220.1(a) merits-appeal spawn on UPC_INF — `upc.inf.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
|
||||
- ✅ R.220.1(a) merits-appeal spawn on UPC_REV — `upc.rev.cfi.appeal_spawn`, 2mo, is_spawn=true → upc.apl.merits — mig 095.
|
||||
|
||||
### 3.2 EPC Implementing Regulations — 4 missing rules
|
||||
|
||||
| EPC ref | Period | Trigger | Freq | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **EPC R.135 (Weiterbehandlung)** | 2 months | Notification of loss of rights | ★★ | Concept `weiterbehandlung` exists in cascade (orphan); no rule. Applies broadly across `epa.grant.exa` and `epa.opp.opd`. |
|
||||
| **EPC R.99(2) / Art. 121** | 2 months | Loss-of-rights notification (further processing) | ★★ | Same family as R.135. |
|
||||
| **EPC Art. 112a(4)** | 2 months / 1 month | Discovery of grounds for review / decision served (whichever later) | ★ | paliad has `epa.opp.boa.r106` (2 months, parent=entsch2) — but the rule doesn't model the "whichever later" outer cap (12 months from decision per Art. 112a(4)). |
|
||||
| **EPC Art. 99(1) — opposition fee paid** | 9 months (no extension) | Mention of grant in Patentblatt | ★★★ | `epa.opp.opd.frist` IS modelled correctly at 9 months. **Note however:** the rule is on `epa.opp.opd` but the *trigger* is opposition-fee-paid (per Art. 99(1) S.2 — "Notice of opposition shall not be deemed to have been filed until the opposition fee has been paid"). Not a gap, but a documentation note. |
|
||||
|
||||
### 3.3 PatG / ZPO — 5 missing rules
|
||||
|
||||
| Citation | Period | Trigger | Freq | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **PatG §123 (Wiedereinsetzung)** | 2 months | Wegfall des Hindernisses (cap 1 year) | ★★ | Cascade concept `wiedereinsetzung` exists; no rule on any DE/DPMA proceeding tree. Same modelling problem as UPC R.320 — bridges proceedings. |
|
||||
| **ZPO §339 (Versäumnisurteil-Einspruch)** | 2 weeks | Service of default judgment | ★ | Cascade concept `versaeumnisurteil-einspruch` orphan. |
|
||||
| **ZPO §544 — Nichtzulassungsbeschwerde-Begründung** | 2 months | Service of OLG-Urteil (NB: NOT from filing of NZB) | ★★ | `de.inf.bgh.nzb_begr` lists `DE.ZPO.544.4`, duration 2mo, parent=urteil_olg — **modelled correctly**. Listed here only to flag that the *parent anchoring* differs from `de.inf.lg.beruf_begr` which is wrong (see §7.1). |
|
||||
| **ZPO §283 (Schriftsatznachreichung) / §296a** | court-set | post-Verhandlung schriftsatzfrist | ★ | Cascade concept `schriftsatznachreichung` orphan. Court-set period — modelling as `is_court_set=true, duration=0` would suffice. |
|
||||
| **PatG §17(2) GebrMG / §18 GebrMG** | 1 month (Beschwerdefrist) | DPMA-Beschluss | ★ | Out of scope per head's confirmation (no GebrMG-rooted proceeding_type yet). Listed to confirm the deliberate gap. |
|
||||
|
||||
### 3.4 DPMA — 0 missing rules
|
||||
|
||||
DPMA coverage is shallow but not gappy. The 3 active types (opposition,
|
||||
BPatG-Beschwerde, BGH-Rechtsbeschwerde) cover the statutory steps. The
|
||||
problems here are **citation drift** (§4.4) and **anchor modeling**
|
||||
(§7.4) rather than missing rules.
|
||||
|
||||
---
|
||||
|
||||
## §4. Findings — Misattributed legal source
|
||||
|
||||
### 4.1 UPC RoP citation drift (5 still live from May 8)
|
||||
|
||||
| Rule | Live `rule_code` | Live `legal_source` | Should be | Source verified |
|
||||
|---|---|---|---|---|
|
||||
| `upc.apl.merits.notice` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.1.a` / `UPC.RoP.224.1.a` | `UPCRoP.224.1.a` youpc DB |
|
||||
| `upc.apl.merits.grounds` | `RoP.220.1` | `UPC.RoP.220.1` | `RoP.224.2.a` / `UPC.RoP.224.2.a` | `UPCRoP.224.2.a` |
|
||||
| `upc.apl.merits.response` | `null` | `null` | `RoP.235.1` / `UPC.RoP.235.1` | `UPCRoP.235.1` |
|
||||
| `upc.rev.cfi.reply` | `null` | `null` | `RoP.051` / `UPC.RoP.51.p1` | `UPCRoP.051.p1` |
|
||||
| `upc.rev.cfi.rejoin` | `null` | `null` | `RoP.052` / `UPC.RoP.52.p1` | `UPCRoP.052.p1` |
|
||||
|
||||
Note on cascade vs proceeding-tree drift on R.220.3 anchoring is in
|
||||
`docs/audit-upc-rop-deadlines-2026-05-08.md` §5.4b — unchanged here.
|
||||
|
||||
### 4.2 UPC RoP citation drift on Rule 49.1 format (1 still live)
|
||||
|
||||
| Rule | Live `rule_code` | Should be |
|
||||
|---|---|---|
|
||||
| `upc.rev.cfi.defence` | `RoP.49.1` | `RoP.049.1` (canonical zero-padded form used by all other UPC rules) |
|
||||
|
||||
### 4.3 DPMA — 3 mis-attributed citations
|
||||
|
||||
| Rule | Live citation | Problem | Verified |
|
||||
|---|---|---|---|
|
||||
| `dpma.opp.dpma.erwiderung` | `§ 59 PatG` / `DE.PatG.59.3` | §59(3) PatG addresses *Anhörung*, not a 4-month response period. No statutory Erwiderungsfrist exists in §59. The 4-month figure is DPMA-internal practice. | WebFetch [gesetze-im-internet.de/patg/__59.html](https://www.gesetze-im-internet.de/patg/__59.html) 2026-05-25 |
|
||||
| `dpma.appeal.bpatg.begruendung` | `§ 75 PatG` / `DE.PatG.75.1` | §75 PatG is exclusively about *aufschiebende Wirkung* (suspensive effect). It does not establish any Begründungsfrist. No fixed Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 — it is set by the BPatG in the individual case. | WebFetch [gesetze-im-internet.de/patg/__75.html](https://www.gesetze-im-internet.de/patg/__75.html) + [§73](https://www.gesetze-im-internet.de/patg/__73.html) 2026-05-25 |
|
||||
| `dpma.appeal.bpatg.beschwerde` | `§ 73 PatG` / `DE.PatG.73.2` | §73 contains the 1-month deadline correctly; the `.2` subscript however refers to §73(2) which is about Beschwerdebefugnis — the *Frist* is in §73(2) S.4 ("Die Beschwerdefrist beträgt einen Monat …"). Citation should be `DE.PatG.73.2.s4` or simply `DE.PatG.73.2`. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
|
||||
|
||||
### 4.4 DE patent / civil — 4 mis-attributed citations
|
||||
|
||||
| Rule | Live citation | Problem | Verified |
|
||||
|---|---|---|---|
|
||||
| `de.null.bpatg.erwidg` | `§ 82 PatG` / `DE.PatG.82.1` | §82(1) is the 1-month *Erklärungsfrist* ("sich darüber zu erklären"); the 2-month full *Klageerwiderung* is in §82(3). Citation should be `DE.PatG.82.3`. Duration (2 months) is correct. | WebFetch [§82](https://www.gesetze-im-internet.de/patg/__82.html) 2026-05-25 |
|
||||
| `de.null.bpatg.replik_klaeger` | `§ 83 PatG` / `DE.PatG.83.2` | §83(2) is about the *Hinweisbeschluss* form; the Replik / Schriftsatz windows fall under §83(2) S.3 (Reaktion auf Hinweis). Citation OK at section level but ambiguous. **Borderline — flag, not a hard bug.** | gesetze-im-internet.de |
|
||||
| `de.null.bgh.begruendung` | `§ 111 PatG` / `DE.PatG.111.1` | §111 PatG defines the *Grounds* of Berufung (Verletzung des Bundesrechts), not a Begründungsfrist. The 3-month figure is supplied via §117 PatG → ZPO §520(2). Citation should be `DE.ZPO.520.2` (the actual time-limit source). | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) 2026-05-25 |
|
||||
| `de.null.bgh.erwiderung` | `§ 111 PatG` / `DE.PatG.111.3` | §111 has no Erwiderungsfrist clause. The actual Erwiderungsfrist for BGH-Nichtigkeitsberufung is set by the court per §117 PatG → ZPO §521(2) (court-discretionary). Duration (2 months) is approximate — typical court-set period is 2 months but it's not fixed. **Should be modelled as court-set.** | WebFetch [§111](https://www.gesetze-im-internet.de/patg/__111.html) + ZPO §521 2026-05-25 |
|
||||
|
||||
### 4.5 EPA — 1 mis-attributed citation
|
||||
|
||||
| Rule | Live citation | Problem |
|
||||
|---|---|---|
|
||||
| `epa.opp.opd.erwidg` | `R. 79(1) EPÜ` / `EU.EPC-R.79.1` | Duration (4 months) is correct as the *typical* EPO-set period under the 2016 streamlined-opposition guidelines, but **R.79(1) does not specify a fixed period** — the Opposition Division sets it. The 4 months is administrative practice (EPO Guidelines D-IV, 5.2). Should be modelled as court-set with 4 months as the default-display value. |
|
||||
|
||||
---
|
||||
|
||||
## §5. Findings — Wrong period (statute says X, paliad says Y)
|
||||
|
||||
| Rule | Live period | Statutory period | Source | Freq |
|
||||
|---|---|---|---|---|
|
||||
| **`upc.rev.cfi.defence`** | 3 months | **2 months** | RoP.049.1: *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* — verified verbatim from `UPCRoP.049.1` (youpc DB). Flagged 2026-05-08; still live. | ★★★ |
|
||||
| **`upc.rev.cfi.rejoin`** | 2 months | **1 month** | RoP.052: *"Within one month of the service of the Reply the defendant may lodge a Rejoinder to the Reply to the Defence to revocation"* — verified verbatim from `UPCRoP.052.p1`. Flagged 2026-05-08; still live. | ★★★ |
|
||||
| **`upc.apl.merits.response`** | 2 months | **3 months** | RoP.235.1: *"Within three months of service of the Statement of grounds of appeal pursuant to Rule 224.2(a), any other party … may lodge a Statement of response"* — verified verbatim from `UPCRoP.235.1`. New finding — May 8 audit recorded the duration as 3 months but the live row has always been 2 (migration 012:153 originally seeded 2). | ★★★ |
|
||||
| **`upc.pi.cfi.response`** | 0 / "court-set" (`is_court_set=false`, `duration=0`, `parent_id=NULL`) | court-set, judge-discretion under R.211.2 | RoP.211.2 — judge sets the inter-partes hearing date. Modelling is half-broken: `duration=0` with `parent_id=NULL` makes the calculator treat this as a root anchor rather than a court-set placeholder. Should set `is_court_set=true` and chain `parent_id=app`. | ★★ |
|
||||
|
||||
(All other rules audited have correct durations.)
|
||||
|
||||
---
|
||||
|
||||
## §6. Findings — Wrong party
|
||||
|
||||
No clear party mis-assignments found in the live data. Two notes worth
|
||||
recording, not bugs:
|
||||
|
||||
- `upc.inf.cfi.app_to_amend` carries `primary_party='claimant'`. The
|
||||
defendant in an INF case is the alleged infringer; the patent
|
||||
proprietor (=claimant) is who would file an Application to Amend
|
||||
the patent. **Correct.** Listed here only because R.30 reads "the
|
||||
defendant" in some summaries — those refer to the claimant of the
|
||||
CCR (= defendant of the INF), which loops back to the same person
|
||||
who is the INF-claimant / patent-proprietor.
|
||||
- `dpma.opp.dpma.erwiderung` carries `primary_party='defendant'`. In an
|
||||
EPA-style opposition, the patent proprietor is the "defendant" of the
|
||||
opposition. Consistent with EPA convention. **Correct.**
|
||||
|
||||
---
|
||||
|
||||
## §7. Findings — Wrong sequencing / anchoring
|
||||
|
||||
### 7.1 `de.inf.lg.beruf_begr` chains parent = `berufung`, should anchor on `urteil` directly
|
||||
|
||||
| Live | Per ZPO §520(2) |
|
||||
|---|---|
|
||||
| `de.inf.lg.beruf_begr.parent_id = de.inf.lg.berufung`, `duration = 2 months` → effective end = trigger + 1mo (Berufung) + 2mo = **3 months** after Urteil service | "Die Frist für die Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der Zustellung des in vollständiger Form abgefassten Urteils" → **2 months** after Urteil service |
|
||||
|
||||
Verified verbatim via WebFetch
|
||||
[gesetze-im-internet.de/zpo/__520.html](https://www.gesetze-im-internet.de/zpo/__520.html)
|
||||
2026-05-25.
|
||||
|
||||
The companion `de.inf.olg.begruendung` is **correct** — parent =
|
||||
`urteil_lg`, 2mo, so end = Urteil + 2mo. Same statute, two paliad
|
||||
rules, two different anchorings: this is a real bug in `de.inf.lg`.
|
||||
|
||||
### 7.2 `de.inf.lg.replik` and `de.inf.lg.duplik` have `parent_id = NULL`
|
||||
|
||||
This is the bug head flagged. Live data:
|
||||
|
||||
| submission_code | name | duration | parent_id | sequence_order |
|
||||
|---|---|---|---|---|
|
||||
| `de.inf.lg.klage` | Klageerhebung | 0 mo | NULL | 0 |
|
||||
| `de.inf.lg.anzeige` | Anzeige Verteidigungsbereitschaft | 2 wk | `de.inf.lg.klage` | 10 |
|
||||
| `de.inf.lg.erwidg` | Klageerwiderung | 6 wk | `de.inf.lg.klage` (court-set=true post mig 095) | 20 |
|
||||
| **`de.inf.lg.replik`** | Replik | **4 wk** | **NULL** | 30 |
|
||||
| **`de.inf.lg.duplik`** | Duplik | **4 wk** | **NULL** | 40 |
|
||||
| `de.inf.lg.termin` | Haupttermin | 0 mo | NULL (court-set) | 50 |
|
||||
| `de.inf.lg.urteil` | Urteil | 0 mo | NULL (court-set) | 60 |
|
||||
| `de.inf.lg.berufung` | Berufungsfrist | 1 mo | NULL | 70 |
|
||||
| `de.inf.lg.beruf_begr` | Berufungsbegründung | 2 mo | `de.inf.lg.berufung` | 80 |
|
||||
|
||||
With `parent_id = NULL` the calculator anchors Replik on the
|
||||
triggerDate (= Klageerhebung), and same for Duplik. So both render
|
||||
"4 Wochen ab Klageerhebung" — i.e. before the Klageerwiderung is
|
||||
even due. Correct chain should be:
|
||||
|
||||
- `replik.parent_id = de.inf.lg.erwidg`, with `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO — typ. 4 weeks default)
|
||||
- `duplik.parent_id = de.inf.lg.replik`, same shape
|
||||
|
||||
Both rules lack `legal_source` and `rule_code`, which is consistent
|
||||
with them being court-set Schriftsatzfristen (no statutory clamp).
|
||||
Recommendation in §10.
|
||||
|
||||
### 7.3 `upc.apl.merits.grounds` has `parent_id = NULL`
|
||||
|
||||
This anchors Grounds on the user-supplied trigger date (=Entscheidung
|
||||
service). **Correct** behaviour per RoP.224.2.a: *"within four months
|
||||
of service of a decision referred to in Rule 220.1(a) and (b)"*.
|
||||
|
||||
If `parent_id` were set to `upc.apl.merits.notice` (as the May 8 audit
|
||||
hypothesised), the chain would compound (1-day notice + 4mo grounds =
|
||||
~4mo + 1 day), accidentally landing near the right end-date for the
|
||||
common case but wrong by up to 2 months in the edge case (when notice
|
||||
is filed early). **No fix needed; document the intent.** (This is
|
||||
the change the May 8 audit recommended; it was applied in mig 097 or
|
||||
earlier.)
|
||||
|
||||
### 7.4 DPMA Pathway-A anchors are partially modelled
|
||||
|
||||
- `dpma.appeal.bgh.begruendung` chains parent = `rechtsbeschwerde`
|
||||
(1mo + 1mo = 2mo from BPatG-Entscheidung). Per PatG §102 the
|
||||
Rechtsbeschwerdebegründungsfrist is 1 month from filing of the
|
||||
Rechtsbeschwerde — **correct**.
|
||||
- `dpma.appeal.bpatg.begruendung` chains parent = `beschwerde`
|
||||
(1mo + 1mo = 2mo from DPMA-Entscheidung). **No statutory basis for
|
||||
the 1-month figure** (see §4.3). Should be court-set.
|
||||
|
||||
### 7.5 EPA grant timeline — `epa.grant.exa.r71_3` and `.approval` have `parent_id = NULL`
|
||||
|
||||
Live:
|
||||
|
||||
| Rule | Duration | parent_id | Issue |
|
||||
|---|---|---|---|
|
||||
| `epa.grant.exa.r71_3` | 0 mo | NULL | Should chain on `exam_req` (after examination request is granted, EPO issues R.71(3) communication). NULL parent + 0 duration = root anchor at trigger date — works only if user enters the R.71(3) date as trigger; doesn't compose with the rest of the tree. |
|
||||
| `epa.grant.exa.approval` | 4 mo | NULL | Per R.71(3) approval period: 4 months from notification. **Anchor should be `r71_3`**, not NULL. As-is, "Zustimmung + Übersetzung" appears as a free-standing 4-mo-from-trigger row that has nothing to do with the rest of the timeline. |
|
||||
|
||||
### 7.6 Summary
|
||||
|
||||
| # | Rule | Bug |
|
||||
|---|---|---|
|
||||
| 1 | `de.inf.lg.beruf_begr` | parent should be NULL (anchored on Urteil-trigger) not `berufung` — off by 1 month, ★★★ |
|
||||
| 2 | `de.inf.lg.replik` | parent should be `erwidg` not NULL, ★★★ |
|
||||
| 3 | `de.inf.lg.duplik` | parent should be `replik` not NULL, ★★★ |
|
||||
| 4 | `dpma.appeal.bpatg.begruendung` | should be court-set; current 1-month period has no statutory basis, ★★ |
|
||||
| 5 | `dpma.appeal.bpatg.beschwerde` parent is `entscheidung` — OK, just a citation issue (§4.3) | (citation only) |
|
||||
| 6 | `epa.grant.exa.r71_3` parent | should chain on `exam_req`, ★ |
|
||||
| 7 | `epa.grant.exa.approval` parent | should chain on `r71_3`, ★ |
|
||||
| 8 | `upc.pi.cfi.response` | court-set placeholder with `parent_id=NULL` and `is_court_set=false` — should chain on `app` with `is_court_set=true`, ★★ |
|
||||
|
||||
---
|
||||
|
||||
## §8. Findings — Duplicates
|
||||
|
||||
No genuine duplicates. The closest cases:
|
||||
|
||||
- `upc.inf.cfi.reply` + `upc.inf.cfi.def_to_ccr` both fire at 2mo after
|
||||
`sod` under `with_ccr`. They cover different actions (Reply to SoD
|
||||
vs. Defence to CCR + Reply to SoD combined) per RoP.029.a vs .b.
|
||||
**Not a duplicate** — distinct rule codes.
|
||||
- `upc.rev.cfi.reply` (2mo, no rule_code) and the older `REV.rev_reply`
|
||||
on the archived litigation type — the archived type is hidden
|
||||
(`pt.is_active = false`) so this isn't a duplicate the user sees.
|
||||
Recommendation in §10 to drop the archived corpus once mig 093's
|
||||
audit window closes.
|
||||
- `epa.opp.boa.r106` (Art. 112a review) appears only on
|
||||
`epa.opp.boa`, not on `epa.opp.opd` — correct, since Art. 112a
|
||||
review is only available against a Boards-of-Appeal decision.
|
||||
|
||||
---
|
||||
|
||||
## §9. Ambiguities — decisions m needs to make
|
||||
|
||||
These are not bugs the coder can fix. They are judgement calls about
|
||||
how to model the law.
|
||||
|
||||
### 9.1 Court-set vs fixed-period for richterliche Fristen
|
||||
|
||||
The cleanest source-of-truth for these is "no statutory duration —
|
||||
court sets the period in the individual case." Modelling them as a
|
||||
fixed period with a wrong citation is the bug pattern we keep finding:
|
||||
|
||||
- `dpma.opp.dpma.erwiderung` (4 mo) — DPMA practice, not §59 PatG.
|
||||
- `dpma.appeal.bpatg.begruendung` (1 mo) — no statutory basis.
|
||||
- `de.inf.olg.erwiderung` (1 mo, §521(2)) — §521(2) is explicitly
|
||||
discretionary ("Der Vorsitzende oder das Berufungsgericht **kann**
|
||||
der Gegenpartei eine Frist … bestimmen"). Verified WebFetch
|
||||
[gesetze-im-internet.de/zpo/__521.html](https://www.gesetze-im-internet.de/zpo/__521.html)
|
||||
2026-05-25.
|
||||
- `de.null.bgh.erwiderung` (2 mo, "§111(3) PatG") — court-set per §117
|
||||
PatG → ZPO §521(2).
|
||||
- `de.null.bpatg.duplik` (1 mo, §83 PatG) — court-set; the 1-month
|
||||
default is BPatG practice.
|
||||
- `de.inf.lg.replik`, `.duplik` (4 wk each) — court-set per
|
||||
§283 / §296a ZPO + §276(1) S.2.
|
||||
- `epa.opp.opd.erwidg` (4 mo, "R.79(1)") — EPO-set per Guidelines.
|
||||
|
||||
**Question (Q1):** Should paliad continue to display these with a
|
||||
default duration but flag them as "richterliche Frist — vom Gericht
|
||||
festgesetzt", OR should they all flip to `is_court_set=true,
|
||||
duration=0` and force the user to enter the actual court-set date?
|
||||
|
||||
Head's 2026-05-25 13:13 signal confirms: m's preference is that "Frist
|
||||
vom Gericht bestimmt" be flagged as needing case-by-case anchoring,
|
||||
not displayed as a fixed period. So default answer = flip to
|
||||
`is_court_set=true` and keep the typical period as the *Default*
|
||||
display value (the calculator already supports this since the
|
||||
mig 095 / `de.inf.lg.erwidg` patch). But the trade-off is a UX
|
||||
regression: most users will not enter the actual court-set date
|
||||
and the timeline will then show "vom Gericht bestimmt" everywhere.
|
||||
|
||||
### 9.2 R.198 / R.213 "31 days OR 20 working days, whichever is longer"
|
||||
|
||||
Two RoP rules need a primitive paliad doesn't have:
|
||||
- A `working_days` duration unit (counts business-day arithmetic via
|
||||
the holiday service).
|
||||
- A `combine = 'max'` operator that compares two durations and picks
|
||||
the later end-date.
|
||||
|
||||
**Question (Q2):** Implement the primitive (~120 LoC migration + ~80 LoC
|
||||
Go), or document both rules as "manual calculation required, see RoP"
|
||||
in the UI? Real R.198 / R.213 cases are rare (saisie + PI). The May 8
|
||||
audit suggested deferring; pauli's 2026-05-13 audit §7.1 made the
|
||||
case for adding `combine_op` as part of a broader Pipeline A/C merge.
|
||||
|
||||
### 9.3 R.245.2 rehearing "whichever is later" trigger
|
||||
|
||||
R.245.2.a/b: deadline 2 months from final decision OR from defect
|
||||
discovery, whichever is *later*. Plus outer cap 12 months. Needs:
|
||||
|
||||
- Multi-anchor trigger event (user supplies 2 dates).
|
||||
- `combine = 'max'` between anchors.
|
||||
- Outer-cap arithmetic (separate concept from duration).
|
||||
|
||||
**Question (Q3):** Defer (specialist, vanishingly rare) or build the
|
||||
primitives?
|
||||
|
||||
### 9.4 EPC Art. 112a review — outer cap
|
||||
|
||||
Same shape as R.245.2: 2 months from defect discovery, outer cap 12
|
||||
months from decision. `epa.opp.boa.r106` models the 2-month period
|
||||
but not the cap.
|
||||
|
||||
### 9.5 PatG §123 Wiedereinsetzung calendar arithmetic
|
||||
|
||||
Cascade card (slug `wiedereinsetzung`) exists. The 2mo / 1-year
|
||||
arithmetic anchors on the *missed* deadline, not on a forward-looking
|
||||
event. paliad's `paliad.deadline_rules` schema has no natural shape
|
||||
for this — it would need either a special-case Go helper, or a
|
||||
"backward-from-missed-deadline" mode that no rule today uses.
|
||||
|
||||
**Question (Q4):** Worth modelling? The cascade card already routes
|
||||
the user to the concept; computing the calendar deadline is an
|
||||
incremental win.
|
||||
|
||||
### 9.6 ZPO §339 Versäumnisurteil-Einspruch
|
||||
|
||||
Cascade card orphan. 2 weeks from service of the default judgment.
|
||||
Trivial to add as a `de.inf.lg.einspruch_vu` rule (court-decision
|
||||
anchor + 2wk fixed). **Question (Q5):** Add as a child of
|
||||
`de.inf.lg.urteil` (with `condition_expr={"flag":"with_vu"}`), or
|
||||
as a separate proceeding `de.inf.lg.vu`?
|
||||
|
||||
### 9.7 Litigation-vs-fristenrechner archived corpus
|
||||
|
||||
The 40 rules on `_archived_litigation` (mig 093 retirement holding pen)
|
||||
still occupy the rule table. They're invisible to all UIs.
|
||||
|
||||
**Question (Q6):** Drop them now (data clean-up), or keep until the
|
||||
mig 093 audit window closes formally?
|
||||
|
||||
### 9.8 R.79(2) further-party observations period
|
||||
|
||||
EPC R.79(2) creates a separate notification window for additional
|
||||
opponents. paliad's `epa.opp.opd.r79_further` is modelled as
|
||||
`duration=0, is_bilateral=true`. **Question (Q7):** Is this even worth
|
||||
keeping? Real workflow: EPO sets a separate period in each
|
||||
intervention case. Hard to template.
|
||||
|
||||
### 9.9 R.116(1) EPC oral-proceedings cut-off
|
||||
|
||||
paliad has it as `duration=0, parent_id=entsch` (`epa.opp.opd.r116`) /
|
||||
`parent_id=oral` (`epa.opp.boa.r116`). R.116(1) actually says the
|
||||
EPO sets a "final date for making written submissions" when issuing
|
||||
the summons. So it's a court-set period, not zero-duration.
|
||||
**Question (Q8):** flip to `is_court_set=true` like the §276(1) ZPO
|
||||
fix in mig 095?
|
||||
|
||||
### 9.10 R.131.2 indication of damages period
|
||||
|
||||
paliad models `upc.dmgs.cfi.app` as a 0-duration root anchor (court
|
||||
sets when the damages-determination phase opens, per R.131.2). This
|
||||
is correct shape but means the entire damages tree is unanchored
|
||||
until the user provides the trigger date manually.
|
||||
|
||||
**Question (Q9):** Wire `is_spawn` from `upc.inf.cfi.decision` to
|
||||
`upc.dmgs.cfi.app` (parallel to the mig-095 appeal-spawn)?
|
||||
|
||||
### 9.11 PatG §17 GebrMG / §18 GebrMG
|
||||
|
||||
No GebrMG-rooted proceeding_type exists in paliad. Head confirmed
|
||||
out-of-scope for this audit. **Question (Q10):** Add a `de.gm.lg`
|
||||
proceeding for GebrMG-Löschungsverfahren if HLC sees them?
|
||||
|
||||
### 9.12 Proceeding-tree vs cascade parity
|
||||
|
||||
paliad has 9 cascade-only concepts with `rule_count = 0` (the orphans
|
||||
listed in `audit-fristen-logic-2026-05-13.md` §3.4). The audit-fristen
|
||||
audit covers this; restating here only to note that the parity gap
|
||||
is the largest single source of "the cascade card promises a
|
||||
calculation but doesn't deliver one."
|
||||
|
||||
**Question (Q11):** Same as the audit-fristen Q8 — priority order
|
||||
for the 9 orphan concepts? My ranking: wiedereinsetzung >
|
||||
schriftsatznachreichung > versäumnisurteil-einspruch >
|
||||
weiterbehandlung > rest.
|
||||
|
||||
### 9.13 R.220.3 anchor
|
||||
|
||||
See `audit-upc-rop-deadlines-2026-05-08.md` §5.4b. paliad anchors
|
||||
`upc.apl.order.discretion` on the original order (`order`), but
|
||||
the 15-day clock per RoP.220.3 runs from the refusal-of-leave
|
||||
date (or day-15 fall-back). Off by up to 15 days in the edge case.
|
||||
**Question (Q12):** add an explicit `app_ord.refusal` court-set
|
||||
intermediate node?
|
||||
|
||||
### 9.14 EP_GRANT publish date — priority vs filing
|
||||
|
||||
`epa.grant.exa.publish` correctly has `anchor_alt='priority_date'`.
|
||||
This was open in the May 8 audit and is now closed. **No question —
|
||||
listed to confirm.**
|
||||
|
||||
### 9.15 Cross-proceeding spawn execution
|
||||
|
||||
mig 095 added two `is_spawn=true` rules (`inf.appeal_spawn`,
|
||||
`rev.appeal_spawn` → `upc.apl.merits`). The May 13 audit §1.6 +
|
||||
§6.8 noted spawn execution is half-wired in `projection_service.go`.
|
||||
**Question (Q13):** wire end-to-end now (so the spawned appeal
|
||||
timeline appears in SmartTimeline), or accept the half-wired state?
|
||||
|
||||
---
|
||||
|
||||
## §10. Recommended fixes (prioritised)
|
||||
|
||||
### Tier 0 — hard duration / sequencing / anchor bugs (ship first)
|
||||
|
||||
| # | Rule | Fix | Reason / source | Freq |
|
||||
|---|---|---|---|---|
|
||||
| T0.1 | `upc.rev.cfi.defence` | `duration_value = 2` (was 3), `rule_code = 'RoP.049.1'`, `legal_source = 'UPC.RoP.49.1'` | §5 — every UPC_REV tracked in paliad today computes Defence at wrong month for the last ~3 months | ★★★ |
|
||||
| T0.2 | `upc.rev.cfi.rejoin` | `duration_value = 1` (was 2), `rule_code = 'RoP.052'`, `legal_source = 'UPC.RoP.52.p1'` | §5 — same as T0.1 | ★★★ |
|
||||
| T0.3 | `upc.apl.merits.response` | `duration_value = 3` (was 2), `rule_code = 'RoP.235.1'`, `legal_source = 'UPC.RoP.235.1'` | §5 — every main-track appellate respondent | ★★★ |
|
||||
| T0.4 | `de.inf.lg.beruf_begr` | `parent_id = NULL` (was `de.inf.lg.berufung`) — runs 2 months from triggerDate (Urteil-service) per ZPO §520(2) | §7.1 — every DE-LG-Verletzung appeal | ★★★ |
|
||||
| T0.5 | `de.inf.lg.replik` | `parent_id = de.inf.lg.erwidg`, `is_court_set = true` (richterliche Frist § 276(1) S.2 / § 283 ZPO), keep 4-week default | §7.2 — bug head flagged | ★★★ |
|
||||
| T0.6 | `de.inf.lg.duplik` | `parent_id = de.inf.lg.replik`, `is_court_set = true` | §7.2 | ★★★ |
|
||||
| T0.7 | `upc.rev.cfi.reply` | `rule_code = 'RoP.051'`, `legal_source = 'UPC.RoP.51.p1'` (duration 2mo unchanged) | §4.1 | ★★★ |
|
||||
| T0.8 | `upc.rev.cfi.rejoin` (citation only) | covered in T0.2 | — | — |
|
||||
| T0.9 | `upc.apl.merits.notice` | `rule_code = 'RoP.224.1.a'`, `legal_source = 'UPC.RoP.224.1.a'` (duration unchanged) | §4.1 | ★★ |
|
||||
| T0.10 | `upc.apl.merits.grounds` | `rule_code = 'RoP.224.2.a'`, `legal_source = 'UPC.RoP.224.2.a'` (duration unchanged) | §4.1 | ★★ |
|
||||
| T0.11 | `upc.rev.cfi.defence` rule_code zero-pad | covered in T0.1 | — | — |
|
||||
| T0.12 | `dpma.opp.dpma.erwiderung` | flip to `is_court_set = true`, keep 4-month default-display value, drop the misleading `DE.PatG.59.3` citation (or replace with "DPMA-Richtlinien D-IV 5.2") | §4.3 + §9.1 | ★★ |
|
||||
| T0.13 | `dpma.appeal.bpatg.begruendung` | flip to `is_court_set = true`, drop the `DE.PatG.75.1` citation, keep 1-month default | §4.3 + §9.1 | ★★ |
|
||||
| T0.14 | `de.null.bpatg.erwidg` | citation `DE.PatG.82.3` (was 82.1); duration (2mo) correct | §4.4 | ★★ |
|
||||
| T0.15 | `de.null.bgh.begruendung` | citation `DE.ZPO.520.2` via PatG §117 (was DE.PatG.111.1); duration (3mo) correct | §4.4 | ★★ |
|
||||
| T0.16 | `de.null.bgh.erwiderung` | flip to `is_court_set = true`; citation `DE.ZPO.521.2 via PatG §117` (was DE.PatG.111.3); duration (2mo) becomes default-display | §4.4 + §9.1 | ★★ |
|
||||
| T0.17 | `epa.opp.opd.erwidg` | flip to `is_court_set = true`, keep 4-month default | §4.5 + §9.1 | ★★ |
|
||||
|
||||
**16 hard fixes.** All within the existing schema (no new columns).
|
||||
Each is a single-row UPDATE plus an audit-log entry.
|
||||
|
||||
### Tier 1 — high-value missing rules (★★ / ★★★)
|
||||
|
||||
| # | Rule | Add | Freq |
|
||||
|---|---|---|---|
|
||||
| T1.1 | `upc.inf.cfi.cmo_review` | 15 days from CMO service (R.333.2) | ★★ |
|
||||
| T1.2 | `upc.inf.cfi.confidentiality_response` | 14 days from opp. confidentiality app (R.262.2) | ★★ |
|
||||
| T1.3 | `upc.apl.order.grounds_orders` | 15 days from order service (R.224.2(b)) | ★★ |
|
||||
| T1.4 | `upc.apl.order.response_orders` | 15 days from grounds service (R.235.2) | ★★ |
|
||||
| T1.5 | `upc.inf.cfi.cons_orders` | 2 months from validity decision (R.118.4) | ★★ |
|
||||
| T1.6 | `upc.inf.cfi.rectification` | 1 month from decision (R.353) | ★ |
|
||||
| T1.7 | `upc.pi.cfi.deficiency` | 14 days from PI deficiency notification (R.207.6.a) | ★★ |
|
||||
| T1.8 | `upc.pi.cfi.merits_start` | 31d OR 20wd from PI grant (R.213) — **blocked on Q2** | ★★ |
|
||||
| T1.9 | `upc.inf.cfi.translation_request` | 1 month **before** oral hearing (R.109.1) | ★★ |
|
||||
| T1.10 | `upc.inf.cfi.interpreter_cost` | 2 weeks **before** oral hearing (R.109.4) | ★★ |
|
||||
| T1.11 | `upc.inf.cfi.translations_lodge` | 2 weeks after summons (R.109.5) | ★★ |
|
||||
| T1.12 | `upc.pi.cfi.response` re-anchor | court-set, parent=`app` (currently a broken root) | ★★ |
|
||||
|
||||
**12 rule-adds.** T1.9/.10 are the only `timing='before'` rules in the
|
||||
entire UPC corpus; schema already supports `before` but no rule
|
||||
populates it. Verify the backward-snap-to-working-day logic in
|
||||
`internal/services/deadline_calculator.go` before merging
|
||||
(2026-04-30 audit §5.4 raised the concern).
|
||||
|
||||
### Tier 2 — broader coverage (★ specialist + Wiedereinsetzung family)
|
||||
|
||||
| # | Rule | Add | Notes |
|
||||
|---|---|---|---|
|
||||
| T2.1 | `de.inf.lg.einspruch_vu` | 2 weeks from service of Versäumnisurteil (ZPO §339) | Q5 — proceeding shape decision |
|
||||
| T2.2 | `upc.inf.cfi.wiedereinsetzung` | 2 mo / 1-year-cap from Wegfall des Hindernisses (R.320) | Q4 — needs special arithmetic |
|
||||
| T2.3 | `de.inf.lg.wiedereinsetzung` | 2 mo / 1-year-cap (PatG §123 / ZPO §233 ff.) | Q4 |
|
||||
| T2.4 | `epa.grant.exa.weiterbehandlung` | 2 mo from loss-of-rights notification (EPC R.135) | — |
|
||||
| T2.5 | `upc.inf.cfi.prelim_reply` | 14 days from PO service (R.20.2) | Companion to R.19 (mig 095 added it) |
|
||||
| T2.6 | `upc.apl.order.discretion_anchor` | add explicit `refusal` intermediate node so R.220.3 anchors correctly (Q12) | |
|
||||
| T2.7 | `upc.dmgs.cfi.app` spawn | `is_spawn=true` from `upc.inf.cfi.decision` (Q9) | |
|
||||
| T2.8 | `upc.disc.cfi.app` spawn | same shape as T2.7 | |
|
||||
| T2.9 | `epa.grant.exa.r71_3` re-anchor | parent = `exam_req` (§7.5) | |
|
||||
| T2.10 | `epa.grant.exa.approval` re-anchor | parent = `r71_3` (§7.5) | |
|
||||
| T2.11 | `upc.inf.cfi.appeal_spawn` cross-proc wiring | finish the half-wired spawn execution (Q13) | |
|
||||
|
||||
### Tier 3 — tooling primitives (block multiple rules)
|
||||
|
||||
| # | Primitive | Blocks | Notes |
|
||||
|---|---|---|---|
|
||||
| T3.1 | `duration_unit = 'working_days'` | R.198, R.213 | Schema already accepts the string; add to calculator + UI |
|
||||
| T3.2 | `combine_op = 'max'` | R.198, R.213, R.245.2 | Column already exists per pauli's 2026-05-13 audit |
|
||||
| T3.3 | Multi-anchor "whichever later" trigger | R.245.2.a/b | UI + service work |
|
||||
| T3.4 | Outer-cap modelling (`outer_cap_value` + `outer_cap_unit`) | R.245.2 (12mo), R.320 (12mo), EPC Art.112a(4) (12mo) | Schema add |
|
||||
| T3.5 | "Before"-mode backward snap to working day | R.109.1, R.109.4 | Calculator change (audit-fristenrechner-completeness-2026-04-30.md §5.4) |
|
||||
| T3.6 | Cross-proceeding spawn end-to-end (`is_spawn`) | T2.7, T2.8, T2.11 | Pauli's §6.8 |
|
||||
|
||||
### Tier 4 — out-of-scope until separate prioritisation
|
||||
|
||||
- DNI family (R.63 / R.67.1 / R.69.1 / R.69.2). Zero published filings 2026-Q1.
|
||||
- Registry-correction family (R.16.3.a, R.27.2, R.89.2, R.253.2). Most natural in cascade, not proceeding-tree.
|
||||
- GebrMG (no proceeding_type today).
|
||||
- R.245 rehearing family (specialist).
|
||||
- R.155 cost-decision opposition chain (specialist).
|
||||
- R.144 UPC_DAMAGES tree-end row (cosmetic).
|
||||
- R.79(2) EPC further-parties period (modelling unclear — Q7).
|
||||
|
||||
---
|
||||
|
||||
## §11. Next-step proposals (suggested fix-task slicing)
|
||||
|
||||
The audit identifies **41 distinct actionable items.** Below is a
|
||||
suggested decomposition into fix-tasks that can be assigned
|
||||
independently. Sequence reflects "Wave 0 must precede Wave 1" only
|
||||
where there's a real dependency (most slices are independent).
|
||||
|
||||
### Wave 0 — Tier 0 duration / sequencing / anchor fixes (single fix-task)
|
||||
|
||||
**Proposed task:** `t-paliad-264 — Tier 0 deadline-rule corrections
|
||||
(duration, anchor, citation) from t-paliad-263 audit`
|
||||
|
||||
- 16 row UPDATEs (T0.1–T0.17, deduplicated to 16 distinct rows since
|
||||
T0.8 is covered by T0.2 and T0.11 by T0.1).
|
||||
- One migration file (~120 LoC SQL).
|
||||
- All within existing schema. No new columns.
|
||||
- Idempotent guards on every UPDATE (only fire when the row still has
|
||||
the old value, per the mig 095 convention).
|
||||
- Adds 16 entries to `paliad.deadline_rule_audit` (per the mig 079
|
||||
trigger).
|
||||
- Verification block: `DO $$ … RAISE EXCEPTION …` per mig 095.
|
||||
- **Branch:** `mai/<coder>/t-paliad-264-tier0-deadline-fixes`.
|
||||
- **Owner:** coder.
|
||||
- **Why first:** all 16 affect either calendar correctness (5 hard
|
||||
duration/anchor bugs) or citation correctness (the 11 metadata
|
||||
fixes are what a lawyer would cite-check against). T0.1–T0.6 are
|
||||
user-visible silent wrongs; ship them.
|
||||
|
||||
### Wave 1 — Tier 1 rule additions (single fix-task)
|
||||
|
||||
**Proposed task:** `t-paliad-265 — Tier 1 deadline-rule additions
|
||||
(12 high-frequency rules)`
|
||||
|
||||
- 11 INSERTs + 1 UPDATE re-anchor (T1.12 `upc.pi.cfi.response`).
|
||||
- T1.8 (`upc.pi.cfi.merits_start`) **excluded** — blocked on T3.1/T3.2.
|
||||
- One migration file (~250 LoC SQL).
|
||||
- Add cascade leaves + concepts where needed (each rule should be
|
||||
reachable from Pathway B too).
|
||||
- **Branch:** `mai/<coder>/t-paliad-265-tier1-rule-additions`.
|
||||
- **Owner:** coder. **Legal review:** m must verify each rule before
|
||||
merge (single round of grilling).
|
||||
|
||||
### Wave 2 — Q1 court-set audit decision (separate spike)
|
||||
|
||||
**Proposed task:** `t-paliad-266 — Decide court-set vs fixed-period
|
||||
modelling for richterliche Fristen (Q1 in t-paliad-263 audit)`
|
||||
|
||||
- Inventor / pauli reviews §9.1 with m.
|
||||
- Decision artefact: list of rules to flip vs keep, plus UX guideline
|
||||
for what the timeline displays for `is_court_set=true` rules.
|
||||
- **Owner:** pauli. **m signs off.**
|
||||
|
||||
### Wave 3 — Tier 3 tooling primitives (multi-task)
|
||||
|
||||
Each Tier 3 row is its own task because each touches schema + service +
|
||||
calculator + UI:
|
||||
|
||||
- `t-paliad-267 — working_days unit + combine_op='max' (R.198, R.213)`
|
||||
- `t-paliad-268 — Outer-cap modelling (R.245.2, R.320, Art.112a)`
|
||||
- `t-paliad-269 — Multi-anchor "whichever later" triggers (R.245.2)`
|
||||
- `t-paliad-270 — Backward-snap for `before`-mode rules (R.109.1/.4)`
|
||||
- `t-paliad-271 — Cross-proceeding spawn end-to-end execution`
|
||||
|
||||
Each is foundational for multiple Tier 2 rules; can ship independently.
|
||||
|
||||
### Wave 4 — Tier 2 specialist rules (multi-task, after their primitives land)
|
||||
|
||||
Each Tier 2 row is its own task or batched into 2-3 tasks by topical
|
||||
area:
|
||||
|
||||
- `t-paliad-272 — Wiedereinsetzung / Weiterbehandlung family (T2.2, T2.3, T2.4)` — depends on T3.4 (outer cap).
|
||||
- `t-paliad-273 — UPC follow-on spawns (T2.7, T2.8, T2.11)` — depends on T3.6.
|
||||
- `t-paliad-274 — UPC tail rules (T2.5, T2.6, R.353, etc.)`
|
||||
- `t-paliad-275 — EPA grant timeline re-anchoring (T2.9, T2.10)`.
|
||||
|
||||
### Wave 5 — Concept-layer parity (separate audit)
|
||||
|
||||
The 9 orphan concepts (`audit-fristen-logic-2026-05-13.md` §3.4 + Q11
|
||||
here) need a parallel audit pass to map cascade → rule. Recommend
|
||||
spinning a `t-paliad-276 — Cascade-rule parity audit` task once the
|
||||
above land.
|
||||
|
||||
### Wave 6 — Documentation + retire
|
||||
|
||||
- `t-paliad-277 — Drop `_archived_litigation` proceeding_type` once
|
||||
mig 093's audit window closes (Q6).
|
||||
- `t-paliad-278 — Document Tier 4 deferrals in
|
||||
`docs/feature-roadmap.md`` so the gap-list isn't lost.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — file references
|
||||
|
||||
**Live state queried via Supabase MCP, 2026-05-25 14:00–15:00 UTC:**
|
||||
|
||||
- `paliad.proceeding_types` — 21 active rows (20 fristenrechner + 1
|
||||
archived).
|
||||
- `paliad.deadline_rules` — 132 active + 40 archived rows
|
||||
(`lifecycle_state='published'`).
|
||||
- `paliad.deadline_rule_audit` — diff history.
|
||||
- `data.laws_contents` (youpc) — UPC RoP + EPC verbatim text
|
||||
(`law_type IN ('UPCRoP','EPC')`).
|
||||
|
||||
**paliad migrations consulted:**
|
||||
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original
|
||||
seed.
|
||||
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
|
||||
— DE_INF_OLG / DE_INF_BGH split.
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql`
|
||||
— first RoP audit fix-pass.
|
||||
- `internal/db/migrations/079_*` — `paliad.deadline_rule_audit`
|
||||
trigger.
|
||||
- `internal/db/migrations/091_drop_legacy_rule_columns.up.sql` —
|
||||
cleanup.
|
||||
- `internal/db/migrations/093_retire_litigation_category.up.sql` —
|
||||
archived 40 rules.
|
||||
- `internal/db/migrations/095_fristen_gap_fill.up.sql` — t-paliad-205
|
||||
R.19 + R.220.1(a) gap fill.
|
||||
- `internal/db/migrations/096_proceeding_code_rename.up.sql` — code
|
||||
rename to `<jurisdiction>.<proceeding>.<instance>` form.
|
||||
- `internal/db/migrations/097_legal_citation_backfill.up.sql` —
|
||||
legal_source / rule_code backfill.
|
||||
- `internal/db/migrations/100_ccr_visible_rule.up.sql` —
|
||||
`upc.ccr.cfi` alias.
|
||||
- `internal/db/migrations/104_einspruch_name_and_ccr_priority.up.sql`
|
||||
— Einspruch rename.
|
||||
|
||||
**Companion audits:**
|
||||
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — curie /
|
||||
t-paliad-084.
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — curie / t-paliad-159.
|
||||
- `docs/audit-fristen-logic-2026-05-13.md` — pauli / t-paliad-157
|
||||
(schema audit, ground-truth on column semantics).
|
||||
- `docs/proposals/fristen-gap-fill-2026-05-18.md` — m's 0.3 decisions
|
||||
that shipped as mig 095.
|
||||
|
||||
**Authoritative source URLs (all verified 2026-05-25):**
|
||||
|
||||
- UPC RoP consolidated 18.05.2023: https://www.unifiedpatentcourt.org/sites/default/files/upc_documents/rop_application_-_consolidated_18_05_2023.pdf
|
||||
- EPC 17th ed.: https://www.epo.org/en/legal/epc/2020/index.html
|
||||
- EPC R.71 (and other Implementing Reg Rules): https://www.epo.org/en/legal/epc/2020/r71.html
|
||||
- PatG: https://www.gesetze-im-internet.de/patg/
|
||||
- §59 https://www.gesetze-im-internet.de/patg/__59.html
|
||||
- §73 https://www.gesetze-im-internet.de/patg/__73.html
|
||||
- §75 https://www.gesetze-im-internet.de/patg/__75.html
|
||||
- §82 https://www.gesetze-im-internet.de/patg/__82.html
|
||||
- §110 https://www.gesetze-im-internet.de/patg/__110.html
|
||||
- §111 https://www.gesetze-im-internet.de/patg/__111.html
|
||||
- ZPO: https://www.gesetze-im-internet.de/zpo/
|
||||
- §520 https://www.gesetze-im-internet.de/zpo/__520.html
|
||||
- §521 https://www.gesetze-im-internet.de/zpo/__521.html
|
||||
- GebrMG: https://www.gesetze-im-internet.de/gebrmg/
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — coverage tally
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---:|---:|
|
||||
| present-correct | 78 | 59 % |
|
||||
| present-wrong (DURATION) | 3 | 2 % |
|
||||
| present-wrong (anchor/sequence) | 5 | 4 % |
|
||||
| present-wrong (citation only) | 11 | 8 % |
|
||||
| court-set-mismodelled-as-fixed | 6 | 5 % |
|
||||
| **subtotal: still actionable** | **25** | **19 %** |
|
||||
| missing (statute defines, paliad doesn't) | 30 | (gap, vs 132 baseline) |
|
||||
| n/a (RoP / EPC / PatG section creates no time-limit) | 8 | 6 % |
|
||||
| present-correct, no fix needed | (78 above) | |
|
||||
|
||||
**Headline figures for m:**
|
||||
|
||||
- Of the 132 statutory deadlines paliad currently models, **25 carry
|
||||
an actionable bug** (19%). Of those, **5 are user-visible
|
||||
calendar-correctness bugs** (the 3 duration bugs + the 2
|
||||
sequencing/anchor bugs head flagged + me). The other 20 are
|
||||
citation drift or court-set mismodelling — fix-them-quietly
|
||||
category.
|
||||
- An additional **30 statutory deadlines are not modelled at all**
|
||||
(the missing list in §3). Of those, **~12 are ★★★ / ★★ frequency**
|
||||
(Tier 1 in §10); the remaining ~18 are ★ specialist.
|
||||
- The 5 duration / sequencing bugs alone are **the most important
|
||||
takeaway**: every UPC_REV proceeding, every UPC main-track appeal
|
||||
respondent, and every DE-LG-Verletzung timeline tracked in paliad
|
||||
today computes wrong dates.
|
||||
|
||||
End of audit. Awaiting m's review of §9 Q1–Q13 + Tier 0 sign-off
|
||||
before fix-tasks (Wave 0) get cut.
|
||||
@@ -18,6 +18,9 @@ import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -46,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||
import { renderPaliadin } from "./src/paliadin";
|
||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||
import { renderAdminBackups } from "./src/admin-backups";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -252,6 +256,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -285,6 +292,7 @@ async function build() {
|
||||
// skip the re-fetch.
|
||||
join(import.meta.dir, "src/client/paliadin-widget.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-backups.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -376,6 +384,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
@@ -408,6 +419,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
|
||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
||||
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
96
frontend/src/admin-backups.tsx
Normal file
96
frontend/src/admin-backups.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// global_admin only — gated by adminGate(...) in handlers.go. Shows the
|
||||
// chronological list of backup runs (one row per kind in
|
||||
// {scheduled, on_demand}) plus a button to kick off an on-demand backup.
|
||||
// Catalog rows + the "run now" action are fetched client-side via
|
||||
// /api/admin/backups.
|
||||
export function renderAdminBackups(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.backups.title">Backups — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/backups" />
|
||||
<BottomNav currentPath="/admin/backups" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.backups.heading">Backups</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
|
||||
Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
id="admin-backups-run-btn"
|
||||
type="button"
|
||||
data-i18n="admin.backups.run_now"
|
||||
>
|
||||
Backup jetzt erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="entity-table-wrap">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.backups.col.started">Erstellt</th>
|
||||
<th data-i18n="admin.backups.col.kind">Auslöser</th>
|
||||
<th data-i18n="admin.backups.col.status">Status</th>
|
||||
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
|
||||
<th data-i18n="admin.backups.col.size">Größe</th>
|
||||
<th data-i18n="admin.backups.col.rows">Zeilen</th>
|
||||
<th data-i18n="admin.backups.col.actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-tbody">
|
||||
<tr>
|
||||
<td colspan={7} data-i18n="admin.backups.loading">Lade …</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="admin-backups-empty" style="display:none">
|
||||
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
|
||||
</div>
|
||||
|
||||
<p className="tool-footer-note" id="admin-backups-footer">
|
||||
<span data-i18n="admin.backups.footer.note">
|
||||
Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-backups.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
192
frontend/src/client/admin-backups.ts
Normal file
192
frontend/src/client/admin-backups.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// Reads /api/admin/backups (chronological list) and wires the
|
||||
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
|
||||
// Synchronous: the server holds the connection for the duration of
|
||||
// the backup (sub-second at firm-scale today), then returns the new
|
||||
// catalog row inline. No polling needed at v1's data shape; if the
|
||||
// run takes > 5 minutes the handler returns 500 and the UI surfaces
|
||||
// the error.
|
||||
|
||||
interface BackupRow {
|
||||
id: string;
|
||||
kind: "scheduled" | "on_demand";
|
||||
status: "running" | "done" | "failed";
|
||||
requested_by?: string;
|
||||
requested_by_email: string;
|
||||
audit_id?: string;
|
||||
storage_uri?: string;
|
||||
size_bytes?: number;
|
||||
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
|
||||
sheet_count?: number;
|
||||
warnings?: unknown;
|
||||
error?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
await refreshList();
|
||||
wireRunButton();
|
||||
});
|
||||
|
||||
function wireRunButton(): void {
|
||||
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = t("admin.backups.running") || "Läuft …";
|
||||
clearFeedback();
|
||||
try {
|
||||
const r = await fetch("/api/admin/backups/run", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({ error: "request failed" }));
|
||||
showFeedback("error", body.error || `HTTP ${r.status}`);
|
||||
return;
|
||||
}
|
||||
// The created row is in the response; refresh the list to land it.
|
||||
await refreshList();
|
||||
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
|
||||
} catch (e) {
|
||||
showFeedback("error", (e as Error).message || "network error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshList(): Promise<void> {
|
||||
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
|
||||
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
|
||||
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
|
||||
if (!tbody) return;
|
||||
if (!rows || rows.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
if (empty) empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = "none";
|
||||
tbody.innerHTML = rows.map(renderRow).join("");
|
||||
}
|
||||
|
||||
function renderRow(b: BackupRow): string {
|
||||
const started = formatTimestamp(b.started_at);
|
||||
const kind =
|
||||
b.kind === "scheduled"
|
||||
? t("admin.backups.kind.scheduled") || "Geplant"
|
||||
: t("admin.backups.kind.on_demand") || "Manuell";
|
||||
const status = renderStatus(b);
|
||||
const requestedBy =
|
||||
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
|
||||
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
|
||||
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
|
||||
const action = renderAction(b);
|
||||
return `<tr>
|
||||
<td>${started}</td>
|
||||
<td>${kind}</td>
|
||||
<td>${status}</td>
|
||||
<td>${requestedBy}</td>
|
||||
<td>${size}</td>
|
||||
<td>${rows}</td>
|
||||
<td>${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderStatus(b: BackupRow): string {
|
||||
switch (b.status) {
|
||||
case "done":
|
||||
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
|
||||
case "running":
|
||||
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
|
||||
case "failed":
|
||||
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
|
||||
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
|
||||
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
|
||||
default:
|
||||
return escapeHTML(b.status);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAction(b: BackupRow): string {
|
||||
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
|
||||
return "—";
|
||||
}
|
||||
const label = t("admin.backups.download") || "Download";
|
||||
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return escapeHTML(iso);
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => {
|
||||
switch (c) {
|
||||
case "&": return "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
case "'": return "'";
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return escapeHTML(s);
|
||||
}
|
||||
|
||||
function showFeedback(kind: "success" | "error", text: string): void {
|
||||
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
||||
el.style.display = "";
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||
if (!el) return;
|
||||
el.style.display = "none";
|
||||
el.textContent = "";
|
||||
el.classList.remove("form-msg-success", "form-msg-error");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -43,6 +47,10 @@ let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
|
||||
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
|
||||
// "Termin bearbeiten" in the withdraw warning modal.
|
||||
let pendingEditMode = false;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -207,10 +215,14 @@ function renderHeader() {
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
|
||||
// withdraw modal, pendingEditMode unfreezes the form so Save can route
|
||||
// to /edit-entity (which keeps the request pending + merges payload).
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const freeze = isPending && !pendingEditMode;
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
.forEach((el) => { el.disabled = freeze; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
// t-paliad-252 — pending-edit mode routes through /edit-entity which
|
||||
// keeps the request pending + merges fields into payload. clear_project
|
||||
// and project_id are NOT in the counter-allowlist (yet) — the requester
|
||||
// can't move projects on a pending request from this surface.
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const editFields = { ...payload };
|
||||
delete editFields.clear_project;
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: editFields }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
// Exit pending-edit mode so the form re-freezes (still pending).
|
||||
pendingEditMode = false;
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -312,12 +357,37 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-252 — withdraw warning modal replaces the old confirm().
|
||||
// Returns:
|
||||
// "edit" → unfreeze the edit form (pending-edit mode); Save will
|
||||
// route through /api/approval-requests/{id}/edit-entity
|
||||
// "withdraw" → destructive: the existing /revoke endpoint
|
||||
// null → user cancelled
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "appointment",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
pendingEditMode = true;
|
||||
if (btn) btn.disabled = false;
|
||||
// renderHeader re-evaluates the freeze and unfreezes the form now
|
||||
// that pendingEditMode is set. Focus the first editable field so the
|
||||
// user can type immediately.
|
||||
renderHeader();
|
||||
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
|
||||
titleEl?.focus();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
// CREATE lifecycle: entity gone → back to the list.
|
||||
window.location.href = "/events?type=appointment";
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
|
||||
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
||||
//
|
||||
// Before t-paliad-252 the deadline + appointment detail pages did a
|
||||
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
||||
// For pending CREATE lifecycles that endpoint silently DELETES the
|
||||
// underlying entity row — m's "withdrawing the approval deletes the event"
|
||||
// surprise.
|
||||
//
|
||||
// This modal replaces the confirm() with three explicit paths:
|
||||
//
|
||||
// 1. Cancel — does nothing
|
||||
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
||||
// through POST /approval-requests/{id}/
|
||||
// edit-entity which keeps the request
|
||||
// pending and merges the new fields
|
||||
// into approval_request.payload
|
||||
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
||||
// löschen behaviour (delete for CREATE, revert
|
||||
// for UPDATE/COMPLETE, cancel for
|
||||
// DELETE-lifecycle requests)
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
||||
// three-button row sits cleanly inside the body — the primitive only
|
||||
// supports one secondary action, but we paint the destructive button as a
|
||||
// separate row above the footer.
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export type WithdrawAction = "edit" | "withdraw";
|
||||
|
||||
export interface WithdrawWarningArgs {
|
||||
// entityType drives the copy ("event" vs "appointment" labels).
|
||||
entityType: "deadline" | "appointment";
|
||||
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
||||
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
||||
// cancelling the deletion request).
|
||||
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
||||
}
|
||||
|
||||
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
||||
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
||||
export async function openWithdrawWarningModal(
|
||||
args: WithdrawWarningArgs,
|
||||
): Promise<WithdrawAction | null> {
|
||||
const body = document.createElement("div");
|
||||
body.className = "withdraw-warning-body";
|
||||
|
||||
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
||||
// knows what the destructive button will actually do. The /revoke
|
||||
// backend behaviour:
|
||||
// - create → DELETE the entity (the "surprise" m flagged)
|
||||
// - update → revert to pre_image
|
||||
// - complete → revert to pre-complete state
|
||||
// - delete → cancel the delete request (entity stays alive)
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "withdraw-warning-intro";
|
||||
intro.textContent = leadCopyFor(args);
|
||||
body.appendChild(intro);
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "withdraw-warning-sub muted";
|
||||
sub.textContent = subCopyFor(args);
|
||||
body.appendChild(sub);
|
||||
|
||||
// The destructive button lives inside the body — the openModal primitive
|
||||
// only exposes one secondary button slot, and we want the safe "Edit"
|
||||
// path to be the primary CTA. Painting it in red here, separated from
|
||||
// the footer, signals "this is the dangerous option" without competing
|
||||
// visually with the primary CTA.
|
||||
const destructiveRow = document.createElement("div");
|
||||
destructiveRow.className = "withdraw-warning-destructive-row";
|
||||
const destructiveBtn = document.createElement("button");
|
||||
destructiveBtn.type = "button";
|
||||
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
||||
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
||||
destructiveRow.appendChild(destructiveBtn);
|
||||
body.appendChild(destructiveRow);
|
||||
|
||||
return new Promise<WithdrawAction | null>((resolve) => {
|
||||
let chosen: WithdrawAction | null = null;
|
||||
|
||||
// The destructive button has to close the modal and return "withdraw".
|
||||
// We need access to the modal's internal close() — fortunately openModal
|
||||
// exposes it via the primary handler's first arg. We pass through the
|
||||
// outer resolve and let the primary handler (Edit) own the close-fn
|
||||
// route. For the destructive button we resolve the outer promise
|
||||
// directly and then synthesise an ESC keypress so the modal dismisses
|
||||
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
||||
// the modal already supports. (openModal's onClose fires on every
|
||||
// dismiss path including the primary handler resolution.)
|
||||
destructiveBtn.addEventListener("click", () => {
|
||||
chosen = "withdraw";
|
||||
// The unified openModal primitive (modal.ts) wires its dismiss path
|
||||
// through the native <dialog>'s `cancel` event. Dispatching it on
|
||||
// the parent <dialog> runs the same finish() → onClose → resolve
|
||||
// sequence as ESC / backdrop. We then map the resolved `null` back
|
||||
// to "withdraw" via the captured `chosen` in onClose below.
|
||||
const dialogEl = body.closest("dialog");
|
||||
dialogEl?.dispatchEvent(new Event("cancel"));
|
||||
});
|
||||
|
||||
void openModal<WithdrawAction>({
|
||||
title: t("approvals.withdraw.modal.title"),
|
||||
body,
|
||||
size: "md",
|
||||
classNames: "withdraw-warning-modal",
|
||||
primary: {
|
||||
label: t("approvals.withdraw.primary.label"),
|
||||
handler: (close) => {
|
||||
chosen = "edit";
|
||||
close("edit");
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.withdraw.cancel") },
|
||||
onClose: () => {
|
||||
// Resolves whatever was chosen via the destructive button OR the
|
||||
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
||||
// null which is the right "cancel" semantics.
|
||||
resolve(chosen);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function leadCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return args.entityType === "appointment"
|
||||
? t("approvals.withdraw.lead.create.appointment")
|
||||
: t("approvals.withdraw.lead.create.deadline");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.lead.delete");
|
||||
default:
|
||||
// update / complete / unknown → revert semantics
|
||||
return t("approvals.withdraw.lead.update");
|
||||
}
|
||||
}
|
||||
|
||||
function subCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return t("approvals.withdraw.sub.create");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.sub.delete");
|
||||
default:
|
||||
return t("approvals.withdraw.sub.update");
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,65 @@ describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — includeHidden (edit mode)", () => {
|
||||
test("hidden widgets are skipped by default", () => {
|
||||
const out = placeWidgets([
|
||||
spec("visible", 0, 0, 6),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
]);
|
||||
expect(out.has("visible")).toBe(true);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
test("includeHidden:true places hidden widgets after visible ones", () => {
|
||||
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
|
||||
// widgets MUST receive a placement, otherwise applyLayout leaves
|
||||
// their inline grid-column empty and CSS Grid auto-flows them as
|
||||
// 1×1 slivers ("super slim greyed-out column").
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 12),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.has("hidden")).toBe(true);
|
||||
const h = out.get("hidden")!;
|
||||
// Must keep its requested width (6), not collapse to 1.
|
||||
expect(h.w).toBe(6);
|
||||
// Must land below the visible widget — never overlap or steal cells.
|
||||
expect(h.y).toBeGreaterThanOrEqual(1);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
|
||||
// Hidden widget stored at (0, 0) shouldn't displace a visible
|
||||
// widget that wants (0, 0). The visible pass runs first, claims
|
||||
// (0, 0); the hidden widget is then placed wherever free — the
|
||||
// placer happily fits it next to the visible widget on the same
|
||||
// row if there's room. The hard invariant is just no-overlap.
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 6),
|
||||
spec("hidden-at-origin", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.has("hidden-at-origin")).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("multiple hidden widgets all receive valid placements", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("h1", undefined, undefined, 6, 1, false),
|
||||
spec("h2", undefined, undefined, 6, 1, false),
|
||||
spec("h3", undefined, undefined, 12, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.size).toBe(4);
|
||||
for (const r of out.values()) {
|
||||
expect(r.w).toBeGreaterThanOrEqual(1);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
}
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
|
||||
@@ -133,10 +133,30 @@ function findFreeSlot(
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to every visible
|
||||
// widget. Hidden widgets are skipped and contribute no placement.
|
||||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||||
// needs.
|
||||
export interface PlaceOptions {
|
||||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||||
// where the user can see + un-hide them inline. The two-pass order
|
||||
// (visible first, then hidden) guarantees hidden widgets never
|
||||
// displace visible ones: they get whatever cells are left below the
|
||||
// active layout. Default false matches view-mode behaviour and the
|
||||
// persistence path (materializePositions) where hidden widgets
|
||||
// retain their stored coordinates instead of being repacked.
|
||||
//
|
||||
// Without this option, hidden widgets in edit mode were left without
|
||||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||||
// default only visible widgets receive placements; pass
|
||||
// {includeHidden:true} to also place hidden widgets after the visible
|
||||
// pass (used by applyLayout in edit mode).
|
||||
//
|
||||
// Algorithm: iterate widgets in input order. For each visible widget:
|
||||
// Algorithm — per pass:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
@@ -150,8 +170,15 @@ function findFreeSlot(
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
//
|
||||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||||
// own auto-flow cursor; the hidden pass continues from where the
|
||||
// visible pass left off so the hidden widgets stack right under the
|
||||
// active layout. The shared Occupancy bitmap guarantees the second
|
||||
// pass can never overlap a placed visible widget.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
options: PlaceOptions = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
@@ -165,8 +192,7 @@ export function placeWidgets(
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
const placeOne = (w: WidgetPlacementInput): void => {
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
@@ -210,6 +236,28 @@ export function placeWidgets(
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
};
|
||||
|
||||
// Pass 1: visible widgets. They own the active layout.
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
|
||||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||||
// start of the next row before the second pass so the hidden tray
|
||||
// visually separates from the active layout — even if the last
|
||||
// visible widget left half a row open.
|
||||
if (options.includeHidden) {
|
||||
if (cursorX > 0) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
for (const w of widgets) {
|
||||
if (w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
@@ -1922,10 +1922,15 @@ function applyLayout(): void {
|
||||
if (k) byKey.set(k, el);
|
||||
});
|
||||
|
||||
// Compute effective placements (with auto-flow fill-in for missing
|
||||
// y values). The visible widgets are placed deterministically so the
|
||||
// grid renders identically across reloads.
|
||||
const placements = computePlacements(currentLayout.widgets);
|
||||
// Compute effective placements. In edit mode we also include hidden
|
||||
// widgets so they render at their stored (or default) dimensions
|
||||
// dimmed-but-visible — without this they'd inherit no inline grid-
|
||||
// column and CSS Grid would auto-flow them as 1×1 slivers, producing
|
||||
// the "super slim greyed-out column" symptom (m/paliad#73). In view
|
||||
// mode hidden widgets are display:none and reserve no cells.
|
||||
const placements = computePlacements(currentLayout.widgets, {
|
||||
includeHidden: editMode,
|
||||
});
|
||||
|
||||
for (const w of currentLayout.widgets) {
|
||||
const el = byKey.get(w.key);
|
||||
@@ -1952,7 +1957,15 @@ function applyLayout(): void {
|
||||
// overlap invariant: if two widgets request colliding cells (drag-drop
|
||||
// swap with mismatched widths, resize-grow into a sibling, etc.) the
|
||||
// later one is shifted down to the next free row. See m/paliad#70.
|
||||
function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRect> {
|
||||
//
|
||||
// includeHidden=true is used by applyLayout in edit mode to also place
|
||||
// hidden widgets after the visible pass — so the hidden tray renders
|
||||
// at proper size below the active layout. Default (false) matches the
|
||||
// persistence + render paths where hidden widgets carry no placement.
|
||||
function computePlacements(
|
||||
widgets: DashboardWidgetRef[],
|
||||
options: { includeHidden?: boolean } = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
visible: w.visible,
|
||||
@@ -1962,7 +1975,7 @@ function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRec
|
||||
h: w.h,
|
||||
bound: toBound(lookupCatalog(w.key)),
|
||||
}));
|
||||
return placeWidgets(inputs);
|
||||
return placeWidgets(inputs, options);
|
||||
}
|
||||
|
||||
function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
|
||||
|
||||
289
frontend/src/client/date-range-picker-pure.test.ts
Normal file
289
frontend/src/client/date-range-picker-pure.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Unit tests for the date-range picker's pure helpers (t-paliad-248).
|
||||
// Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import {
|
||||
horizonBounds,
|
||||
isValidHorizon,
|
||||
isValidISODate,
|
||||
validateCustomRange,
|
||||
parseURL,
|
||||
serializeURL,
|
||||
isDefault,
|
||||
ALL_HORIZONS,
|
||||
PAST_HORIZONS,
|
||||
NEXT_HORIZONS,
|
||||
type TimeHorizon,
|
||||
type TimeSpec,
|
||||
} from "./date-range-picker-pure";
|
||||
|
||||
// Anchor the clock so day-arithmetic assertions don't drift with the
|
||||
// wall clock. 2026-05-25 00:00 UTC matches the Go-side bounds test.
|
||||
const NOW = new Date(Date.UTC(2026, 4, 25));
|
||||
const DAY = (offsetDays: number): Date =>
|
||||
new Date(NOW.getTime() + offsetDays * 86_400_000);
|
||||
|
||||
describe("ALL_HORIZONS / PAST / NEXT registries", () => {
|
||||
test("registries sum to a known total without overlap", () => {
|
||||
// 6 past + 6 next + any + custom = 14 fan chips (custom is the
|
||||
// trailing entry in ALL_HORIZONS; `all` is intentionally absent —
|
||||
// surfaces don't render the legacy bidirectional-unbounded chip).
|
||||
expect(ALL_HORIZONS.length).toBe(14);
|
||||
expect(PAST_HORIZONS.length).toBe(6);
|
||||
expect(NEXT_HORIZONS.length).toBe(6);
|
||||
expect(new Set(ALL_HORIZONS).size).toBe(ALL_HORIZONS.length);
|
||||
});
|
||||
|
||||
test("PAST_HORIZONS are all past_*", () => {
|
||||
for (const h of PAST_HORIZONS) {
|
||||
expect(h.startsWith("past_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("NEXT_HORIZONS are all next_*", () => {
|
||||
for (const h of NEXT_HORIZONS) {
|
||||
expect(h.startsWith("next_")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ALL_HORIZONS ends with custom and contains any in the middle", () => {
|
||||
expect(ALL_HORIZONS.at(-1)).toBe("custom");
|
||||
expect(ALL_HORIZONS).toContain("any");
|
||||
});
|
||||
});
|
||||
|
||||
describe("horizonBounds", () => {
|
||||
test("future fan: bounds anchor at today, extend forward", () => {
|
||||
expect(horizonBounds("next_1d", NOW)).toEqual({ from: DAY(0), to: DAY(1) });
|
||||
expect(horizonBounds("next_7d", NOW)).toEqual({ from: DAY(0), to: DAY(7) });
|
||||
expect(horizonBounds("next_14d", NOW)).toEqual({ from: DAY(0), to: DAY(14) });
|
||||
expect(horizonBounds("next_30d", NOW)).toEqual({ from: DAY(0), to: DAY(30) });
|
||||
expect(horizonBounds("next_90d", NOW)).toEqual({ from: DAY(0), to: DAY(90) });
|
||||
});
|
||||
|
||||
test("past fan: bounds extend back, upper bound is tomorrow (exclusive end-of-today)", () => {
|
||||
expect(horizonBounds("past_1d", NOW)).toEqual({ from: DAY(-1), to: DAY(1) });
|
||||
expect(horizonBounds("past_7d", NOW)).toEqual({ from: DAY(-7), to: DAY(1) });
|
||||
expect(horizonBounds("past_14d", NOW)).toEqual({ from: DAY(-14), to: DAY(1) });
|
||||
expect(horizonBounds("past_30d", NOW)).toEqual({ from: DAY(-30), to: DAY(1) });
|
||||
expect(horizonBounds("past_90d", NOW)).toEqual({ from: DAY(-90), to: DAY(1) });
|
||||
});
|
||||
|
||||
test("next_all is one-sided: from=today, to undefined", () => {
|
||||
const b = horizonBounds("next_all", NOW);
|
||||
expect(b.from).toEqual(DAY(0));
|
||||
expect(b.to).toBeUndefined();
|
||||
});
|
||||
|
||||
test("past_all is one-sided: from undefined, to=tomorrow", () => {
|
||||
const b = horizonBounds("past_all", NOW);
|
||||
expect(b.from).toBeUndefined();
|
||||
expect(b.to).toEqual(DAY(1));
|
||||
});
|
||||
|
||||
test("any / all / custom: both bounds undefined", () => {
|
||||
expect(horizonBounds("any", NOW)).toEqual({});
|
||||
expect(horizonBounds("all", NOW)).toEqual({});
|
||||
expect(horizonBounds("custom", NOW)).toEqual({});
|
||||
});
|
||||
|
||||
test("bounds anchor on UTC start-of-day regardless of input clock time", () => {
|
||||
const nowAfternoon = new Date(Date.UTC(2026, 4, 25, 14, 37, 0));
|
||||
const nowMidnight = new Date(Date.UTC(2026, 4, 25, 0, 0, 0));
|
||||
expect(horizonBounds("past_7d", nowAfternoon)).toEqual(horizonBounds("past_7d", nowMidnight));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHorizon", () => {
|
||||
test("accepts every entry in ALL_HORIZONS plus 'all' (legacy)", () => {
|
||||
for (const h of ALL_HORIZONS) {
|
||||
expect(isValidHorizon(h)).toBe(true);
|
||||
}
|
||||
expect(isValidHorizon("all")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unknown strings, numbers, undefined, null", () => {
|
||||
expect(isValidHorizon("next_5d")).toBe(false);
|
||||
expect(isValidHorizon("past_100d")).toBe(false);
|
||||
expect(isValidHorizon("")).toBe(false);
|
||||
expect(isValidHorizon(7)).toBe(false);
|
||||
expect(isValidHorizon(undefined)).toBe(false);
|
||||
expect(isValidHorizon(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidISODate", () => {
|
||||
test("accepts valid YYYY-MM-DD", () => {
|
||||
expect(isValidISODate("2026-05-25")).toBe(true);
|
||||
expect(isValidISODate("2026-12-31")).toBe(true);
|
||||
expect(isValidISODate("2024-02-29")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects shape mismatches", () => {
|
||||
expect(isValidISODate("2026/05/25")).toBe(false);
|
||||
expect(isValidISODate("25.05.2026")).toBe(false);
|
||||
expect(isValidISODate("2026-5-25")).toBe(false);
|
||||
expect(isValidISODate("")).toBe(false);
|
||||
expect(isValidISODate(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects calendar-impossible dates (Date.parse silently rolls over)", () => {
|
||||
expect(isValidISODate("2026-02-30")).toBe(false);
|
||||
expect(isValidISODate("2026-13-01")).toBe(false);
|
||||
expect(isValidISODate("2026-04-31")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects 2025-02-29 (non-leap February)", () => {
|
||||
expect(isValidISODate("2025-02-29")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCustomRange", () => {
|
||||
test("requires both bounds present and valid", () => {
|
||||
expect(validateCustomRange(undefined, undefined)).toBe("date_range.custom.invalid_missing");
|
||||
expect(validateCustomRange("2026-05-25", undefined)).toBe("date_range.custom.invalid_missing");
|
||||
expect(validateCustomRange(undefined, "2026-05-25")).toBe("date_range.custom.invalid_missing");
|
||||
});
|
||||
|
||||
test("rejects malformed dates with format error", () => {
|
||||
expect(validateCustomRange("bogus", "2026-05-25")).toBe("date_range.custom.invalid_format");
|
||||
expect(validateCustomRange("2026-13-01", "2026-12-31")).toBe("date_range.custom.invalid_format");
|
||||
});
|
||||
|
||||
test("rejects to <= from with invalid error", () => {
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-25")).toBe("date_range.custom.invalid");
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-24")).toBe("date_range.custom.invalid");
|
||||
});
|
||||
|
||||
test("accepts strictly-ordered valid pair", () => {
|
||||
expect(validateCustomRange("2026-05-25", "2026-05-26")).toBeNull();
|
||||
expect(validateCustomRange("2026-01-01", "2026-12-31")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseURL", () => {
|
||||
test("missing horizon yields contract default", () => {
|
||||
expect(parseURL(new URLSearchParams(""))).toEqual({ horizon: "any" });
|
||||
expect(parseURL(new URLSearchParams(""), { default: "next_30d" })).toEqual({ horizon: "next_30d" });
|
||||
});
|
||||
|
||||
test("unknown horizon falls back to default, doesn't throw", () => {
|
||||
expect(parseURL(new URLSearchParams("horizon=mystery"), { default: "next_7d" }))
|
||||
.toEqual({ horizon: "next_7d" });
|
||||
});
|
||||
|
||||
test("every fan horizon round-trips on a fresh URLSearchParams", () => {
|
||||
for (const h of ALL_HORIZONS) {
|
||||
if (h === "custom") continue;
|
||||
const params = new URLSearchParams(`horizon=${h}`);
|
||||
expect(parseURL(params)).toEqual({ horizon: h });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom horizon reads from+to", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
|
||||
expect(parseURL(params)).toEqual({
|
||||
horizon: "custom",
|
||||
from: "2026-03-15",
|
||||
to: "2026-04-30",
|
||||
});
|
||||
});
|
||||
|
||||
test("custom with malformed dates falls back to default rather than half-state", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-99-99&horizon_to=2026-04-30");
|
||||
expect(parseURL(params, { default: "next_30d" })).toEqual({ horizon: "next_30d" });
|
||||
});
|
||||
|
||||
test("custom with from>=to falls back", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-05-25&horizon_to=2026-05-25");
|
||||
expect(parseURL(params)).toEqual({ horizon: "any" });
|
||||
});
|
||||
|
||||
test("custom URL key override", () => {
|
||||
const params = new URLSearchParams("range=past_30d");
|
||||
expect(parseURL(params, { key: "range" })).toEqual({ horizon: "past_30d" });
|
||||
expect(parseURL(params)).toEqual({ horizon: "any" }); // default `horizon` key absent
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeURL", () => {
|
||||
test("default horizon is omitted (canonical URL stays short)", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "any" }, params);
|
||||
expect(params.toString()).toBe("");
|
||||
});
|
||||
|
||||
test("explicit default param removed when value matches default", () => {
|
||||
const params = new URLSearchParams("horizon=past_30d&other=keep");
|
||||
serializeURL({ horizon: "past_30d" }, params, { default: "past_30d" });
|
||||
expect(params.toString()).toBe("other=keep");
|
||||
});
|
||||
|
||||
test("non-default horizon is written", () => {
|
||||
const params = new URLSearchParams("other=keep");
|
||||
serializeURL({ horizon: "next_7d" }, params);
|
||||
expect(params.toString()).toBe("other=keep&horizon=next_7d");
|
||||
});
|
||||
|
||||
test("custom writes horizon+from+to", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params);
|
||||
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30");
|
||||
});
|
||||
|
||||
test("custom partial bounds: from/to are written individually", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15" }, params);
|
||||
expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15");
|
||||
});
|
||||
|
||||
test("stale params cleared on re-serialize", () => {
|
||||
const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30&other=keep");
|
||||
serializeURL({ horizon: "past_30d" }, params);
|
||||
expect(params.toString()).toBe("other=keep&horizon=past_30d");
|
||||
// Stale from/to must be gone.
|
||||
expect(params.has("horizon_from")).toBe(false);
|
||||
expect(params.has("horizon_to")).toBe(false);
|
||||
});
|
||||
|
||||
test("key override propagates to from/to", () => {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params, { key: "range" });
|
||||
expect(params.toString()).toBe("range=custom&range_from=2026-03-15&range_to=2026-04-30");
|
||||
});
|
||||
|
||||
test("URL round-trips through parse → serialize → parse", () => {
|
||||
const specs: TimeSpec[] = [
|
||||
{ horizon: "any" },
|
||||
{ horizon: "next_7d" },
|
||||
{ horizon: "past_all" },
|
||||
{ horizon: "next_all" },
|
||||
{ horizon: "custom", from: "2026-03-15", to: "2026-04-30" },
|
||||
];
|
||||
for (const spec of specs) {
|
||||
const params = new URLSearchParams();
|
||||
serializeURL(spec, params);
|
||||
expect(parseURL(params)).toEqual(spec);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefault", () => {
|
||||
test("true when horizon matches default exactly", () => {
|
||||
expect(isDefault({ horizon: "any" }, "any")).toBe(true);
|
||||
expect(isDefault({ horizon: "next_30d" }, "next_30d")).toBe(true);
|
||||
});
|
||||
|
||||
test("false when horizon differs", () => {
|
||||
expect(isDefault({ horizon: "past_7d" }, "any")).toBe(false);
|
||||
expect(isDefault({ horizon: "next_30d" }, "next_7d")).toBe(false);
|
||||
});
|
||||
|
||||
test("custom is never default — even when bounds match", () => {
|
||||
// No surface treats "custom" as the natural default, so any custom
|
||||
// selection IS user-driven and the closed button must surface
|
||||
// the non-default indicator.
|
||||
expect(isDefault({ horizon: "custom", from: "2026-01-01", to: "2026-12-31" }, "custom" as TimeHorizon))
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
292
frontend/src/client/date-range-picker-pure.ts
Normal file
292
frontend/src/client/date-range-picker-pure.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
// date-range-picker-pure.ts — pure helpers for the symmetric date-range
|
||||
// picker (t-paliad-248). No DOM access; runnable under `bun test`. The
|
||||
// picker's boot client (date-range-picker.ts) drives the popover, but
|
||||
// every interesting decision — what does "Letzte 7 Tage" mean today,
|
||||
// what URL params should land, when is a custom range valid — lives
|
||||
// here so it can be tested without a browser.
|
||||
//
|
||||
// The Go side (internal/services/view_service.go:computeViewSpecBounds)
|
||||
// is the canonical materializer; horizonBounds() below MUST stay in
|
||||
// step with it. The bounds test in pure-tests pins the shape so a
|
||||
// divergent change to one side breaks the assertions on the other.
|
||||
|
||||
import type { I18nKey } from "../i18n-keys";
|
||||
|
||||
/**
|
||||
* TimeHorizon — the full 14-value union the symmetric picker can emit.
|
||||
* Mirrors `internal/services/filter_spec.go` TimeHorizon.
|
||||
*
|
||||
* The fan chips: 6 past + 6 next + the ALLES centre (`any`) + custom.
|
||||
* `all` is the legacy bidirectional-unbounded value, gated to
|
||||
* scope=explicit by the validator (Q26); the picker doesn't surface it
|
||||
* but parseURL accepts it for back-compat with saved Custom Views.
|
||||
*/
|
||||
export type TimeHorizon =
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
/**
|
||||
* TimeSpec — the wire shape mirrored from the Go FilterSpec.TimeSpec.
|
||||
* `from`/`to` are ISO YYYY-MM-DD strings — UTC dates, not timestamps.
|
||||
* Times-of-day intentionally absent from the picker's contract.
|
||||
*/
|
||||
export interface TimeSpec {
|
||||
horizon: TimeHorizon;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full list of horizon values the picker is willing to render
|
||||
* as chips. Order is the picker's reading order — past edge → past
|
||||
* → ALLES → next → next edge, with `custom` last because it lives
|
||||
* below the chip rows in the popover, not in the row itself.
|
||||
*/
|
||||
export const ALL_HORIZONS: readonly TimeHorizon[] = [
|
||||
"past_all",
|
||||
"past_90d",
|
||||
"past_30d",
|
||||
"past_14d",
|
||||
"past_7d",
|
||||
"past_1d",
|
||||
"any",
|
||||
"next_1d",
|
||||
"next_7d",
|
||||
"next_14d",
|
||||
"next_30d",
|
||||
"next_90d",
|
||||
"next_all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
// Strict-validity set. Includes the legacy bidirectional-unbounded `all`
|
||||
// horizon so a saved Custom View JSON ({"horizon":"all", …}) deserializes
|
||||
// without falling back to the surface default. The picker UI itself
|
||||
// doesn't surface a chip for `all` — it's read in, kept as state, but
|
||||
// the chip the user sees light up is `any` (the centre ALLES button).
|
||||
const ALL_HORIZONS_SET: ReadonlySet<string> = new Set([...ALL_HORIZONS, "all"]);
|
||||
|
||||
/**
|
||||
* Past chips, in reading order (outermost → innermost). The picker
|
||||
* renders this left-to-right in the popover's past fan.
|
||||
*/
|
||||
export const PAST_HORIZONS: readonly TimeHorizon[] = [
|
||||
"past_all",
|
||||
"past_90d",
|
||||
"past_30d",
|
||||
"past_14d",
|
||||
"past_7d",
|
||||
"past_1d",
|
||||
];
|
||||
|
||||
/**
|
||||
* Future chips, in reading order (innermost → outermost). The picker
|
||||
* renders this left-to-right in the popover's future fan.
|
||||
*/
|
||||
export const NEXT_HORIZONS: readonly TimeHorizon[] = [
|
||||
"next_1d",
|
||||
"next_7d",
|
||||
"next_14d",
|
||||
"next_30d",
|
||||
"next_90d",
|
||||
"next_all",
|
||||
];
|
||||
|
||||
/**
|
||||
* The i18n key for the closed-button label and chip text of every
|
||||
* horizon. Lives here (not in the TSX) so a single dictionary lookup
|
||||
* sites can hand back a translated string at any point.
|
||||
*/
|
||||
export const HORIZON_LABEL_KEY: Record<TimeHorizon, I18nKey> = {
|
||||
past_all: "date_range.horizon.past_all",
|
||||
past_90d: "date_range.horizon.past_90d",
|
||||
past_30d: "date_range.horizon.past_30d",
|
||||
past_14d: "date_range.horizon.past_14d",
|
||||
past_7d: "date_range.horizon.past_7d",
|
||||
past_1d: "date_range.horizon.past_1d",
|
||||
any: "date_range.horizon.any",
|
||||
next_1d: "date_range.horizon.next_1d",
|
||||
next_7d: "date_range.horizon.next_7d",
|
||||
next_14d: "date_range.horizon.next_14d",
|
||||
next_30d: "date_range.horizon.next_30d",
|
||||
next_90d: "date_range.horizon.next_90d",
|
||||
next_all: "date_range.horizon.next_all",
|
||||
all: "date_range.horizon.any", // legacy alias — surfaces "Alles" in the closed label
|
||||
custom: "date_range.horizon.custom",
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounds for a given horizon, anchored at `now`. Pure function: the
|
||||
* caller passes the clock so tests can pin a specific day without
|
||||
* mocking Date. Bounds are UTC dates; the `to` bound is exclusive
|
||||
* (start-of-day-after) so "past 7d" includes today.
|
||||
*
|
||||
* Returns `{}` for `any` / `all` / `custom` — the picker's surface
|
||||
* lifts the from/to out of TimeSpec directly when horizon === custom,
|
||||
* and treats unbounded values as "no narrowing in that direction".
|
||||
*/
|
||||
export function horizonBounds(
|
||||
horizon: TimeHorizon,
|
||||
now: Date,
|
||||
): { from?: Date; to?: Date } {
|
||||
const day = new Date(Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
));
|
||||
const offset = (days: number): Date =>
|
||||
new Date(day.getTime() + days * 86_400_000);
|
||||
|
||||
switch (horizon) {
|
||||
case "past_1d": return { from: offset(-1), to: offset(1) };
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_14d": return { from: offset(-14), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "past_all": return { to: offset(1) };
|
||||
case "next_1d": return { from: day, to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_14d": return { from: day, to: offset(14) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
case "next_all": return { from: day };
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidHorizon — narrows an unknown string to a TimeHorizon, used
|
||||
* by parseURL and by surface-side URL alias adapters.
|
||||
*/
|
||||
export function isValidHorizon(s: unknown): s is TimeHorizon {
|
||||
return typeof s === "string" && ALL_HORIZONS_SET.has(s);
|
||||
}
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* isValidISODate — `YYYY-MM-DD` shape check plus a real-date validity
|
||||
* check (rejects 2026-02-30). Doesn't enforce timezone or floor at any
|
||||
* particular date.
|
||||
*/
|
||||
export function isValidISODate(s: unknown): s is string {
|
||||
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
|
||||
const ms = Date.parse(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(ms)) return false;
|
||||
// Reject 2026-02-30 etc. — Date.parse accepts those by rolling over.
|
||||
return new Date(ms).toISOString().slice(0, 10) === s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a custom range. Returns null on success, an i18n key
|
||||
* pointing at the error message on failure.
|
||||
*
|
||||
* Rules:
|
||||
* - Both `from` and `to` must be valid ISO YYYY-MM-DD.
|
||||
* - `to` must be strictly after `from` (single-day ranges use
|
||||
* `from=2026-05-25&to=2026-05-26`, NOT `from=to=2026-05-25`).
|
||||
*/
|
||||
export function validateCustomRange(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
): I18nKey | null {
|
||||
if (!from || !to) return "date_range.custom.invalid_missing";
|
||||
if (!isValidISODate(from) || !isValidISODate(to)) return "date_range.custom.invalid_format";
|
||||
if (Date.parse(`${from}T00:00:00Z`) >= Date.parse(`${to}T00:00:00Z`)) {
|
||||
return "date_range.custom.invalid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URLContract — the picker's stable URL serialization. Surfaces can
|
||||
* override the param name via `key` so two pickers on the same page
|
||||
* (rare) don't collide.
|
||||
*/
|
||||
export interface URLContract {
|
||||
/** Base param name, defaults to "horizon". */
|
||||
key?: string;
|
||||
/** Default value omitted from URL (matches surface's natural default). */
|
||||
default?: TimeHorizon;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseURL — reads a URL search-params object into a TimeSpec.
|
||||
*
|
||||
* ?horizon=past_30d → {horizon:"past_30d"}
|
||||
* ?horizon=custom&from=2026-03-15&to=… → {horizon:"custom",from,to}
|
||||
* (no params) → {horizon: contract.default ?? "any"}
|
||||
*
|
||||
* Unknown / malformed values fall back to the default. Out-of-shape
|
||||
* custom dates clamp to {horizon: default} — the picker never lands
|
||||
* in a half-custom state from a URL.
|
||||
*/
|
||||
export function parseURL(
|
||||
params: URLSearchParams,
|
||||
contract: URLContract = {},
|
||||
): TimeSpec {
|
||||
const key = contract.key ?? "horizon";
|
||||
const fallback: TimeHorizon = contract.default ?? "any";
|
||||
|
||||
const raw = params.get(key);
|
||||
if (raw === null) return { horizon: fallback };
|
||||
if (!isValidHorizon(raw)) return { horizon: fallback };
|
||||
if (raw !== "custom") return { horizon: raw };
|
||||
|
||||
const from = params.get(`${key}_from`) ?? undefined;
|
||||
const to = params.get(`${key}_to`) ?? undefined;
|
||||
if (validateCustomRange(from, to) !== null) {
|
||||
return { horizon: fallback };
|
||||
}
|
||||
return { horizon: "custom", from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* serializeURL — writes a TimeSpec into the URL search-params object,
|
||||
* mutating the passed-in instance. Values equal to the surface
|
||||
* default are OMITTED — the canonical URL stays short.
|
||||
*
|
||||
* Always deletes `horizon`, `<key>_from`, `<key>_to` first so a
|
||||
* re-serialise after the picker reverts to default cleans up rather
|
||||
* than accumulating stale entries.
|
||||
*/
|
||||
export function serializeURL(
|
||||
spec: TimeSpec,
|
||||
params: URLSearchParams,
|
||||
contract: URLContract = {},
|
||||
): void {
|
||||
const key = contract.key ?? "horizon";
|
||||
const fromKey = `${key}_from`;
|
||||
const toKey = `${key}_to`;
|
||||
|
||||
params.delete(key);
|
||||
params.delete(fromKey);
|
||||
params.delete(toKey);
|
||||
|
||||
if (spec.horizon === (contract.default ?? "any") && spec.horizon !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.horizon === "custom") {
|
||||
params.set(key, "custom");
|
||||
if (spec.from) params.set(fromKey, spec.from);
|
||||
if (spec.to) params.set(toKey, spec.to);
|
||||
return;
|
||||
}
|
||||
|
||||
params.set(key, spec.horizon);
|
||||
}
|
||||
|
||||
/**
|
||||
* isDefault — used by surfaces to decide whether to render the
|
||||
* "value is non-default" dot on the closed button.
|
||||
*/
|
||||
export function isDefault(spec: TimeSpec, defaultHorizon: TimeHorizon): boolean {
|
||||
if (spec.horizon !== defaultHorizon) return false;
|
||||
if (spec.horizon === "custom") return false;
|
||||
return true;
|
||||
}
|
||||
470
frontend/src/client/date-range-picker.ts
Normal file
470
frontend/src/client/date-range-picker.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
// date-range-picker.ts — boot client + DOM mount for the symmetric
|
||||
// date-range picker (t-paliad-248). The picker is a controlled
|
||||
// component: callers pass `value` + `onChange`, the component renders
|
||||
// the trigger button + popover scaffold, the popover materialises a
|
||||
// chip row and (when "Anpassen" is picked) an inline date-pair editor.
|
||||
//
|
||||
// The picker reuses the existing `.agenda-chip` styling for chips and
|
||||
// the `.multi-panel` popover pattern (auto-positioned under a
|
||||
// `.multi-anchor` wrapper). Both patterns are battle-tested by the
|
||||
// filter-bar + multi-select widgets — no new design tokens, no new
|
||||
// dark-mode contrast risk.
|
||||
|
||||
import { t } from "./i18n";
|
||||
import {
|
||||
ALL_HORIZONS,
|
||||
HORIZON_LABEL_KEY,
|
||||
NEXT_HORIZONS,
|
||||
PAST_HORIZONS,
|
||||
isDefault,
|
||||
isValidISODate,
|
||||
validateCustomRange,
|
||||
type TimeHorizon,
|
||||
type TimeSpec,
|
||||
} from "./date-range-picker-pure";
|
||||
|
||||
export interface MountOpts {
|
||||
/** Current value. The picker is fully controlled. */
|
||||
value: TimeSpec;
|
||||
/** Fired on every committed change (chip click or Anwenden). */
|
||||
onChange(next: TimeSpec): void;
|
||||
/**
|
||||
* Which horizon constitutes the "default" for this surface. Used
|
||||
* for the non-default indicator dot. Defaults to `"any"`.
|
||||
*/
|
||||
defaultHorizon?: TimeHorizon;
|
||||
/**
|
||||
* Which chips to render. Order is preserved. Defaults to the full
|
||||
* 14-chip fan from ALL_HORIZONS.
|
||||
*/
|
||||
presets?: readonly TimeHorizon[];
|
||||
/**
|
||||
* Stable surface tag — feeds into the `data-testid` on every DOM
|
||||
* node the picker creates so tests can scope. Example: "agenda",
|
||||
* "filter-bar.time", "audit-log".
|
||||
*/
|
||||
surface: string;
|
||||
/**
|
||||
* Optional prefix for the closed-button label. The label always
|
||||
* starts with the resolved horizon name (e.g. "Letzte 30 Tage").
|
||||
* Surfaces that want a heading prefix ("Zeitraum: Letzte 30 Tage")
|
||||
* pass it here.
|
||||
*/
|
||||
labelPrefix?: string;
|
||||
}
|
||||
|
||||
export interface PickerHandle {
|
||||
/** Root element — append to the host container. */
|
||||
element: HTMLElement;
|
||||
/** Read the current value (may have been edited via Anpassen). */
|
||||
getValue(): TimeSpec;
|
||||
/** Update the value from the host (e.g. after URL change). */
|
||||
setValue(next: TimeSpec): void;
|
||||
/** Force-close the popover. Safe to call when already closed. */
|
||||
close(): void;
|
||||
/** Detach event listeners + remove from DOM. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a date-range picker. The returned `element` is a single
|
||||
* inline node containing both the trigger button and the popover
|
||||
* (absolutely positioned via `.multi-anchor` + `.multi-panel`).
|
||||
*
|
||||
* The popover stays in the DOM permanently; opening/closing toggles
|
||||
* the `[hidden]` attribute. This keeps the chip's tab-order stable
|
||||
* and matches the multi-select widget's behaviour.
|
||||
*/
|
||||
export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
||||
const presets = opts.presets ?? ALL_HORIZONS;
|
||||
const defaultHorizon = opts.defaultHorizon ?? "any";
|
||||
let value: TimeSpec = normalize(opts.value);
|
||||
|
||||
// Cached drafts for the "Anpassen" editor — preserved across
|
||||
// open/close so the user doesn't lose their typing if they peek
|
||||
// away. Seeded from the live value when the editor opens.
|
||||
let customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
||||
let customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
||||
let customEditorOpen = value.horizon === "custom";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "date-range-anchor multi-anchor";
|
||||
root.dataset.testid = `${opts.surface}.date-range-picker`;
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "date-range-trigger";
|
||||
trigger.setAttribute("aria-haspopup", "dialog");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.dataset.testid = `${opts.surface}.date-range-trigger`;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "date-range-panel multi-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-label", t("date_range.dialog.label"));
|
||||
panel.hidden = true;
|
||||
panel.dataset.testid = `${opts.surface}.date-range-panel`;
|
||||
|
||||
root.appendChild(trigger);
|
||||
root.appendChild(panel);
|
||||
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
|
||||
// Open/close wiring. Click outside the root collapses the popover;
|
||||
// Esc inside it bubbles up to the same handler via keydown delegate.
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (panel.hidden) return;
|
||||
if (e.target instanceof Node && root.contains(e.target)) return;
|
||||
closePopover();
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (panel.hidden) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
closePopover();
|
||||
trigger.focus();
|
||||
}
|
||||
};
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (panel.hidden) openPopover();
|
||||
else closePopover();
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
|
||||
function openPopover(): void {
|
||||
panel.hidden = false;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
// Re-render to reflect the very latest value (host may have
|
||||
// patched via setValue between open/close).
|
||||
renderPanel();
|
||||
// Move keyboard focus into the panel so Esc works without a
|
||||
// prior click. The first chip is the natural landing spot.
|
||||
const firstChip = panel.querySelector<HTMLButtonElement>(".date-range-chip");
|
||||
firstChip?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closePopover(): void {
|
||||
panel.hidden = true;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function commit(next: TimeSpec, closeAfter: boolean): void {
|
||||
value = normalize(next);
|
||||
customEditorOpen = value.horizon === "custom";
|
||||
if (value.horizon === "custom") {
|
||||
customFromDraft = value.from ?? "";
|
||||
customToDraft = value.to ?? "";
|
||||
}
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
opts.onChange(value);
|
||||
if (closeAfter) {
|
||||
closePopover();
|
||||
trigger.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrigger(): void {
|
||||
trigger.replaceChildren();
|
||||
if (!isDefault(value, defaultHorizon)) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "date-range-trigger-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
trigger.appendChild(dot);
|
||||
}
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "date-range-trigger-label";
|
||||
labelSpan.textContent = labelFor(value, opts.labelPrefix);
|
||||
trigger.appendChild(labelSpan);
|
||||
|
||||
const chev = document.createElement("span");
|
||||
chev.className = "date-range-trigger-chev";
|
||||
chev.setAttribute("aria-hidden", "true");
|
||||
chev.textContent = "▾";
|
||||
trigger.appendChild(chev);
|
||||
}
|
||||
|
||||
function renderPanel(): void {
|
||||
panel.replaceChildren();
|
||||
|
||||
// Three groups in a single row: past fan / ALLES centre / next fan.
|
||||
const row = document.createElement("div");
|
||||
row.className = "date-range-row";
|
||||
|
||||
const pastGroup = renderFan(
|
||||
PAST_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"past",
|
||||
);
|
||||
const centerGroup = renderCenter();
|
||||
const nextGroup = renderFan(
|
||||
NEXT_HORIZONS.filter((h) => presets.includes(h)),
|
||||
"next",
|
||||
);
|
||||
|
||||
if (pastGroup) row.appendChild(pastGroup);
|
||||
if (centerGroup) row.appendChild(centerGroup);
|
||||
if (nextGroup) row.appendChild(nextGroup);
|
||||
|
||||
panel.appendChild(row);
|
||||
|
||||
// Custom-range section ("Anpassen"). Toggle button + collapsible
|
||||
// date-pair editor below.
|
||||
if (presets.includes("custom")) {
|
||||
panel.appendChild(renderCustomSection());
|
||||
}
|
||||
}
|
||||
|
||||
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
|
||||
if (horizons.length === 0) return null;
|
||||
const group = document.createElement("div");
|
||||
group.className = `date-range-fan date-range-fan--${side}`;
|
||||
group.setAttribute("role", "group");
|
||||
group.setAttribute("aria-label", side === "past"
|
||||
? t("date_range.fan.past.label")
|
||||
: t("date_range.fan.future.label"));
|
||||
for (const h of horizons) {
|
||||
group.appendChild(makeChip(h));
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderCenter(): HTMLElement | null {
|
||||
if (!presets.includes("any")) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "date-range-center";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "date-range-center-btn";
|
||||
if (value.horizon === "any" || value.horizon === "all") {
|
||||
btn.classList.add("date-range-center-btn--active");
|
||||
}
|
||||
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
|
||||
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
|
||||
|
||||
const glyph = document.createElement("span");
|
||||
glyph.className = "date-range-center-glyph";
|
||||
glyph.setAttribute("aria-hidden", "true");
|
||||
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
|
||||
const label = document.createElement("span");
|
||||
label.className = "date-range-center-label";
|
||||
label.textContent = t("date_range.center.label");
|
||||
btn.appendChild(glyph);
|
||||
btn.appendChild(label);
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
commit({ horizon: "any" }, /*closeAfter*/ true);
|
||||
});
|
||||
|
||||
wrap.appendChild(btn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function makeChip(h: TimeHorizon): HTMLButtonElement {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip date-range-chip";
|
||||
if (value.horizon === h) chip.classList.add("agenda-chip-active");
|
||||
chip.setAttribute("aria-pressed", String(value.horizon === h));
|
||||
chip.textContent = t(HORIZON_LABEL_KEY[h]);
|
||||
chip.dataset.testid = `${opts.surface}.date-range-chip.${h}`;
|
||||
chip.addEventListener("click", () => {
|
||||
commit({ horizon: h }, /*closeAfter*/ true);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
function renderCustomSection(): HTMLElement {
|
||||
const section = document.createElement("div");
|
||||
section.className = "date-range-custom";
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.className = "agenda-chip date-range-chip date-range-chip--custom";
|
||||
if (value.horizon === "custom") toggleBtn.classList.add("agenda-chip-active");
|
||||
toggleBtn.setAttribute("aria-expanded", String(customEditorOpen));
|
||||
toggleBtn.dataset.testid = `${opts.surface}.date-range-chip.custom`;
|
||||
toggleBtn.textContent = t("date_range.horizon.custom");
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
customEditorOpen = !customEditorOpen;
|
||||
renderPanel();
|
||||
if (customEditorOpen) {
|
||||
// Focus the first input on expand.
|
||||
panel.querySelector<HTMLInputElement>(".date-range-custom-from")?.focus();
|
||||
}
|
||||
});
|
||||
section.appendChild(toggleBtn);
|
||||
|
||||
if (!customEditorOpen) return section;
|
||||
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "date-range-custom-editor";
|
||||
|
||||
const fromWrap = document.createElement("label");
|
||||
fromWrap.className = "date-range-custom-field";
|
||||
const fromLbl = document.createElement("span");
|
||||
fromLbl.className = "date-range-custom-label";
|
||||
fromLbl.textContent = t("date_range.custom.from");
|
||||
const fromInput = document.createElement("input");
|
||||
fromInput.type = "date";
|
||||
fromInput.lang = "de";
|
||||
fromInput.className = "date-range-custom-from";
|
||||
fromInput.value = customFromDraft;
|
||||
fromInput.dataset.testid = `${opts.surface}.date-range-custom-from`;
|
||||
fromInput.addEventListener("input", () => {
|
||||
customFromDraft = fromInput.value;
|
||||
refreshValidity();
|
||||
});
|
||||
fromWrap.appendChild(fromLbl);
|
||||
fromWrap.appendChild(fromInput);
|
||||
|
||||
const toWrap = document.createElement("label");
|
||||
toWrap.className = "date-range-custom-field";
|
||||
const toLbl = document.createElement("span");
|
||||
toLbl.className = "date-range-custom-label";
|
||||
toLbl.textContent = t("date_range.custom.to");
|
||||
const toInput = document.createElement("input");
|
||||
toInput.type = "date";
|
||||
toInput.lang = "de";
|
||||
toInput.className = "date-range-custom-to";
|
||||
toInput.value = customToDraft;
|
||||
toInput.dataset.testid = `${opts.surface}.date-range-custom-to`;
|
||||
toInput.addEventListener("input", () => {
|
||||
customToDraft = toInput.value;
|
||||
refreshValidity();
|
||||
});
|
||||
toWrap.appendChild(toLbl);
|
||||
toWrap.appendChild(toInput);
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "date-range-custom-apply";
|
||||
applyBtn.textContent = t("date_range.custom.apply");
|
||||
applyBtn.dataset.testid = `${opts.surface}.date-range-custom-apply`;
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const err = validateCustomRange(customFromDraft, customToDraft);
|
||||
if (err !== null) {
|
||||
showError(err);
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
{ horizon: "custom", from: customFromDraft, to: customToDraft },
|
||||
/*closeAfter*/ true,
|
||||
);
|
||||
});
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "date-range-custom-cancel";
|
||||
cancelBtn.textContent = t("date_range.custom.cancel");
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
customEditorOpen = false;
|
||||
// Restore drafts from live value so a re-open shows the
|
||||
// committed state rather than the abandoned typing.
|
||||
customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
||||
customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
||||
renderPanel();
|
||||
});
|
||||
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "date-range-custom-error";
|
||||
errEl.hidden = true;
|
||||
errEl.dataset.testid = `${opts.surface}.date-range-custom-error`;
|
||||
|
||||
editor.appendChild(fromWrap);
|
||||
editor.appendChild(toWrap);
|
||||
editor.appendChild(applyBtn);
|
||||
editor.appendChild(cancelBtn);
|
||||
editor.appendChild(errEl);
|
||||
section.appendChild(editor);
|
||||
|
||||
refreshValidity();
|
||||
|
||||
function refreshValidity(): void {
|
||||
const err = validateCustomRange(customFromDraft, customToDraft);
|
||||
if (err === null) {
|
||||
applyBtn.disabled = false;
|
||||
errEl.hidden = true;
|
||||
errEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
applyBtn.disabled = true;
|
||||
// Only surface the *content* error (`invalid` = inverted range)
|
||||
// while the user is typing. Empty / format errors are visible
|
||||
// through the disabled-Anwenden state alone — surfacing them on
|
||||
// every keystroke would be noisy.
|
||||
if (err === "date_range.custom.invalid") {
|
||||
showError(err);
|
||||
} else {
|
||||
errEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(key: Parameters<typeof t>[0]): void {
|
||||
errEl.textContent = t(key);
|
||||
errEl.hidden = false;
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
return {
|
||||
element: root,
|
||||
getValue: () => normalize(value),
|
||||
setValue(next: TimeSpec) {
|
||||
value = normalize(next);
|
||||
customEditorOpen = value.horizon === "custom";
|
||||
if (value.horizon === "custom") {
|
||||
customFromDraft = value.from ?? "";
|
||||
customToDraft = value.to ?? "";
|
||||
}
|
||||
renderTrigger();
|
||||
renderPanel();
|
||||
},
|
||||
close: closePopover,
|
||||
destroy() {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(spec: TimeSpec): TimeSpec {
|
||||
if (spec.horizon === "custom") {
|
||||
return {
|
||||
horizon: "custom",
|
||||
from: spec.from && isValidISODate(spec.from) ? spec.from : undefined,
|
||||
to: spec.to && isValidISODate(spec.to) ? spec.to : undefined,
|
||||
};
|
||||
}
|
||||
return { horizon: spec.horizon };
|
||||
}
|
||||
|
||||
function labelFor(spec: TimeSpec, prefix?: string): string {
|
||||
let body: string;
|
||||
if (spec.horizon === "custom") {
|
||||
if (spec.from && spec.to) {
|
||||
body = t("date_range.button.label.custom_range")
|
||||
.replace("{from}", formatISO(spec.from))
|
||||
.replace("{to}", formatISO(spec.to));
|
||||
} else {
|
||||
body = t("date_range.horizon.custom");
|
||||
}
|
||||
} else {
|
||||
body = t(HORIZON_LABEL_KEY[spec.horizon]);
|
||||
}
|
||||
return prefix ? `${prefix}: ${body}` : body;
|
||||
}
|
||||
|
||||
function formatISO(iso: string): string {
|
||||
if (!isValidISODate(iso)) return iso;
|
||||
// DE locale: DD.MM.YYYY. The picker is German-first; surfaces in EN
|
||||
// can override via labelPrefix or by formatting before commit if
|
||||
// they want a different shape.
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -20,6 +22,9 @@ interface Deadline {
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-258 — lawyer's free-text rule label when the deadline was
|
||||
// saved in Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,7 +62,21 @@ interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
rule_code?: string;
|
||||
legal_source?: string | null;
|
||||
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
|
||||
// when the user flips to Auto on the edit form.
|
||||
concept_default_event_type_id?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
jurisdiction: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -70,6 +92,30 @@ let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
|
||||
// On enterEdit we initialise the mode from the persisted deadline:
|
||||
// rule_id set → "auto"
|
||||
// custom_rule_text set, no rule_id → "custom"
|
||||
// neither set → "auto" (so the Type-driven
|
||||
// resolver fills in immediately).
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
|
||||
// modal, the entity is still in approval_status='pending'. Save must POST
|
||||
// to /api/approval-requests/{id}/edit-entity (which keeps the request
|
||||
// pending + merges the new fields into payload) instead of the regular
|
||||
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
||||
// from edit mode + after a successful save.
|
||||
let pendingEditMode = false;
|
||||
|
||||
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
||||
// modal handler (initWithdraw) can route into pending-edit mode without
|
||||
// duplicating the edit-mode toggle logic.
|
||||
let pendingEnterEdit: (() => void) | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
||||
@@ -165,17 +211,66 @@ function populateProjectPicker() {
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
async function loadAllRules() {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadline-rules`);
|
||||
if (!resp.ok) return;
|
||||
const all: DeadlineRule[] = await resp.json();
|
||||
rule = all.find((r) => r.id === ruleID) || null;
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function lookupRule(ruleID: string): DeadlineRule | null {
|
||||
return rulesByID.get(ruleID) || null;
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType mirrors the create-form resolver: pick the
|
||||
// canonical rule for the chosen event_type, prioritising the project's
|
||||
// proceeding then jurisdiction match.
|
||||
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const projID = deadline?.project_id;
|
||||
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
|
||||
if (proj && proj.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypeByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
return resolveAutoRuleForType(picked[0]);
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -227,9 +322,15 @@ function render() {
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("deadline-rule-display")!;
|
||||
// t-paliad-258 — display priority:
|
||||
// 1. catalog rule (canonical Name · Citation pattern)
|
||||
// 2. custom_rule_text + Custom badge
|
||||
// 3. legacy rule_code-only (Fristenrechner saves)
|
||||
// 4. "—"
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
|
||||
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
|
||||
} else if (deadline.rule_code) {
|
||||
// Fristenrechner-saved deadlines carry rule_code directly without
|
||||
// a rule_id (no rule UUID round-trips through the public API).
|
||||
@@ -353,6 +454,49 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(r, esc);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("deadline-title-display")!;
|
||||
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
|
||||
@@ -366,6 +510,11 @@ function initEdit() {
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
|
||||
const ruleDisplay = document.getElementById("deadline-rule-display");
|
||||
const ruleEdit = document.getElementById("deadline-rule-edit");
|
||||
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -381,6 +530,20 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
|
||||
// from the persisted deadline. Display element stays visible so the
|
||||
// user keeps "before / after" context while editing.
|
||||
if (ruleEdit) ruleEdit.style.display = "";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "none";
|
||||
if (deadline?.custom_rule_text && !deadline.rule_id) {
|
||||
ruleMode = "custom";
|
||||
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
|
||||
} else {
|
||||
ruleMode = "auto";
|
||||
if (ruleCustomInput) ruleCustomInput.value = "";
|
||||
}
|
||||
applyRuleModeUI();
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -399,12 +562,71 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
if (ruleEdit) ruleEdit.style.display = "none";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
pendingEditMode = false;
|
||||
}
|
||||
|
||||
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
|
||||
// time the Type picker changes, so just-toggling-to-Auto immediately
|
||||
// surfaces a fresh resolution.
|
||||
ruleToggleBtn?.addEventListener("click", () => {
|
||||
ruleMode = ruleMode === "auto" ? "custom" : "auto";
|
||||
applyRuleModeUI();
|
||||
if (ruleMode === "custom") ruleCustomInput?.focus();
|
||||
});
|
||||
|
||||
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
|
||||
// route into pending-edit mode without re-running the edit-button
|
||||
// visibility gate (which hides the button during pending).
|
||||
pendingEnterEdit = () => {
|
||||
pendingEditMode = true;
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// head = event_type label (if exactly one Typ chip in edit)
|
||||
// || Auto-resolved rule's canonical label (Name · Citation)
|
||||
// || saved rule's canonical label
|
||||
// || custom_rule_text (when in Custom mode + non-empty)
|
||||
// || rule_code-only legacy fallback
|
||||
// || "Neue Frist" fallback
|
||||
// suffix = " — <project.reference>" when not already in head
|
||||
titleDefaultBtn?.addEventListener("click", () => {
|
||||
if (!deadline) return;
|
||||
let head = "";
|
||||
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
||||
if (ids.length === 1) {
|
||||
const et = eventTypeByID.get(ids[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head) {
|
||||
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
|
||||
if (r) head = formatRuleLabel(r);
|
||||
}
|
||||
if (!head && ruleMode === "custom") {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = formatRuleLabel(rule);
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
}
|
||||
if (!head) head = t("deadlines.field.title.default_fallback");
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
||||
titleEdit.value = head;
|
||||
titleEdit.focus();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
@@ -424,6 +646,48 @@ function initEdit() {
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
// t-paliad-258 — rule_set discriminator tells the service this
|
||||
// PATCH carries an Auto/Custom rule change. Both columns are
|
||||
// mutually exclusive at the persistence boundary.
|
||||
payload.rule_set = true;
|
||||
if (ruleMode === "auto") {
|
||||
const r = currentAutoRule();
|
||||
payload.rule_id = r ? r.id : null;
|
||||
payload.custom_rule_text = null;
|
||||
} else {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
payload.rule_id = null;
|
||||
payload.custom_rule_text = txt || null;
|
||||
}
|
||||
|
||||
// t-paliad-252 — pending-edit mode routes through the new endpoint
|
||||
// that updates the entity + merges payload into the still-pending
|
||||
// approval_request. Outside pending-edit mode the regular PATCH
|
||||
// path remains the authoritative one (with its existing 409-on-
|
||||
// pending guard).
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: payload }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error))
|
||||
|| (t("approvals.withdraw.error") || "Fehler");
|
||||
window.alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -501,19 +765,39 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
||||
//
|
||||
// Click flow: open the withdraw warning modal (replaces the old
|
||||
// confirm()). The modal returns one of:
|
||||
//
|
||||
// "edit" — open the edit form in pending-edit mode; Save calls
|
||||
// /api/approval-requests/{id}/edit-entity which keeps the
|
||||
// request pending + merges the new fields into payload
|
||||
// "withdraw" — destructive: call the existing /revoke endpoint
|
||||
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
||||
// cancel-delete for DELETE lifecycle)
|
||||
// null — user cancelled; nothing happens
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "deadline",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
btn.disabled = false;
|
||||
pendingEnterEdit?.();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → existing destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -521,14 +805,16 @@ function initWithdraw() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
// and the badge / buttons rerender accordingly. For CREATE
|
||||
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
// CREATE lifecycle deleted the entity — bounce to the list.
|
||||
window.location.href = "/events?type=deadline";
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -592,8 +878,14 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
await Promise.all([
|
||||
loadProject(deadline.project_id),
|
||||
loadAllProjects(),
|
||||
loadPendingRequest(),
|
||||
loadAllRules(),
|
||||
loadProceedingTypes(),
|
||||
]);
|
||||
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
// chips off the cached map, and the display element re-renders on the
|
||||
@@ -614,6 +906,11 @@ async function main() {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
initialIDs: deadline.event_type_ids ?? [],
|
||||
currentUserAdmin: me?.global_role === "global_admin",
|
||||
onChange: () => {
|
||||
// Type change shifts the Auto-resolved rule. Refresh the
|
||||
// read-only display panel (no-op outside edit mode / Custom).
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -8,22 +8,21 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// Used by the Type→Rule resolver to narrow rule candidates to the
|
||||
// project's own proceeding when one applies. Optional because clients
|
||||
// and matter-level projects don't carry a proceeding type.
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,23 +31,37 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
legal_source?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for the rule's concept. The
|
||||
// catalog is indexed by it so we can resolve Type → canonical Rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
|
||||
// auto — rule_id resolved from the chosen event_type, rendered
|
||||
// read-only as "Auto: Name · Citation".
|
||||
// custom — free-text input; submits as custom_rule_text on the API.
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
let preselectedProjectID = "";
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
@@ -62,6 +75,13 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
|
||||
return `${pt.jurisdiction} — ${name}`;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
@@ -69,6 +89,7 @@ async function loadProjects() {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
projectsByID = new Map(projects.map((p) => [p.id, p]));
|
||||
if (projects.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
||||
@@ -82,7 +103,7 @@ async function loadProjects() {
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
@@ -91,122 +112,167 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
for (const r of rules) {
|
||||
const code = r.rule_code || r.code || "";
|
||||
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
/* non-fatal — rule display falls back to "—" */
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
// resolveAutoRuleForType picks the best-match catalog rule for the
|
||||
// chosen event type, scoring by:
|
||||
// 1. project's proceeding_type_id (if known) — exact match wins,
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
|
||||
// jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise the first candidate in canonical sequence_order.
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
// Returns null when no rule maps. Callers render that as "no Auto rule
|
||||
// available" so the user can flip to Custom or pick a different Type.
|
||||
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
if (project?.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypesByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
// currentAutoRule returns the catalog rule the Auto mode would resolve
|
||||
// to for the current form state, or null when no Type is picked or no
|
||||
// rule maps. Centralised so the Auto display, submitForm, and the
|
||||
// Standardtitel button all agree on the same resolution.
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
return resolveAutoRuleForType(picked[0], projectID);
|
||||
}
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
// refreshRuleAutoDisplay updates the read-only Auto display panel to
|
||||
// reflect the rule that would be saved in Auto mode. Hides itself when
|
||||
// the user is in Custom mode (the input takes its place).
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function setRuleMode(mode: RuleMode): void {
|
||||
ruleMode = mode;
|
||||
applyRuleModeUI();
|
||||
if (mode === "custom") {
|
||||
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. canonical rule name (when Auto resolves to a rule)
|
||||
// 3. custom rule text (when in Custom mode)
|
||||
// 4. proceeding type name (when project carries one)
|
||||
// 5. fallback i18n key
|
||||
// Suffix: " — <project-reference>" when not already in head.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
if (!head) {
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) head = formatRuleLabel(rule);
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
if (pt) head = proceedingLabel(pt);
|
||||
}
|
||||
if (!head) {
|
||||
head = t("deadlines.field.title.default_fallback");
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -217,7 +283,6 @@ async function submitForm(e: Event) {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
|
||||
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
|
||||
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
|
||||
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!projectID || !title || !due) {
|
||||
@@ -234,7 +299,15 @@ async function submitForm(e: Event) {
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
// Rule field: Auto resolves to rule_id, Custom sends the free text.
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) payload.rule_id = rule.id;
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) payload.custom_rule_text = txt;
|
||||
}
|
||||
if (notes) payload.notes = notes;
|
||||
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
||||
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
||||
@@ -252,8 +325,8 @@ async function submitForm(e: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
if (preselectedProjectIDLocal) {
|
||||
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
@@ -275,6 +348,16 @@ function detectPreselect() {
|
||||
if (fromQuery) preselectedProjectID = fromQuery;
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -288,8 +371,6 @@ async function loadMe() {
|
||||
|
||||
// t-paliad-154 — fetch the effective approval policy for (project,
|
||||
// deadline, create) and reveal the form-time hint when it applies.
|
||||
// Hidden when no policy applies. Re-runs on project change so the hint
|
||||
// updates if the user picks a different project mid-form.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("deadline-approval-hint");
|
||||
const text = document.getElementById("deadline-approval-hint-text");
|
||||
@@ -308,7 +389,6 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
@@ -343,44 +423,51 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// Type change shifts which Auto rule resolves; re-render the
|
||||
// read-only Auto display panel.
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
|
||||
// Preload event_types for the Auto display + Standardtitel resolver.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
refreshRuleAutoDisplay();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
.catch(() => {/* non-fatal */});
|
||||
|
||||
// Rule mode toggle.
|
||||
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
|
||||
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
|
||||
applyRuleModeUI();
|
||||
|
||||
// Approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
void refreshApprovalHint();
|
||||
// Project change can shift which Auto rule resolves (via the
|
||||
// project's proceeding_type_id).
|
||||
refreshRuleAutoDisplay();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
|
||||
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
|
||||
if (!titleInput) return;
|
||||
const derived = computeDefaultTitle();
|
||||
if (derived) titleInput.value = derived;
|
||||
titleInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
|
||||
// jurisdiction). Any non-null value matches event_types.jurisdiction;
|
||||
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
|
||||
let activeJurisdiction: string | null = null;
|
||||
|
||||
// Surface every jurisdiction present in the data — "any" stays bucketed
|
||||
// separately so users still have a "show generic-only" chip. EPA is
|
||||
// canonicalised to EPO in event_types (see mig 074); the chip label
|
||||
// shows EPA to match the legal vocabulary the lawyers use.
|
||||
const jurisdictionsPresent = new Set<string>();
|
||||
for (const et of opts.types) {
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
if (j) jurisdictionsPresent.add(j);
|
||||
}
|
||||
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
|
||||
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
|
||||
// Any jurisdiction in the data that isn't in our ordered list lands at
|
||||
// the end so the chip row never silently drops a court flavour.
|
||||
for (const j of jurisdictionsPresent) {
|
||||
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
|
||||
}
|
||||
|
||||
function chipLabel(j: string): string {
|
||||
if (j === "EPO") return "EPA";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
return j;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
|
||||
<div class="event-type-browse-header">
|
||||
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
|
||||
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
|
||||
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
|
||||
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
|
||||
${chipJurisdictions
|
||||
.map(
|
||||
(j) =>
|
||||
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
|
||||
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
|
||||
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
|
||||
|
||||
const groups = groupByCategory(opts.types);
|
||||
|
||||
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
|
||||
return j;
|
||||
}
|
||||
|
||||
function jurisdictionMatches(et: EventType): boolean {
|
||||
if (activeJurisdiction === null) return true;
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
return j === activeJurisdiction;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!jurisdictionMatches(et)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
|
||||
renderList();
|
||||
});
|
||||
|
||||
chipButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const raw = btn.dataset.jurisdiction ?? "";
|
||||
activeJurisdiction = raw === "" ? null : raw;
|
||||
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
|
||||
btn.classList.add("event-type-browse-chip--active");
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -66,6 +67,9 @@ interface EventListItem {
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was created
|
||||
// via the Custom rule path. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
event_type_ids?: string[];
|
||||
|
||||
// appointment-only
|
||||
@@ -264,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||||
// REGEL is meant for the legal reference, not the rule's display
|
||||
// name (which is the title column's job).
|
||||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
// t-paliad-258 addendum — canonical display contract: Name primary,
|
||||
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
|
||||
// Custom rules render the lawyer's free text + a "Custom" badge.
|
||||
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
|
||||
// show the bare citation as last-resort fallback.
|
||||
const hasName = (item.rule_name && item.rule_name.trim()) ||
|
||||
(item.rule_name_en && item.rule_name_en.trim());
|
||||
if (hasName || (item.rule_code && item.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{
|
||||
name: item.rule_name || "",
|
||||
name_en: item.rule_name_en,
|
||||
rule_code: item.rule_code,
|
||||
},
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (item.custom_rule_text && item.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import type { BarState, AxisKey } from "./types";
|
||||
import { mountDateRangePicker } from "../date-range-picker";
|
||||
import {
|
||||
ALL_HORIZONS as DRP_ALL_HORIZONS,
|
||||
type TimeHorizon as DRPTimeHorizon,
|
||||
type TimeSpec as DRPTimeSpec,
|
||||
} from "../date-range-picker-pure";
|
||||
import type { BarState, AxisKey, InboxFocus } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
// Read the current value for this axis.
|
||||
@@ -47,6 +53,8 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
|
||||
case "shape": return renderShapeAxis(ctx);
|
||||
case "density": return renderDensityAxis(ctx);
|
||||
case "sort": return renderSortAxis(ctx);
|
||||
case "unread_only": return renderUnreadOnlyAxis(ctx);
|
||||
case "inbox_focus": return renderInboxFocusAxis(ctx);
|
||||
|
||||
// Per-source predicates that need their own widgets and a roundtrip
|
||||
// through fetched option lists. Phase 2+ will fill these in by
|
||||
@@ -57,60 +65,63 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// time — symmetric date-range picker (t-paliad-248, replaces the t-163
|
||||
// chip-cluster + disabled Anpassen stub). The picker emits a TimeSpec
|
||||
// (horizon + optional custom from/to); the bar patches that onto
|
||||
// BarState.time directly.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||
next_7d: "views.bar.time.next_7d",
|
||||
next_30d: "views.bar.time.next_30d",
|
||||
next_90d: "views.bar.time.next_90d",
|
||||
past_7d: "views.bar.time.past_7d",
|
||||
past_30d: "views.bar.time.past_30d",
|
||||
past_90d: "views.bar.time.past_90d",
|
||||
any: "views.bar.time.any",
|
||||
all: "views.bar.time.all",
|
||||
custom: "views.bar.time.custom",
|
||||
};
|
||||
|
||||
// Default chip set when the surface doesn't override. Matches the
|
||||
// forward-leaning bias of the legacy filter-bar default (the universal
|
||||
// substrate is more often used for "what's coming up" than "what just
|
||||
// happened") but now covers the full symmetric fan plus past_30d for
|
||||
// quick recent-history lookups.
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"next_7d", "next_30d", "next_90d", "past_30d", "any",
|
||||
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
|
||||
const wrap = group("views.bar.label.time");
|
||||
const row = chipRow();
|
||||
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// "any" / "all" are both unbounded — clearing state is the cleanest
|
||||
// representation, so each maps to "no overlay" rather than a stored
|
||||
// horizon. The chip's active state then keys off "no time set".
|
||||
const current = ctx.get("time")?.horizon ?? "any";
|
||||
for (const preset of presets) {
|
||||
if (preset === "custom") continue; // custom rendered separately below
|
||||
const isUnbounded = preset === "any" || preset === "all";
|
||||
const isActive = isUnbounded
|
||||
? !ctx.get("time")
|
||||
: preset === current;
|
||||
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
|
||||
chip.addEventListener("click", () => {
|
||||
if (isUnbounded) {
|
||||
const presetSource = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// The picker's pure module owns the complete chip set; we narrow it
|
||||
// here to whatever this surface declares (preserving the surface's
|
||||
// chip order so timePresets remains the override knob it always was).
|
||||
const presets: DRPTimeHorizon[] = presetSource.flatMap((p) =>
|
||||
DRP_ALL_HORIZONS.includes(p as DRPTimeHorizon) ? [p as DRPTimeHorizon] : [],
|
||||
);
|
||||
|
||||
const current = ctx.get("time");
|
||||
const initialValue: DRPTimeSpec = current
|
||||
? { horizon: current.horizon as DRPTimeHorizon, from: current.from, to: current.to }
|
||||
: { horizon: "any" };
|
||||
|
||||
const picker = mountDateRangePicker({
|
||||
value: initialValue,
|
||||
onChange(next) {
|
||||
// The bar treats `any` as "no time overlay" (matches the legacy
|
||||
// chip-cluster's behaviour) so the BarState stays minimal when
|
||||
// the user lands on the centre ALLES button.
|
||||
if (next.horizon === "any") {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
return;
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Custom range — placeholder chip; opens a small popover with two
|
||||
// <input type="date"> in Phase 2. For Phase 1 we render the chip
|
||||
// disabled with a tooltip so the affordance is discoverable.
|
||||
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
|
||||
customChip.classList.add("filter-bar-chip-pending");
|
||||
customChip.title = t("views.bar.time.custom.coming_soon");
|
||||
customChip.disabled = true;
|
||||
row.appendChild(customChip);
|
||||
wrap.appendChild(row);
|
||||
ctx.patch({
|
||||
time: {
|
||||
horizon: next.horizon as TimeHorizonValue,
|
||||
from: next.horizon === "custom" ? next.from : undefined,
|
||||
to: next.horizon === "custom" ? next.to : undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
defaultHorizon: "any",
|
||||
presets,
|
||||
surface: "filter-bar.time",
|
||||
labelPrefix: t("views.bar.label.time"),
|
||||
});
|
||||
|
||||
wrap.appendChild(picker.element);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -484,6 +495,56 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// unread_only — single binary chip (t-paliad-249, inbox only)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderUnreadOnlyAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.unread_only");
|
||||
const row = chipRow();
|
||||
const isUnread = ctx.get("unread_only") !== false; // default on
|
||||
const unreadChip = chipBtn(t("views.bar.unread_only.on"), isUnread);
|
||||
unreadChip.addEventListener("click", () => ctx.patch({ unread_only: true }));
|
||||
const allChip = chipBtn(t("views.bar.unread_only.off"), !isUnread);
|
||||
allChip.addEventListener("click", () => ctx.patch({ unread_only: false }));
|
||||
row.appendChild(unreadChip);
|
||||
row.appendChild(allChip);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// inbox_focus — coarse 4-chip cluster (t-paliad-249, inbox only)
|
||||
//
|
||||
// Head's UX refinement #2 (2026-05-25): users pick "what to see" in
|
||||
// human terms, not abstract event-kind names. The overlay translates
|
||||
// the chip to a (Sources, ProjectEventPredicates.EventTypes,
|
||||
// ApprovalRequestPredicates.EntityTypes) triple at spec-resolve time
|
||||
// (see applyInboxFocusOverlay in url-codec.ts).
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const INBOX_FOCUS_CHIPS: Array<{ value: InboxFocus; key: I18nKey }> = [
|
||||
{ value: "alles", key: "views.bar.inbox_focus.alles" },
|
||||
{ value: "genehmigungen", key: "views.bar.inbox_focus.genehmigungen" },
|
||||
{ value: "plus_termine", key: "views.bar.inbox_focus.plus_termine" },
|
||||
{ value: "plus_fristen", key: "views.bar.inbox_focus.plus_fristen" },
|
||||
];
|
||||
|
||||
function renderInboxFocusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.inbox_focus");
|
||||
const row = chipRow();
|
||||
const current: InboxFocus = ctx.get("inbox_focus") ?? "alles";
|
||||
for (const f of INBOX_FOCUS_CHIPS) {
|
||||
const chip = chipBtn(t(f.key), f.value === current);
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ inbox_focus: f.value === "alles" ? undefined : f.value });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shared helpers — group + chip + row
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -333,9 +333,65 @@ export function computeEffective(
|
||||
render.list = { ...(render.list ?? {}), density: state.density };
|
||||
}
|
||||
|
||||
// Inbox overlays (t-paliad-249).
|
||||
//
|
||||
// unread_only is a top-level FilterSpec field; the server resolves
|
||||
// the actual cursor at run-time. Default-on for the inbox surface is
|
||||
// baked into the base spec — but we ALSO need to write `true` here
|
||||
// when the user explicitly picks the chip so the server doesn't
|
||||
// confuse "user wants unread" with "user wants no filter".
|
||||
if (state.unread_only !== undefined) {
|
||||
filter.unread_only = state.unread_only;
|
||||
}
|
||||
|
||||
// inbox_focus is a coarse axis that overlays Sources + a few
|
||||
// per-source predicates. Translate here so the server sees a clean
|
||||
// spec; the validator + RunSpec don't need to know about the chip.
|
||||
if (state.inbox_focus && state.inbox_focus !== "alles") {
|
||||
applyInboxFocusOverlay(filter, state.inbox_focus);
|
||||
}
|
||||
|
||||
return { filter, render };
|
||||
}
|
||||
|
||||
// applyInboxFocusOverlay narrows the spec to the chip's intent.
|
||||
// Mutates `filter` in place. Called only when state.inbox_focus is
|
||||
// set to a non-default value.
|
||||
//
|
||||
// Contract:
|
||||
// - "genehmigungen" → drop project_event from sources entirely.
|
||||
// - "plus_termine" → keep both sources; narrow project_event to
|
||||
// appointment_* kinds; narrow approval_request
|
||||
// entity_types to ["appointment"].
|
||||
// - "plus_fristen" → keep both sources; narrow project_event to
|
||||
// deadline_* kinds; narrow approval_request
|
||||
// entity_types to ["deadline"].
|
||||
function applyInboxFocusOverlay(filter: FilterSpec, focus: Exclude<NonNullable<BarState["inbox_focus"]>, "alles">): void {
|
||||
filter.predicates = filter.predicates ?? {};
|
||||
if (focus === "genehmigungen") {
|
||||
filter.sources = filter.sources.filter((s) => s !== "project_event");
|
||||
delete filter.predicates.project_event;
|
||||
return;
|
||||
}
|
||||
const kindPrefix = focus === "plus_fristen" ? "deadline_" : "appointment_";
|
||||
const entity = focus === "plus_fristen" ? "deadline" : "appointment";
|
||||
|
||||
if (filter.sources.includes("project_event")) {
|
||||
const baseKinds = filter.predicates.project_event?.event_types ?? [];
|
||||
const narrowed = baseKinds.filter((k) => k.startsWith(kindPrefix));
|
||||
filter.predicates.project_event = {
|
||||
...(filter.predicates.project_event ?? {}),
|
||||
event_types: narrowed,
|
||||
};
|
||||
}
|
||||
if (filter.sources.includes("approval_request")) {
|
||||
filter.predicates.approval_request = {
|
||||
...(filter.predicates.approval_request ?? {}),
|
||||
entity_types: [entity],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// isDirty — used to enable the Reset button only when there's something
|
||||
// to reset to.
|
||||
function isDirty(state: BarState): boolean {
|
||||
|
||||
@@ -25,7 +25,17 @@ export type AxisKey =
|
||||
| "timeline_track"
|
||||
| "shape"
|
||||
| "sort"
|
||||
| "density";
|
||||
| "density"
|
||||
// Inbox-only (t-paliad-249): unread/all toggle + coarse focus chip
|
||||
// (Alles / Genehmigungen / +Termine / +Fristen). The focus chip
|
||||
// overlays Sources + per-source predicates at resolve-time.
|
||||
| "unread_only"
|
||||
| "inbox_focus";
|
||||
|
||||
// Inbox focus chip values. "alles" is the default — both sources, full
|
||||
// curated kinds. Other values narrow at the bar's resolve step. See
|
||||
// applyInboxFocusOverlay() in url-codec.ts for the spec rewrite.
|
||||
export type InboxFocus = "alles" | "genehmigungen" | "plus_termine" | "plus_fristen";
|
||||
|
||||
// Effective spec — the result of overlaying URL + localStorage prefs
|
||||
// on top of the base spec. Handed back to onResult so the surface can
|
||||
@@ -62,10 +72,20 @@ export interface BarState {
|
||||
shape?: RenderShape;
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
|
||||
// Inbox (t-paliad-249)
|
||||
unread_only?: boolean;
|
||||
inbox_focus?: InboxFocus;
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
// Mirrors internal/services/filter_spec.go TimeHorizon. t-paliad-248
|
||||
// added the symmetric 1d / 14d / all chips on each side; the union
|
||||
// here is the wire-shape the URL codec parses and the picker emits.
|
||||
horizon:
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ describe("filter-bar/url-codec", () => {
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
// Includes the t-paliad-248 symmetric additions (1d / 14d / all on each side).
|
||||
for (const h of [
|
||||
"next_1d", "next_7d", "next_14d", "next_30d", "next_90d", "next_all",
|
||||
"past_1d", "past_7d", "past_14d", "past_30d", "past_90d", "past_all",
|
||||
"any", "all",
|
||||
] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
@@ -99,4 +104,28 @@ describe("filter-bar/url-codec", () => {
|
||||
params.set("density", "huge");
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
|
||||
// t-paliad-249 — inbox axes
|
||||
test("unread_only round-trips both states", () => {
|
||||
expect(roundTrip({ unread_only: true })).toEqual({ unread_only: true });
|
||||
expect(roundTrip({ unread_only: false })).toEqual({ unread_only: false });
|
||||
});
|
||||
|
||||
test("unread_only undefined stays out of the URL", () => {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar({}, params);
|
||||
expect(params.has("unread")).toBe(false);
|
||||
});
|
||||
|
||||
test("inbox_focus round-trips for non-default values", () => {
|
||||
for (const f of ["genehmigungen", "plus_termine", "plus_fristen"] as const) {
|
||||
expect(roundTrip({ inbox_focus: f })).toEqual({ inbox_focus: f });
|
||||
}
|
||||
});
|
||||
|
||||
test("inbox_focus alles is omitted (it's the default)", () => {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar({ inbox_focus: "alles" }, params);
|
||||
expect(params.has("focus")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// Empty / default values are NOT written — the URL stays clean for
|
||||
// users who don't tweak. The page's base spec is the implicit baseline.
|
||||
|
||||
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
|
||||
import type { BarState, TimeOverlay, ProjectOverlay, InboxFocus } from "./types";
|
||||
|
||||
const PERSONAL_PROJECT_SENTINEL = "personal";
|
||||
|
||||
@@ -108,6 +108,16 @@ export function parseBar(params: URLSearchParams, ns?: string): BarState {
|
||||
const density = params.get(k("density"));
|
||||
if (density === "comfortable" || density === "compact") out.density = density;
|
||||
|
||||
// inbox (t-paliad-249)
|
||||
const unread = params.get(k("unread"));
|
||||
if (unread === "0") out.unread_only = false;
|
||||
else if (unread === "1") out.unread_only = true;
|
||||
|
||||
const focus = params.get(k("focus"));
|
||||
if (focus === "genehmigungen" || focus === "plus_termine" || focus === "plus_fristen" || focus === "alles") {
|
||||
out.inbox_focus = focus as InboxFocus;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -127,6 +137,7 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
|
||||
"pe_kind",
|
||||
"tl_status", "tl_track",
|
||||
"shape", "sort", "density",
|
||||
"unread", "focus",
|
||||
]) {
|
||||
params.delete(k(key));
|
||||
}
|
||||
@@ -168,16 +179,31 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
|
||||
if (state.shape) params.set(k("shape"), state.shape);
|
||||
if (state.sort) params.set(k("sort"), state.sort);
|
||||
if (state.density) params.set(k("density"), state.density);
|
||||
|
||||
// inbox (t-paliad-249). unread_only is tri-state in BarState (undefined
|
||||
// means "page default"); we only write a key when the user has flipped
|
||||
// it explicitly so the URL stays clean for the default landing state.
|
||||
if (state.unread_only === false) params.set(k("unread"), "0");
|
||||
else if (state.unread_only === true) params.set(k("unread"), "1");
|
||||
if (state.inbox_focus && state.inbox_focus !== "alles") {
|
||||
params.set(k("focus"), state.inbox_focus);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
switch (s) {
|
||||
case "next_1d":
|
||||
case "next_7d":
|
||||
case "next_14d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "next_all":
|
||||
case "past_1d":
|
||||
case "past_7d":
|
||||
case "past_14d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "past_all":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
|
||||
@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossar",
|
||||
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
|
||||
"nav.checklisten": "Checklisten",
|
||||
"nav.submissions": "Schriftsätze",
|
||||
"nav.gerichte": "Gerichte",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.akten": "Akten",
|
||||
@@ -301,9 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
@@ -416,6 +417,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -873,11 +882,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
|
||||
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
|
||||
"deadlines.field.rule.custom_badge": "Eigen",
|
||||
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
|
||||
"deadlines.field.title.default_btn": "Standardtitel",
|
||||
"deadlines.field.title.default_fallback": "Neue Frist",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -1104,6 +1117,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Termin ge\u00e4ndert",
|
||||
"event.title.appointment_deleted": "Termin gel\u00f6scht",
|
||||
"event.title.appointment_project_changed": "Termin verschoben",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Genehmigung entschieden",
|
||||
"event.title.member_role_changed": "Teamrolle ge\u00e4ndert",
|
||||
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
|
||||
// a paired card with the original lifecycle event (e.g.
|
||||
// "Frist angelegt" + "Genehmigung erteilt von Bert").
|
||||
@@ -1335,7 +1352,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.grant_date": "Erteilungstag",
|
||||
"projects.field.court": "Gericht",
|
||||
"projects.field.case_number": "Aktenzeichen (Gericht)",
|
||||
"projects.field.proceeding_type_id": "Verfahrensart",
|
||||
"projects.field.proceeding_type_id": "Verfahrenstyp",
|
||||
"projects.field.proceeding_type": "Verfahrenstyp",
|
||||
"projects.field.proceeding_type.unset": "(nicht gesetzt)",
|
||||
"projects.field.proceeding_type.hint": "Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.",
|
||||
"projects.field.our_side": "Wir vertreten",
|
||||
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
@@ -1422,17 +1442,67 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.tab.settings": "Verwaltung",
|
||||
"projects.detail.export.button": "Daten exportieren",
|
||||
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
|
||||
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Bitte zuerst einen Verfahrenstyp setzen.",
|
||||
"projects.detail.settings.export.heading": "Daten exportieren",
|
||||
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
|
||||
"projects.detail.settings.archive.heading": "Projekt archivieren",
|
||||
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
|
||||
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
|
||||
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
"projects.detail.submissions.col.party": "Partei",
|
||||
"projects.detail.submissions.col.source": "Rechtsgrundlage",
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.action.edit": "Bearbeiten",
|
||||
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
|
||||
"submissions.draft.back": "← Zurück zum Projekt",
|
||||
"submissions.draft.loading": "Lädt…",
|
||||
"submissions.draft.notfound": "Schriftsatz nicht gefunden oder keine Berechtigung.",
|
||||
"submissions.draft.action.export": "Als .docx exportieren",
|
||||
"submissions.draft.action.new": "+ Neuer Entwurf",
|
||||
"submissions.draft.action.delete": "Löschen",
|
||||
"submissions.draft.switcher.label": "Entwurf",
|
||||
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
|
||||
"submissions.draft.preview.title": "Vorschau",
|
||||
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
|
||||
"submissions.index.loading": "Lädt…",
|
||||
"submissions.index.empty": "Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.",
|
||||
"submissions.index.empty.cta": "+ Neuer Entwurf",
|
||||
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
|
||||
"submissions.index.col.project": "Projekt",
|
||||
"submissions.index.col.submission": "Schriftsatz",
|
||||
"submissions.index.col.draft": "Entwurf",
|
||||
"submissions.index.col.updated": "Zuletzt geändert",
|
||||
"submissions.index.action.new": "+ Neuer Entwurf",
|
||||
// t-paliad-243 — global Schriftsatz picker (/submissions/new).
|
||||
"submissions.new.title": "Neuer Schriftsatz — Paliad",
|
||||
"submissions.new.back": "← Zurück zur Übersicht",
|
||||
"submissions.new.heading": "Neuer Schriftsatz",
|
||||
"submissions.new.subtitle": "Wählen Sie eine Vorlage. Optional verknüpfen Sie den Entwurf mit einem Projekt — sonst füllen Sie alle Variablen manuell.",
|
||||
"submissions.new.search.placeholder": "Suche nach Schriftsatz, Code oder Norm…",
|
||||
"submissions.new.loading": "Lädt…",
|
||||
"submissions.new.error": "Katalog konnte nicht geladen werden.",
|
||||
"submissions.new.col.name": "Schriftsatz",
|
||||
"submissions.new.col.party": "Partei",
|
||||
"submissions.new.col.source": "Rechtsgrundlage",
|
||||
"submissions.new.col.actions": "Entwurf starten",
|
||||
"submissions.new.empty.filtered": "Keine passenden Schriftsätze. Filter zurücksetzen.",
|
||||
"submissions.new.picker.title": "Projekt wählen",
|
||||
"submissions.new.picker.placeholder": "Projekt suchen (Titel oder Aktenzeichen)…",
|
||||
"submissions.new.picker.loading": "Lädt Projekte…",
|
||||
"submissions.new.picker.empty": "Keine sichtbaren Projekte.",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
@@ -1546,9 +1616,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Fortschritt",
|
||||
"projects.detail.checklisten.col.created": "Angelegt",
|
||||
"projects.detail.checklisten.hint.prefix": "Instanzen werden auf der Vorlagen-Seite unter ",
|
||||
"projects.detail.checklisten.hint.prefix": "Vorlagen werden auf der ",
|
||||
"projects.detail.checklisten.hint.link": "Checklisten",
|
||||
"projects.detail.checklisten.hint.suffix": " angelegt.",
|
||||
"projects.detail.checklisten.hint.suffix": "-Seite angelegt und bearbeitet.",
|
||||
"projects.detail.checklisten.add": "Checkliste hinzuf\u00fcgen",
|
||||
"projects.detail.checklisten.add.search": "Vorlage suchen\u2026",
|
||||
"projects.detail.checklisten.add.empty_pick": "Keine passenden Vorlagen gefunden.",
|
||||
"projects.detail.checklisten.add.created": "Checkliste hinzugef\u00fcgt.",
|
||||
"projects.detail.checklisten.add.error": "Checkliste konnte nicht angelegt werden.",
|
||||
"projects.detail.delete": "Projekt archivieren",
|
||||
"projects.detail.delete.confirm.title": "Projekt wirklich archivieren?",
|
||||
"projects.detail.delete.confirm.body": "Das Projekt wird archiviert. Es kann nicht direkt wiederhergestellt werden.",
|
||||
@@ -1595,6 +1670,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
|
||||
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
|
||||
"projects.detail.team.invite.cta": "Einladen",
|
||||
// t-paliad-231 — pure-client mailto: button on the Team tab. No
|
||||
// server call; opens the local mail client with every selected
|
||||
// member queued in the To: line.
|
||||
"projects.team.mailto.label": "Mail an Auswahl",
|
||||
"projects.team.mailto.empty": "Mindestens ein Mitglied auswählen",
|
||||
"projects.team.mailto.count": "{n} ausgewählt",
|
||||
"projects.team.mailto.select_all": "Alle sichtbaren auswählen",
|
||||
"projects.team.mailto.select_row": "Mitglied auswählen",
|
||||
"projects.view.tree": "Baumansicht",
|
||||
"projects.tree.toggle": "Aufklappen / Zuklappen",
|
||||
"projects.tree.loading": "Baum wird geladen\u2026",
|
||||
@@ -2036,8 +2119,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
|
||||
"paliadin.error.connection_lost": "Verbindung verloren.",
|
||||
"paliadin.error.upstream": "Fehler beim Senden.",
|
||||
"paliadin.error.upstream_silence": "Paliadin meldet sich nicht mehr — Verbindung wird beendet.",
|
||||
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
|
||||
"paliadin.late.checking": "Verbindung verloren — Paliadin denkt vielleicht noch. Lade frische Antwort …",
|
||||
"paliadin.late.lost": "Antwort konnte nicht zugestellt werden — bitte Frage erneut stellen.",
|
||||
"paliadin.late.marker": "verspätet",
|
||||
"paliadin.thinking": "Paliadin denkt nach",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "Was kann ich für dich tun?",
|
||||
@@ -2155,6 +2243,20 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
|
||||
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
|
||||
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
|
||||
"inbox.title.feed": "Inbox — Paliad",
|
||||
"inbox.heading.feed": "Inbox",
|
||||
"inbox.subtitle.feed": "Neuigkeiten zu Ihren Projekten und offene Genehmigungen.",
|
||||
"inbox.action.mark_all_seen": "Alles als gelesen markieren",
|
||||
"inbox.action.open": "Öffnen",
|
||||
"inbox.empty.feed": "Keine Neuigkeiten in den letzten 30 Tagen.",
|
||||
"views.bar.label.unread_only": "Lesestatus",
|
||||
"views.bar.unread_only.on": "Nur ungelesen",
|
||||
"views.bar.unread_only.off": "Alle",
|
||||
"views.bar.label.inbox_focus": "Anzeigen",
|
||||
"views.bar.inbox_focus.alles": "Alles",
|
||||
"views.bar.inbox_focus.genehmigungen": "Nur Genehmigungen",
|
||||
"views.bar.inbox_focus.plus_termine": "+ Termine",
|
||||
"views.bar.inbox_focus.plus_fristen": "+ Fristen",
|
||||
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
|
||||
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
|
||||
"admin.email_templates.title": "Email-Templates — Paliad",
|
||||
@@ -2266,6 +2368,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit-Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
|
||||
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||
"nav.admin.backups": "Backups",
|
||||
"admin.backups.title": "Backups — Paliad",
|
||||
"admin.backups.heading": "Backups",
|
||||
"admin.backups.subtitle": "Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.",
|
||||
"admin.backups.run_now": "Backup jetzt erstellen",
|
||||
"admin.backups.running": "Läuft …",
|
||||
"admin.backups.success": "Backup erfolgreich erstellt.",
|
||||
"admin.backups.empty": "Noch keine Backups vorhanden.",
|
||||
"admin.backups.loading": "Lade …",
|
||||
"admin.backups.col.started": "Erstellt",
|
||||
"admin.backups.col.kind": "Auslöser",
|
||||
"admin.backups.col.status": "Status",
|
||||
"admin.backups.col.requested_by": "Angefordert von",
|
||||
"admin.backups.col.size": "Größe",
|
||||
"admin.backups.col.rows": "Sheets",
|
||||
"admin.backups.col.actions": "Aktion",
|
||||
"admin.backups.kind.scheduled": "Geplant",
|
||||
"admin.backups.kind.on_demand": "Manuell",
|
||||
"admin.backups.status.running": "Läuft …",
|
||||
"admin.backups.status.done": "✓ Fertig",
|
||||
"admin.backups.status.failed": "✗ Fehlgeschlagen",
|
||||
"admin.backups.download": "Download",
|
||||
"admin.backups.footer.note": "Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.",
|
||||
"admin.audit.title": "Audit-Log — Paliad",
|
||||
"admin.audit.heading": "Audit-Log",
|
||||
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
|
||||
@@ -2365,6 +2492,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Abbrechen",
|
||||
"event_types.browse.selected_count": "{n} ausgewählt",
|
||||
"event_types.browse.jurisdiction.none": "Allgemein",
|
||||
"event_types.browse.jurisdiction.all": "Alle Gerichte",
|
||||
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
|
||||
"event_types.filter.all": "Alle Typen",
|
||||
"event_types.filter.untyped": "— Ohne Typ —",
|
||||
"event_types.filter.search": "Typ suchen…",
|
||||
@@ -2510,6 +2639,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.withdraw.cancel": "Abbrechen",
|
||||
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
|
||||
"approvals.withdraw.primary.label": "Termin bearbeiten",
|
||||
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
|
||||
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
|
||||
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
|
||||
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
|
||||
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
|
||||
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
|
||||
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
|
||||
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -2567,11 +2707,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.scope.my_subtree": "Mein Teilbaum",
|
||||
"views.scope.explicit": "Bestimmte Projekte",
|
||||
"views.scope.personal_only": "Nur persönliche",
|
||||
"views.horizon.next_1d": "Morgen",
|
||||
"views.horizon.next_7d": "Nächste 7 Tage",
|
||||
"views.horizon.next_14d": "Nächste 14 Tage",
|
||||
"views.horizon.next_30d": "Nächste 30 Tage",
|
||||
"views.horizon.next_90d": "Nächste 90 Tage",
|
||||
"views.horizon.next_all": "Ganze Zukunft",
|
||||
"views.horizon.past_1d": "Letzter Tag",
|
||||
"views.horizon.past_7d": "Letzte 7 Tage",
|
||||
"views.horizon.past_14d": "Letzte 14 Tage",
|
||||
"views.horizon.past_30d": "Letzte 30 Tage",
|
||||
"views.horizon.past_90d": "Letzte 90 Tage",
|
||||
"views.horizon.past_all": "Ganze Vergangenheit",
|
||||
"views.horizon.any": "Beliebig",
|
||||
"views.horizon.all": "Komplett (alle Daten)",
|
||||
"views.horizon.custom": "Benutzerdefiniert",
|
||||
@@ -2655,16 +2802,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.density": "Dichte",
|
||||
"views.bar.label.sort": "Sortierung",
|
||||
"views.bar.common.all": "Alle",
|
||||
"views.bar.time.next_7d": "7 Tage",
|
||||
"views.bar.time.next_30d": "30 Tage",
|
||||
"views.bar.time.next_90d": "90 Tage",
|
||||
"views.bar.time.past_7d": "Letzte 7 T.",
|
||||
"views.bar.time.past_30d": "Letzte 30 T.",
|
||||
"views.bar.time.past_90d": "Letzte 90 T.",
|
||||
"views.bar.time.any": "Beliebig",
|
||||
"views.bar.time.all": "Alle Zeit",
|
||||
"views.bar.time.custom": "Anpassen",
|
||||
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
|
||||
// views.bar.time.* keys retired in t-paliad-248 — the filter-bar time
|
||||
// axis now mounts the symmetric date-range picker, whose labels live
|
||||
// under date_range.horizon.* (see end of this dict). The picker reuses
|
||||
// views.bar.label.time as the closed-button prefix.
|
||||
"views.bar.personal.on": "Nur eigene",
|
||||
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
|
||||
"views.bar.approval_role.self_requested": "Eigene Anfragen",
|
||||
@@ -2704,21 +2845,26 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
|
||||
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
"nav.admin.rules": "Regeln verwalten",
|
||||
"nav.admin.rules_export": "Regel-Migrations",
|
||||
"admin.card.rules.title": "Regeln verwalten",
|
||||
"admin.card.rules.desc": "Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
|
||||
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
|
||||
// (URL change is Slice B.6); the visible labels rename. Canonical
|
||||
// `admin.procedural_events.*` aliases live after the EN block — they
|
||||
// pin the contract for when .tsx files rebind in Slice B (B.5).
|
||||
"nav.admin.rules": "Verfahrensschritte verwalten",
|
||||
"nav.admin.rules_export": "Verfahrensschritt-Migrations",
|
||||
"admin.card.rules.title": "Verfahrensschritte verwalten",
|
||||
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
|
||||
|
||||
"admin.rules.list.title": "Regeln verwalten — Paliad",
|
||||
"admin.rules.list.heading": "Regeln verwalten",
|
||||
"admin.rules.list.subtitle": "Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neue Regel",
|
||||
"admin.rules.list.title": "Verfahrensschritte verwalten — Paliad",
|
||||
"admin.rules.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.rules.list.subtitle": "Verfahrensschritte (Schriftsätze, Anhörungen, Entscheidungen, …) anlegen, bearbeiten und freigeben. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.rules.list.export": "Migrations exportieren",
|
||||
"admin.rules.tab.rules": "Regeln",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Lade…",
|
||||
"admin.rules.empty": "Keine Regeln für die gewählten Filter.",
|
||||
"admin.rules.error.load": "Konnte Regeln nicht laden.",
|
||||
"admin.rules.empty": "Keine Verfahrensschritte für die gewählten Filter.",
|
||||
"admin.rules.error.load": "Konnte Verfahrensschritte nicht laden.",
|
||||
|
||||
"admin.rules.filter.proceeding": "Verfahrenstyp",
|
||||
"admin.rules.filter.proceeding.any": "Alle",
|
||||
@@ -2729,7 +2875,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.filter.search": "Suche",
|
||||
"admin.rules.filter.search.placeholder": "Name, Submission Code, Rechtsgrundlage…",
|
||||
|
||||
"admin.rules.col.submission_code": "Submission Code / Einreichung-Kennung",
|
||||
"admin.rules.col.submission_code": "Code (Verfahrensschritt)",
|
||||
"admin.rules.col.legal_citation": "Rechtsgrundlage",
|
||||
"admin.rules.col.name": "Name",
|
||||
"admin.rules.col.proceeding": "Verfahrenstyp",
|
||||
@@ -2759,8 +2905,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.orphans.reason.manual_unbound": "Manuell entkoppelt",
|
||||
"admin.rules.orphans.resolved": "Orphan zugeordnet.",
|
||||
|
||||
"admin.rules.modal.new.title": "Neue Regel anlegen",
|
||||
"admin.rules.modal.new.body": "Eine neue Regel wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
|
||||
"admin.rules.modal.new.title": "Neuen Verfahrensschritt anlegen",
|
||||
"admin.rules.modal.new.body": "Ein neuer Verfahrensschritt wird als Draft angelegt. Bitte einen Grund (mind. 10 Zeichen) angeben — dieser wandert ins Audit-Log und beim Export in die Migration.",
|
||||
"admin.rules.modal.resolve.title": "Orphan zuordnen",
|
||||
"admin.rules.modal.resolve.body": "Bitte einen Grund (mind. 10 Zeichen) angeben. Die Regel-Verknüpfung wird sofort auf der Deadline gespeichert.",
|
||||
"admin.rules.modal.reason": "Grund",
|
||||
@@ -2775,12 +2921,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.modal.error.create": "Anlegen fehlgeschlagen.",
|
||||
"admin.rules.modal.error.resolve": "Zuordnung fehlgeschlagen.",
|
||||
|
||||
"admin.rules.edit.title": "Regel bearbeiten — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Regel laden…",
|
||||
"admin.rules.edit.breadcrumb": "← Regeln verwalten",
|
||||
"admin.rules.edit.error.bad_id": "Ungültige Regel-ID in der URL.",
|
||||
"admin.rules.edit.error.not_found": "Regel nicht gefunden.",
|
||||
"admin.rules.edit.error.load": "Konnte Regel nicht laden.",
|
||||
"admin.rules.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Verfahrensschritt laden…",
|
||||
"admin.rules.edit.breadcrumb": "← Verfahrensschritte verwalten",
|
||||
"admin.rules.edit.error.bad_id": "Ungültige Verfahrensschritt-ID in der URL.",
|
||||
"admin.rules.edit.error.not_found": "Verfahrensschritt nicht gefunden.",
|
||||
"admin.rules.edit.error.load": "Konnte Verfahrensschritt nicht laden.",
|
||||
|
||||
"admin.rules.edit.section.identity": "Identität",
|
||||
"admin.rules.edit.section.proceeding": "Verfahren & Trigger",
|
||||
@@ -2793,14 +2939,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.name": "Name (DE)",
|
||||
"admin.rules.edit.field.name_en": "Name (EN)",
|
||||
"admin.rules.edit.field.description": "Beschreibung",
|
||||
"admin.rules.edit.field.submission_code": "Submission Code / Einreichung-Kennung",
|
||||
"admin.rules.edit.field.submission_code": "Code (Verfahrensschritt-Identifikator)",
|
||||
"admin.rules.edit.field.rule_code": "Rechtsgrundlage (Kurzform)",
|
||||
"admin.rules.edit.field.legal_source": "Rechtsgrundlage (Langform)",
|
||||
"admin.rules.edit.field.proceeding": "Verfahrenstyp",
|
||||
"admin.rules.edit.field.proceeding.none": "—",
|
||||
"admin.rules.edit.field.trigger": "Trigger-Ereignis",
|
||||
"admin.rules.edit.field.trigger.none": "—",
|
||||
"admin.rules.edit.field.parent": "Parent-Regel (UUID)",
|
||||
"admin.rules.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
|
||||
"admin.rules.edit.field.concept": "Konzept (UUID)",
|
||||
"admin.rules.edit.field.sequence_order": "Reihenfolge",
|
||||
"admin.rules.edit.field.duration_value": "Dauer",
|
||||
@@ -2812,7 +2958,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.alt_rule_code": "Alt-Rule-Code",
|
||||
"admin.rules.edit.field.anchor_alt": "Alt-Anchor",
|
||||
"admin.rules.edit.field.primary_party": "Primäre Partei",
|
||||
"admin.rules.edit.field.event_type": "Event-Typ (frei)",
|
||||
"admin.rules.edit.field.event_type": "Art des Verfahrensschritts (filing / hearing / decision / order)",
|
||||
"admin.rules.edit.field.deadline_notes": "Hinweise (DE)",
|
||||
"admin.rules.edit.field.deadline_notes_en": "Hinweise (EN)",
|
||||
"admin.rules.edit.field.priority": "Priorität",
|
||||
@@ -2891,6 +3037,53 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.export.ok": "{n} Audit-Zeilen exportiert.",
|
||||
"admin.rules.export.error": "Export fehlgeschlagen.",
|
||||
"admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). Symmetric past/future chip fan
|
||||
// around an ALLES centre. Used by the filter-bar 'time' axis from
|
||||
// Slice A onwards; future slices will migrate /agenda and
|
||||
// /admin/audit-log to the same component.
|
||||
"date_range.button.label": "Zeitraum",
|
||||
"date_range.button.label.custom_range": "Von {from} bis {to}",
|
||||
"date_range.horizon.next_1d": "Morgen",
|
||||
"date_range.horizon.next_7d": "Nächste 7 Tage",
|
||||
"date_range.horizon.next_14d": "Nächste 14 Tage",
|
||||
"date_range.horizon.next_30d": "Nächste 30 Tage",
|
||||
"date_range.horizon.next_90d": "Nächste 90 Tage",
|
||||
"date_range.horizon.next_all": "Ganze Zukunft",
|
||||
"date_range.horizon.past_1d": "Letzter Tag",
|
||||
"date_range.horizon.past_7d": "Letzte 7 Tage",
|
||||
"date_range.horizon.past_14d": "Letzte 14 Tage",
|
||||
"date_range.horizon.past_30d": "Letzte 30 Tage",
|
||||
"date_range.horizon.past_90d": "Letzte 90 Tage",
|
||||
"date_range.horizon.past_all": "Ganze Vergangenheit",
|
||||
"date_range.horizon.any": "Alles",
|
||||
"date_range.horizon.custom": "Anpassen",
|
||||
"date_range.dialog.label": "Zeitraum wählen",
|
||||
"date_range.fan.past.label": "Vergangenheit",
|
||||
"date_range.fan.future.label": "Zukunft",
|
||||
"date_range.center.label": "Alles",
|
||||
"date_range.custom.from": "Von",
|
||||
"date_range.custom.to": "Bis",
|
||||
"date_range.custom.apply": "Anwenden",
|
||||
"date_range.custom.cancel": "Abbrechen",
|
||||
"date_range.custom.invalid": "Bis-Datum muss nach Von-Datum liegen.",
|
||||
"date_range.custom.invalid_format": "Datum nicht erkannt (Format JJJJ-MM-TT).",
|
||||
"date_range.custom.invalid_missing": "Bitte beide Datumsfelder ausfüllen.",
|
||||
|
||||
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
|
||||
// The values are identical to the legacy `admin.rules.*` keys above —
|
||||
// these aliases let .tsx files rebind in Slice B (B.5) without
|
||||
// touching DE/EN strings then. Adding/changing values? Update BOTH
|
||||
// sides.
|
||||
"admin.procedural_events.list.title": "Verfahrensschritte verwalten — Paliad",
|
||||
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
|
||||
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
|
||||
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
|
||||
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
|
||||
"admin.procedural_events.edit.field.event_kind": "Art des Verfahrensschritts (filing / hearing / decision / order)",
|
||||
"admin.procedural_events.edit.field.parent": "Übergeordneter Verfahrensschritt (UUID)",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -2904,6 +3097,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossary",
|
||||
"nav.gebuehrentabellen": "Fee Schedules",
|
||||
"nav.checklisten": "Checklists",
|
||||
"nav.submissions": "Submissions",
|
||||
"nav.gerichte": "Courts",
|
||||
"nav.logout": "Sign Out",
|
||||
"nav.akten": "Matters",
|
||||
@@ -3175,9 +3369,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
@@ -3297,6 +3491,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -3747,11 +3949,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "e.g. File statement of defence",
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
|
||||
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
|
||||
"deadlines.field.rule.custom_badge": "Custom",
|
||||
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
|
||||
"deadlines.field.title.default_btn": "Default title",
|
||||
"deadlines.field.title.default_fallback": "New deadline",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -3961,6 +4167,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Appointment updated",
|
||||
"event.title.appointment_deleted": "Appointment deleted",
|
||||
"event.title.appointment_project_changed": "Appointment moved",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Approval decided",
|
||||
"event.title.member_role_changed": "Team role changed",
|
||||
// 4-eye approval lifecycle (t-paliad-138).
|
||||
"event.title.deadline_approval_requested": "Approval requested",
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
@@ -4191,6 +4401,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Court",
|
||||
"projects.field.case_number": "Case number (court)",
|
||||
"projects.field.proceeding_type_id": "Proceeding type",
|
||||
"projects.field.proceeding_type": "Proceeding type",
|
||||
"projects.field.proceeding_type.unset": "(unset)",
|
||||
"projects.field.proceeding_type.hint": "Determines which submission templates show up on this proceeding.",
|
||||
"projects.field.our_side": "We represent",
|
||||
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
@@ -4277,17 +4490,66 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.tab.settings": "Settings",
|
||||
"projects.detail.export.button": "Export data",
|
||||
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
|
||||
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Please set a proceeding type first.",
|
||||
"projects.detail.settings.export.heading": "Export data",
|
||||
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
|
||||
"projects.detail.settings.archive.heading": "Archive project",
|
||||
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
|
||||
"projects.detail.settings.archive.cta": "Open edit dialog",
|
||||
"projects.detail.submissions.empty": "No submission templates are configured yet.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
"projects.detail.submissions.col.party": "Party",
|
||||
"projects.detail.submissions.col.source": "Legal basis",
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.action.edit": "Edit",
|
||||
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Edit submission — Paliad",
|
||||
"submissions.draft.back": "← Back to project",
|
||||
"submissions.draft.loading": "Loading…",
|
||||
"submissions.draft.notfound": "Submission not found or insufficient access.",
|
||||
"submissions.draft.action.export": "Export as .docx",
|
||||
"submissions.draft.action.new": "+ New draft",
|
||||
"submissions.draft.action.delete": "Delete",
|
||||
"submissions.draft.switcher.label": "Draft",
|
||||
"submissions.draft.name.placeholder": "Name of this draft",
|
||||
"submissions.draft.preview.title": "Preview",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
"submissions.index.subtitle": "Your submission drafts across every visible project.",
|
||||
"submissions.index.loading": "Loading…",
|
||||
"submissions.index.empty": "No drafts yet. Start a new draft — with or without a project.",
|
||||
"submissions.index.empty.cta": "+ New draft",
|
||||
"submissions.index.error": "Could not load submissions.",
|
||||
"submissions.index.col.project": "Project",
|
||||
"submissions.index.col.submission": "Submission",
|
||||
"submissions.index.col.draft": "Draft",
|
||||
"submissions.index.col.updated": "Last updated",
|
||||
"submissions.index.action.new": "+ New draft",
|
||||
"submissions.new.title": "New submission — Paliad",
|
||||
"submissions.new.back": "← Back to drafts",
|
||||
"submissions.new.heading": "New submission",
|
||||
"submissions.new.subtitle": "Pick a template. Optionally bind it to a project — otherwise all variables are filled manually.",
|
||||
"submissions.new.search.placeholder": "Search by name, code or statute…",
|
||||
"submissions.new.loading": "Loading…",
|
||||
"submissions.new.error": "Could not load catalog.",
|
||||
"submissions.new.col.name": "Submission",
|
||||
"submissions.new.col.party": "Party",
|
||||
"submissions.new.col.source": "Legal source",
|
||||
"submissions.new.col.actions": "Start draft",
|
||||
"submissions.new.empty.filtered": "No submissions match the filters. Reset them to see the full catalog.",
|
||||
"submissions.new.picker.title": "Pick a project",
|
||||
"submissions.new.picker.placeholder": "Search project (title or reference)…",
|
||||
"submissions.new.picker.loading": "Loading projects…",
|
||||
"submissions.new.picker.empty": "No visible projects.",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
@@ -4400,9 +4662,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Progress",
|
||||
"projects.detail.checklisten.col.created": "Created",
|
||||
"projects.detail.checklisten.hint.prefix": "Instances are created on the template page under ",
|
||||
"projects.detail.checklisten.hint.prefix": "Templates are created and edited on the ",
|
||||
"projects.detail.checklisten.hint.link": "Checklists",
|
||||
"projects.detail.checklisten.hint.suffix": ".",
|
||||
"projects.detail.checklisten.hint.suffix": " page.",
|
||||
"projects.detail.checklisten.add": "Add checklist",
|
||||
"projects.detail.checklisten.add.search": "Search template…",
|
||||
"projects.detail.checklisten.add.empty_pick": "No matching templates.",
|
||||
"projects.detail.checklisten.add.created": "Checklist added.",
|
||||
"projects.detail.checklisten.add.error": "Could not create checklist.",
|
||||
"projects.detail.delete": "Archive project",
|
||||
"projects.detail.delete.confirm.title": "Archive project?",
|
||||
"projects.detail.delete.confirm.body": "The project will be archived. It cannot be directly restored.",
|
||||
@@ -4449,6 +4716,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.team.invite.hint": "User not found?",
|
||||
"projects.detail.team.invite.hint_email": "No one with that email.",
|
||||
"projects.detail.team.invite.cta": "Invite",
|
||||
// t-paliad-231 — pure-client mailto: button on the Team tab.
|
||||
"projects.team.mailto.label": "Mail to selection",
|
||||
"projects.team.mailto.empty": "Select at least one member",
|
||||
"projects.team.mailto.count": "{n} selected",
|
||||
"projects.team.mailto.select_all": "Select all visible",
|
||||
"projects.team.mailto.select_row": "Select member",
|
||||
"projects.view.tree": "Tree view",
|
||||
"projects.tree.toggle": "Expand / collapse",
|
||||
"projects.tree.loading": "Loading tree…",
|
||||
@@ -4885,8 +5158,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
|
||||
"paliadin.error.connection_lost": "Connection lost.",
|
||||
"paliadin.error.upstream": "Send failed.",
|
||||
"paliadin.error.upstream_silence": "Paliadin went silent — closing the connection.",
|
||||
"paliadin.late.waiting": "Will fill in the response when it arrives …",
|
||||
"paliadin.late.checking": "Connection lost — Paliadin may still be thinking. Fetching fresh answer …",
|
||||
"paliadin.late.lost": "Answer couldn't be delivered — please ask again.",
|
||||
"paliadin.late.marker": "late",
|
||||
"paliadin.thinking": "Paliadin is thinking",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "What can I help you with?",
|
||||
@@ -5004,6 +5282,20 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"inbox.empty.admin_nudge.title": "No approval policies configured yet?",
|
||||
"inbox.empty.admin_nudge.body": "Set which lifecycle events require 4-eye review.",
|
||||
"inbox.empty.admin_nudge.cta": "Configure approval policies",
|
||||
"inbox.title.feed": "Inbox — Paliad",
|
||||
"inbox.heading.feed": "Inbox",
|
||||
"inbox.subtitle.feed": "Updates on your projects and open approvals.",
|
||||
"inbox.action.mark_all_seen": "Mark all as read",
|
||||
"inbox.action.open": "Open",
|
||||
"inbox.empty.feed": "No updates in the last 30 days.",
|
||||
"views.bar.label.unread_only": "Read state",
|
||||
"views.bar.unread_only.on": "Unread only",
|
||||
"views.bar.unread_only.off": "All",
|
||||
"views.bar.label.inbox_focus": "Show",
|
||||
"views.bar.inbox_focus.alles": "Everything",
|
||||
"views.bar.inbox_focus.genehmigungen": "Approvals only",
|
||||
"views.bar.inbox_focus.plus_termine": "+ Appointments",
|
||||
"views.bar.inbox_focus.plus_fristen": "+ Deadlines",
|
||||
"deadlines.form.approval_hint": "4-eye review required",
|
||||
"appointments.form.approval_hint": "4-eye review required",
|
||||
"admin.email_templates.title": "Email Templates — Paliad",
|
||||
@@ -5115,6 +5407,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Admin audit log (t-paliad-071)
|
||||
"nav.admin.audit": "Audit Log",
|
||||
"nav.admin.partner_units": "Partner Units",
|
||||
|
||||
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||
"nav.admin.backups": "Backups",
|
||||
"admin.backups.title": "Backups — Paliad",
|
||||
"admin.backups.heading": "Backups",
|
||||
"admin.backups.subtitle": "Full snapshots of all data — manual or scheduled.",
|
||||
"admin.backups.run_now": "Run backup now",
|
||||
"admin.backups.running": "Running …",
|
||||
"admin.backups.success": "Backup created successfully.",
|
||||
"admin.backups.empty": "No backups yet.",
|
||||
"admin.backups.loading": "Loading …",
|
||||
"admin.backups.col.started": "Started",
|
||||
"admin.backups.col.kind": "Trigger",
|
||||
"admin.backups.col.status": "Status",
|
||||
"admin.backups.col.requested_by": "Requested by",
|
||||
"admin.backups.col.size": "Size",
|
||||
"admin.backups.col.rows": "Sheets",
|
||||
"admin.backups.col.actions": "Action",
|
||||
"admin.backups.kind.scheduled": "Scheduled",
|
||||
"admin.backups.kind.on_demand": "Manual",
|
||||
"admin.backups.status.running": "Running …",
|
||||
"admin.backups.status.done": "✓ Done",
|
||||
"admin.backups.status.failed": "✗ Failed",
|
||||
"admin.backups.download": "Download",
|
||||
"admin.backups.footer.note": "Scheduled backups land in a later slice. Manual backups are available now.",
|
||||
"admin.audit.title": "Audit Log — Paliad",
|
||||
"admin.audit.heading": "Audit Log",
|
||||
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
|
||||
@@ -5214,6 +5531,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Cancel",
|
||||
"event_types.browse.selected_count": "{n} selected",
|
||||
"event_types.browse.jurisdiction.none": "Any",
|
||||
"event_types.browse.jurisdiction.all": "All courts",
|
||||
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
|
||||
"event_types.filter.all": "All types",
|
||||
"event_types.filter.untyped": "— Untyped —",
|
||||
"event_types.filter.search": "Search type…",
|
||||
@@ -5359,6 +5678,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.withdraw.cancel": "Cancel",
|
||||
"approvals.withdraw.modal.title": "Withdraw approval request?",
|
||||
"approvals.withdraw.primary.label": "Edit event",
|
||||
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
|
||||
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
|
||||
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
|
||||
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
|
||||
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
|
||||
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
|
||||
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
|
||||
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
@@ -5416,11 +5746,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.scope.my_subtree": "My subtree",
|
||||
"views.scope.explicit": "Specific projects",
|
||||
"views.scope.personal_only": "Personal only",
|
||||
"views.horizon.next_1d": "Tomorrow",
|
||||
"views.horizon.next_7d": "Next 7 days",
|
||||
"views.horizon.next_14d": "Next 14 days",
|
||||
"views.horizon.next_30d": "Next 30 days",
|
||||
"views.horizon.next_90d": "Next 90 days",
|
||||
"views.horizon.next_all": "All future",
|
||||
"views.horizon.past_1d": "Last day",
|
||||
"views.horizon.past_7d": "Last 7 days",
|
||||
"views.horizon.past_14d": "Last 14 days",
|
||||
"views.horizon.past_30d": "Last 30 days",
|
||||
"views.horizon.past_90d": "Last 90 days",
|
||||
"views.horizon.past_all": "All past",
|
||||
"views.horizon.any": "Any",
|
||||
"views.horizon.all": "All-time",
|
||||
"views.horizon.custom": "Custom",
|
||||
@@ -5503,16 +5840,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.density": "Density",
|
||||
"views.bar.label.sort": "Sort",
|
||||
"views.bar.common.all": "All",
|
||||
"views.bar.time.next_7d": "7 days",
|
||||
"views.bar.time.next_30d": "30 days",
|
||||
"views.bar.time.next_90d": "90 days",
|
||||
"views.bar.time.past_7d": "Past 7d",
|
||||
"views.bar.time.past_30d": "Past 30 d.",
|
||||
"views.bar.time.past_90d": "Past 90 d.",
|
||||
"views.bar.time.any": "Any",
|
||||
"views.bar.time.all": "All time",
|
||||
"views.bar.time.custom": "Custom",
|
||||
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
|
||||
// views.bar.time.* keys retired in t-paliad-248 — see the DE block
|
||||
// for context. The filter-bar time axis now mounts the symmetric
|
||||
// date-range picker whose labels live under date_range.horizon.*.
|
||||
"views.bar.personal.on": "Mine only",
|
||||
"views.bar.approval_role.approver_eligible": "To approve",
|
||||
"views.bar.approval_role.self_requested": "My requests",
|
||||
@@ -5552,21 +5882,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.save.error.network": "Network error — please retry.",
|
||||
|
||||
// t-paliad-192 Slice 11b — Admin rule-editor UI.
|
||||
"nav.admin.rules": "Manage Rules",
|
||||
"nav.admin.rules_export": "Rule Migrations",
|
||||
"admin.card.rules.title": "Manage Rules",
|
||||
"admin.card.rules.desc": "Author, edit and publish deadline rules. Audit log, preview, migration export.",
|
||||
// t-paliad-262 Slice A — "Rule" relabelled as "Procedural event".
|
||||
"nav.admin.rules": "Manage procedural events",
|
||||
"nav.admin.rules_export": "Procedural-event migrations",
|
||||
"admin.card.rules.title": "Manage procedural events",
|
||||
"admin.card.rules.desc": "Author, edit and publish procedural-event templates. Audit log, preview, migration export.",
|
||||
|
||||
"admin.rules.list.title": "Manage Rules — Paliad",
|
||||
"admin.rules.list.heading": "Manage Rules",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish deadline rules. Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New Rule",
|
||||
"admin.rules.list.title": "Manage procedural events — Paliad",
|
||||
"admin.rules.list.heading": "Manage procedural events",
|
||||
"admin.rules.list.subtitle": "Author, edit and publish procedural events (filings, hearings, decisions, …). Lifecycle: draft → published → archived.",
|
||||
"admin.rules.list.new": "+ New procedural event",
|
||||
"admin.rules.list.export": "Export migrations",
|
||||
"admin.rules.tab.rules": "Rules",
|
||||
"admin.rules.tab.orphans": "Orphans",
|
||||
"admin.rules.loading": "Loading…",
|
||||
"admin.rules.empty": "No rules for the chosen filters.",
|
||||
"admin.rules.error.load": "Could not load rules.",
|
||||
"admin.rules.empty": "No procedural events for the chosen filters.",
|
||||
"admin.rules.error.load": "Could not load procedural events.",
|
||||
|
||||
"admin.rules.filter.proceeding": "Proceeding type",
|
||||
"admin.rules.filter.proceeding.any": "Any",
|
||||
@@ -5577,7 +5908,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.filter.search": "Search",
|
||||
"admin.rules.filter.search.placeholder": "Name, submission code, legal citation…",
|
||||
|
||||
"admin.rules.col.submission_code": "Submission code",
|
||||
"admin.rules.col.submission_code": "Code (procedural event)",
|
||||
"admin.rules.col.legal_citation": "Legal citation",
|
||||
"admin.rules.col.name": "Name",
|
||||
"admin.rules.col.proceeding": "Proceeding type",
|
||||
@@ -5607,8 +5938,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.orphans.reason.manual_unbound": "Manually unbound",
|
||||
"admin.rules.orphans.resolved": "Orphan resolved.",
|
||||
|
||||
"admin.rules.modal.new.title": "Create new rule",
|
||||
"admin.rules.modal.new.body": "A new rule will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
|
||||
"admin.rules.modal.new.title": "Create new procedural event",
|
||||
"admin.rules.modal.new.body": "A new procedural event will be created as a draft. Please supply a reason (≥10 chars) — recorded in the audit log and exported into the migration file.",
|
||||
"admin.rules.modal.resolve.title": "Resolve orphan",
|
||||
"admin.rules.modal.resolve.body": "Please supply a reason (≥10 chars). The rule binding is persisted immediately on the deadline.",
|
||||
"admin.rules.modal.reason": "Reason",
|
||||
@@ -5623,12 +5954,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.modal.error.create": "Creation failed.",
|
||||
"admin.rules.modal.error.resolve": "Resolution failed.",
|
||||
|
||||
"admin.rules.edit.title": "Edit Rule — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Loading rule…",
|
||||
"admin.rules.edit.breadcrumb": "← Manage Rules",
|
||||
"admin.rules.edit.error.bad_id": "Invalid rule id in URL.",
|
||||
"admin.rules.edit.error.not_found": "Rule not found.",
|
||||
"admin.rules.edit.error.load": "Could not load rule.",
|
||||
"admin.rules.edit.title": "Edit procedural event — Paliad",
|
||||
"admin.rules.edit.heading.loading": "Loading procedural event…",
|
||||
"admin.rules.edit.breadcrumb": "← Manage procedural events",
|
||||
"admin.rules.edit.error.bad_id": "Invalid procedural-event id in URL.",
|
||||
"admin.rules.edit.error.not_found": "Procedural event not found.",
|
||||
"admin.rules.edit.error.load": "Could not load procedural event.",
|
||||
|
||||
"admin.rules.edit.section.identity": "Identity",
|
||||
"admin.rules.edit.section.proceeding": "Proceeding & Trigger",
|
||||
@@ -5641,14 +5972,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.name": "Name (DE)",
|
||||
"admin.rules.edit.field.name_en": "Name (EN)",
|
||||
"admin.rules.edit.field.description": "Description",
|
||||
"admin.rules.edit.field.submission_code": "Submission code",
|
||||
"admin.rules.edit.field.submission_code": "Code (procedural-event identifier)",
|
||||
"admin.rules.edit.field.rule_code": "Legal citation (short form)",
|
||||
"admin.rules.edit.field.legal_source": "Legal citation (long form)",
|
||||
"admin.rules.edit.field.proceeding": "Proceeding type",
|
||||
"admin.rules.edit.field.proceeding.none": "—",
|
||||
"admin.rules.edit.field.trigger": "Trigger event",
|
||||
"admin.rules.edit.field.trigger.none": "—",
|
||||
"admin.rules.edit.field.parent": "Parent rule (UUID)",
|
||||
"admin.rules.edit.field.parent": "Parent procedural event (UUID)",
|
||||
"admin.rules.edit.field.concept": "Concept (UUID)",
|
||||
"admin.rules.edit.field.sequence_order": "Order",
|
||||
"admin.rules.edit.field.duration_value": "Duration",
|
||||
@@ -5660,7 +5991,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.edit.field.alt_rule_code": "Alt rule code",
|
||||
"admin.rules.edit.field.anchor_alt": "Alt anchor",
|
||||
"admin.rules.edit.field.primary_party": "Primary party",
|
||||
"admin.rules.edit.field.event_type": "Event type (free)",
|
||||
"admin.rules.edit.field.event_type": "Procedural-event kind (filing / hearing / decision / order)",
|
||||
"admin.rules.edit.field.deadline_notes": "Notes (DE)",
|
||||
"admin.rules.edit.field.deadline_notes_en": "Notes (EN)",
|
||||
"admin.rules.edit.field.priority": "Priority",
|
||||
@@ -5739,6 +6070,48 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.rules.export.ok": "{n} audit rows exported.",
|
||||
"admin.rules.export.error": "Export failed.",
|
||||
"admin.rules.export.no_pending": "No pending audit rows to export.",
|
||||
|
||||
// Date-range picker (t-paliad-248). See DE block above for details.
|
||||
"date_range.button.label": "Time range",
|
||||
"date_range.button.label.custom_range": "From {from} to {to}",
|
||||
"date_range.horizon.next_1d": "Tomorrow",
|
||||
"date_range.horizon.next_7d": "Next 7 days",
|
||||
"date_range.horizon.next_14d": "Next 14 days",
|
||||
"date_range.horizon.next_30d": "Next 30 days",
|
||||
"date_range.horizon.next_90d": "Next 90 days",
|
||||
"date_range.horizon.next_all": "All future",
|
||||
"date_range.horizon.past_1d": "Last day",
|
||||
"date_range.horizon.past_7d": "Last 7 days",
|
||||
"date_range.horizon.past_14d": "Last 14 days",
|
||||
"date_range.horizon.past_30d": "Last 30 days",
|
||||
"date_range.horizon.past_90d": "Last 90 days",
|
||||
"date_range.horizon.past_all": "All past",
|
||||
"date_range.horizon.any": "All",
|
||||
"date_range.horizon.custom": "Customize",
|
||||
"date_range.dialog.label": "Choose time range",
|
||||
"date_range.fan.past.label": "Past",
|
||||
"date_range.fan.future.label": "Future",
|
||||
"date_range.center.label": "All",
|
||||
"date_range.custom.from": "From",
|
||||
"date_range.custom.to": "To",
|
||||
"date_range.custom.apply": "Apply",
|
||||
"date_range.custom.cancel": "Cancel",
|
||||
"date_range.custom.invalid": "End date must be strictly after start date.",
|
||||
"date_range.custom.invalid_format": "Date not recognised (format YYYY-MM-DD).",
|
||||
"date_range.custom.invalid_missing": "Please fill in both date fields.",
|
||||
|
||||
// t-paliad-262 Slice A — canonical `procedural_event` i18n contract.
|
||||
// Mirrors the DE block; values identical to the legacy
|
||||
// `admin.rules.*` keys. Adding/changing values? Update BOTH sides.
|
||||
"admin.procedural_events.list.title": "Manage procedural events — Paliad",
|
||||
"admin.procedural_events.list.heading": "Manage procedural events",
|
||||
"admin.procedural_events.list.new": "+ New procedural event",
|
||||
"admin.procedural_events.col.code": "Code (procedural event)",
|
||||
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
|
||||
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
|
||||
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",
|
||||
"admin.procedural_events.edit.field.event_kind": "Procedural-event kind (filing / hearing / decision / order)",
|
||||
"admin.procedural_events.edit.field.parent": "Parent procedural event (UUID)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,37 +6,45 @@ import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { openApprovalEditModal } from "./components/approval-edit-modal";
|
||||
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
// /inbox client — t-paliad-249 unified inbox feed.
|
||||
//
|
||||
// The bar owns every axis the old tab UI exposed plus more:
|
||||
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
|
||||
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
|
||||
// - approval_status: chip cluster (default: pending)
|
||||
// - approval_entity_type: chip pair (Frist / Termin)
|
||||
// - time: chip cluster (Any default)
|
||||
// - density: comfortable / compact
|
||||
// - sort: date asc / desc
|
||||
// The bar exposes:
|
||||
// - inbox_focus: coarse Alles / Genehmigungen / +Termine / +Fristen
|
||||
// - unread_only: Nur ungelesen / Alle (default: ungelesen)
|
||||
// - time: last 30 days default; chip cluster + custom range
|
||||
// - project: single-select autocomplete from visible projects
|
||||
// - approval_viewer_role: Zur Genehmigung / Eigene / Alle sichtbaren
|
||||
// - approval_status / approval_entity_type / project_event_kind: power-user overrides
|
||||
// - sort / density: newest first default
|
||||
//
|
||||
// Row rendering: shape-list.ts with row_action="approve" stamps the
|
||||
// inbox markup (entity title, diff, approve/reject/revoke buttons).
|
||||
// We wire action click handlers in onResult and refresh through the
|
||||
// bar handle.
|
||||
// Row rendering: shape-list.ts with row_action="inbox" dispatches per
|
||||
// row.kind. Approval rows keep approve/reject/revoke; project_event
|
||||
// rows render compact with an Öffnen link.
|
||||
|
||||
const INBOX_AXES: AxisKey[] = [
|
||||
"inbox_focus",
|
||||
"unread_only",
|
||||
"time",
|
||||
"project",
|
||||
"approval_viewer_role",
|
||||
"approval_status",
|
||||
"approval_entity_type",
|
||||
"density",
|
||||
"project_event_kind",
|
||||
"sort",
|
||||
"density",
|
||||
];
|
||||
|
||||
// Last paint's newest row timestamp — used to pin mark-all-seen so a
|
||||
// second tab can't race the cursor past items the user hasn't seen.
|
||||
let newestVisibleAt: string | null = null;
|
||||
|
||||
let bar: BarHandle | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
applyLegacyTabRedirect();
|
||||
wireMarkAllSeen();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
@@ -105,15 +113,25 @@ function paint(
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
results.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.empty.pending_mine");
|
||||
empty.textContent = t("inbox.empty.feed");
|
||||
newestVisibleAt = null;
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
empty.style.display = "none";
|
||||
|
||||
// Remember the newest timestamp so mark-all-seen can pin the cursor
|
||||
// to it (race-safety: a second tab adding a row between this paint
|
||||
// and the click won't get wiped out).
|
||||
newestVisibleAt = result.rows.reduce<string | null>((acc, r) => {
|
||||
if (!acc) return r.event_date;
|
||||
return r.event_date > acc ? r.event_date : acc;
|
||||
}, null);
|
||||
|
||||
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
||||
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
||||
// RenderSpec sets row_action="inbox" so we get the unified dispatch
|
||||
// (approval rows + project_event rows).
|
||||
renderListShape(results, result.rows, render);
|
||||
|
||||
// Wire action handlers on the freshly stamped DOM. The action
|
||||
@@ -122,6 +140,38 @@ function paint(
|
||||
wireApprovalActions(results);
|
||||
}
|
||||
|
||||
// wireMarkAllSeen wires the page-header "Alles als gelesen markieren"
|
||||
// button. POSTs the newest visible row's timestamp as `up_to` so a
|
||||
// stale second tab can't rewind anyone else's cursor; on success the
|
||||
// bar refreshes (rows newer than now disappear under unread_only) and
|
||||
// the sidebar badge re-counts.
|
||||
function wireMarkAllSeen(): void {
|
||||
const btn = document.getElementById("inbox-mark-all-seen") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const body = newestVisibleAt ? JSON.stringify({ up_to: newestVisibleAt }) : "{}";
|
||||
const r = await fetch("/api/inbox/mark-all-seen", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
if (!r.ok) {
|
||||
alert(t("approvals.error.internal"));
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
// Late-response polling (t-paliad-235 rewrite).
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
// When the SSE stream closes mid-turn with an error event, the bubble
|
||||
// can't tell from the wire whether (a) the upstream is still finishing
|
||||
// the turn and we just lost transport, or (b) the upstream is truly
|
||||
// dead.
|
||||
//
|
||||
// This module hits the dispatching recovery endpoint
|
||||
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
|
||||
//
|
||||
// - aichat backend → asks aichat via its conversation API whether
|
||||
// the turn actually completed upstream
|
||||
// - legacy backend → reads the local row (paliad's filesystem
|
||||
// janitor patches it when claude writes the
|
||||
// response file late)
|
||||
//
|
||||
// The endpoint returns:
|
||||
//
|
||||
// recovery_state="recovered" → response is in the payload, render it
|
||||
// recovery_state="pending" → keep polling
|
||||
// recovery_state="lost" → upstream is truly gone, give up
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
@@ -28,6 +37,10 @@ export interface LatePollOptions {
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
// onLost — backend confirmed the turn is unrecoverable. Caller should
|
||||
// swap the bubble copy to the "verloren" string. Distinct from
|
||||
// onGiveUp (which fires only on the local timeout).
|
||||
onLost?: () => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +48,20 @@ export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
interface RecoverResponse {
|
||||
turn_id: string;
|
||||
started_at: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
recovery_state: "recovered" | "pending" | "lost";
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
const body = (await r.json()) as RecoverResponse;
|
||||
if (body.recovery_state === "recovered" && body.response) {
|
||||
opts.onLateResponse(toLateTurn(body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
if (body.recovery_state === "lost") {
|
||||
opts.onLost?.();
|
||||
return;
|
||||
}
|
||||
// pending — keep polling
|
||||
} else if (r.status === 404) {
|
||||
// Row gone — give up. Different signal from `lost`: a missing row
|
||||
// is a paliad-side bookkeeping problem; aichat may still have the
|
||||
// answer but we can't surface it without the row.
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
// the dispatch endpoint on the very first tick (gives the upstream a
|
||||
// moment to actually settle the row after the stream drop).
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLateTurn(body: RecoverResponse): LateTurn {
|
||||
return {
|
||||
turn_id: body.turn_id,
|
||||
response: body.response,
|
||||
error_code: body.error_code,
|
||||
finished_at: body.finished_at,
|
||||
duration_ms: body.duration_ms,
|
||||
used_tools: body.used_tools ?? [],
|
||||
rows_seen: body.rows_seen ?? [],
|
||||
chip_count: body.chip_count ?? 0,
|
||||
classifier_tag: body.classifier_tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
startWidgetThinking(placeholder);
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateWidgetThinking(placeholder, elapsed);
|
||||
});
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
if (typeof data.delta === "string" && data.delta) {
|
||||
// Streamed delta (aichat backend) — append.
|
||||
stopWidgetThinking(placeholder);
|
||||
fullText += data.delta;
|
||||
setBubbleText(placeholder, fullText);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot full-text payload.
|
||||
fullText = String(data.text || "");
|
||||
stopWidgetThinking(placeholder);
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopWidgetThinking(placeholder);
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
stopWidgetThinking(placeholder);
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function startWidgetThinking(bubble: HTMLElement): void {
|
||||
if (bubble.querySelector(".paliadin-widget-thinking")) return;
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// the canonical "denkt nach" signal.
|
||||
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
const node = document.createElement("div");
|
||||
node.className = "paliadin-widget-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-widget-thinking-label"></span>
|
||||
<span class="paliadin-widget-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-widget-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
updateWidgetThinking(bubble, 0);
|
||||
}
|
||||
|
||||
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
|
||||
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
|
||||
}
|
||||
}
|
||||
|
||||
function stopWidgetThinking(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-widget-thinking")?.remove();
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLost(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-widget-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-widget-bubble--lost");
|
||||
setBubbleText(bubble, t("paliadin.late.lost"));
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
|
||||
@@ -3,16 +3,25 @@ import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
|
||||
// t-paliad-235).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// State machine: empty → typing → sending → thinking → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
|
||||
//
|
||||
// `content` events from the aichat backend arrive as incremental
|
||||
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
|
||||
// no typewriter simulation needed. Legacy backends still emit a single
|
||||
// `{text: "..."}` payload and we fall back to the typewriter for that
|
||||
// shape.
|
||||
//
|
||||
// `thinking` events fire while the upstream is alive but hasn't
|
||||
// produced content yet (or stalled mid-stream); the bubble renders a
|
||||
// pulse + counter so the user can SEE the chat is still working.
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
// Show the thinking pulse immediately — the placeholder text already
|
||||
// says "denkt nach", but the visible pulse + counter is the live
|
||||
// proof-of-life signal m needs to trust that the chat is working.
|
||||
startThinkingIndicator(placeholder);
|
||||
// Reset the streamed accumulator for this turn.
|
||||
placeholder.dataset.fullText = "";
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") {
|
||||
elapsed = data.elapsed_seconds;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateThinkingIndicator(placeholder, elapsed);
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const delta = typeof data.delta === "string" ? data.delta : "";
|
||||
if (delta) {
|
||||
// Aichat streaming path — accumulate the delta into the bubble.
|
||||
stopThinkingIndicator(placeholder);
|
||||
const current = placeholder.dataset.fullText ?? "";
|
||||
const next = current + delta;
|
||||
placeholder.dataset.fullText = next;
|
||||
writeStreamedText(placeholder, next);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot path — full body in `text`.
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
stopThinkingIndicator(placeholder);
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopThinkingIndicator(placeholder);
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
@@ -210,12 +247,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
stopThinkingIndicator(placeholder);
|
||||
// Honest copy: we don't claim "nachgereicht" because the recovery
|
||||
// path may report "lost". Frame it as "checking" while we ask the
|
||||
// backend whether the turn actually completed upstream.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
errText + " " + t("paliadin.late.checking");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// thinking indicator — proof-of-life pulse + elapsed counter
|
||||
// =============================================================================
|
||||
|
||||
function startThinkingIndicator(bubble: HTMLElement): void {
|
||||
// Append a thinking node next to the bubble text (sibling, so the
|
||||
// typewriter rewriting text content doesn't clobber it). The node
|
||||
// shows a pulse dot + the elapsed counter.
|
||||
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (node) return; // already running
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// now the canonical "denkt nach" signal. Leaving the text in place
|
||||
// would render the same phrase twice.
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
node = document.createElement("div");
|
||||
node.className = "paliadin-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-thinking-label"></span>
|
||||
<span class="paliadin-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
// Initial 0s — replaced as soon as a thinking event arrives or our
|
||||
// local ticker fires.
|
||||
updateThinkingIndicator(bubble, 0);
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function stopThinkingIndicator(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-thinking")?.remove();
|
||||
}
|
||||
|
||||
function formatThinkingSeconds(s: number): string {
|
||||
if (s < 0) s = 0;
|
||||
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
|
||||
}
|
||||
|
||||
// writeStreamedText fills the bubble with raw text as it accumulates.
|
||||
// Cheaper than the typewriter — we already have the real cadence from
|
||||
// the wire, no need to simulate it.
|
||||
function writeStreamedText(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (!node) return;
|
||||
node.textContent = text;
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (stream) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// Server emits SSE error events as JSON `{code, message}`. Map known
|
||||
// codes to localised, user-friendly text; fall through to a generic
|
||||
// "connection lost" for anything we don't recognise (including raw
|
||||
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
|
||||
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
// startLatePoll registers the recovery-endpoint poller for one errored
|
||||
// turn. When the row gains a response we swap the bubble's content +
|
||||
// drop the error class + retroactively replace the history entry
|
||||
// (which was never written for the failed turn — append now so reload
|
||||
// renders the late reply). When the backend confirms the turn is
|
||||
// "lost", we swap the bubble to the honest "verloren" copy.
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyLostResponse(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--lost");
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (node) node.textContent = t("paliadin.late.lost");
|
||||
}
|
||||
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { t, tDyn } from "./i18n";
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// Shared logic for the Project form rendered by ProjectFormFields.tsx.
|
||||
// Used by /projects/new and the edit modal on /projects/{id}.
|
||||
|
||||
export interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active fristenrechner-category proceeding
|
||||
// types — the only set a project may bind to (mig 087/088 + service
|
||||
// validation guard `validateProceedingTypeCategory`). Cached at module
|
||||
// level so the page only pays for one fetch even when both the new-
|
||||
// project page and the edit modal exercise the picker.
|
||||
export async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectMini {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -136,6 +165,34 @@ export function wireTypeChange() {
|
||||
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
|
||||
}
|
||||
|
||||
// populateProceedingTypeSelect fills #project-proceeding-type-id with one
|
||||
// option per fristenrechner-category proceeding type, ordered by `code`
|
||||
// (so the user scans `de.*`, `dpma.*`, `epa.*`, `upc.*` in stable
|
||||
// jurisdiction-grouped order). The first option is the empty "unset"
|
||||
// choice already in the markup; this helper only appends rows below it.
|
||||
// Idempotent — clearing rows[1..] on re-call so a re-open of the edit
|
||||
// modal doesn't double-render the list.
|
||||
export async function populateProceedingTypeSelect(): Promise<void> {
|
||||
const sel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const rows = await loadProceedingTypes();
|
||||
rows.sort((a, b) => a.code.localeCompare(b.code));
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
const isEN = getLang() === "en";
|
||||
for (const row of rows) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(row.id);
|
||||
const label = isEN && row.name_en ? row.name_en : row.name;
|
||||
opt.textContent = `${label} (${row.code})`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Honour a pre-selection value that prefillForm wrote before the
|
||||
// option set existed. dataset.preselect is set to "" or the saved id;
|
||||
// restoring it here keeps the edit modal's saved value visible.
|
||||
const preselect = sel.dataset.preselect;
|
||||
if (preselect !== undefined) sel.value = preselect;
|
||||
}
|
||||
|
||||
// readPayload collects the form's current values into a CreateProjectInput /
|
||||
// UpdateProjectInput compatible JSON payload. Returns null + sets msg when
|
||||
// title is missing.
|
||||
@@ -208,6 +265,22 @@ export function readPayload(
|
||||
stringField("project-court", "court");
|
||||
stringField("project-case-number", "case_number");
|
||||
|
||||
// Proceeding type — optional picker. Per t-paliad-232, an empty
|
||||
// pick simply omits the key from the payload (create: column stays
|
||||
// NULL; edit: server's `omitempty` skips the SET). Clearing a
|
||||
// previously-set value isn't supported in this slice; once bound,
|
||||
// a project's proceeding type can be swapped but not unset from
|
||||
// the form. The server's validateProceedingTypeCategory backs the
|
||||
// selected id with a category check.
|
||||
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (ptSel) {
|
||||
const v = ptSel.value.trim();
|
||||
if (v) {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n)) payload.proceeding_type_id = n;
|
||||
}
|
||||
}
|
||||
|
||||
// Client Role (DB column: our_side) — case-only after t-paliad-222.
|
||||
// The select uses "" for the unset option; the service maps empty
|
||||
// string to NULL via nullableOurSide.
|
||||
@@ -259,6 +332,16 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
|
||||
// Proceeding-type picker — populated lazily by populateProceedingTypeSelect.
|
||||
// Set the value here even if the options haven't arrived yet; the post-
|
||||
// populate render runs ApplyProceedingTypeValue to re-select the saved id
|
||||
// once the option exists.
|
||||
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (ptSel) {
|
||||
const v = p.proceeding_type_id == null ? "" : String(p.proceeding_type_id);
|
||||
ptSel.dataset.preselect = v;
|
||||
ptSel.value = v;
|
||||
}
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import {
|
||||
wireTypeChange,
|
||||
prefillForm,
|
||||
readPayload,
|
||||
populateProceedingTypeSelect,
|
||||
loadProceedingTypes as loadProceedingTypesShared,
|
||||
} from "./project-form";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -139,6 +143,11 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was saved in
|
||||
// Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
@@ -172,7 +181,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -184,6 +194,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -211,6 +222,9 @@ interface ChecklistTemplateSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE?: string;
|
||||
descriptionEN?: string;
|
||||
regime?: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
@@ -236,6 +250,12 @@ let attachedUnits: AttachedUnit[] = [];
|
||||
let allUnits: { id: string; name: string; office: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
|
||||
|
||||
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
|
||||
// mailto: button on the Team tab. Pure client state, wiped on page
|
||||
// navigation. Pruned to currently-visible user_ids on every renderTeam
|
||||
// so removed/filtered-out members don't ride along in the next mailto.
|
||||
const selectedMailUserIDs: Set<string> = new Set();
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
@@ -377,18 +397,26 @@ function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
|
||||
// horizons that show up on the Verlauf bar. Forward-looking horizons
|
||||
// (next_*) are absent on this surface — the timePresets override hides
|
||||
// them — but the function tolerates them for forward-compatibility with
|
||||
// the SmartTimeline redesign.
|
||||
// the SmartTimeline redesign. Open-ended ranges (next_all / past_all)
|
||||
// leave the matching bound undefined; the upstream filter treats that
|
||||
// as "no narrowing in that direction".
|
||||
function horizonBounds(horizon: string): { from?: Date; to?: Date } {
|
||||
const now = new Date();
|
||||
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const offset = (days: number) => new Date(day.getTime() + days * 86400000);
|
||||
switch (horizon) {
|
||||
case "past_1d": return { from: offset(-1), to: offset(1) };
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_14d": return { from: offset(-14), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "past_all": return { to: offset(1) };
|
||||
case "next_1d": return { from: day, to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_14d": return { from: day, to: offset(14) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
case "next_all": return { from: day };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -791,6 +819,9 @@ interface UnionEvent {
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
custom_rule_text?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
@@ -818,6 +849,9 @@ async function loadDeadlines(id: string) {
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
rule_name: it.rule_name,
|
||||
rule_name_en: it.rule_name_en,
|
||||
custom_rule_text: it.custom_rule_text,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
@@ -987,6 +1021,27 @@ function fmtDateOnly(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatDeadlineRuleCell renders the REGEL column for the project
|
||||
// detail Fristen table using the canonical t-paliad-258 contract:
|
||||
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
|
||||
// 2. custom_rule_text → text + "Custom" badge
|
||||
// 3. legacy rule_code-only saves → bare citation
|
||||
// 4. otherwise "—"
|
||||
function formatDeadlineRuleCell(f: Deadline): string {
|
||||
const hasName = (f.rule_name && f.rule_name.trim()) ||
|
||||
(f.rule_name_en && f.rule_name_en.trim());
|
||||
if (hasName || (f.rule_code && f.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (f.custom_rule_text && f.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
@@ -1025,7 +1080,7 @@ function renderDeadlines() {
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -1173,13 +1228,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -1443,36 +1501,9 @@ function initSmartTimelineAddModal(id: string) {
|
||||
initCounterclaimRoute(id, modal, choices, form);
|
||||
}
|
||||
|
||||
interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active proceeding types for the project
|
||||
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
|
||||
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
|
||||
// picker only ever shows those — never the 7 legacy litigation codes
|
||||
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
|
||||
// server-side service validation + DB trigger (mig 088) are the
|
||||
// defence-in-depth backstops for any non-UI writer.
|
||||
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// loadProceedingTypes is shared from ./project-form so the counterclaim
|
||||
// modal here and the project-edit picker hit the same cache.
|
||||
const loadProceedingTypes = loadProceedingTypesShared;
|
||||
|
||||
function initCounterclaimRoute(
|
||||
id: string,
|
||||
@@ -1649,18 +1680,34 @@ function showTab(tab: TabId) {
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
async function loadAndRenderChecklistInstances(projectID: string) {
|
||||
if (checklistInstancesInited) return;
|
||||
let checklistCatalogLoaded = false;
|
||||
|
||||
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
|
||||
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
|
||||
// the second open doesn't refetch the catalog (t-paliad-239).
|
||||
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
|
||||
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists`);
|
||||
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
|
||||
checklistCatalogLoaded = true;
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
|
||||
if (checklistInstancesInited && !force) return;
|
||||
checklistInstancesInited = true;
|
||||
try {
|
||||
const [instResp, tplResp] = await Promise.all([
|
||||
const [instResp] = await Promise.all([
|
||||
fetch(`/api/projects/${projectID}/checklists`),
|
||||
fetch(`/api/checklists`),
|
||||
loadChecklistCatalog(),
|
||||
]);
|
||||
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
|
||||
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
|
||||
} catch {
|
||||
checklistInstances = [];
|
||||
}
|
||||
@@ -1720,6 +1767,143 @@ function renderChecklistInstances() {
|
||||
});
|
||||
}
|
||||
|
||||
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
|
||||
// project-detail Checklists tab (t-paliad-239). Opens a template picker
|
||||
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
|
||||
// current project_id and the template title as the instance name.
|
||||
function initAddChecklistModal(projectID: string) {
|
||||
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
|
||||
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
|
||||
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
|
||||
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
|
||||
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
|
||||
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
|
||||
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
|
||||
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
};
|
||||
|
||||
const renderPicker = () => {
|
||||
const isEN = getLang() === "en";
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const all = Object.values(checklistTemplates);
|
||||
all.sort((a, b) => {
|
||||
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
|
||||
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
|
||||
return at.localeCompare(bt, isEN ? "en" : "de");
|
||||
});
|
||||
const filtered = q
|
||||
? all.filter((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
return title.toLowerCase().includes(q)
|
||||
|| desc.toLowerCase().includes(q)
|
||||
|| (tpl.regime || "").toLowerCase().includes(q);
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = filtered.map((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
const regime = tpl.regime || "";
|
||||
const regimeChip = regime
|
||||
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
|
||||
: "";
|
||||
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
|
||||
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
|
||||
<div class="add-checklist-row-head">
|
||||
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
|
||||
${regimeChip}
|
||||
</div>
|
||||
${descLine}
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug!;
|
||||
void pickTemplate(slug, btn);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
|
||||
const tpl = checklistTemplates[slug];
|
||||
if (!tpl) return;
|
||||
const isEN = getLang() === "en";
|
||||
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
close();
|
||||
flashTabMsg(t("projects.detail.checklisten.add.created"));
|
||||
await loadAndRenderChecklistInstances(projectID, true);
|
||||
} catch {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let flashTimer = 0;
|
||||
const flashTabMsg = (text: string) => {
|
||||
tabMsg.textContent = text;
|
||||
tabMsg.className = "form-msg form-msg-success";
|
||||
if (flashTimer) window.clearTimeout(flashTimer);
|
||||
flashTimer = window.setTimeout(() => {
|
||||
tabMsg.textContent = "";
|
||||
tabMsg.className = "form-msg";
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", async () => {
|
||||
await loadChecklistCatalog();
|
||||
search.value = "";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
renderPicker();
|
||||
modal.style.display = "flex";
|
||||
search.focus();
|
||||
});
|
||||
closeBtn.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
search.addEventListener("input", renderPicker);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -1753,9 +1937,14 @@ async function prepareEditForm() {
|
||||
// as the new parent (server would reject anyway).
|
||||
await loadParentCandidates(project?.id);
|
||||
initParentPicker();
|
||||
await populateProceedingTypeSelect();
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
// openEditModal opens the project-edit modal, optionally scrolling +
|
||||
// focusing a specific field after the form is prefilled. Callers like
|
||||
// the Schriftsätze empty-state CTA pass focusFieldID="project-proceeding-
|
||||
// type-id" to land the user directly on the picker they came to set.
|
||||
function openEditModal(focusFieldID?: string) {
|
||||
if (!project) return;
|
||||
const modal = document.getElementById("project-edit-modal");
|
||||
const msg = document.getElementById("project-edit-msg");
|
||||
@@ -1791,6 +1980,19 @@ function openEditModal() {
|
||||
};
|
||||
}
|
||||
renderTypeChangeWarning();
|
||||
if (focusFieldID) {
|
||||
// Wait a tick so the modal has laid out before scrolling — the
|
||||
// wrapping flex container is display:flex so the field's offset
|
||||
// height is only reliable after the next animation frame.
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(focusFieldID);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (target instanceof HTMLSelectElement || target instanceof HTMLInputElement) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
@@ -1869,13 +2071,37 @@ function initEditModal() {
|
||||
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
|
||||
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
|
||||
|
||||
editBtn.addEventListener("click", openEditModal);
|
||||
editBtn.addEventListener("click", () => openEditModal());
|
||||
closeBtn.addEventListener("click", closeEditModal);
|
||||
cancelBtn.addEventListener("click", closeEditModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeEditModal();
|
||||
});
|
||||
|
||||
// Schriftsätze empty-state CTA — when the panel reports "no proceeding
|
||||
// set", clicking the button opens the edit modal directly on the
|
||||
// Verfahrenstyp picker so the lawyer can resolve the gap in one step
|
||||
// (t-paliad-232).
|
||||
const submissionsCTA = document.getElementById(
|
||||
"project-submissions-edit-cta",
|
||||
) as HTMLButtonElement | null;
|
||||
if (submissionsCTA) {
|
||||
submissionsCTA.addEventListener("click", () => {
|
||||
openEditModal("project-proceeding-type-id");
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2092,6 +2318,7 @@ async function main() {
|
||||
initSmartTimelineClientToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initAddChecklistModal(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
@@ -2507,6 +2734,7 @@ async function loadUserList() {
|
||||
function renderTeam() {
|
||||
const body = document.getElementById("team-body")!;
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
|
||||
|
||||
// Existing team-body shows the direct + ancestor-inherited members
|
||||
// returned by /api/projects/{id}/team. The derived + descendant
|
||||
@@ -2517,12 +2745,21 @@ function renderTeam() {
|
||||
if (totalRows === 0) {
|
||||
body.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
if (mailtoControls) mailtoControls.style.display = "none";
|
||||
selectedMailUserIDs.clear();
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
|
||||
// Prune the selection to whoever is actually rendered in team-body
|
||||
// right now (e.g. a member just got removed). Invariant: selection ⊆
|
||||
// currently-visible team-body rows.
|
||||
pruneMailSelectionToVisible();
|
||||
|
||||
// t-paliad-223: callers with effective_project_admin authority see an
|
||||
// inline <select> on the Rolle cell. Everyone else sees the read-only
|
||||
@@ -2563,7 +2800,17 @@ function renderTeam() {
|
||||
? renderResponsibilitySelect(m.user_id, responsibility)
|
||||
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
|
||||
|
||||
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
|
||||
// rows with a real email participate in the mailto: build; rows
|
||||
// without are still rendered with a disabled checkbox so the
|
||||
// column geometry stays uniform.
|
||||
const hasEmail = !!(m.user_email && m.user_email.trim());
|
||||
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
|
||||
const disabled = hasEmail ? "" : " disabled";
|
||||
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
|
||||
|
||||
return `<tr>
|
||||
${checkboxCell}
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
@@ -2574,6 +2821,21 @@ function renderTeam() {
|
||||
})
|
||||
.join("");
|
||||
|
||||
// t-paliad-231 — wire row checkboxes + master + mailto button.
|
||||
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const userID = cb.dataset.userId!;
|
||||
if (cb.checked) selectedMailUserIDs.add(userID);
|
||||
else selectedMailUserIDs.delete(userID);
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
});
|
||||
});
|
||||
wireMailtoMaster();
|
||||
wireMailtoButton();
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!project) return;
|
||||
@@ -2786,17 +3048,21 @@ function canExportProject(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
@@ -2807,6 +3073,17 @@ function wireExportButton(projectID: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
@@ -2860,6 +3137,113 @@ async function showTeamErrorToast(resp: Response): Promise<void> {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// t-paliad-231 — mailto: selection helpers for the Team tab. The
|
||||
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
|
||||
// elsewhere; this is the non-admin / quick-CC variant that opens the
|
||||
// user's local mail client. Pure client; no server call.
|
||||
function pruneMailSelectionToVisible(): void {
|
||||
const visible = new Set<string>();
|
||||
for (const m of teamMembers) {
|
||||
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
|
||||
}
|
||||
for (const id of Array.from(selectedMailUserIDs)) {
|
||||
if (!visible.has(id)) selectedMailUserIDs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedMailRecipients(): BroadcastRecipient[] {
|
||||
const out: BroadcastRecipient[] = [];
|
||||
for (const m of teamMembers) {
|
||||
if (!selectedMailUserIDs.has(m.user_id)) continue;
|
||||
if (!m.user_email || !m.user_email.trim()) continue;
|
||||
out.push({
|
||||
user_id: m.user_id,
|
||||
email: m.user_email,
|
||||
display_name: m.user_display_name || m.user_email,
|
||||
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
|
||||
role_on_project: m.responsibility || "member",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncMailtoButton(): void {
|
||||
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
|
||||
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
|
||||
if (!btn || !label) return;
|
||||
const n = selectedMailRecipients().length;
|
||||
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
|
||||
if (n === 0) {
|
||||
btn.disabled = true;
|
||||
label.textContent = baseLabel;
|
||||
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
label.textContent = `${baseLabel} (${n})`;
|
||||
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
|
||||
btn.title = tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMasterCheckbox(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
// Only count rows that actually rendered with an enabled checkbox —
|
||||
// members without an email don't participate.
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
||||
"#team-body .team-mail-select:not(:disabled)",
|
||||
);
|
||||
const total = checkboxes.length;
|
||||
let selected = 0;
|
||||
checkboxes.forEach((cb) => {
|
||||
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
|
||||
});
|
||||
master.disabled = total === 0;
|
||||
if (total === 0 || selected === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
} else if (selected === total) {
|
||||
master.checked = true;
|
||||
master.indeterminate = false;
|
||||
} else {
|
||||
master.checked = false;
|
||||
master.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// wireMailtoMaster is idempotent — registers once via a sentinel data
|
||||
// attr so re-renders don't stack click handlers.
|
||||
function wireMailtoMaster(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master || master.dataset.wired === "1") return;
|
||||
master.dataset.wired = "1";
|
||||
master.addEventListener("change", () => {
|
||||
const turnOn = master.checked;
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
|
||||
.forEach((cb) => {
|
||||
const id = cb.dataset.userId!;
|
||||
if (turnOn) selectedMailUserIDs.add(id);
|
||||
else selectedMailUserIDs.delete(id);
|
||||
cb.checked = turnOn;
|
||||
});
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
});
|
||||
}
|
||||
|
||||
function wireMailtoButton(): void {
|
||||
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
|
||||
if (!btn || btn.dataset.wired === "1") return;
|
||||
btn.dataset.wired = "1";
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const recipients = selectedMailRecipients();
|
||||
if (recipients.length === 0) return;
|
||||
window.location.href = buildMailtoHref(recipients);
|
||||
});
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
const addBtn = document.getElementById("team-add-btn") as HTMLButtonElement | null;
|
||||
const form = document.getElementById("team-form") as HTMLFormElement | null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
wireTypeChange,
|
||||
showFieldsForType,
|
||||
readPayload,
|
||||
populateProceedingTypeSelect,
|
||||
} from "./project-form";
|
||||
|
||||
// /projects/new client. Posts v2 CreateProjectInput shape using the shared
|
||||
@@ -106,5 +107,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadParentCandidates();
|
||||
initParentPicker();
|
||||
await applyParentFromQueryString();
|
||||
// Fire-and-forget — the picker is hidden until type=case, so no need
|
||||
// to block initial render on the fetch.
|
||||
void populateProceedingTypeSelect();
|
||||
submitForm();
|
||||
});
|
||||
|
||||
87
frontend/src/client/rule-label.ts
Normal file
87
frontend/src/client/rule-label.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// rule-label — canonical display contract for deadline rules.
|
||||
//
|
||||
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
|
||||
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
|
||||
// invented its own pattern: sometimes citation-only, sometimes name-only,
|
||||
// sometimes "code — name". m flagged this on the first submissions in a
|
||||
// proceeding sequence where the inconsistency was most visible.
|
||||
//
|
||||
// Canonical pattern: **Name primary, Citation muted secondary**.
|
||||
// Text: "Notice of Appeal · UPC.RoP.220.1"
|
||||
// HTML: <span class="rule-label-name">Notice of Appeal</span>
|
||||
// <span class="rule-label-sep"> · </span>
|
||||
// <span class="rule-label-cite">UPC.RoP.220.1</span>
|
||||
//
|
||||
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
|
||||
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
|
||||
// so list/detail surfaces can render both shapes uniformly.
|
||||
|
||||
import { getLang, t } from "./i18n";
|
||||
|
||||
export interface RuleLike {
|
||||
name: string;
|
||||
name_en?: string | null;
|
||||
// The catalog carries multiple citation fields depending on which
|
||||
// surface populated it. Order of preference: legal_source > rule_code
|
||||
// > code. All three are accepted so callers don't have to normalise.
|
||||
rule_code?: string | null;
|
||||
code?: string | null;
|
||||
legal_source?: string | null;
|
||||
}
|
||||
|
||||
// formatRuleLabel returns the canonical plain-text label.
|
||||
// Falls back gracefully when either side is missing.
|
||||
export function formatRuleLabel(r: RuleLike): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
return name || cite || "";
|
||||
}
|
||||
|
||||
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
|
||||
// styling. The caller passes the HTML-escape helper so we don't pull a
|
||||
// dependency on a specific esc() module — every surface already has one.
|
||||
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) {
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(name)}</span>` +
|
||||
`<span class="rule-label-sep"> · </span>` +
|
||||
`<span class="rule-label-cite">${esc(cite)}</span>`
|
||||
);
|
||||
}
|
||||
return esc(name || cite || "");
|
||||
}
|
||||
|
||||
// ruleCitation returns the best-available citation string for a rule.
|
||||
// Exported so callers that need the bare code (e.g. CalDAV exports,
|
||||
// inline data attributes) can pull it without going through the label
|
||||
// formatter.
|
||||
export function ruleCitation(r: RuleLike): string {
|
||||
return r.legal_source || r.rule_code || r.code || "";
|
||||
}
|
||||
|
||||
// formatCustomRuleLabelHTML — render a free-text custom rule label with
|
||||
// a "Custom" badge slot. Used by surfaces that may display either a
|
||||
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
|
||||
// the text is empty so callers can fall through to "—".
|
||||
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(trimmed)}</span>` +
|
||||
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
// formatCustomRuleLabel — plain-text equivalent of the above.
|
||||
export function formatCustomRuleLabel(text: string | null | undefined): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return `${trimmed} · ${badge}`;
|
||||
}
|
||||
@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
|
||||
const SIDEBAR_WIDTH_MIN = 180;
|
||||
const SIDEBAR_WIDTH_MAX = 480;
|
||||
const SIDEBAR_WIDTH_DEFAULT = 240;
|
||||
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
|
||||
// on every scroll event, restored on initSidebar() so a full-page nav
|
||||
// click doesn't bounce the user back to the top of a long sidebar
|
||||
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
|
||||
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
|
||||
// starts that tab fresh at the top, which matches user expectation.
|
||||
const SCROLL_KEY = "paliad.sidebar.scroll";
|
||||
|
||||
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
|
||||
// BottomNav menu slot can call it without duplicating the open/close
|
||||
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
|
||||
}
|
||||
|
||||
// readStoredScroll returns the persisted scrollTop or 0 when missing /
|
||||
// malformed. Bounds are checked at apply time against the actual
|
||||
// scrollHeight, so a stale value pointing past the current scroll range
|
||||
// is harmless (the browser clamps assignments to [0, max]).
|
||||
function readStoredScroll(): number {
|
||||
const raw = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (raw === null) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function applySidebarScroll(nav: HTMLElement, px: number): void {
|
||||
if (px <= 0) return;
|
||||
nav.scrollTop = px;
|
||||
}
|
||||
|
||||
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
|
||||
// first load and removes the stale entry. Drop this fallback once the rename
|
||||
// grace period is over.
|
||||
@@ -79,6 +103,7 @@ export function initSidebar() {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
initSidebarScrollRestore(sidebar);
|
||||
|
||||
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
|
||||
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
|
||||
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
|
||||
// sessionStorage so the user's scroll position survives a full-page
|
||||
// navigation (every sidebar link click is a real reload — see m/paliad#85).
|
||||
// Restore is synchronous on init so the first paint is already at the
|
||||
// right offset; the passive scroll listener persists subsequent moves.
|
||||
// reapplySidebarScroll() exists so callers that mutate sidebar content
|
||||
// async (initUserViewsGroup appending /api/user-views into the Ansichten
|
||||
// group) can nudge the scroll back to where it was after the layout shift.
|
||||
function initSidebarScrollRestore(sidebar: HTMLElement): void {
|
||||
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
nav.addEventListener("scroll", () => {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function reapplySidebarScroll(): void {
|
||||
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
}
|
||||
|
||||
// Changelog badge — fetches the count of entries newer than the locally
|
||||
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
|
||||
// link. Skipped on the changelog page itself because changelog.ts stamps
|
||||
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// The synchronous restore in initSidebarScrollRestore() happened
|
||||
// before these views were appended, so a saved scrollTop that
|
||||
// pointed below the Ansichten group would now sit on the wrong
|
||||
// row. Re-apply once the layout has stabilised.
|
||||
reapplySidebarScroll();
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
|
||||
1186
frontend/src/client/submission-draft.ts
Normal file
1186
frontend/src/client/submission-draft.ts
Normal file
File diff suppressed because it is too large
Load Diff
130
frontend/src/client/submissions-index.ts
Normal file
130
frontend/src/client/submissions-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
||||
// /api/user/submission-drafts and renders one entity-table row per
|
||||
// draft. Row click → editor at /projects/{project_id}/submissions/
|
||||
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
||||
// contract: a table whose rows look clickable must navigate on click;
|
||||
// inner links / buttons keep their own affordance.
|
||||
|
||||
interface DraftRow {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference?: string | null;
|
||||
submission_code: string;
|
||||
name: string;
|
||||
last_exported_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let drafts: DraftRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const isEN = getLang() === "en";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-index-loading")!;
|
||||
const empty = document.getElementById("submissions-index-empty")!;
|
||||
const error = document.getElementById("submissions-index-error")!;
|
||||
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/user/submission-drafts");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drafts = (data.drafts ?? []) as DraftRow[];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (drafts.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const body = document.getElementById("submissions-index-body")!;
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
||||
|
||||
body.innerHTML = drafts.map((d) => {
|
||||
const projectCell = (() => {
|
||||
if (!d.project_id) {
|
||||
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
||||
}
|
||||
const title = esc(d.project_title ?? "");
|
||||
if (d.project_reference) {
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
||||
}
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
||||
})();
|
||||
|
||||
const href = d.project_id
|
||||
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
||||
: `/submissions/draft/${esc(d.id)}`;
|
||||
|
||||
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
||||
<td>${projectCell}</td>
|
||||
<td>${esc(d.submission_code)}</td>
|
||||
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
||||
<td>${esc(fmtDate(d.updated_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
||||
const href = row.dataset.href!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Inner <a> elements (project link, draft name) handle their own
|
||||
// navigation — let the browser dispatch them.
|
||||
if ((e.target as HTMLElement).closest("a, button")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
||||
// static markup — keep the import so future dynamic strings can hook
|
||||
// in without re-importing).
|
||||
void t;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
if (drafts.length > 0) render();
|
||||
});
|
||||
void load();
|
||||
});
|
||||
368
frontend/src/client/submissions-new.ts
Normal file
368
frontend/src/client/submissions-new.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
// by text + chip, and offers two start paths per row: with project
|
||||
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
entries: CatalogEntry[];
|
||||
activeProceeding: string | null; // null = all
|
||||
searchTerm: string;
|
||||
pickerForCode: string | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
entries: [],
|
||||
activeProceeding: null,
|
||||
searchTerm: "",
|
||||
pickerForCode: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return getLang() === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function partyLabel(role: string | undefined): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN() ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN() ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN() ? "Both" : "Beide";
|
||||
case "court": return isEN() ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-new-loading")!;
|
||||
const error = document.getElementById("submissions-new-error")!;
|
||||
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/submissions/catalog");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as CatalogResponse;
|
||||
state.entries = data.entries ?? [];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
renderChips();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderChips(): void {
|
||||
const host = document.getElementById("submissions-new-proceeding-chips");
|
||||
if (!host) return;
|
||||
const seen = new Map<string, string>();
|
||||
for (const e of state.entries) {
|
||||
if (!seen.has(e.proceeding_code)) {
|
||||
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
||||
}
|
||||
}
|
||||
const chips: string[] = [];
|
||||
const allLabel = isEN() ? "All" : "Alle";
|
||||
const allActive = state.activeProceeding === null;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
||||
for (const [code, name] of seen) {
|
||||
const active = state.activeProceeding === code;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
||||
}
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code ?? "";
|
||||
state.activeProceeding = code === "" ? null : code;
|
||||
renderChips();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filtered(): CatalogEntry[] {
|
||||
const term = state.searchTerm.trim().toLowerCase();
|
||||
return state.entries.filter((e) => {
|
||||
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
||||
return false;
|
||||
}
|
||||
if (term === "") return true;
|
||||
const name = isEN() && e.name_en ? e.name_en : e.name;
|
||||
const hay = [
|
||||
name,
|
||||
e.submission_code,
|
||||
e.legal_source ?? "",
|
||||
e.proceeding_code,
|
||||
e.proceeding_name,
|
||||
e.proceeding_name_en,
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(): void {
|
||||
const body = document.getElementById("submissions-new-body");
|
||||
const empty = document.getElementById("submissions-new-empty");
|
||||
const wrap = document.getElementById("submissions-new-tablewrap");
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
// Group by proceeding.
|
||||
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
||||
for (const e of rows) {
|
||||
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
||||
const bucket = groups.get(e.proceeding_code);
|
||||
if (bucket) {
|
||||
bucket.entries.push(e);
|
||||
} else {
|
||||
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
||||
}
|
||||
}
|
||||
|
||||
const colspan = 4;
|
||||
const html: string[] = [];
|
||||
for (const [code, group] of groups) {
|
||||
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) void startDraft(code, null);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) openProjectPicker(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: CatalogEntry): string {
|
||||
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
||||
const source = entry.legal_source ?? "";
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
||||
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
||||
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${esc(name)}</span>
|
||||
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${esc(partyLabel(entry.primary_party))}</td>
|
||||
<td>${esc(source)}</td>
|
||||
<td class="submission-action-cell">
|
||||
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/submission-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* ignore */ }
|
||||
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
||||
const id = view.draft.id;
|
||||
const pid = view.draft.project_id;
|
||||
const code = view.draft.submission_code;
|
||||
if (pid) {
|
||||
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
||||
} else {
|
||||
window.location.href = `/submissions/draft/${id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submissions-new createDraft:", err);
|
||||
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pickerProjects: ProjectRow[] = [];
|
||||
let pickerLoaded = false;
|
||||
|
||||
function openProjectPicker(submissionCode: string): void {
|
||||
state.pickerForCode = submissionCode;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!pickerLoaded) {
|
||||
void loadPickerProjects();
|
||||
} else {
|
||||
renderPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectPicker(): void {
|
||||
state.pickerForCode = null;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function loadPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submissions-new-project-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as ProjectRow[];
|
||||
pickerProjects = rows ?? [];
|
||||
pickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submissions-new loadPickerProjects:", err);
|
||||
pickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderPickerList();
|
||||
}
|
||||
|
||||
function renderPickerList(): void {
|
||||
const list = document.getElementById("submissions-new-project-list");
|
||||
const empty = document.getElementById("submissions-new-project-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = pickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
const code = state.pickerForCode;
|
||||
if (pid && code) {
|
||||
closeProjectPicker();
|
||||
void startDraft(code, pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wireToolbar(): void {
|
||||
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
||||
if (search) {
|
||||
search.addEventListener("input", () => {
|
||||
state.searchTerm = search.value;
|
||||
renderTable();
|
||||
});
|
||||
}
|
||||
|
||||
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
||||
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (pickerSearch) {
|
||||
pickerSearch.addEventListener("input", () => renderPickerList());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireToolbar();
|
||||
void loadCatalog();
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
// Submissions panel — fetches the full submission catalog across every
|
||||
// proceeding and renders it grouped by proceeding, with the project's
|
||||
// own proceeding pinned at the top.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
||||
// broadened it to the catalog: from any project a lawyer can pick a
|
||||
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
||||
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
||||
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
||||
// so cross-proceeding picks still render cleanly.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -23,11 +26,15 @@ interface SubmissionEntry {
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
project_proceeding_code?: string;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
// t-paliad-242: the catalog is shown to every project regardless of
|
||||
// whether a proceeding is bound — the no-proceeding hint stays as a
|
||||
// soft nudge above the table, but no longer hides the catalog.
|
||||
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
||||
? ""
|
||||
: "none";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
// Group entries by proceeding_code. Build a stable group order:
|
||||
// project's own proceeding first (when present), then alphabetical
|
||||
// by proceeding_code for the rest.
|
||||
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
||||
for (const entry of data.entries) {
|
||||
const key = entry.proceeding_code || "";
|
||||
const groupName = isEN && entry.proceeding_name_en
|
||||
? entry.proceeding_name_en
|
||||
: entry.proceeding_name;
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) {
|
||||
bucket.entries.push(entry);
|
||||
} else {
|
||||
groups.set(key, { name: groupName, entries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
const ownCode = data.project_proceeding_code ?? "";
|
||||
const orderedCodes: string[] = [];
|
||||
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
||||
for (const code of Array.from(groups.keys()).sort()) {
|
||||
if (code !== ownCode) orderedCodes.push(code);
|
||||
}
|
||||
|
||||
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
||||
const colspan = 4;
|
||||
|
||||
const html: string[] = [];
|
||||
for (const code of orderedCodes) {
|
||||
const group = groups.get(code);
|
||||
if (!group) continue;
|
||||
const isOwn = code === ownCode;
|
||||
const label = group.name + (isOwn ? ownSuffix : "");
|
||||
const headerClass = isOwn
|
||||
? "entity-table-group-header entity-table-group-header--own"
|
||||
: "entity-table-group-header";
|
||||
html.push(`<tr class="${headerClass}">`
|
||||
+ `<th colspan="${colspan}" scope="colgroup">`
|
||||
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
||||
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
||||
+ `</th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry, data.project_id, isEN));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per render to avoid stale closures
|
||||
// from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
||||
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
||||
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
||||
const action = `${editBtn} ${generateBtn}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -341,28 +341,64 @@ function buildProjectFilter() {
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
// Wait for /api/me so the affordance never flickers between admin (form)
|
||||
// and non-admin (mailto) on initial paint. canBroadcast() already returns
|
||||
// false when me is null but we'd briefly render the mailto anchor before
|
||||
// the admin form, which is visually jarring.
|
||||
if (!me) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
|
||||
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
|
||||
if (canBroadcast()) {
|
||||
// Admin path (global_admin or project-lead-of-selected): opens the
|
||||
// in-app compose modal that POSTs to /api/team/broadcast.
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${label} ${counter}
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
} else {
|
||||
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
|
||||
// the current filter set. href is refreshed in updateBroadcastButton()
|
||||
// whenever filters change so the link always reflects what's visible.
|
||||
wrap.innerHTML = `
|
||||
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
|
||||
${label} ${counter}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const recipients = displayedRecipients();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
if (countEl) countEl.textContent = String(recipients.length);
|
||||
const btn = document.getElementById("team-broadcast-btn");
|
||||
if (!btn) return;
|
||||
if (btn.tagName === "BUTTON") {
|
||||
(btn as HTMLButtonElement).disabled = recipients.length === 0;
|
||||
} else {
|
||||
// Anchor (non-admin): regenerate the mailto: href against the current
|
||||
// visible recipients, and disable the affordance when empty so a click
|
||||
// doesn't open an empty mail composer.
|
||||
const a = btn as HTMLAnchorElement;
|
||||
if (recipients.length === 0) {
|
||||
a.setAttribute("href", "mailto:");
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.style.pointerEvents = "none";
|
||||
a.style.opacity = "0.5";
|
||||
} else {
|
||||
a.setAttribute("href", buildMailtoHref(recipients));
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.style.pointerEvents = "";
|
||||
a.style.opacity = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,14 +709,21 @@ function renderSelectionFooter(): void {
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
|
||||
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
|
||||
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
|
||||
// compose modal; non-admin gets a native mailto: anchor pre-filled with
|
||||
// the explicit selection.
|
||||
const adminPath = canBroadcast();
|
||||
const sendAction = adminPath
|
||||
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
|
||||
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
|
||||
footer.innerHTML = `
|
||||
<span class="team-selection-count">${esc(countLabel)}</span>
|
||||
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
|
||||
${esc(t("team.selection.clear") || "Auswahl aufheben")}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" id="team-selection-send">
|
||||
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
|
||||
</button>
|
||||
${sendAction}
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
if (adminPath) {
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
// Anchor path has no click handler — native href open is the action.
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,6 +25,70 @@ import {
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
"dpma.appeal.bpatg",
|
||||
"dpma.appeal.bgh",
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -154,20 +219,31 @@ async function doCalc() {
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. The root rule (isRootEvent=true) is
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank). Fallback respects language —
|
||||
// proceedingNameEN is consulted on EN before the DE proceedingName
|
||||
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
// 1. Server-supplied triggerEventLabel from proceeding_types
|
||||
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
||||
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
||||
// all carry a non-zero duration off the trigger date so none is
|
||||
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
||||
// misnamed the input as the proceeding itself.
|
||||
// 2. Root rule (isRootEvent=true) — the first event in the
|
||||
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
||||
// Nichtigkeitsklage for upc.rev.cfi.
|
||||
// 3. Active proceeding name — last-resort fallback. Language-aware
|
||||
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
||||
if (curated) return curated;
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
if (getLang() === "en") {
|
||||
if (lang === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
|
||||
: "";
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -283,18 +365,53 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
// `@page paliad-landscape`, so flipping this class is what lets a
|
||||
// user print the horizontal timeline in landscape without affecting
|
||||
// the columns view (which stays portrait).
|
||||
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
|
||||
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
@@ -309,6 +426,38 @@ function initViewToggle() {
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
// initPerspectiveControls hydrates side+appellant from the URL,
|
||||
// reflects state into the radio inputs, and wires onchange handlers
|
||||
// that update state + URL + re-render. Re-render path skips the
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -378,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
|
||||
@@ -204,6 +204,12 @@ function setActiveShape(shape: RenderShape | null): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shape === shape);
|
||||
});
|
||||
// Mirror the active shape on <body> so the print stylesheet can opt
|
||||
// calendar / timeline into landscape (`@page paliad-landscape`) while
|
||||
// list / cards stay portrait — t-paliad-233.
|
||||
for (const s of ["list", "cards", "calendar", "timeline"]) {
|
||||
document.body.classList.toggle(`views-shape-active-${s}`, shape === s);
|
||||
}
|
||||
}
|
||||
|
||||
let timelineHandle: ChartHandle | null = null;
|
||||
|
||||
@@ -32,6 +32,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowAction === "inbox") {
|
||||
host.appendChild(renderInboxList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
@@ -147,8 +152,22 @@ function formatColumn(row: ViewRow, col: string): string {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "rule": {
|
||||
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
|
||||
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
|
||||
const lang = getLang();
|
||||
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
|
||||
const name = (row.detail[nameKey] as string | undefined)
|
||||
|| (row.detail.rule_name as string | undefined)
|
||||
|| "";
|
||||
const cite = (row.detail.rule_code as string | undefined) ?? "";
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
if (name) return name;
|
||||
if (cite) return cite;
|
||||
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
|
||||
if (custom.trim()) return `${custom} · Custom`;
|
||||
return "—";
|
||||
}
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
@@ -219,111 +238,215 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
for (const row of rows) {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
ul.appendChild(li);
|
||||
ul.appendChild(renderApprovalRow(row));
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
// renderApprovalRow stamps one <li> for an approval_request row.
|
||||
// Factored out of renderApprovalList in t-paliad-249 so the unified
|
||||
// inbox dispatch (renderInboxList) can reuse the exact same markup for
|
||||
// approval rows interleaved with project_event rows.
|
||||
export function renderApprovalRow(row: ViewRow): HTMLLIElement {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// All four actions are stamped on every pending row; the per-viewer
|
||||
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
||||
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
||||
// (2026-05-17): show what's possible but disable what isn't, rather
|
||||
// than alert-after-click. The server still enforces — disabled buttons
|
||||
// are a UI hint, not a security gate.
|
||||
//
|
||||
// suggest_changes is hidden for non-update lifecycles (the backend
|
||||
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
||||
// so we don't even render the button for them).
|
||||
actions.appendChild(approvalActionBtn("approve", detail));
|
||||
if (detail.lifecycle_event === "update") {
|
||||
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
||||
}
|
||||
actions.appendChild(approvalActionBtn("reject", detail));
|
||||
actions.appendChild(approvalActionBtn("revoke", detail));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
// Back-link from the OLD changes_requested row to the NEW pending
|
||||
// counter row (t-paliad-216). Hydrated server-side as
|
||||
// detail.next_request_id; the surface renders a link that scrolls
|
||||
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
||||
// older rows pre-mig-103, or rows where the server hasn't joined the
|
||||
// back-pointer).
|
||||
if (detail.status === "changes_requested" && detail.next_request_id) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "inbox-row-next-request";
|
||||
link.href = `#request-${detail.next_request_id}`;
|
||||
link.dataset.nextRequestId = detail.next_request_id;
|
||||
const deciderName = detail.decider_name || "";
|
||||
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
||||
li.appendChild(link);
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// row_action = "inbox" — unified inbox layout (t-paliad-249)
|
||||
//
|
||||
// Dispatches per row.kind so approval_request rows reuse the existing
|
||||
// approve/reject/revoke markup while project_event rows render as a
|
||||
// compact stream row (timestamp + actor + title + project chip +
|
||||
// Öffnen link to the underlying entity).
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderInboxList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list inbox-list--unified";
|
||||
for (const row of rows) {
|
||||
if (row.kind === "approval_request") {
|
||||
ul.appendChild(renderApprovalRow(row));
|
||||
} else if (row.kind === "project_event") {
|
||||
ul.appendChild(renderProjectEventInboxRow(row));
|
||||
}
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
interface ProjectEventDetail {
|
||||
event_type?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
function renderProjectEventInboxRow(row: ViewRow): HTMLLIElement {
|
||||
const detail = (row.detail || {}) as ProjectEventDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row inbox-row--project-event";
|
||||
li.dataset.eventId = row.id;
|
||||
if (detail.event_type) li.dataset.eventType = detail.event_type;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
// Prefer the row.title (server-side authored, project-aware); fall
|
||||
// back to a synthesised event-kind label so a malformed row never
|
||||
// produces an empty <li>.
|
||||
const kindLabelText = detail.event_type ? t(("event.title." + detail.event_type) as I18nKey) : "";
|
||||
title.textContent = row.title || kindLabelText || "—";
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const parts: string[] = [];
|
||||
if (row.project_title) parts.push(row.project_title);
|
||||
if (row.actor_name) parts.push(row.actor_name);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
if (detail.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "inbox-row-description";
|
||||
desc.textContent = detail.description;
|
||||
li.appendChild(desc);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
const openLink = projectEventLink(row, detail);
|
||||
if (openLink) actions.appendChild(openLink);
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
// projectEventLink builds an "Öffnen" anchor that points to the most
|
||||
// useful target for the event kind. Falls back to the project detail
|
||||
// page when the kind doesn't carry a richer pointer.
|
||||
//
|
||||
// Slice B can deepen this (e.g. note_created → scroll to note anchor);
|
||||
// keep it minimal for Slice A.
|
||||
function projectEventLink(row: ViewRow, detail: ProjectEventDetail): HTMLAnchorElement | null {
|
||||
if (!row.project_id) return null;
|
||||
const kind = detail.event_type ?? "";
|
||||
const a = document.createElement("a");
|
||||
a.className = "inbox-row-open";
|
||||
a.textContent = t("inbox.action.open");
|
||||
if (kind.startsWith("deadline_")) {
|
||||
a.href = `/projects/${row.project_id}#deadlines`;
|
||||
} else if (kind.startsWith("appointment_")) {
|
||||
a.href = `/projects/${row.project_id}#appointments`;
|
||||
} else if (kind === "note_created") {
|
||||
a.href = `/projects/${row.project_id}#notes`;
|
||||
} else {
|
||||
a.href = `/projects/${row.project_id}`;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (detail.payload || {}) as Record<string, unknown>;
|
||||
|
||||
@@ -99,6 +99,21 @@ export interface PredecessorMissingPayload {
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-237 — server tells us the anchored rule belongs to the
|
||||
// parent infringement project, not this CCR. Frontend renders the
|
||||
// message with a clickable link to the parent project.
|
||||
export interface CrossProceedingAnchorPayload {
|
||||
error: "cross_proceeding_anchor";
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
parent_project_id: string;
|
||||
parent_project_title: string;
|
||||
parent_project_url: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
@@ -822,7 +837,13 @@ function buildAnchorEditor(
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
const payload = (await resp.json()) as
|
||||
| PredecessorMissingPayload
|
||||
| CrossProceedingAnchorPayload;
|
||||
if (payload.error === "cross_proceeding_anchor") {
|
||||
renderCrossProceedingError(msg, payload, opts);
|
||||
return;
|
||||
}
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
@@ -886,6 +907,34 @@ function renderPredecessorError(
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
// t-paliad-237 — rule belongs to the parent inf project, not this CCR.
|
||||
// Render the bilingual message + a link to the parent project so the
|
||||
// user can navigate over and anchor the rule there. We deliberately do
|
||||
// NOT auto-route the write across projects (out of scope per brief).
|
||||
function renderCrossProceedingError(
|
||||
msg: HTMLElement,
|
||||
payload: CrossProceedingAnchorPayload,
|
||||
opts: RenderOptions,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--cross-proceeding");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const main = document.createElement("p");
|
||||
main.textContent = lang === "en" ? payload.message_en : payload.message_de;
|
||||
msg.appendChild(main);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "smart-timeline-anchor-parent-link";
|
||||
link.href = payload.parent_project_url;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Open „${payload.parent_project_title}“`
|
||||
: `„${payload.parent_project_title}“ öffnen`;
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface ScopeSpec {
|
||||
}
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_7d" | "past_30d" | "past_90d"
|
||||
| "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
|
||||
| "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
@@ -67,6 +67,11 @@ export interface FilterSpec {
|
||||
scope: ScopeSpec;
|
||||
time: TimeSpec;
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
// Inbox unread-only overlay (t-paliad-249). When true, the view
|
||||
// service drops project_event rows older than the caller's
|
||||
// users.inbox_seen_at cursor. Pending approval_requests always
|
||||
// survive — the cursor can't bury an in-flight approval.
|
||||
unread_only?: boolean;
|
||||
}
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
|
||||
@@ -79,7 +84,7 @@ export interface TimelineCVConfig {
|
||||
range_to?: string;
|
||||
}
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "inbox" | "none";
|
||||
|
||||
export interface ListConfig {
|
||||
columns?: string[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
@@ -65,3 +66,141 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
|
||||
// in pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// document.createElement which isn't available in plain bun test).
|
||||
//
|
||||
// Scenario fixture mirrors the UPC Appeal "both parties" case m
|
||||
// pasted into #81: every filing rule carries party='both' so the
|
||||
// legacy mirror path duplicates every row across both columns.
|
||||
// With ?appellant= set, the duplicate must collapse to a single
|
||||
// row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
|
||||
const both = (name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
...both(name, due),
|
||||
party,
|
||||
});
|
||||
|
||||
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
partySpecific("court", "Urteil", "2026-10-01"),
|
||||
],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "A", sameDate),
|
||||
partySpecific("defendant", "B", sameDate),
|
||||
partySpecific("court", "C", sameDate),
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
|
||||
partySpecific("court", "Decision", ""),
|
||||
]);
|
||||
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
|
||||
["Statement of Claim"],
|
||||
["Oral Hearing"],
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,16 @@ export interface DeadlineResponse {
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
// triggerEventLabel / triggerEventLabelEN: optional caption for the
|
||||
// "Auslösendes Ereignis" / "Triggering event" field on
|
||||
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
|
||||
// when set (mig 121). The page prefers this over the proceedingName
|
||||
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
|
||||
// uses this so the field reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -412,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (mirror)
|
||||
//
|
||||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||||
// collapse to a single row in the appellant's column — the intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
side?: Side;
|
||||
// appellant: which side initiated the appeal / counterclaim.
|
||||
// When set, party=both rows go to the appellant's column ONLY
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
// so unit tests can hit the pure routing logic without going through
|
||||
// document.createElement (no jsdom in this repo).
|
||||
export interface ColumnsRow {
|
||||
key: string;
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
opponent: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||||
// column placement (including the side-swap + appellant-collapse
|
||||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||||
// declaration order (each keyed by sequence).
|
||||
export function bucketDeadlinesIntoColumns(
|
||||
deadlines: CalculatedDeadline[],
|
||||
opts: BucketingOpts = {},
|
||||
): ColumnsRow[] {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | null =
|
||||
opts.appellant === "claimant" ? claimantColumn
|
||||
: opts.appellant === "defendant" ? defendantColumn
|
||||
: null;
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
const rowsMap = new Map<string, ColumnsRow>();
|
||||
const ensureRow = (key: string): ColumnsRow => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
r = { key, ours: [], court: [], opponent: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
row[claimantColumn].push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
row[defendantColumn].push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
@@ -462,17 +554,28 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
|
||||
}
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
@@ -487,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -171,6 +171,16 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-proceeding-type-id" data-i18n="projects.field.proceeding_type">Verfahrenstyp</label>
|
||||
<select id="project-proceeding-type-id">
|
||||
<option value="" data-i18n="projects.field.proceeding_type.unset">(nicht gesetzt)</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.proceeding_type.hint">
|
||||
Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
|
||||
<select id="project-our-side">
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
||||
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
||||
// affordance reads as "a draft document" at a glance.
|
||||
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
||||
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
@@ -202,6 +207,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||
|
||||
@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-title-col">
|
||||
<h1 id="deadline-title-display" />
|
||||
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
|
||||
{/* t-paliad-251 Part 4 — Standardtitel button only
|
||||
visible in edit mode; clicking replaces the
|
||||
title with a default derived from the project
|
||||
and the deadline's event types / rule. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
<dd>
|
||||
<span id="deadline-rule-display">—</span>
|
||||
{/* t-paliad-258 — Auto / Custom rule editor.
|
||||
Mirrors /deadlines/new: read-only Auto display
|
||||
(resolved from Type) or free-text Custom input,
|
||||
with a toggle link. Hidden outside edit mode. */}
|
||||
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
|
||||
currently-known context (event type → rule → proceeding
|
||||
type → fallback) with the project reference as suffix.
|
||||
Always replaces the title; no destructive confirmation
|
||||
because the user invoked it explicitly. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-title"
|
||||
@@ -57,58 +72,42 @@ export function renderDeadlinesNew(): string {
|
||||
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
|
||||
Auto (default): rule_id derived from the chosen
|
||||
Type, displayed read-only with a canonical
|
||||
"Name · Citation" label. Custom: free-text input,
|
||||
no catalog FK. Toggle switches modes. */}
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
<div className="form-field-label-row">
|
||||
<label data-i18n="deadlines.field.rule">Regel</label>
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -90,6 +90,28 @@ export type I18nKey =
|
||||
| "admin.audit.source.reminder_log"
|
||||
| "admin.audit.subtitle"
|
||||
| "admin.audit.title"
|
||||
| "admin.backups.col.actions"
|
||||
| "admin.backups.col.kind"
|
||||
| "admin.backups.col.requested_by"
|
||||
| "admin.backups.col.rows"
|
||||
| "admin.backups.col.size"
|
||||
| "admin.backups.col.started"
|
||||
| "admin.backups.col.status"
|
||||
| "admin.backups.download"
|
||||
| "admin.backups.empty"
|
||||
| "admin.backups.footer.note"
|
||||
| "admin.backups.heading"
|
||||
| "admin.backups.kind.on_demand"
|
||||
| "admin.backups.kind.scheduled"
|
||||
| "admin.backups.loading"
|
||||
| "admin.backups.run_now"
|
||||
| "admin.backups.running"
|
||||
| "admin.backups.status.done"
|
||||
| "admin.backups.status.failed"
|
||||
| "admin.backups.status.running"
|
||||
| "admin.backups.subtitle"
|
||||
| "admin.backups.success"
|
||||
| "admin.backups.title"
|
||||
| "admin.broadcasts.col.count"
|
||||
| "admin.broadcasts.col.sender"
|
||||
| "admin.broadcasts.col.sent_at"
|
||||
@@ -268,6 +290,15 @@ export type I18nKey =
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
| "admin.partner_units.title"
|
||||
| "admin.procedural_events.col.code"
|
||||
| "admin.procedural_events.edit.breadcrumb"
|
||||
| "admin.procedural_events.edit.field.code"
|
||||
| "admin.procedural_events.edit.field.event_kind"
|
||||
| "admin.procedural_events.edit.field.parent"
|
||||
| "admin.procedural_events.edit.title"
|
||||
| "admin.procedural_events.list.heading"
|
||||
| "admin.procedural_events.list.new"
|
||||
| "admin.procedural_events.list.title"
|
||||
| "admin.rules.col.legal_citation"
|
||||
| "admin.rules.col.lifecycle"
|
||||
| "admin.rules.col.modified"
|
||||
@@ -682,9 +713,20 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.cancel"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.destructive.label"
|
||||
| "approvals.withdraw.error"
|
||||
| "approvals.withdraw.lead.create.appointment"
|
||||
| "approvals.withdraw.lead.create.deadline"
|
||||
| "approvals.withdraw.lead.delete"
|
||||
| "approvals.withdraw.lead.update"
|
||||
| "approvals.withdraw.modal.title"
|
||||
| "approvals.withdraw.primary.label"
|
||||
| "approvals.withdraw.sub.create"
|
||||
| "approvals.withdraw.sub.delete"
|
||||
| "approvals.withdraw.sub.update"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -1104,6 +1146,33 @@ export type I18nKey =
|
||||
| "dashboard.urgency.urgent"
|
||||
| "dashboard.when.today"
|
||||
| "dashboard.when.tomorrow"
|
||||
| "date_range.button.label"
|
||||
| "date_range.button.label.custom_range"
|
||||
| "date_range.center.label"
|
||||
| "date_range.custom.apply"
|
||||
| "date_range.custom.cancel"
|
||||
| "date_range.custom.from"
|
||||
| "date_range.custom.invalid"
|
||||
| "date_range.custom.invalid_format"
|
||||
| "date_range.custom.invalid_missing"
|
||||
| "date_range.custom.to"
|
||||
| "date_range.dialog.label"
|
||||
| "date_range.fan.future.label"
|
||||
| "date_range.fan.past.label"
|
||||
| "date_range.horizon.any"
|
||||
| "date_range.horizon.custom"
|
||||
| "date_range.horizon.next_14d"
|
||||
| "date_range.horizon.next_1d"
|
||||
| "date_range.horizon.next_30d"
|
||||
| "date_range.horizon.next_7d"
|
||||
| "date_range.horizon.next_90d"
|
||||
| "date_range.horizon.next_all"
|
||||
| "date_range.horizon.past_14d"
|
||||
| "date_range.horizon.past_1d"
|
||||
| "date_range.horizon.past_30d"
|
||||
| "date_range.horizon.past_7d"
|
||||
| "date_range.horizon.past_90d"
|
||||
| "date_range.horizon.past_all"
|
||||
| "deadlines.action.reopen"
|
||||
| "deadlines.adjusted"
|
||||
| "deadlines.adjusted.holiday"
|
||||
@@ -1112,6 +1181,10 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1138,8 +1211,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1227,12 +1300,16 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.auto_no_match"
|
||||
| "deadlines.field.rule.auto_pick_type"
|
||||
| "deadlines.field.rule.custom_badge"
|
||||
| "deadlines.field.rule.custom_placeholder"
|
||||
| "deadlines.field.rule.mode.toggle_to_auto"
|
||||
| "deadlines.field.rule.mode.toggle_to_custom"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.default_btn"
|
||||
| "deadlines.field.title.default_fallback"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
| "deadlines.filter.akte.all"
|
||||
@@ -1366,6 +1443,10 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1532,6 +1613,7 @@ export type I18nKey =
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
| "event.title.appointment_updated"
|
||||
| "event.title.approval_decided"
|
||||
| "event.title.checklist_created"
|
||||
| "event.title.checklist_deleted"
|
||||
| "event.title.checklist_linked"
|
||||
@@ -1550,6 +1632,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_reopened"
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.member_role_changed"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
@@ -1574,6 +1657,8 @@ export type I18nKey =
|
||||
| "event_types.browse.apply"
|
||||
| "event_types.browse.cancel"
|
||||
| "event_types.browse.empty"
|
||||
| "event_types.browse.jurisdiction.all"
|
||||
| "event_types.browse.jurisdiction.filter_label"
|
||||
| "event_types.browse.jurisdiction.none"
|
||||
| "event_types.browse.search"
|
||||
| "event_types.browse.selected_count"
|
||||
@@ -1716,9 +1801,15 @@ export type I18nKey =
|
||||
| "glossar.suggest.success"
|
||||
| "glossar.suggest.title"
|
||||
| "glossar.title"
|
||||
| "inbox.action.mark_all_seen"
|
||||
| "inbox.action.open"
|
||||
| "inbox.empty.admin_nudge.body"
|
||||
| "inbox.empty.admin_nudge.cta"
|
||||
| "inbox.empty.admin_nudge.title"
|
||||
| "inbox.empty.feed"
|
||||
| "inbox.heading.feed"
|
||||
| "inbox.subtitle.feed"
|
||||
| "inbox.title.feed"
|
||||
| "index.checklisten.desc"
|
||||
| "index.checklisten.title"
|
||||
| "index.cost.desc"
|
||||
@@ -1869,6 +1960,7 @@ export type I18nKey =
|
||||
| "login.title"
|
||||
| "modal.close.label"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.backups"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.paliadin"
|
||||
@@ -1904,6 +1996,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -1985,8 +2078,11 @@ export type I18nKey =
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.error.upstream_silence"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.checking"
|
||||
| "paliadin.late.lost"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
@@ -1996,6 +2092,8 @@ export type I18nKey =
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.thinking"
|
||||
| "paliadin.thinking.seconds"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
@@ -2132,6 +2230,11 @@ export type I18nKey =
|
||||
| "projects.detail.appointments.form.cancel"
|
||||
| "projects.detail.appointments.form.submit"
|
||||
| "projects.detail.back"
|
||||
| "projects.detail.checklisten.add"
|
||||
| "projects.detail.checklisten.add.created"
|
||||
| "projects.detail.checklisten.add.empty_pick"
|
||||
| "projects.detail.checklisten.add.error"
|
||||
| "projects.detail.checklisten.add.search"
|
||||
| "projects.detail.checklisten.col.created"
|
||||
| "projects.detail.checklisten.col.name"
|
||||
| "projects.detail.checklisten.col.progress"
|
||||
@@ -2177,6 +2280,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2250,6 +2358,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
@@ -2258,12 +2367,14 @@ export type I18nKey =
|
||||
| "projects.detail.submissions.col.source"
|
||||
| "projects.detail.submissions.empty"
|
||||
| "projects.detail.submissions.empty.no_proceeding"
|
||||
| "projects.detail.submissions.empty.no_proceeding.cta"
|
||||
| "projects.detail.submissions.hint"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
@@ -2354,6 +2465,9 @@ export type I18nKey =
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
| "projects.field.patent_number"
|
||||
| "projects.field.proceeding_type"
|
||||
| "projects.field.proceeding_type.hint"
|
||||
| "projects.field.proceeding_type.unset"
|
||||
| "projects.field.proceeding_type_id"
|
||||
| "projects.field.ref"
|
||||
| "projects.field.ref.placeholder"
|
||||
@@ -2399,6 +2513,11 @@ export type I18nKey =
|
||||
| "projects.team.error.generic"
|
||||
| "projects.team.error.last_admin"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.mailto.count"
|
||||
| "projects.team.mailto.empty"
|
||||
| "projects.team.mailto.label"
|
||||
| "projects.team.mailto.select_all"
|
||||
| "projects.team.mailto.select_row"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
| "projects.team.profession.none"
|
||||
@@ -2476,6 +2595,45 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
| "submissions.index.col.draft"
|
||||
| "submissions.index.col.project"
|
||||
| "submissions.index.col.submission"
|
||||
| "submissions.index.col.updated"
|
||||
| "submissions.index.empty"
|
||||
| "submissions.index.empty.cta"
|
||||
| "submissions.index.error"
|
||||
| "submissions.index.heading"
|
||||
| "submissions.index.loading"
|
||||
| "submissions.index.subtitle"
|
||||
| "submissions.index.title"
|
||||
| "submissions.new.back"
|
||||
| "submissions.new.col.actions"
|
||||
| "submissions.new.col.name"
|
||||
| "submissions.new.col.party"
|
||||
| "submissions.new.col.source"
|
||||
| "submissions.new.empty.filtered"
|
||||
| "submissions.new.error"
|
||||
| "submissions.new.heading"
|
||||
| "submissions.new.loading"
|
||||
| "submissions.new.picker.empty"
|
||||
| "submissions.new.picker.loading"
|
||||
| "submissions.new.picker.placeholder"
|
||||
| "submissions.new.picker.title"
|
||||
| "submissions.new.search.placeholder"
|
||||
| "submissions.new.subtitle"
|
||||
| "submissions.new.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
@@ -2570,12 +2728,17 @@ export type I18nKey =
|
||||
| "views.bar.deadline_status.pending"
|
||||
| "views.bar.density.comfortable"
|
||||
| "views.bar.density.compact"
|
||||
| "views.bar.inbox_focus.alles"
|
||||
| "views.bar.inbox_focus.genehmigungen"
|
||||
| "views.bar.inbox_focus.plus_fristen"
|
||||
| "views.bar.inbox_focus.plus_termine"
|
||||
| "views.bar.label.appointment_type"
|
||||
| "views.bar.label.approval_entity"
|
||||
| "views.bar.label.approval_role"
|
||||
| "views.bar.label.approval_status"
|
||||
| "views.bar.label.deadline_status"
|
||||
| "views.bar.label.density"
|
||||
| "views.bar.label.inbox_focus"
|
||||
| "views.bar.label.personal"
|
||||
| "views.bar.label.project_event_kind"
|
||||
| "views.bar.label.shape"
|
||||
@@ -2583,6 +2746,7 @@ export type I18nKey =
|
||||
| "views.bar.label.time"
|
||||
| "views.bar.label.timeline_status"
|
||||
| "views.bar.label.timeline_track"
|
||||
| "views.bar.label.unread_only"
|
||||
| "views.bar.personal.on"
|
||||
| "views.bar.save.cancel"
|
||||
| "views.bar.save.confirm"
|
||||
@@ -2600,16 +2764,6 @@ export type I18nKey =
|
||||
| "views.bar.shape.list"
|
||||
| "views.bar.sort.date_asc"
|
||||
| "views.bar.sort.date_desc"
|
||||
| "views.bar.time.all"
|
||||
| "views.bar.time.any"
|
||||
| "views.bar.time.custom"
|
||||
| "views.bar.time.custom.coming_soon"
|
||||
| "views.bar.time.next_30d"
|
||||
| "views.bar.time.next_7d"
|
||||
| "views.bar.time.next_90d"
|
||||
| "views.bar.time.past_30d"
|
||||
| "views.bar.time.past_7d"
|
||||
| "views.bar.time.past_90d"
|
||||
| "views.bar.timeline_status.court_set"
|
||||
| "views.bar.timeline_status.done"
|
||||
| "views.bar.timeline_status.macro.future"
|
||||
@@ -2622,6 +2776,8 @@ export type I18nKey =
|
||||
| "views.bar.timeline_track.counterclaim"
|
||||
| "views.bar.timeline_track.off_script"
|
||||
| "views.bar.timeline_track.parent"
|
||||
| "views.bar.unread_only.off"
|
||||
| "views.bar.unread_only.on"
|
||||
| "views.calendar.mobile_fallback"
|
||||
| "views.col.actor"
|
||||
| "views.col.appointment_type"
|
||||
@@ -2682,11 +2838,18 @@ export type I18nKey =
|
||||
| "views.horizon.all"
|
||||
| "views.horizon.any"
|
||||
| "views.horizon.custom"
|
||||
| "views.horizon.next_14d"
|
||||
| "views.horizon.next_1d"
|
||||
| "views.horizon.next_30d"
|
||||
| "views.horizon.next_7d"
|
||||
| "views.horizon.next_90d"
|
||||
| "views.horizon.next_all"
|
||||
| "views.horizon.past_14d"
|
||||
| "views.horizon.past_1d"
|
||||
| "views.horizon.past_30d"
|
||||
| "views.horizon.past_7d"
|
||||
| "views.horizon.past_90d"
|
||||
| "views.horizon.past_all"
|
||||
| "views.kind.appointment"
|
||||
| "views.kind.approval_request"
|
||||
| "views.kind.deadline"
|
||||
|
||||
@@ -5,15 +5,14 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /inbox — t-paliad-163 universal-filter migration.
|
||||
// /inbox — t-paliad-249 unified inbox feed.
|
||||
//
|
||||
// The page is a thin shell around two host divs: one for the
|
||||
// <FilterBar> primitive and one for the result list. The bar takes
|
||||
// care of every axis (approval_viewer_role chip cluster replaces the
|
||||
// two-tab UI; status / entity_type / time chips are new affordances).
|
||||
// Rows render via shape-list.ts with row_action="approve" — the
|
||||
// inbox-specific markup that produces the diff + approve/reject/revoke
|
||||
// buttons. Action handlers are wired in client/inbox.ts.
|
||||
// Since t-paliad-249 the page is a thin shell around the FilterBar +
|
||||
// result list as before, but the InboxSystemView now spans both
|
||||
// approval_request and project_event sources. Rows render via
|
||||
// shape-list.ts's row_action="inbox" dispatch — approval rows keep
|
||||
// the existing diff + approve/reject/revoke markup, project_event
|
||||
// rows render as compact stream items.
|
||||
//
|
||||
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
|
||||
// to ?a_role=self_requested before the bar mounts so old bookmarks
|
||||
@@ -28,7 +27,7 @@ export function renderInbox(): string {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="approvals.title">Genehmigungen — Paliad</title>
|
||||
<title data-i18n="inbox.title.feed">Inbox — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
@@ -39,10 +38,24 @@ export function renderInbox(): string {
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="approvals.heading">Genehmigungen</h1>
|
||||
<p className="tool-subtitle" data-i18n="approvals.subtitle">
|
||||
4-Augen-Prüfung für Fristen und Termine.
|
||||
</p>
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="inbox.heading.feed">Inbox</h1>
|
||||
<p className="tool-subtitle" data-i18n="inbox.subtitle.feed">
|
||||
Neuigkeiten zu Ihren Projekten und offene Genehmigungen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inbox-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="inbox-mark-all-seen"
|
||||
className="btn-secondary"
|
||||
data-i18n="inbox.action.mark_all_seen"
|
||||
>
|
||||
Alles als gelesen markieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="inbox-filter-bar" />
|
||||
|
||||
@@ -104,7 +104,7 @@ export function renderKostenrechner(): string {
|
||||
<title data-i18n="kosten.title">Prozesskostenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-kostenrechner">
|
||||
<Sidebar currentPath="/tools/kostenrechner" />
|
||||
<BottomNav currentPath="/tools/kostenrechner" />
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function renderProjectsChart(): string {
|
||||
<title data-i18n="projects.chart.title">Projekt-Chart — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-projects-chart">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
|
||||
@@ -89,20 +89,9 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Sits
|
||||
last in the tab list per t-paliad-245. */}
|
||||
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -286,9 +275,33 @@ export function renderProjectsDetail(): string {
|
||||
<p className="form-msg" id="team-msg" />
|
||||
</form>
|
||||
|
||||
{/* t-paliad-231 — pure-client mailto: for non-admins.
|
||||
Button stays disabled until at least one row is
|
||||
selected; click opens a mailto: with every selected
|
||||
member in the To: line. No server call. */}
|
||||
<div className="party-controls team-mailto-controls" id="team-mailto-controls" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="team-mailto-btn"
|
||||
className="btn-secondary btn-small"
|
||||
disabled
|
||||
data-i18n-title="projects.team.mailto.empty"
|
||||
title="Mindestens ein Mitglied auswählen"
|
||||
>
|
||||
<span id="team-mailto-label" data-i18n="projects.team.mailto.label">Mail an Auswahl</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="party-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="team-col-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="team-select-master"
|
||||
aria-label="Alle sichtbaren auswählen"
|
||||
/>
|
||||
</th>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.profession">Profession</th>
|
||||
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
|
||||
@@ -572,6 +585,12 @@ export function renderProjectsDetail(): string {
|
||||
|
||||
{/* Checklists (Checklisten) */}
|
||||
<section className="entity-tab-panel" id="tab-checklists" style="display:none">
|
||||
<div className="party-controls">
|
||||
<button id="checklist-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projects.detail.checklisten.add">
|
||||
Checkliste hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-msg" id="project-checklists-msg" />
|
||||
<p id="project-checklists-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.checklisten.empty">
|
||||
Für dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
|
||||
</p>
|
||||
@@ -589,25 +608,35 @@ export function renderProjectsDetail(): string {
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle checklists-hint">
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Vorlagen werden auf der </span>
|
||||
<a href="/checklists" data-i18n="projects.detail.checklisten.hint.link">Checklisten</a>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix"> angelegt.</span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix">-Seite angelegt und bearbeitet.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
|
||||
the original t-paliad-215 list to the full
|
||||
cross-proceeding catalog. The table shows every
|
||||
active filing rule across every proceeding, grouped
|
||||
by proceeding; the project's own proceeding is
|
||||
pinned to the top. The no-proceeding hint stays as
|
||||
a soft nudge above the catalog (the table renders
|
||||
regardless). */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Bitte zuerst einen Verfahrenstyp setzen.
|
||||
</p>
|
||||
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-submissions-edit-cta"
|
||||
className="btn-primary btn-small"
|
||||
data-i18n="projects.detail.submissions.empty.no_proceeding.cta">
|
||||
Projekt bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
@@ -627,11 +656,38 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Each
|
||||
sub-section hides itself if the caller is not entitled
|
||||
(export: §4 gate; archive: global_admin). */}
|
||||
<section className="entity-tab-panel" id="tab-settings" style="display:none">
|
||||
<div className="settings-section" id="project-settings-export" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
|
||||
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" id="project-settings-archive" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
|
||||
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-settings-archive-link"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.settings.archive.cta">
|
||||
Bearbeiten öffnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Full edit modal — same form as /projects/new, pre-filled. */}
|
||||
@@ -663,6 +719,33 @@ export function renderProjectsDetail(): string {
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Danger zone — destructive action, visually separated from Save/Cancel. */}
|
||||
<div className="modal-danger-zone" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-link-danger" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-checklist-instance modal (t-paliad-239) — picks a
|
||||
template and POSTs to /api/checklists/{slug}/instances
|
||||
with the current project_id; the row appears in the
|
||||
Checklists tab list on success. */}
|
||||
<div className="modal-overlay" id="add-checklist-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="projects.detail.checklisten.add">Checkliste hinzufügen</h2>
|
||||
<button className="modal-close" id="add-checklist-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="text" id="add-checklist-search" autocomplete="off" data-i18n-placeholder="projects.detail.checklisten.add.search" placeholder="Vorlage suchen…" />
|
||||
</div>
|
||||
<div className="add-checklist-list" id="add-checklist-list" />
|
||||
<p className="entity-events-empty" id="add-checklist-empty" style="display:none" data-i18n="projects.detail.checklisten.add.empty_pick">
|
||||
Keine passenden Vorlagen gefunden.
|
||||
</p>
|
||||
<p className="form-msg" id="add-checklist-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
143
frontend/src/submission-draft.tsx
Normal file
143
frontend/src/submission-draft.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-238 Slice A — dedicated Submissions/Schriftsätze editor page.
|
||||
//
|
||||
// Lawyer picks (or creates) a draft for one (project, submission_code),
|
||||
// edits placeholder variables in a sticky sidebar, sees a read-only
|
||||
// HTML preview of the merged document, and exports the result as
|
||||
// .docx. Drafts persist server-side per paliad.submission_drafts.
|
||||
//
|
||||
// Pure shell: client/submission-draft.ts hydrates draft list + variable
|
||||
// form + preview pane after page load. The same dist/submission-draft.html
|
||||
// serves every (project_id, submission_code, [draft_id]) URL.
|
||||
//
|
||||
// Design ref: docs/design-submission-page-2026-05-22.md §6.
|
||||
|
||||
export function renderSubmissionDraft(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.draft.title">Schriftsatz bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-submission-draft">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submission-draft-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="submission-draft-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="submissions.draft.back">
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="submission-draft-loading" className="entity-loading">
|
||||
<p data-i18n="submissions.draft.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="submissions.draft.notfound">
|
||||
Schriftsatz nicht gefunden oder keine Berechtigung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-error" className="entity-empty" style="display:none" />
|
||||
|
||||
<div id="submission-draft-body" style="display:none">
|
||||
<header className="submission-draft-header">
|
||||
<div className="submission-draft-header-text">
|
||||
<h1 id="submission-draft-title" />
|
||||
<p id="submission-draft-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="submission-draft-header-actions">
|
||||
<button
|
||||
id="submission-draft-export-btn"
|
||||
type="button"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.draft.action.export">
|
||||
Als .docx exportieren
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — draft switcher + variable groups. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
<header className="submission-draft-preview-header">
|
||||
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
|
||||
<span
|
||||
className="submission-draft-preview-hint"
|
||||
data-i18n="submissions.draft.preview.hint">
|
||||
Read-only Vorschau — finale Bearbeitung in Word.
|
||||
</span>
|
||||
</header>
|
||||
<div className="submission-draft-preview" id="submission-draft-preview" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/submission-draft.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
84
frontend/src/submissions-index.tsx
Normal file
84
frontend/src/submissions-index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
|
||||
// entry that lists every draft the caller owns across visible projects.
|
||||
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
|
||||
// this page only adds a discovery surface and click-through to it.
|
||||
|
||||
export function renderSubmissionsIndex(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.index.title">Schriftsätze — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="submissions-index-headline">
|
||||
<div>
|
||||
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
|
||||
Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-index-loading"
|
||||
data-i18n="submissions.index.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-empty" style="display:none">
|
||||
<p data-i18n="submissions.index.empty">
|
||||
Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.
|
||||
</p>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-error" style="display:none">
|
||||
<p data-i18n="submissions.index.error">Schriftsätze konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.index.col.project">Projekt</th>
|
||||
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
|
||||
<th data-i18n="submissions.index.col.draft">Entwurf</th>
|
||||
<th data-i18n="submissions.index.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-index-body" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
118
frontend/src/submissions-new.tsx
Normal file
118
frontend/src/submissions-new.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-243 — global Schriftsatz picker. Lists the full
|
||||
// cross-proceeding submission catalog (grouped by proceeding,
|
||||
// filterable) and lets the lawyer start a draft with or without
|
||||
// binding a project. Picking "Ohne Projekt" jumps straight to
|
||||
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
|
||||
// autocomplete project picker, then redirects to the project-scoped
|
||||
// editor.
|
||||
|
||||
export function renderSubmissionsNew(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.new.title">Neuer Schriftsatz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submissions-new-page">
|
||||
<div className="container">
|
||||
<a href="/submissions" className="back-link"
|
||||
data-i18n="submissions.new.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
|
||||
Wählen Sie eine Vorlage. Optional verknüpfen Sie den
|
||||
Entwurf mit einem Projekt — sonst füllen Sie alle
|
||||
Variablen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="submissions-new-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.search.placeholder"
|
||||
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
|
||||
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-new-loading"
|
||||
data-i18n="submissions.new.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-new-error" style="display:none">
|
||||
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
|
||||
<th data-i18n="submissions.new.col.party">Partei</th>
|
||||
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-new-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="entity-empty" id="submissions-new-empty" style="display:none">
|
||||
<span data-i18n="submissions.new.empty.filtered">
|
||||
Keine passenden Schriftsätze. Filter zurücksetzen.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
|
||||
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="modal-card">
|
||||
<header className="modal-header">
|
||||
<h2 data-i18n="submissions.new.picker.title">Projekt wählen</h2>
|
||||
<button type="button" id="submissions-new-project-modal-close"
|
||||
className="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-project-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.picker.placeholder"
|
||||
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
|
||||
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
|
||||
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.loading">Lädt Projekte…</p>
|
||||
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function renderVerfahrensablauf(): string {
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-verfahrensablauf">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
DROP COLUMN IF EXISTS aichat_conversation_id;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-235: track aichat conversation id on each paliadin turn so the
|
||||
-- recovery endpoint can ask aichat for the late-arriving response when
|
||||
-- paliad's stream connection drops mid-turn.
|
||||
--
|
||||
-- The PALIADIN_BACKEND=aichat path persists this from the upstream
|
||||
-- /chat/turn/stream `done` frame's conversation_id. PALIADIN_BACKEND=legacy
|
||||
-- turns leave it NULL — the filesystem janitor is still the recovery path
|
||||
-- there.
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
ADD COLUMN IF NOT EXISTS aichat_conversation_id uuid;
|
||||
|
||||
COMMENT ON COLUMN paliad.paliadin_turns.aichat_conversation_id IS
|
||||
'Aichat backend conversation id (t-paliad-235). Set when the streaming /chat/turn/stream done frame arrives, or when the recovery endpoint asks aichat to disambiguate which conversation this turn lives in. NULL for legacy backend turns and for aichat turns that errored before the conversation id was resolved.';
|
||||
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-238: revert submission_drafts table.
|
||||
--
|
||||
-- The shared paliad.tg_set_updated_at trigger function is intentionally
|
||||
-- left in place — it may be in use by other tables. DROP TABLE cascades
|
||||
-- to the trigger that references it on this table only.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- t-paliad-238: dedicated Submissions/Schriftsätze page.
|
||||
--
|
||||
-- paliad.submission_drafts holds the lawyer's per-(project, submission_code)
|
||||
-- draft state for the new editor at /projects/{id}/submissions/{code}/draft.
|
||||
-- Each row is one named draft owned by one user; multiple drafts per
|
||||
-- (project, submission_code, user_id) are supported via the `name` field
|
||||
-- (auto-generated "Entwurf 1", "Entwurf 2", … and lawyer-renameable).
|
||||
--
|
||||
-- `variables` carries the lawyer's overrides for the placeholder map
|
||||
-- assembled at export time by SubmissionVarsService — empty string forces
|
||||
-- the [KEIN WERT: …] marker; absent key falls back to the resolved bag.
|
||||
--
|
||||
-- `last_exported_at` / `last_exported_sha` record provenance of the most
|
||||
-- recent .docx export (template SHA pinned for audit). Audit rows live
|
||||
-- in paliad.system_audit_log + paliad.project_events; this table is the
|
||||
-- lawyer's working state, not the audit log.
|
||||
--
|
||||
-- Visibility: per project CLAUDE.md, every project-scoped resource gates
|
||||
-- on paliad.can_see_project. UPDATE / DELETE additionally require the
|
||||
-- draft's user_id to match auth.uid() so two co-team-members don't stomp
|
||||
-- on each other's drafts (head-confirmed Q-E4 owner-scoped pick).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
|
||||
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
last_exported_at timestamptz,
|
||||
last_exported_sha text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at maintenance: trigger function lives once per schema; reuse if
|
||||
-- it already exists, otherwise create the standard shape.
|
||||
CREATE OR REPLACE FUNCTION paliad.tg_set_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_drafts_set_updated_at ON paliad.submission_drafts;
|
||||
CREATE TRIGGER submission_drafts_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_drafts IS
|
||||
't-paliad-238: per-(project, submission_code, user) named drafts for the dedicated Submissions/Schriftsätze page. Each row holds the lawyer-edited variable overrides for the .docx export. Audit rows live in paliad.system_audit_log + paliad.project_events.';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- t-paliad-243 revert: restore NOT NULL on project_id.
|
||||
--
|
||||
-- The revert refuses to run if any project-less draft exists — those
|
||||
-- rows would silently fail the NOT NULL re-imposition and corrupt the
|
||||
-- migration runner's state. The safe revert path is to surface the
|
||||
-- conflict to the operator who can decide whether to attach the rows
|
||||
-- to a project or delete them before retrying the down.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts WHERE project_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot re-impose NOT NULL on paliad.submission_drafts.project_id: '
|
||||
'project-less drafts exist. Attach them to a project or delete '
|
||||
'them, then re-run the down migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id SET NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
@@ -0,0 +1,70 @@
|
||||
-- t-paliad-243: drafts may exist without a project attached.
|
||||
--
|
||||
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
|
||||
-- draft straight from the top-level Schriftsätze sidebar, with or
|
||||
-- without binding it to a project. project_id therefore becomes
|
||||
-- optional. Existing rows are unaffected; new rows may insert NULL.
|
||||
--
|
||||
-- RLS rewrite: every policy splits on (project_id IS NULL):
|
||||
--
|
||||
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
|
||||
-- inheritance-aware visibility).
|
||||
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
|
||||
-- project-less draft is a personal scratch
|
||||
-- space — never shared, never visible to
|
||||
-- other team members.
|
||||
--
|
||||
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
|
||||
-- only writes user_id = auth.uid(); a project-scoped insert additionally
|
||||
-- requires can_see_project.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id DROP NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND user_id = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Drop the optional trigger-event label columns added in
|
||||
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
|
||||
-- their override; the frontend falls back to proceedingName.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS trigger_event_label_en,
|
||||
DROP COLUMN IF EXISTS trigger_event_label_de;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
|
||||
--
|
||||
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
|
||||
-- to `paliad.proceeding_types.name` whenever the calculator finds no
|
||||
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
|
||||
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
|
||||
-- the trigger date, so the caption reads "Berufungsverfahren" /
|
||||
-- "Appeal" — the proceeding itself — instead of the appealable
|
||||
-- decision that actually starts the clock.
|
||||
--
|
||||
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
|
||||
-- pair on proceeding_types. When set, the calculator surfaces it on the
|
||||
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
|
||||
-- proceedingName. No deadline-rule additions, no slug changes; existing
|
||||
-- proceeding_type.code stays stable (hard rule from the issue).
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
|
||||
|
||||
-- UPC Appeal: the trigger date is the date of the appealable first-instance
|
||||
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
|
||||
-- service of the decision per R.220.1(a)/(b)).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
|
||||
trigger_event_label_en = 'Appealable Decision'
|
||||
WHERE code = 'upc.apl.merits';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-258: revert the additive custom_rule_text column.
|
||||
-- Drop the column; rows that used the Custom path lose their free-text
|
||||
-- label and read as "no rule".
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS custom_rule_text;
|
||||
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
|
||||
-- deadline form.
|
||||
--
|
||||
-- t-paliad-251 shipped the form with a full deadline_rules catalog
|
||||
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
|
||||
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
|
||||
--
|
||||
-- 1. Auto — rule_id derived from the chosen event_type, displayed
|
||||
-- read-only.
|
||||
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
|
||||
-- stored here.
|
||||
--
|
||||
-- The column is additive + nullable: existing rows keep their
|
||||
-- deadline_rule_id and read as Auto-equivalent. A future row with both
|
||||
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN IF NOT EXISTS custom_rule_text text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
|
||||
'Free-text rule label entered when the lawyer chose Custom on the '
|
||||
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
|
||||
'the application layer: Auto path sets rule_id and leaves this '
|
||||
'NULL; Custom path sets this and leaves rule_id NULL. Display '
|
||||
'surfaces prefer the rule_id-joined deadline_rules.name when '
|
||||
'present, else fall back to custom_rule_text + a "Custom" badge.';
|
||||
11
internal/db/migrations/123_backups.down.sql
Normal file
11
internal/db/migrations/123_backups.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- t-paliad-246 / m/paliad#77 — revert Backup Mode catalog table.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 123 down: drop paliad.backups catalog (t-paliad-246 / m/paliad#77 Slice A)',
|
||||
true);
|
||||
|
||||
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||
DROP INDEX IF EXISTS paliad.backups_kind_status_idx;
|
||||
DROP INDEX IF EXISTS paliad.backups_started_at_desc_idx;
|
||||
DROP TABLE IF EXISTS paliad.backups;
|
||||
86
internal/db/migrations/123_backups.up.sql
Normal file
86
internal/db/migrations/123_backups.up.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
|
||||
--
|
||||
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
|
||||
-- run (on-demand or scheduled). The catalog is operational metadata for
|
||||
-- the /admin/backups UI (size, row counts, storage URI, status). The
|
||||
-- audit chain stays on paliad.system_audit_log — this table is the
|
||||
-- richer-shape duplicate that the UI lists from without parsing JSON.
|
||||
--
|
||||
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
|
||||
-- under the migration-runner role, so we don't add a write RLS policy
|
||||
-- for end users. SELECT is admin-only, mirroring system_audit_log.
|
||||
--
|
||||
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
|
||||
true);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.backups (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
|
||||
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
|
||||
-- requested_by is NULL for kind='scheduled' (no human caller).
|
||||
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- requested_by_email is captured at write time so the row survives
|
||||
-- a subsequent user deletion. For scheduled runs we write a sentinel
|
||||
-- like 'system@paliad' (no real user attached).
|
||||
requested_by_email text NOT NULL,
|
||||
-- audit_id back-references the system_audit_log row written before
|
||||
-- the artifact is generated. Nullable so a catalog row can still be
|
||||
-- INSERTed if the audit write itself fails (defense-in-depth).
|
||||
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
|
||||
-- storage_uri is populated when status flips to 'done'. Resolves
|
||||
-- through the Go-side ArtifactStore interface ('file://...' for
|
||||
-- LocalDiskStore today; future stores get their own URI scheme).
|
||||
storage_uri text,
|
||||
size_bytes bigint,
|
||||
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
sheet_count int,
|
||||
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- error is NULL unless status='failed'. Free-form, captured from
|
||||
-- the Go-side error.Error().
|
||||
error text,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
-- deleted_at marks artifacts the lifecycle cleanup removed from
|
||||
-- storage (Slice B). The catalog row itself stays forever — it's
|
||||
-- part of the audit chain. NULL means "still on disk".
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- Read patterns:
|
||||
-- - "show me recent backups" — started_at DESC
|
||||
-- - "find last successful scheduled backup today" — kind + status + started_at
|
||||
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
|
||||
ON paliad.backups (started_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
|
||||
ON paliad.backups (kind, status);
|
||||
|
||||
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path
|
||||
-- under the migration-runner role (no end-user write surface).
|
||||
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||
CREATE POLICY backups_select_admin ON paliad.backups
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.backups IS
|
||||
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.requested_by_email IS
|
||||
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.storage_uri IS
|
||||
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
|
||||
|
||||
COMMENT ON COLUMN paliad.backups.deleted_at IS
|
||||
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Revert t-paliad-264 / m/paliad#95.
|
||||
-- Restores Replik and Duplik to parent_id = NULL with the pre-fix
|
||||
-- "Frist vom Gericht bestimmt" placeholder note. The pre-fix rows
|
||||
-- carried legal_source = NULL and is_court_set = false; both
|
||||
-- placeholder durations (4 weeks) are left untouched (the .up
|
||||
-- migration did not modify them).
|
||||
--
|
||||
-- audit_reason set_config required for the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 124 revert: unwind de.inf.lg Replik/Duplik sequencing back to pre-#95 placeholder state',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
legal_source = NULL,
|
||||
deadline_notes = 'Frist vom Gericht bestimmt',
|
||||
deadline_notes_en = NULL
|
||||
WHERE submission_code = 'de.inf.lg.replik'
|
||||
AND is_active = true;
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
is_court_set = false,
|
||||
legal_source = NULL,
|
||||
deadline_notes = 'Frist vom Gericht bestimmt',
|
||||
deadline_notes_en = NULL
|
||||
WHERE submission_code = 'de.inf.lg.duplik'
|
||||
AND is_active = true;
|
||||
@@ -0,0 +1,94 @@
|
||||
-- t-paliad-264 / m/paliad#95 — Fix de.inf.lg Replik + Duplik sequencing.
|
||||
--
|
||||
-- BEFORE this migration, the de.inf.lg rules for Replik and Duplik
|
||||
-- had parent_id = NULL with duration_value = 4 weeks each. The
|
||||
-- projection therefore anchored both off the proceeding's trigger
|
||||
-- date (Klageerhebung) and added 4 weeks → both rows rendered at the
|
||||
-- same calendar date, BEFORE Klageerwiderung (which sits at
|
||||
-- Klageerhebung + 6 weeks per § 276 Abs. 1 S. 2 ZPO).
|
||||
--
|
||||
-- Correct ZPO sequence for first-instance infringement before the
|
||||
-- Landgericht is:
|
||||
--
|
||||
-- Klageerhebung (§ 253 ZPO)
|
||||
-- → Anzeige der Verteidigungsbereitschaft (§ 276 Abs. 1 S. 1 ZPO,
|
||||
-- 2 Wochen ab Zustellung der Klage)
|
||||
-- → Klageerwiderung (§ 276 Abs. 1 S. 2 + § 277 ZPO; vom Gericht
|
||||
-- gesetzte Frist von mindestens 2 Wochen, in der Praxis 6 Wochen)
|
||||
-- → Replik (vom Gericht gesetzte Frist; Anordnungskompetenz aus
|
||||
-- § 273 ZPO, prozessuale Förderungspflicht der Parteien aus
|
||||
-- § 282 ZPO; in der Praxis ~ 4 Wochen ab Zustellung der
|
||||
-- Klageerwiderung)
|
||||
-- → Duplik (vom Gericht gesetzte Frist; § 273, § 282 ZPO; in der
|
||||
-- Praxis ~ 4 Wochen ab Zustellung der Replik)
|
||||
--
|
||||
-- Replik and Duplik have NO statutory period — the Landgericht fixes
|
||||
-- the period in its prozessleitende Verfügung. We model them as
|
||||
-- is_court_set = true with a placeholder 4-week duration anchored on
|
||||
-- the immediately preceding filing so the timeline (a) renders them
|
||||
-- in strict chronological order and (b) gives the lawyer a sane
|
||||
-- notional date that can be overridden via "Datum setzen" once the
|
||||
-- court issues the actual period.
|
||||
--
|
||||
-- legal_source set to DE.ZPO.273 (Vorbereitung des Termins —
|
||||
-- court's case-management power that authorises setting Replik /
|
||||
-- Duplik periods). The full citation chain (§§ 273, 282 ZPO) lives
|
||||
-- in deadline_notes so the rendered card explains the source.
|
||||
--
|
||||
-- Scope strictly de.inf.lg / cfi per the t-paliad-264 brief. Other
|
||||
-- jurisdictions are out of scope and will be addressed via curie's
|
||||
-- m/paliad#94 audit follow-ups (Wave 0+).
|
||||
--
|
||||
-- Slot note: this migration originally landed as 123 in an earlier
|
||||
-- iteration; cronus's t-paliad-246 Backup-Mode migration won slot
|
||||
-- 123 in parallel-merge order, so this one shifted to 124.
|
||||
--
|
||||
-- Idempotency: each UPDATE is guarded by a WHERE clause that only
|
||||
-- matches the pre-fix row state (parent_id IS NULL on Replik /
|
||||
-- Duplik, since that was the load-bearing bug). A re-apply against
|
||||
-- a DB that already carries the fix matches zero rows and no-ops —
|
||||
-- no duplicate audit-log rows in paliad.deadline_rule_audit, no
|
||||
-- redundant writes. Mig 095 convention.
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on any UPDATE without it.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 124: t-paliad-264 / m/paliad#95 — anchor de.inf.lg Replik on Klageerwiderung and Duplik on Replik, mark both is_court_set per § 273 ZPO',
|
||||
true);
|
||||
|
||||
-- Replik anchors on Klageerwiderung (de.inf.lg.erwidg).
|
||||
-- Guard: parent_id IS NULL — only fires against the pre-fix shape.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.erwidg'
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
),
|
||||
is_court_set = true,
|
||||
legal_source = 'DE.ZPO.273',
|
||||
deadline_notes = 'Frist vom Gericht in der prozessleitenden Verfügung bestimmt (§ 273 ZPO, prozessuale Förderungspflicht § 282 ZPO). In der Praxis ca. 4 Wochen ab Zustellung der Klageerwiderung; mit "Datum setzen" überschreiben, sobald die gerichtliche Verfügung vorliegt.',
|
||||
deadline_notes_en = 'Period set by the court in its case-management order (§ 273 ZPO; parties'' duty to file timely under § 282 ZPO). Typically ca. 4 weeks after service of the Statement of Defence; use "Set date" to override once the court issues the actual period.'
|
||||
WHERE submission_code = 'de.inf.lg.replik'
|
||||
AND is_active = true
|
||||
AND parent_id IS NULL;
|
||||
|
||||
-- Duplik anchors on Replik (de.inf.lg.replik).
|
||||
-- Guard: parent_id IS NULL — only fires against the pre-fix shape.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.replik'
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
),
|
||||
is_court_set = true,
|
||||
legal_source = 'DE.ZPO.273',
|
||||
deadline_notes = 'Frist vom Gericht in der prozessleitenden Verfügung bestimmt (§ 273 ZPO, prozessuale Förderungspflicht § 282 ZPO). In der Praxis ca. 4 Wochen ab Zustellung der Replik; mit "Datum setzen" überschreiben, sobald die gerichtliche Verfügung vorliegt.',
|
||||
deadline_notes_en = 'Period set by the court in its case-management order (§ 273 ZPO; parties'' duty to file timely under § 282 ZPO). Typically ca. 4 weeks after service of the Reply; use "Set date" to override once the court issues the actual period.'
|
||||
WHERE submission_code = 'de.inf.lg.duplik'
|
||||
AND is_active = true
|
||||
AND parent_id IS NULL;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Down migration for 125_cross_cutting_filter_legal_source.up.sql.
|
||||
--
|
||||
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
|
||||
-- rows) and removes the trigger-207 backfill row. Two steps in
|
||||
-- forward-reverse order so the matview drop doesn't trip on the
|
||||
-- deadline_rules delete.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 125 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
|
||||
true);
|
||||
|
||||
-- 1. Drop the matview before pulling rows underneath it.
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
-- 2. Delete the trigger 207 backfill row.
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id = 207
|
||||
AND sequence_order = 1207;
|
||||
|
||||
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
|
||||
-- trigger rows).
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
'r:' || dr.id::text AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
dr.id AS rule_id,
|
||||
NULL::bigint AS trigger_event_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction AS jurisdiction,
|
||||
pt.display_order AS proceeding_display_order,
|
||||
dr.submission_code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active
|
||||
AND pt.is_active
|
||||
AND pt.category = 'fristenrechner'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'trigger'::text,
|
||||
't:' || te.id::text,
|
||||
dc.id,
|
||||
dc.slug,
|
||||
dc.name_de,
|
||||
dc.name_en,
|
||||
dc.description,
|
||||
dc.aliases,
|
||||
dc.party,
|
||||
dc.category,
|
||||
dc.sort_order,
|
||||
NULL::uuid,
|
||||
te.id,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'cross-cutting'::text,
|
||||
9999::int AS proceeding_display_order,
|
||||
te.code,
|
||||
te.name_de,
|
||||
te.name,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::int,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
WHERE te.is_active;
|
||||
|
||||
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||
@@ -0,0 +1,216 @@
|
||||
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
|
||||
-- by court system in the event-type / Fristen search modal.
|
||||
--
|
||||
-- Two things land here:
|
||||
--
|
||||
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
|
||||
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
|
||||
-- trigger_event but never seeded its event_deadlines counterpart;
|
||||
-- mig 092 then dropped event_deadlines after copying the four
|
||||
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
|
||||
-- so trigger 207 stayed orphaned with no duration / legal_source.
|
||||
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
|
||||
-- par with the four siblings (2 months from removal of obstacle,
|
||||
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
|
||||
-- matview a legal_source to surface for the UPC trigger pill.
|
||||
-- Pattern mirrors the four sibling rows mig 085 inserted.
|
||||
--
|
||||
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
|
||||
-- paliad.deadline_rules for trigger pills, exposing the trigger's
|
||||
-- legal_source on the row. The cross-cutting concept card pills
|
||||
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
|
||||
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
|
||||
-- match against the active forum-bucket filter — see
|
||||
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
|
||||
-- (added in this same change). Without the matview surfacing
|
||||
-- legal_source for trigger rows, every cross-cutting sub-row
|
||||
-- ignored the court-system chip selection (the bug m reported).
|
||||
--
|
||||
-- The materialised view paliad.deadline_search refreshes on the next
|
||||
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
|
||||
-- the new legal_source column for triggers becomes searchable as soon
|
||||
-- as the deploy restarts the process. No matview refresh from the
|
||||
-- migration itself.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 125: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Backfill: deadline_rules row for trigger 207.
|
||||
--
|
||||
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
|
||||
-- mig 085's guard so re-runs are no-ops once the row is present.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_rules (
|
||||
id,
|
||||
proceeding_type_id,
|
||||
parent_id,
|
||||
trigger_event_id,
|
||||
spawn_proceeding_type_id,
|
||||
submission_code,
|
||||
name,
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
duration_unit,
|
||||
timing,
|
||||
alt_duration_value,
|
||||
alt_duration_unit,
|
||||
combine_op,
|
||||
rule_code,
|
||||
deadline_notes,
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
lifecycle_state,
|
||||
draft_of,
|
||||
published_at,
|
||||
concept_id
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
NULL::integer,
|
||||
NULL::uuid,
|
||||
207,
|
||||
NULL::integer,
|
||||
NULL::text,
|
||||
'Wiedereinsetzungsantrag (UPC R.320)',
|
||||
'Petition for re-establishment of rights (UPC R.320)',
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
'months',
|
||||
'after',
|
||||
NULL::integer,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
|
||||
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
|
||||
'UPC.RoP.320',
|
||||
NULL::jsonb,
|
||||
1207,
|
||||
true,
|
||||
'mandatory',
|
||||
'published',
|
||||
NULL::uuid,
|
||||
now(),
|
||||
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.trigger_event_id = 207
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
|
||||
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
|
||||
-- verbatim from mig 098 §5.
|
||||
--
|
||||
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
|
||||
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
|
||||
-- row with proceeding_type_id IS NULL. A trigger event without that
|
||||
-- row leaves legal_source NULL and the trigger pill keeps its current
|
||||
-- "no jurisdiction filter match" semantics — same shape as before this
|
||||
-- migration, just structurally surfaceable.
|
||||
-- =============================================================================
|
||||
|
||||
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
|
||||
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
'r:' || dr.id::text AS row_key,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dc.sort_order AS concept_sort_order,
|
||||
dr.id AS rule_id,
|
||||
NULL::bigint AS trigger_event_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction AS jurisdiction,
|
||||
pt.display_order AS proceeding_display_order,
|
||||
dr.submission_code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active
|
||||
AND pt.is_active
|
||||
AND pt.category = 'fristenrechner'
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'trigger'::text,
|
||||
't:' || te.id::text,
|
||||
dc.id,
|
||||
dc.slug,
|
||||
dc.name_de,
|
||||
dc.name_en,
|
||||
dc.description,
|
||||
dc.aliases,
|
||||
dc.party,
|
||||
dc.category,
|
||||
dc.sort_order,
|
||||
NULL::uuid,
|
||||
te.id,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
'cross-cutting'::text,
|
||||
9999::int AS proceeding_display_order,
|
||||
te.code,
|
||||
te.name_de,
|
||||
te.name,
|
||||
dr_trig.legal_source AS legal_source,
|
||||
NULL::text,
|
||||
NULL::int,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
LEFT JOIN paliad.deadline_rules dr_trig
|
||||
ON dr_trig.trigger_event_id = te.id
|
||||
AND dr_trig.proceeding_type_id IS NULL
|
||||
AND dr_trig.is_active
|
||||
AND dr_trig.lifecycle_state = 'published'
|
||||
WHERE te.is_active;
|
||||
|
||||
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
|
||||
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
|
||||
4
internal/db/migrations/126_users_inbox_seen_at.down.sql
Normal file
4
internal/db/migrations/126_users_inbox_seen_at.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- t-paliad-249 — drop inbox read cursor.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS inbox_seen_at;
|
||||
21
internal/db/migrations/126_users_inbox_seen_at.up.sql
Normal file
21
internal/db/migrations/126_users_inbox_seen_at.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- t-paliad-249 — /inbox overhaul, Slice A.
|
||||
-- Add a per-user high-watermark read cursor for the inbox feed
|
||||
-- (approval requests + curated project_events). The cursor advances
|
||||
-- only when the user POSTs to /api/inbox/mark-all-seen. NULL means
|
||||
-- "never visited" → every row counts as unread on first paint.
|
||||
--
|
||||
-- Note on the carve-out enforced in service code: pending
|
||||
-- approval_requests count toward the inbox's unread state regardless
|
||||
-- of this column. The cursor narrows the project_event source only,
|
||||
-- so a stale cursor never buries a high-value pending approval.
|
||||
--
|
||||
-- Design ref: docs/design-inbox-overhaul-2026-05-25.md §3.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS inbox_seen_at timestamptz NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.users.inbox_seen_at IS
|
||||
'High-watermark cursor for the /inbox feed. project_events newer '
|
||||
'than this timestamp are unread for the caller; NULL = never '
|
||||
'visited (everything unread). Pending approval_requests bypass '
|
||||
'this column and stay unread until decided.';
|
||||
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
@@ -0,0 +1,146 @@
|
||||
-- Revert t-paliad-263 Wave 0 + m/paliad#99.
|
||||
-- Restores each Tier 0 row to its pre-fix state per
|
||||
-- docs/research-deadlines-completeness-2026-05-25.md §10. T0.5 and
|
||||
-- T0.6 are NOT reverted here — they live in mig 124's down.
|
||||
--
|
||||
-- audit_reason set_config required for the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 127 revert: unwind Tier 0 deadline-rule corrections (Wave 0 + #99)',
|
||||
true);
|
||||
|
||||
-- T0.1 defence: 2mo + RoP.049.1 → 3mo + RoP.49.1
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 3,
|
||||
rule_code = 'RoP.49.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.2 rejoin: 1mo + RoP.052/UPC.RoP.52 → 2mo + NULL/NULL
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.3 response: 3mo + RoP.235.1 → 2mo + NULL
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.4 beruf_begr: parent_id NULL → de.inf.lg.berufung
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.berufung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
LIMIT 1
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.7 reply: clear citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.9 notice: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.220.1',
|
||||
legal_source = 'UPC.RoP.220.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.10 grounds: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.220.1',
|
||||
legal_source = 'UPC.RoP.220.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.12 dpma.opp erwiderung: restore court-set=false + §59 citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 59 PatG',
|
||||
legal_source = 'DE.PatG.59.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.13 dpma.appeal.bpatg begründung: restore court-set=false + §75 citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 75 PatG',
|
||||
legal_source = 'DE.PatG.75.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.14 bpatg erwidg: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 82 PatG',
|
||||
legal_source = 'DE.PatG.82.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.15 bgh begründung: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 111 PatG',
|
||||
legal_source = 'DE.PatG.111.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.16 bgh erwiderung: revert court-set + citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 111 PatG',
|
||||
legal_source = 'DE.PatG.111.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.17 epa.opp opd erwidg: revert court-set
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- #99 upc.inf.cfi.soc: clear citation backfill
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
@@ -0,0 +1,477 @@
|
||||
-- t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections.
|
||||
--
|
||||
-- Source: docs/research-deadlines-completeness-2026-05-25.md §10 Tier 0
|
||||
-- (curie's bulletproof completeness audit, merged 2026-05-25 as commit
|
||||
-- 94a9e7e). 16 distinct single-row UPDATEs across UPC + DE-LG + DPMA +
|
||||
-- EPA proceedings; T0.5 + T0.6 were shipped separately as mig 124
|
||||
-- (m/paliad#95, de.inf.lg Replik/Duplik sequencing) and are not
|
||||
-- repeated here. T0.8 (covered by T0.2) and T0.11 (covered by T0.1)
|
||||
-- are dedup'd out per the audit's own note.
|
||||
--
|
||||
-- Also folds in m/paliad#99 (UPC Statement of Claim missing legal
|
||||
-- citation): upc.inf.cfi.soc.rule_code / legal_source backfilled to
|
||||
-- UPC RoP R.13(1). Same migration file, separate UPDATE block with
|
||||
-- its own guard.
|
||||
--
|
||||
-- All fixes within the existing schema (no new columns). Each UPDATE
|
||||
-- is guarded by a WHERE clause that matches only the pre-fix row
|
||||
-- state (per mig 095 convention) — re-applying against a DB that
|
||||
-- already carries the fix matches zero rows and no-ops, so there are
|
||||
-- no duplicate deadline_rule_audit entries on idempotent re-runs.
|
||||
--
|
||||
-- Verification DO block at the end RAISEs EXCEPTION if any of the
|
||||
-- patched rows is left in an inconsistent shape (mixing pre-fix and
|
||||
-- post-fix state).
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on any UPDATE without it.
|
||||
--
|
||||
-- Slot 127 reserved per paliadin: sequence is 124 brunel #95 (done),
|
||||
-- 125 hermes #97, 126 icarus #80, 127 brunel Wave 0 + #99, 128+ next.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 127: t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections from curie''s audit (docs/research-deadlines-completeness-2026-05-25.md §10) plus UPC SoC R.13 citation',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.1 upc.rev.cfi.defence — duration 3mo → 2mo per RoP.049.1.
|
||||
-- Zero-pads the rule_code citation to canonical form. Audit §5
|
||||
-- (wrong period — every UPC_REV tracked in paliad today computes
|
||||
-- Defence at +3 months, statute says +2). Verbatim from
|
||||
-- UPCRoP.049.1: "The defendant shall lodge a Defence to revocation
|
||||
-- within two months of service of the Statement for revocation."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = 'RoP.049.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 3
|
||||
AND rule_code = 'RoP.49.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.2 upc.rev.cfi.rejoin — duration 2mo → 1mo per RoP.052; add citation.
|
||||
-- Audit §5 (wrong period). Verbatim from UPCRoP.052: "Within one
|
||||
-- month of the service of the Reply the defendant may lodge a
|
||||
-- Rejoinder to the Reply to the Defence to revocation."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 1,
|
||||
rule_code = 'RoP.052',
|
||||
legal_source = 'UPC.RoP.52',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.3 upc.apl.merits.response — duration 2mo → 3mo per RoP.235.1.
|
||||
-- Audit §5 (wrong period — every main-track appellate respondent).
|
||||
-- Verbatim from UPCRoP.235.1: "Within three months of service of
|
||||
-- the Statement of grounds of appeal pursuant to Rule 224.2(a),
|
||||
-- any other party … may lodge a Statement of response."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 3,
|
||||
rule_code = 'RoP.235.1',
|
||||
legal_source = 'UPC.RoP.235.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.4 de.inf.lg.beruf_begr — parent_id = NULL (was de.inf.lg.berufung).
|
||||
-- Audit §7.1 — every DE-LG-Verletzung appeal renders the
|
||||
-- Berufungsbegründung at trigger + 1mo (Berufung) + 2mo = 3 months
|
||||
-- from Urteil-service. Per ZPO §520(2) "die Frist für die
|
||||
-- Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der
|
||||
-- Zustellung des in vollständiger Form abgefassten Urteils" → 2
|
||||
-- months from Urteil-service (parallel to, not chained off, the
|
||||
-- Berufungsfrist itself). NULL parent_id makes the rule anchor
|
||||
-- on the proceeding's trigger date — matches how the symmetric
|
||||
-- de.inf.olg.begruendung is modelled.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.berufung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.5 / T0.6 de.inf.lg.replik + de.inf.lg.duplik — already shipped
|
||||
-- as mig 124 (m/paliad#95). Not repeated here. Idempotency of the
|
||||
-- audit's Tier 0 sweep against a fresh DB is preserved because mig
|
||||
-- 124 runs before this one and is itself guarded.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.7 upc.rev.cfi.reply — backfill rule_code + legal_source per RoP.051.
|
||||
-- Audit §4.1 — duration (2mo) unchanged. Verbatim from UPCRoP.051:
|
||||
-- "Reply to Defence to revocation and Application to amend the
|
||||
-- patent. The claimant in the revocation action may, within two
|
||||
-- months of service of the Defence to revocation and the
|
||||
-- Application to amend the patent, if any, lodge a Reply…"
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.051',
|
||||
legal_source = 'UPC.RoP.51',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.9 upc.apl.merits.notice — citation drift RoP.220.1 → RoP.224.1.a.
|
||||
-- Audit §4.1 — duration unchanged. R.220.1 is the umbrella ("an
|
||||
-- appeal may be brought"); R.224.1(a) carries the Notice-of-appeal
|
||||
-- 2-month period explicitly.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.224.1.a',
|
||||
legal_source = 'UPC.RoP.224.1.a',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.220.1'
|
||||
AND legal_source = 'UPC.RoP.220.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.10 upc.apl.merits.grounds — citation drift RoP.220.1 → RoP.224.2.a.
|
||||
-- Audit §4.1 — duration unchanged. R.224.2(a) sets the Grounds
|
||||
-- 4-month period for decisions referred to in R.220.1(a) and (b).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.224.2.a',
|
||||
legal_source = 'UPC.RoP.224.2.a',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.220.1'
|
||||
AND legal_source = 'UPC.RoP.220.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.12 dpma.opp.dpma.erwiderung — flip is_court_set = true; drop the
|
||||
-- § 59(3) PatG citation. Audit §4.3 + §9.1: §59(3) addresses
|
||||
-- Anhörung, not a 4-month response period. No statutory
|
||||
-- Erwiderungsfrist exists in §59 — the 4-month figure is DPMA
|
||||
-- practice (DPMA-Richtlinien D-IV 5.2). Modelled court-set, the
|
||||
-- 4-month value remains the default-display heuristic the
|
||||
-- lawyer overrides via "Datum setzen".
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.59.3';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.13 dpma.appeal.bpatg.begruendung — flip is_court_set = true; drop
|
||||
-- the § 75 PatG citation. Audit §4.3 + §9.1: §75 PatG addresses
|
||||
-- aufschiebende Wirkung only, not a Begründungsfrist. No fixed
|
||||
-- Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 —
|
||||
-- the BPatG sets it in the individual case. 1-month default
|
||||
-- retained as display heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.75.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.14 de.null.bpatg.erwidg — citation DE.PatG.82.1 → DE.PatG.82.3.
|
||||
-- Audit §4.4 — duration (2 months) is correct. §82(1) carries the
|
||||
-- 1-month Erklärungsfrist ("sich darüber zu erklären"); the full
|
||||
-- Klageerwiderung 2-month period lives in §82(3).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 82 Abs. 3 PatG',
|
||||
legal_source = 'DE.PatG.82.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.82.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.15 de.null.bgh.begruendung — citation DE.PatG.111.1 →
|
||||
-- DE.ZPO.520.2 (via PatG §117). Audit §4.4 — duration (3 months)
|
||||
-- is correct. §111 PatG defines the Grounds of Berufung
|
||||
-- (Verletzung des Bundesrechts), not a Begründungsfrist; the
|
||||
-- 3-month figure is supplied by §117 PatG → ZPO §520(2).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 520 Abs. 2 ZPO i.V.m. § 117 PatG',
|
||||
legal_source = 'DE.ZPO.520.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.111.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.16 de.null.bgh.erwiderung — flip is_court_set = true; recite as
|
||||
-- DE.ZPO.521.2 (via PatG §117). Audit §4.4 + §9.1 — §111 PatG
|
||||
-- has no Erwiderungsfrist clause. The actual Erwiderungsfrist
|
||||
-- for BGH-Nichtigkeitsberufung is set by the court per §117
|
||||
-- PatG → ZPO §521(2). 2-month default retained as display
|
||||
-- heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = '§ 521 Abs. 2 ZPO i.V.m. § 117 PatG',
|
||||
legal_source = 'DE.ZPO.521.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.111.3';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.17 epa.opp.opd.erwidg — flip is_court_set = true. Audit §4.5 +
|
||||
-- §9.1: R.79(1) EPÜ authorises the Opposition Division to set
|
||||
-- the period, but does not specify a fixed 4 months. The 4-month
|
||||
-- figure is administrative practice (EPO Guidelines D-IV 5.2).
|
||||
-- Citation retained as the rule-of-authority for the OD's
|
||||
-- discretion. 4-month default retained as display heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'EU.EPC-R.79.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- m/paliad#99 upc.inf.cfi.soc — backfill UPC RoP R.13(1) citation.
|
||||
-- The Statement of Claim is defined in UPC RoP R.13 (R.13.1
|
||||
-- lists the required contents). The row carries no statutory
|
||||
-- deadline (duration_value = 0, parent_id IS NULL — the SoC is
|
||||
-- the originating filing that anchors the proceeding's trigger
|
||||
-- date), but the catalog UI surfaces the rule citation in
|
||||
-- result cards and the Type=Statement-of-Claim / Rule=Auto
|
||||
-- resolution; both render blank today because rule_code +
|
||||
-- legal_source are NULL. Backfill leaves duration / anchor /
|
||||
-- party untouched.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.013.1',
|
||||
legal_source = 'UPC.RoP.13.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- Hard assertions. Each touched row must end up in its post-fix
|
||||
-- shape. Re-running the migration after a successful first run is a
|
||||
-- no-op for the data but the assertions still pass because they
|
||||
-- check the post-fix state.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
-- T0.1 defence: dur=2 + canonical zero-padded rule_code
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code = 'RoP.049.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.1: upc.rev.cfi.defence not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.2 rejoin: dur=1
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 1
|
||||
AND rule_code = 'RoP.052';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.2: upc.rev.cfi.rejoin not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.3 response: dur=3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 3
|
||||
AND rule_code = 'RoP.235.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.3: upc.apl.merits.response not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.4 beruf_begr: parent_id IS NULL
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.4: de.inf.lg.beruf_begr not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.7 reply: citation backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.051'
|
||||
AND legal_source = 'UPC.RoP.51';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.7: upc.rev.cfi.reply not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.9 notice: citation RoP.224.1.a
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.1.a'
|
||||
AND legal_source = 'UPC.RoP.224.1.a';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.9: upc.apl.merits.notice not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.10 grounds: citation RoP.224.2.a
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.2.a'
|
||||
AND legal_source = 'UPC.RoP.224.2.a';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.10: upc.apl.merits.grounds not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.12 dpma.opp erwiderung: court-set, no citation
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source IS NULL
|
||||
AND rule_code IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.12: dpma.opp.dpma.erwiderung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.13 dpma.appeal.bpatg begründung: court-set, no citation
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source IS NULL
|
||||
AND rule_code IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.13: dpma.appeal.bpatg.begruendung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.14 bpatg erwidg: §82.3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.82.3';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.14: de.null.bpatg.erwidg not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.15 bgh begründung: ZPO §520.2
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.ZPO.520.2';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.15: de.null.bgh.begruendung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.16 bgh erwiderung: court-set, ZPO §521.2
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source = 'DE.ZPO.521.2';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.16: de.null.bgh.erwiderung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.17 epa.opp opd erwidg: court-set
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.17: epa.opp.opd.erwidg not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- #99 upc.inf.cfi.soc: citation backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.013.1'
|
||||
AND legal_source = 'UPC.RoP.13.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 #99: upc.inf.cfi.soc not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
|
||||
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
|
||||
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
|
||||
-- No data revert necessary — the constraint addition was purely
|
||||
-- additive and validated against live data before adding.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
|
||||
-- 'working_days' added to the allowed set.
|
||||
--
|
||||
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
|
||||
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
|
||||
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
|
||||
-- combine-max pattern). The schema currently accepts free-text on
|
||||
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
|
||||
-- in the DB but were silently dropped by the calculator. Adding the CHECK
|
||||
-- pins the contract and prevents typos.
|
||||
--
|
||||
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
|
||||
-- path stays in lockstep with the primary path.
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
|
||||
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
|
||||
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
|
||||
-- alt-unit rows already at 'working_days' — both shapes pass.
|
||||
--
|
||||
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
|
||||
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_duration_unit_check
|
||||
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
|
||||
CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -221,6 +222,16 @@ func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// GET /api/inbox/count — bell badge count for the sidebar.
|
||||
//
|
||||
// Since t-paliad-249 (Slice A) the count is the **unified** unread
|
||||
// count: pending approval requests (regardless of cursor) +
|
||||
// curated project_events (InboxProjectEventKinds) on visible
|
||||
// projects whose created_at is newer than users.inbox_seen_at. See
|
||||
// ApprovalService.UnseenInboxCountForUser for the contract.
|
||||
//
|
||||
// The legacy approval-only count is still reachable via
|
||||
// PendingCountForUser inside the dashboard widget — that path
|
||||
// doesn't go through this endpoint.
|
||||
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -229,7 +240,7 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
|
||||
n, err := dbSvc.approval.UnseenInboxCountForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -237,6 +248,57 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]int{"count": n})
|
||||
}
|
||||
|
||||
// POST /api/inbox/mark-all-seen — advance the caller's inbox read
|
||||
// cursor (paliad.users.inbox_seen_at). Optional body
|
||||
// `{"up_to": "<iso8601>"}` pins the advance to the timestamp of the
|
||||
// newest row the client actually saw — handy when a second tab made
|
||||
// the inbox grow between the read and the click. Missing body =>
|
||||
// advance to now().
|
||||
//
|
||||
// Returns the new cursor as `{"inbox_seen_at": "<iso8601>"}` so the
|
||||
// client can keep its local state in sync.
|
||||
func handleInboxMarkAllSeen(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
UpTo string `json:"up_to"`
|
||||
}
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
}
|
||||
var upTo time.Time
|
||||
if body.UpTo != "" {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, body.UpTo)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "up_to must be RFC3339"})
|
||||
return
|
||||
}
|
||||
upTo = parsed
|
||||
}
|
||||
if err := dbSvc.approval.MarkInboxSeen(r.Context(), uid, upTo); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
cur, err := dbSvc.approval.InboxSeenAt(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{}
|
||||
if cur != nil {
|
||||
resp["inbox_seen_at"] = cur.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// parseInboxFilter pulls common filter knobs off the query string.
|
||||
//
|
||||
// Status / EntityType pass through validation: an unrecognised value is
|
||||
@@ -326,6 +388,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
|
||||
//
|
||||
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
|
||||
// pending create) without withdrawing the request. The non-destructive
|
||||
// sibling of /revoke that m asked for after noticing that withdraw silently
|
||||
// deletes the underlying event.
|
||||
//
|
||||
// Body: {"fields": {<entity-shape>}}
|
||||
// 200: {"status": "ok"}
|
||||
//
|
||||
// Status mapping (mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — payload has no allowlisted fields
|
||||
// 403 not_authorized — caller isn't the requested_by
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — request already decided / revoked
|
||||
type editPendingEntityBody struct {
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body editPendingEntityBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
|
||||
247
internal/handlers/backups.go
Normal file
247
internal/handlers/backups.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package handlers
|
||||
|
||||
// Admin Backup Mode handlers (t-paliad-246 / m/paliad#77 Slice A).
|
||||
//
|
||||
// POST /api/admin/backups/run — kick off an on-demand backup
|
||||
// GET /api/admin/backups — chronological list
|
||||
// GET /api/admin/backups/{id} — single catalog row
|
||||
// GET /api/admin/backups/{id}/file — stream the artifact (records
|
||||
// a backup_downloaded audit row)
|
||||
// GET /admin/backups — admin page (SPA shell)
|
||||
//
|
||||
// Authorisation: every route registers behind adminGate(users, …) in
|
||||
// handlers.go, so every handler in this file can assume the caller is a
|
||||
// global_admin and only validate the request shape.
|
||||
//
|
||||
// The runner is wired in cmd/server/main.go only when PALIAD_EXPORT_DIR
|
||||
// is set. When unset, every handler returns 503 — same shape as
|
||||
// requireDB.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// backupRequestTimeout caps a single on-demand backup. At firm-scale
|
||||
// data shapes (today: ~600 user-content rows + ~1000 reference rows)
|
||||
// a backup runs sub-second; the watchdog surfaces "stuck" as a 500
|
||||
// instead of letting the client hang forever.
|
||||
const backupRequestTimeout = 5 * time.Minute
|
||||
|
||||
// requireBackup writes a 503 if the BackupRunner is not wired (typically
|
||||
// PALIAD_EXPORT_DIR is unset) and returns false. Mirrors requireDB.
|
||||
func requireBackup(w http.ResponseWriter) bool {
|
||||
if dbSvc == nil || dbSvc.backup == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "backup service not configured — set PALIAD_EXPORT_DIR on the server",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleAdminBackupsPage renders the /admin/backups SPA shell. The
|
||||
// catalog rows are fetched client-side via /api/admin/backups.
|
||||
func handleAdminBackupsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-backups.html")
|
||||
}
|
||||
|
||||
// handleAdminRunBackup kicks off a synchronous on-demand backup and
|
||||
// returns the resulting BackupSummary as JSON. Synchronous: at firm-
|
||||
// scale the whole run is under 5s; an async path with polling is Slice
|
||||
// B (the scheduler reuses the same runner internally).
|
||||
//
|
||||
// Returns 201 on success with the catalog row, 500 on failure (the
|
||||
// catalog/audit rows are still flipped to failed/backup_failed before
|
||||
// the response).
|
||||
func handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), backupRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("backup: user lookup failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "user lookup failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
actor := services.BackupActor{
|
||||
ID: &uid,
|
||||
Email: user.Email,
|
||||
Label: user.DisplayName,
|
||||
}
|
||||
result, err := dbSvc.backup.Run(ctx, services.BackupKindOnDemand, actor)
|
||||
if err != nil {
|
||||
log.Printf("backup: Run failed for admin=%s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "backup generation failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the freshly-written catalog row so the UI doesn't need a
|
||||
// follow-up GET to render the new line item.
|
||||
row, err := dbSvc.backup.GetBackup(ctx, result.ID)
|
||||
if err != nil {
|
||||
// The backup did succeed — log + return the bare result.
|
||||
log.Printf("backup: post-run GetBackup failed for %s: %v", result.ID, err)
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, row)
|
||||
}
|
||||
|
||||
// handleAdminListBackups returns the most recent N catalog rows as
|
||||
// JSON. ?limit=N caps the page (default 100).
|
||||
func handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("limit")); q != "" {
|
||||
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
rows, err := dbSvc.backup.ListBackups(r.Context(), limit)
|
||||
if err != nil {
|
||||
log.Printf("backup: list failed: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "list failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []services.BackupSummary{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// handleAdminGetBackup returns one catalog row. Used by the UI for
|
||||
// "is the backup I just kicked off done yet?" polling — though at the
|
||||
// synchronous shape today this rarely matters.
|
||||
func handleAdminGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("backup: get failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// handleAdminDownloadBackup streams the artifact bytes through the
|
||||
// ArtifactStore (LocalDiskStore for v1). Records a backup_downloaded
|
||||
// audit row before flushing.
|
||||
//
|
||||
// 404 if the catalog row is missing; 410 (Gone) if the artifact was
|
||||
// already lifecycle-deleted; 409 if status is not 'done'; 500 on any
|
||||
// store/IO error.
|
||||
func handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) || !requireBackup(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("backup: download GetBackup failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||
return
|
||||
}
|
||||
if row.Status != services.BackupStatusDone || row.StorageURI == nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "backup not available for download",
|
||||
"status": row.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
if row.DeletedAt != nil {
|
||||
// 410 Gone — the artifact is past its retention window. Catalog
|
||||
// row stays as the audit trail; clients should not retry.
|
||||
writeJSON(w, http.StatusGone, map[string]string{
|
||||
"error": "artifact has been removed (retention)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rc, size, err := dbSvc.backup.Store().Get(r.Context(), *row.StorageURI)
|
||||
if err != nil {
|
||||
log.Printf("backup: download store.Get failed for %s: %v", id, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store read failed"})
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Record the download audit row before flushing. If the audit
|
||||
// write fails we still serve the file (the user can see it; the
|
||||
// chain just missed a row — surface in logs).
|
||||
user, uErr := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if uErr == nil && user != nil {
|
||||
auditErr := dbSvc.backup.RecordDownload(r.Context(), id, services.BackupActor{
|
||||
ID: &uid,
|
||||
Email: user.Email,
|
||||
Label: user.DisplayName,
|
||||
})
|
||||
if auditErr != nil {
|
||||
log.Printf("backup: RecordDownload failed for %s by %s: %v", id, uid, auditErr)
|
||||
}
|
||||
} else if uErr != nil {
|
||||
log.Printf("backup: user lookup for audit failed (%s): %v", uid, uErr)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("paliad-backup-%s.zip", row.StartedAt.UTC().Format("20060102T1504Z"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-Paliad-Backup-Id", id.String())
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
log.Printf("backup: response write failed for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,29 @@ func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if _, ok := checklists.Find(slug); !ok {
|
||||
http.NotFound(w, r)
|
||||
// Static catalog match → serve unconditionally; the static templates
|
||||
// are always visible.
|
||||
if _, ok := checklists.Find(slug); ok {
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
// Otherwise fall back to the DB-backed catalog (authored templates,
|
||||
// slug shape "u-a-..." from t-paliad-225). The catalog enforces
|
||||
// visibility per-user; a slug the caller can't see returns
|
||||
// ErrNotVisible and the user gets the same 404 they'd see for an
|
||||
// unknown slug. Without this branch authored checklists 404'd at the
|
||||
// page level even though they showed up in the overview, which is
|
||||
// exactly m's 2026-05-22 report.
|
||||
if dbSvc != nil && dbSvc.checklistCatalog != nil {
|
||||
uid, ok := auth.UserIDFromContext(r.Context())
|
||||
if ok {
|
||||
if _, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug); err == nil {
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -37,6 +37,11 @@ type fileEntry struct {
|
||||
//
|
||||
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
|
||||
// identifier so existing bookmarks keep working post-rebrand.
|
||||
//
|
||||
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
|
||||
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
|
||||
// handleFileDownload serves any slug that lands here, but the public URL
|
||||
// surface for submission templates is the export endpoint, not /files.
|
||||
var fileRegistry = map[string]fileEntry{
|
||||
"hl-patents-style.dotm": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
|
||||
@@ -46,6 +51,92 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
|
||||
},
|
||||
// Per-submission demo template (t-paliad-241). Exercises every
|
||||
// placeholder SubmissionVarsService resolves so the
|
||||
// /projects/{id}/submissions/{code}/draft editor has variables to
|
||||
// substitute. One file per submission_code; future codes register
|
||||
// the same way — slug shape "submission/<code>.docx" so the
|
||||
// namespace stays separate from the universal style template.
|
||||
"submission/de.inf.lg.erwidg.docx": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
|
||||
// that carries every placeholder SubmissionVarsService resolves but no
|
||||
// submission_code-specific body structure. Slot between the per-firm
|
||||
// per-code template and the bare HL Patents Style .dotm fallback: every
|
||||
// submission_code without a dedicated template still renders with
|
||||
// variables substituted instead of the macro-only letterhead.
|
||||
skeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
// universal HL Patents Style as the global fallback.
|
||||
//
|
||||
// Add new entries here as the firm authors per-submission templates;
|
||||
// the file itself lives in mWorkRepo and is served through the shared
|
||||
// Gitea proxy cache so refreshes are visible to all consumers in one
|
||||
// place.
|
||||
var submissionTemplateRegistry = map[string]string{
|
||||
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateBytes returns the per-submission_code template
|
||||
// bytes (and provenance SHA) when one is registered. The bool result
|
||||
// distinguishes "no per-code template registered" (callers fall back to
|
||||
// HL Patents Style) from an upstream fetch error.
|
||||
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
|
||||
slug, ok := submissionTemplateRegistry[submissionCode]
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, true, nil
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
@@ -118,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
|
||||
// resolveSubmissionTemplate's fallback chain — used for every
|
||||
// submission_code that has no dedicated template registered. Same
|
||||
// stale-while-revalidate semantics as the rest of the file proxy: first
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, nil
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
|
||||
@@ -98,6 +98,14 @@ type Services struct {
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode (org-scope admin backups). Nil when
|
||||
// DATABASE_URL or PALIAD_EXPORT_DIR is unset; the /admin/backups
|
||||
// routes return 503 in that case.
|
||||
Backup *services.BackupRunner
|
||||
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -159,6 +167,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +282,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
|
||||
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
|
||||
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
|
||||
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
|
||||
// entry). Lists every draft the caller owns across visible projects.
|
||||
// The per-project Schriftsätze tab keeps the editor itself project-
|
||||
// scoped; this index is the cross-project landing.
|
||||
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
|
||||
protected.HandleFunc("GET /courts", handleCourtsPage)
|
||||
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
|
||||
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
|
||||
@@ -313,6 +328,33 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// writes an audit row.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze draft editor.
|
||||
// Per (project, submission_code, user) named drafts with autosaved
|
||||
// variable overrides; export merges the bag with the universal HL
|
||||
// Patents Style template (Slice A) or the per-code template
|
||||
// (Slice B, future).
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts", handleListSubmissionDrafts)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts", handleCreateSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleGetSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handlePatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
|
||||
// t-paliad-240 — global drafts index (across visible projects).
|
||||
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
|
||||
// t-paliad-243 — global Schriftsätze drafts with optional project
|
||||
// binding. The picker page at /submissions/new lists the full
|
||||
// cross-proceeding catalog (without a project context) and posts to
|
||||
// POST /api/submission-drafts to spawn a draft. The
|
||||
// /api/submission-drafts/{draft_id}* endpoints back the project-less
|
||||
// editor and ALSO accept project-scoped drafts (the draft row
|
||||
// carries its own project_id so the project segment is redundant).
|
||||
protected.HandleFunc("GET /api/submissions/catalog", handleListSubmissionCatalog)
|
||||
protected.HandleFunc("POST /api/submission-drafts", handleCreateGlobalSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/submission-drafts/{draft_id}", handleGetGlobalSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -466,6 +508,20 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-230 Schriftsätze tab — same shape as every other tab above.
|
||||
// Without this route the deep-link 404s; the tab still works via
|
||||
// in-page click since it just toggles a panel.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-238 — dedicated submission draft editor. Both routes
|
||||
// render the project detail page; the actual editor mounts
|
||||
// client-side based on the URL path.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft", gateOnboarded(handleSubmissionDraftPage))
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft/{draft_id}", gateOnboarded(handleSubmissionDraftPage))
|
||||
// t-paliad-243 — global Schriftsätze pages: picker + project-less
|
||||
// editor. Both render dist/* files; client bundles parse the URL
|
||||
// and branch on whether a project segment is present.
|
||||
protected.HandleFunc("GET /submissions/new", gateOnboarded(handleSubmissionsNewPage))
|
||||
protected.HandleFunc("GET /submissions/draft/{draft_id}", gateOnboarded(handleSubmissionDraftGlobalPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
@@ -520,6 +576,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
|
||||
// t-paliad-246 / m/paliad#77 Slice A — Backup Mode admin page +
|
||||
// API. Routes only register when Users is wired (matches the
|
||||
// other admin routes); per-request 503 if BackupRunner itself
|
||||
// is unwired (PALIAD_EXPORT_DIR unset).
|
||||
protected.HandleFunc("GET /admin/backups", adminGate(users, gateOnboarded(handleAdminBackupsPage)))
|
||||
protected.HandleFunc("POST /api/admin/backups/run", adminGate(users, handleAdminRunBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups", adminGate(users, handleAdminListBackups))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
@@ -604,10 +671,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/inbox/pending-mine", handleListInboxPendingMine)
|
||||
protected.HandleFunc("GET /api/inbox/mine", handleListInboxMine)
|
||||
protected.HandleFunc("GET /api/inbox/count", handleInboxCount)
|
||||
protected.HandleFunc("POST /api/inbox/mark-all-seen", handleInboxMarkAllSeen)
|
||||
protected.HandleFunc("GET /api/approval-requests/{id}", handleGetApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
// t-paliad-252 — non-destructive sibling of /revoke: lets the
|
||||
// requester revise the in-flight entity without withdrawing.
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
@@ -649,6 +720,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
|
||||
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
|
||||
protected.HandleFunc("GET /api/paliadin/turns/{id}", handlePaliadinTurnGet)
|
||||
// Recovery endpoint (t-paliad-235): when the SSE stream drops mid-turn,
|
||||
// the frontend hits this to ask whether aichat actually finished the
|
||||
// turn upstream. Dispatches per backend — aichat hits the conversation
|
||||
// API; legacy backends fall through to the local row read + janitor.
|
||||
protected.HandleFunc("GET /api/paliadin/turns/{id}/recover", handlePaliadinTurnRecover)
|
||||
// Crash-resistant history hydrate (t-paliad-161 follow-up): both
|
||||
// Paliadin surfaces use this to seed their UI from the DB before
|
||||
// consulting localStorage.
|
||||
|
||||
@@ -185,16 +185,21 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// runPaliadinTurnAsync executes the turn and writes events into ch.
|
||||
// Uses a 150 s hard timeout independently of the originating request,
|
||||
// which leaves headroom over the shim's 120 s run-turn cap + SSH
|
||||
// overhead (t-paliad-155: cold-start safety for skill + MCP discovery).
|
||||
//
|
||||
// Backend dispatch:
|
||||
// - StreamingPaliadin (aichat) → drives runStreamingTurn which relays
|
||||
// incremental chunks + upstream heartbeats. No hard ceiling on
|
||||
// stream duration; falls back to silence_timeout (silenceTimeout)
|
||||
// if the upstream goes dark.
|
||||
// - Plain Paliadin (legacy local/remote) → one-shot RunTurn with the
|
||||
// original 150 s ceiling (matches the shim's 120 s run-turn cap +
|
||||
// SSH overhead per t-paliad-155).
|
||||
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
defer func() {
|
||||
// Drain + close. The SSE handler reads until the channel closes.
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// Send a meta event so the client can show "Paliadin denkt nach …"
|
||||
send(ch, turnEvent{
|
||||
Kind: "meta",
|
||||
Data: map[string]any{
|
||||
@@ -203,6 +208,16 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
},
|
||||
})
|
||||
|
||||
if streamer, ok := paliadinSvc.(services.StreamingPaliadin); ok {
|
||||
runStreamingTurn(turnID, req, ch, streamer)
|
||||
return
|
||||
}
|
||||
runOneShotTurn(turnID, req, ch)
|
||||
}
|
||||
|
||||
// runOneShotTurn drives the legacy synchronous backends (local-tmux PoC,
|
||||
// remote ssh+paliadin-shim). Preserves the original 150 s ceiling.
|
||||
func runOneShotTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
ctx, cancel := newDetachedContext(150 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -220,9 +235,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
}
|
||||
|
||||
// One-shot content event with the full body. The frontend simulates
|
||||
// streaming with a typewriter effect (cf. design §0.5.5: real
|
||||
// chunked streaming would require Claude to write the response file
|
||||
// progressively — out of PoC scope).
|
||||
// streaming with a typewriter effect.
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{"text": result.Response},
|
||||
@@ -241,6 +254,224 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
})
|
||||
}
|
||||
|
||||
// silenceTimeout is the longest the aichat upstream may stay silent
|
||||
// (no chunk, no heartbeat) before runStreamingTurn gives up and fires
|
||||
// an error frame. 90 s comfortably exceeds aichat's 5 s heartbeat
|
||||
// cadence so a transient stall (model wedge, GC pause) doesn't kill
|
||||
// the turn, while still catching a hard upstream drop.
|
||||
const silenceTimeout = 90 * time.Second
|
||||
|
||||
// streamingThinkingInterval is the cadence at which we emit a synthetic
|
||||
// `thinking` event when the upstream has gone quiet but the connection
|
||||
// is still alive. 5 s matches aichat's own heartbeat tick so the UI
|
||||
// pulse never falls more than 5 s out of date.
|
||||
const streamingThinkingInterval = 5 * time.Second
|
||||
|
||||
// streamingTurnDeadline is the upper bound for a single streaming turn.
|
||||
// Far above any realistic Claude turn but finite so a runaway upstream
|
||||
// (or a paliad bug that never closes the channel) can't leak forever.
|
||||
const streamingTurnDeadline = 30 * time.Minute
|
||||
|
||||
// runStreamingTurn drives an incremental turn against the StreamingPaliadin
|
||||
// backend. Relays chunks → content events, upstream heartbeats →
|
||||
// thinking events, errors → error events. Adds its own silence-watch:
|
||||
// if the upstream emits no event for silenceTimeout, fire an error
|
||||
// frame so the client doesn't sit on a dead stream forever.
|
||||
func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent, streamer services.StreamingPaliadin) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), streamingTurnDeadline)
|
||||
defer cancel()
|
||||
|
||||
events := make(chan services.StreamEvent, 32)
|
||||
startedAt := time.Now()
|
||||
|
||||
// streamerDone closes when the backend's RunTurnStream returns. We
|
||||
// race the silence watcher and the event pump against it so the
|
||||
// goroutine exit is clean either way.
|
||||
type runResult struct {
|
||||
result *services.TurnResult
|
||||
err error
|
||||
}
|
||||
runCh := make(chan runResult, 1)
|
||||
go func() {
|
||||
res, err := streamer.RunTurnStream(ctx, req, events)
|
||||
runCh <- runResult{res, err}
|
||||
}()
|
||||
|
||||
var (
|
||||
lastEventAt = time.Now()
|
||||
usedTools []string
|
||||
rowsSeen []int
|
||||
classifierTag string
|
||||
convID string
|
||||
gotChunk bool
|
||||
errorEmitted bool
|
||||
)
|
||||
|
||||
silenceTicker := time.NewTicker(streamingThinkingInterval)
|
||||
defer silenceTicker.Stop()
|
||||
|
||||
emitThinking := func(elapsedSeconds int) {
|
||||
// Don't emit `thinking` after the first real chunk arrives —
|
||||
// the frontend hides the pulse once content starts flowing
|
||||
// anyway, but we save bandwidth by stopping emission.
|
||||
send(ch, turnEvent{
|
||||
Kind: "thinking",
|
||||
Data: map[string]any{
|
||||
"elapsed_seconds": elapsedSeconds,
|
||||
"since_first": gotChunk,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, more := <-events:
|
||||
if !more {
|
||||
events = nil // disable case
|
||||
continue
|
||||
}
|
||||
lastEventAt = time.Now()
|
||||
switch ev.Kind {
|
||||
case services.StreamChunk:
|
||||
gotChunk = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{
|
||||
"delta": ev.Content,
|
||||
"streamed": true,
|
||||
},
|
||||
})
|
||||
case services.StreamHeartbeat:
|
||||
// Upstream is alive but no chunks yet (or a mid-stream
|
||||
// stall). Pass through with our own thinking shape.
|
||||
send(ch, turnEvent{
|
||||
Kind: "thinking",
|
||||
Data: map[string]any{
|
||||
"elapsed_seconds": ev.ElapsedSeconds,
|
||||
"since_first": gotChunk,
|
||||
"upstream": true,
|
||||
},
|
||||
})
|
||||
case services.StreamMeta:
|
||||
usedTools = ev.UsedTools
|
||||
rowsSeen = ev.RowsSeen
|
||||
classifierTag = ev.ClassifierTag
|
||||
case services.StreamConversation:
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": ev.Code,
|
||||
"message": ev.Message,
|
||||
"retryable": ev.Retryable,
|
||||
},
|
||||
})
|
||||
}
|
||||
case <-silenceTicker.C:
|
||||
elapsed := time.Since(lastEventAt)
|
||||
if elapsed >= silenceTimeout {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_silence",
|
||||
"message": "aichat upstream went silent for over " + silenceTimeout.String(),
|
||||
},
|
||||
})
|
||||
// Cancel the backend so it doesn't keep running.
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
emitThinking(int(time.Since(startedAt).Seconds()))
|
||||
case res := <-runCh:
|
||||
// Drain any remaining events the backend pushed before
|
||||
// closing the channel.
|
||||
if events != nil {
|
||||
for ev := range events {
|
||||
switch ev.Kind {
|
||||
case services.StreamChunk:
|
||||
gotChunk = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{
|
||||
"delta": ev.Content,
|
||||
"streamed": true,
|
||||
},
|
||||
})
|
||||
case services.StreamMeta:
|
||||
usedTools = ev.UsedTools
|
||||
rowsSeen = ev.RowsSeen
|
||||
classifierTag = ev.ClassifierTag
|
||||
case services.StreamConversation:
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": ev.Code,
|
||||
"message": ev.Message,
|
||||
"retryable": ev.Retryable,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.err != nil {
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_error",
|
||||
"message": res.err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
result := res.result
|
||||
if result == nil {
|
||||
// Shouldn't happen — backend contract returns either err
|
||||
// or a result. Defensive bail.
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_error",
|
||||
"message": "stream closed without result",
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.UsedTools != nil {
|
||||
usedTools = result.UsedTools
|
||||
}
|
||||
if result.RowsSeen != nil {
|
||||
rowsSeen = result.RowsSeen
|
||||
}
|
||||
if classifierTag == "" && result.ClassifierTag != "" {
|
||||
classifierTag = result.ClassifierTag
|
||||
}
|
||||
endData := map[string]any{
|
||||
"turn_id": turnID.String(),
|
||||
"used_tools": usedTools,
|
||||
"rows_seen": rowsSeen,
|
||||
"chip_count": result.ChipCount,
|
||||
"classifier_tag": classifierTag,
|
||||
"duration_ms": result.DurationMS,
|
||||
"streamed": true,
|
||||
}
|
||||
if convID != "" {
|
||||
endData["aichat_conversation_id"] = convID
|
||||
}
|
||||
send(ch, turnEvent{Kind: "end", Data: endData})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
|
||||
// to. Reads from the per-turn channel + writes SSE-framed events.
|
||||
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -354,6 +585,114 @@ func handlePaliadinTurnGet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handlePaliadinTurnRecover is the dispatching late-recovery endpoint
|
||||
// (t-paliad-235). Replaces the legacy direct-row-read for the aichat
|
||||
// backend. When the backend implements services.AichatRecoverer (the
|
||||
// PALIADIN_BACKEND=aichat path), we ask aichat directly via its
|
||||
// conversation API whether the turn actually completed upstream after
|
||||
// our stream connection dropped. When it doesn't implement it (legacy
|
||||
// local/remote backends), we fall back to reading the local row —
|
||||
// services.LocalPaliadinService.runJanitor is still the recovery path
|
||||
// there.
|
||||
//
|
||||
// Response shape mirrors handlePaliadinTurnGet so the frontend
|
||||
// late-poll module doesn't need a backend-specific code path.
|
||||
// Additional field `recovery_state` distinguishes:
|
||||
//
|
||||
// "recovered" — the response is in the row (already there, or freshly
|
||||
// written from the upstream check)
|
||||
// "pending" — still no response; caller should keep polling
|
||||
// "lost" — backend confirms the turn is gone (aichat doesn't
|
||||
// have it either). UI should degrade to "verloren".
|
||||
func handlePaliadinTurnRecover(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r)
|
||||
turnID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid turn_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Quick read first — gives us the row regardless of backend.
|
||||
row, err := paliadinSvc.GetTurn(r.Context(), uid, turnID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state := recoveryStateFor(row)
|
||||
|
||||
// Aichat backend: when the row still has no response, ask aichat
|
||||
// whether the turn actually finished upstream.
|
||||
if state == "pending" {
|
||||
if rec, ok := paliadinSvc.(services.AichatRecoverer); ok {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
recovered, recErr := rec.RecoverTurn(ctx, uid, turnID)
|
||||
if recErr != nil {
|
||||
// Log + fall through to a plain pending response — a
|
||||
// transient aichat hiccup shouldn't flip the UI to
|
||||
// "lost".
|
||||
_ = recErr
|
||||
} else if recovered != nil {
|
||||
row = recovered
|
||||
state = recoveryStateFor(row)
|
||||
} else {
|
||||
// Aichat returned a clean "no, I don't have it either".
|
||||
// Only mark as lost when the turn is older than the
|
||||
// upstream's plausible turn budget — otherwise the
|
||||
// recovery just hit the window between paliad's stream
|
||||
// dropping and aichat finishing the run.
|
||||
if recoveryShouldGiveUp(row) {
|
||||
state = "lost"
|
||||
}
|
||||
}
|
||||
} else if recoveryShouldGiveUp(row) {
|
||||
// Legacy backends: rely on the janitor. If we're past the
|
||||
// give-up threshold and still no response, surface "lost".
|
||||
state = "lost"
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"turn_id": row.TurnID.String(),
|
||||
"started_at": row.StartedAt.Format(time.RFC3339),
|
||||
"response": row.Response,
|
||||
"error_code": row.ErrorCode,
|
||||
"finished_at": row.FinishedAt,
|
||||
"duration_ms": row.DurationMS,
|
||||
"used_tools": []string(row.UsedTools),
|
||||
"rows_seen": []int64(row.RowsSeen),
|
||||
"chip_count": row.ChipCount,
|
||||
"classifier_tag": row.ClassifierTag,
|
||||
"recovery_state": state,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// recoveryStateFor returns the lifecycle state of a paliadin turn from
|
||||
// the recovery endpoint's perspective.
|
||||
func recoveryStateFor(row *services.PaliadinTurn) string {
|
||||
if row.Response != nil && *row.Response != "" {
|
||||
return "recovered"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
// recoveryShouldGiveUp returns true when a turn has been pending long
|
||||
// enough that we should surface "lost" rather than asking the user to
|
||||
// keep waiting. 12 minutes is comfortably beyond the longest realistic
|
||||
// Claude turn (cold-start + reasoning + tool calls all bundled).
|
||||
func recoveryShouldGiveUp(row *services.PaliadinTurn) bool {
|
||||
return time.Since(row.StartedAt) > 12*time.Minute
|
||||
}
|
||||
|
||||
// handlePaliadinHistory returns the caller's prior turns for a given
|
||||
// browser session id, oldest → newest. Both Paliadin surfaces (the
|
||||
// inline drawer and the standalone /paliadin page) hit this on mount
|
||||
|
||||
@@ -251,6 +251,23 @@ func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if cpe, ok := services.IsCrossProceedingAnchor(err); ok {
|
||||
parentURL := "/projects/" + cpe.ParentProjectID.String()
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "cross_proceeding_anchor",
|
||||
"requested_rule_code": cpe.RequestedRuleCode,
|
||||
"requested_rule_name_de": cpe.RequestedRuleNameDE,
|
||||
"requested_rule_name_en": cpe.RequestedRuleNameEN,
|
||||
"parent_project_id": cpe.ParentProjectID.String(),
|
||||
"parent_project_title": cpe.ParentProjectTitle,
|
||||
"parent_project_url": parentURL,
|
||||
"message_de": "Diese Frist gehört zum Verletzungsverfahren „" +
|
||||
cpe.ParentProjectTitle + "“. Bitte den Anker dort setzen, nicht auf der Widerklage.",
|
||||
"message_en": "This deadline belongs to the infringement proceeding „" +
|
||||
cpe.ParentProjectTitle + "“. Anchor it on the parent project, not the counterclaim.",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ type dbServices struct {
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
|
||||
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
|
||||
backup *services.BackupRunner
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
1102
internal/handlers/submission_drafts.go
Normal file
1102
internal/handlers/submission_drafts.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,32 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
||||
// reduction of t-paliad-215).
|
||||
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
|
||||
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
|
||||
// from format-only to the same merge engine the draft editor uses).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant filing rules.
|
||||
// has_template is unconditionally true: every project gets
|
||||
// offered the universal HL Patents Style template.
|
||||
// Lists every published filing rule across every active
|
||||
// proceeding the platform knows about, joined with its
|
||||
// proceeding_type so the frontend can group by proceeding.
|
||||
// has_template flips per-row: true when a per-submission .docx
|
||||
// is wired in submissionTemplateRegistry, false when the
|
||||
// editor falls back to the universal HL Patents Style.
|
||||
//
|
||||
// POST /api/projects/{id}/submissions/{code}/generate
|
||||
// Fetches the cached HL Patents Style .dotm (same proxy used
|
||||
// by /files/hl-patents-style.dotm), converts it to a clean
|
||||
// .docx via services.ConvertDotmToDocx, writes one
|
||||
// paliad.system_audit_log row, and streams the result as an
|
||||
// attachment download.
|
||||
//
|
||||
// No variable substitution, no per-submission templates, no
|
||||
// project_events/documents writes. Those layers are deferred to a
|
||||
// future "merge engine" slice; today's generator hands the lawyer a
|
||||
// clean .docx of the firm style and lets them edit and save under
|
||||
// their own filename.
|
||||
// Resolves the template through the cronus fallback chain
|
||||
// (per-firm `submissionTemplateRegistry[code]` first, HL
|
||||
// Patents Style as the universal fallback), builds a fresh
|
||||
// variable bag via SubmissionVarsService.Build, and runs the
|
||||
// SubmissionRenderer merge so every {{placeholder}} resolves
|
||||
// to project state (or `[KEIN WERT: key]` for empties). Writes
|
||||
// one paliad.system_audit_log row and streams the .docx as an
|
||||
// attachment download. The HL Patents Style fallback has no
|
||||
// placeholders today, so for codes without a per-firm template
|
||||
// the renderer is a no-op on substitution but still runs the
|
||||
// .dotm→.docx pre-pass.
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
|
||||
@@ -62,26 +67,48 @@ const hlPatentsStyleSlug = "hl-patents-style.dotm"
|
||||
|
||||
// submissionListEntry is one row in the Schriftsätze panel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
ProceedingCode string `json:"proceeding_code"`
|
||||
ProceedingName string `json:"proceeding_name"`
|
||||
ProceedingNameEN string `json:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
// submissionListResponse wraps the list with a project-level header.
|
||||
//
|
||||
// ProjectProceedingCode names the project's own proceeding so the
|
||||
// frontend can pin its group to the top of the grouped catalog
|
||||
// (t-paliad-242). nil when the project hasn't bound a proceeding yet.
|
||||
type submissionListResponse struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
ProjectProceedingCode *string `json:"project_proceeding_code,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the published filing rules for
|
||||
// the project's proceeding_type. has_template is true for every row —
|
||||
// Slice 1 (t-paliad-230) ships one universal template, so the only
|
||||
// "no template" case is a project that has no proceeding_type bound.
|
||||
// handleListProjectSubmissions returns every published filing rule
|
||||
// across every active proceeding the platform knows about, joined with
|
||||
// its proceeding_type so the Schriftsätze tab can group rows by
|
||||
// proceeding (t-paliad-242 — m wants to see the entire catalog from any
|
||||
// project, not just the rules for the project's own proceeding).
|
||||
//
|
||||
// Visibility is gated on the PROJECT (paliad.can_see_project via
|
||||
// ProjectService.GetByID); the rules themselves are static reference
|
||||
// data shared across the firm.
|
||||
//
|
||||
// has_template flips when a per-submission .docx is wired into
|
||||
// submissionTemplateRegistry (files.go). When false, the universal HL
|
||||
// Patents Style .dotm is the fallback — the editor (t-paliad-238)
|
||||
// resolves both flavours transparently, so every row remains
|
||||
// generatable and editable from the UI.
|
||||
//
|
||||
// Rows are sorted by (proceeding_code, submission_code) so the
|
||||
// frontend's groupBy stays cheap and the order is stable.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -110,53 +137,145 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
Entries: []submissionListEntry{},
|
||||
}
|
||||
|
||||
if project.ProceedingTypeID == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
|
||||
entries, ownCode, err := loadSubmissionCatalog(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
|
||||
log.Printf("submissions: list submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
continue
|
||||
}
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: true,
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *rule.PrimaryParty
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
entry.LegalSource = *rule.LegalSource
|
||||
}
|
||||
resp.Entries = append(resp.Entries, entry)
|
||||
}
|
||||
resp.Entries = entries
|
||||
resp.ProjectProceedingCode = ownCode
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission fetches the universal HL Patents
|
||||
// Style .dotm, converts it to a clean .docx, writes one audit row, and
|
||||
// streams the result. No variable substitution; the bytes that go down
|
||||
// the wire are the firm style template with macros stripped.
|
||||
// handleListSubmissionCatalog returns the same cross-proceeding catalog
|
||||
// without a project context — used by the global /submissions/new
|
||||
// picker (t-paliad-243). No project_proceeding_code is returned since
|
||||
// the picker isn't pinned to one project.
|
||||
func handleListSubmissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
entries, _, err := loadSubmissionCatalog(r.Context(), nil)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list global submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// loadSubmissionCatalog runs the shared catalog query. When
|
||||
// projectProceedingTypeID is non-nil, the returned ownCode points at
|
||||
// that proceeding's code so the frontend can pin its group to the top;
|
||||
// otherwise ownCode is nil.
|
||||
func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([]submissionListEntry, *string, error) {
|
||||
type catalogRow struct {
|
||||
SubmissionCode string `db:"submission_code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
EventType *string `db:"event_type"`
|
||||
PrimaryParty *string `db:"primary_party"`
|
||||
LegalSource *string `db:"legal_source"`
|
||||
ProceedingID int `db:"proceeding_type_id"`
|
||||
ProceedingCode string `db:"proceeding_code"`
|
||||
ProceedingName string `db:"proceeding_name"`
|
||||
ProceedingNameEN string `db:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
var rows []catalogRow
|
||||
err := dbSvc.projects.DB().SelectContext(ctx, &rows,
|
||||
`SELECT dr.submission_code AS submission_code,
|
||||
dr.name AS name,
|
||||
dr.name_en AS name_en,
|
||||
dr.event_type AS event_type,
|
||||
dr.primary_party AS primary_party,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.proceeding_type_id AS proceeding_type_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name,
|
||||
pt.name_en AS proceeding_name_en
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
WHERE dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.event_type = 'filing'
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entries := make([]submissionListEntry, 0, len(rows))
|
||||
var ownCode *string
|
||||
for _, row := range rows {
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: row.SubmissionCode,
|
||||
Name: row.Name,
|
||||
NameEN: row.NameEN,
|
||||
HasTemplate: hasPerSubmissionTemplate(row.SubmissionCode),
|
||||
ProceedingCode: row.ProceedingCode,
|
||||
ProceedingName: row.ProceedingName,
|
||||
ProceedingNameEN: row.ProceedingNameEN,
|
||||
}
|
||||
if row.EventType != nil {
|
||||
entry.EventType = *row.EventType
|
||||
}
|
||||
if row.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *row.PrimaryParty
|
||||
}
|
||||
if row.LegalSource != nil {
|
||||
entry.LegalSource = *row.LegalSource
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if projectProceedingTypeID != nil && row.ProceedingID == *projectProceedingTypeID && ownCode == nil {
|
||||
code := row.ProceedingCode
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
// If the project's proceeding has no filing rules of its own, fall
|
||||
// back to a direct proceeding_types lookup so the frontend can still
|
||||
// pin the right group even when the catalog ordering wouldn't have
|
||||
// surfaced the code via a row.
|
||||
if projectProceedingTypeID != nil && ownCode == nil {
|
||||
var code string
|
||||
if err := dbSvc.projects.DB().GetContext(ctx, &code,
|
||||
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, *projectProceedingTypeID); err == nil && code != "" {
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
return entries, ownCode, nil
|
||||
}
|
||||
|
||||
// hasPerSubmissionTemplate reports whether a per-submission .docx is
|
||||
// wired in the fileRegistry (files.go). false means the editor falls
|
||||
// back to the universal HL Patents Style — still renderable, still
|
||||
// editable, but the UI may want to surface a "universal Vorlage"
|
||||
// indicator. Read-only — no I/O, just a map lookup.
|
||||
func hasPerSubmissionTemplate(submissionCode string) bool {
|
||||
_, ok := submissionTemplateRegistry[submissionCode]
|
||||
return ok
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission resolves the per-submission template
|
||||
// (per-firm first, HL Patents Style fallback), builds a fresh variable
|
||||
// bag from project state via SubmissionVarsService, runs the merge
|
||||
// engine so every {{placeholder}} substitutes, writes one audit row,
|
||||
// and streams the result. Pre-t-paliad-253 this handler ignored the
|
||||
// per-firm registry and returned the bare HL Patents Style .dotm with
|
||||
// no substitution — the "Generieren" button on the Schriftsätze tab
|
||||
// therefore produced a generic firm-style .docx instead of a
|
||||
// project-merged Klageerwiderung, which is what m noticed in
|
||||
// m/paliad#84.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -165,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submissions not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
@@ -179,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
|
||||
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRuleNotFound) {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: load rule %q: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
// ErrNotVisible / project ErrNotFound from the visibility gate
|
||||
// surface through writeServiceError as 404, matching the rest
|
||||
// of the project surfaces.
|
||||
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
dotm, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "template upstream unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
docx, err := services.ConvertDotmToDocx(dotm)
|
||||
if err != nil {
|
||||
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "convert failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
log.Printf("submissions: load user %s: %v", uid, err)
|
||||
}
|
||||
lang := "de"
|
||||
if user != nil && user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
|
||||
filename := submissionFileName(rule, project, lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
// affects the system_audit_log feed — never the user's response.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
|
||||
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
@@ -244,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// errRuleNotFound is the sentinel for "no published rule with that
|
||||
// submission_code" — distinguished from a generic DB error so the
|
||||
// handler returns 404 instead of 500.
|
||||
var errRuleNotFound = errors.New("submission rule not found")
|
||||
|
||||
// loadPublishedRuleByCode fetches the rule the user requested. Only
|
||||
// published+active rows resolve; drafts and archived rules never feed
|
||||
// a real submission.
|
||||
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := dbSvc.projects.DB().GetContext(ctx, &rule,
|
||||
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value, duration_unit,
|
||||
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at, lifecycle_state
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// submissionFileName produces the user-facing download name per
|
||||
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
||||
// Empty case_number drops the segment entirely (no fallback hash —
|
||||
|
||||
@@ -313,6 +313,14 @@ type Deadline struct {
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
// outside that table.
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
// CustomRuleText holds the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
|
||||
// Mutually exclusive with RuleID at the application layer: the Auto
|
||||
// path sets RuleID and leaves this NULL; the Custom path sets this
|
||||
// and leaves RuleID NULL. Display surfaces prefer the joined
|
||||
// deadline_rules.name when RuleID is set, else fall back to this
|
||||
// text + a "Custom" badge.
|
||||
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
@@ -721,6 +729,14 @@ type ProceedingType struct {
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
|
||||
@@ -112,6 +112,11 @@ type AichatPaliadinService struct {
|
||||
// Hook for tests — when non-nil, callHTTP delegates here instead
|
||||
// of hitting the wire. Production code never sets this.
|
||||
httpHook func(ctx context.Context, method, path string, body any, out any) error
|
||||
|
||||
// Hook for tests — when non-nil, callStreamingHTTP delegates here
|
||||
// instead of opening a real SSE connection. Production code never
|
||||
// sets this.
|
||||
streamHook func(ctx context.Context, path string, body any, emit func(streamFrame)) error
|
||||
}
|
||||
|
||||
// ErrAichatAuthFailed signals the aichat service rejected the bearer
|
||||
@@ -217,6 +222,7 @@ func (s *AichatPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
|
||||
body := aichatTurnRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: username,
|
||||
UserID: req.UserID.String(),
|
||||
SessionID: req.SessionID,
|
||||
Message: sanitiseForTmux(req.UserMessage),
|
||||
JWT: jwt,
|
||||
@@ -611,8 +617,13 @@ func (s *AichatPaliadinService) clearPrimed(session string) {
|
||||
// =============================================================================
|
||||
|
||||
type aichatTurnRequest struct {
|
||||
Persona string `json:"persona"`
|
||||
Username string `json:"username"`
|
||||
Persona string `json:"persona"`
|
||||
Username string `json:"username"`
|
||||
// UserID is the paliad user UUID, required by aichat now that a
|
||||
// tenant DB is configured ("user_id is required when a tenant DB
|
||||
// is configured"). Without it /chat/turn 400s and the SSE relay
|
||||
// closes empty → "Verbindung verloren" on the frontend.
|
||||
UserID string `json:"user_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
JWT string `json:"jwt,omitempty"`
|
||||
|
||||
654
internal/services/aichat_paliadin_stream.go
Normal file
654
internal/services/aichat_paliadin_stream.go
Normal file
@@ -0,0 +1,654 @@
|
||||
package services
|
||||
|
||||
// Streaming + recovery support for AichatPaliadinService (t-paliad-235).
|
||||
//
|
||||
// =============================================================================
|
||||
// Upstream contract — /chat/turn/stream
|
||||
// =============================================================================
|
||||
//
|
||||
// Source of truth: m/mAi internal/aichat/api/stream.go. Captured here as
|
||||
// inline doc so future debugging doesn't require chasing across repos:
|
||||
//
|
||||
// Request body: same shape as POST /chat/turn (TurnRequest mirror in
|
||||
// aichat_paliadin.go). Persona must support streaming; paliad's
|
||||
// "paliadin" persona does.
|
||||
//
|
||||
// Response: text/event-stream. Two SSE event flavours:
|
||||
//
|
||||
// 1. The default unnamed `data:` event carries a discriminated-union
|
||||
// JSON object keyed by `"type"`:
|
||||
//
|
||||
// {"type":"chunk","content":"…"}
|
||||
// {"type":"meta","used_tools":[…],"rows_seen":[…],"classifier_tag":"…"}
|
||||
// {"type":"done","turn_id":"…","conversation_id":"…",
|
||||
// "duration_ms":1234,"pane_spawned":false,"resumed":false}
|
||||
// {"type":"error","code":"…","message":"…","retryable":true}
|
||||
//
|
||||
// 2. The named `event: heartbeat` event carries:
|
||||
//
|
||||
// {"elapsed_seconds": N}
|
||||
//
|
||||
// Emitted every 5 s by the upstream while the runner has been
|
||||
// silent (no content). aichat keeps emitting these for the lifetime
|
||||
// of the runner so the client can render "Paliadin denkt nach
|
||||
// (N s)" without conflating with actual content.
|
||||
//
|
||||
// Errors before the stream starts (auth failure, persona unknown,
|
||||
// validation) come back as a normal JSON envelope with the appropriate
|
||||
// HTTP status — not SSE. Those land in callHTTP via decodeAichatError.
|
||||
//
|
||||
// =============================================================================
|
||||
// Conversation-based late recovery
|
||||
// =============================================================================
|
||||
//
|
||||
// Aichat exposes:
|
||||
//
|
||||
// GET /chat/conversations?persona=…&username=…&user_id=…
|
||||
// → list of ConversationSummary, ordered last_turn_at DESC
|
||||
// GET /chat/conversations/{id}/turns
|
||||
// → list of TurnRow (role=user|assistant, body, created_at)
|
||||
//
|
||||
// When paliad's stream drops mid-turn we:
|
||||
// 1. Look up paliad.paliadin_turns.aichat_conversation_id for the row.
|
||||
// 2. If unset (stream dropped before the `done` frame): list the user's
|
||||
// conversations and take the most recent one for the persona —
|
||||
// that's the pane our turn ran against (aichat owns one active
|
||||
// conversation per persona+user, see m/mAi#243).
|
||||
// 3. GET that conversation's turns. Find the latest assistant turn
|
||||
// whose preceding user-role turn body matches our user_message.
|
||||
// 4. Persist the response (completeTurnLate) and return it.
|
||||
//
|
||||
// If aichat returns no matching assistant turn → the turn is truly lost
|
||||
// (transport drop + upstream crash). Recovery returns (nil, nil) and
|
||||
// the handler degrades the UI to "verloren".
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Streaming RunTurnStream
|
||||
// =============================================================================
|
||||
|
||||
// RunTurnStream drives one /chat/turn/stream turn against aichat and
|
||||
// relays incremental events onto `events`. Closes `events` before
|
||||
// returning. Implements StreamingPaliadin.
|
||||
func (s *AichatPaliadinService) RunTurnStream(ctx context.Context, req TurnRequest, events chan<- StreamEvent) (*TurnResult, error) {
|
||||
defer close(events)
|
||||
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
turnID := uuid.New()
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
if err := s.insertTurnRow(ctx, &PaliadinTurn{
|
||||
TurnID: turnID,
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
StartedAt: startedAt,
|
||||
UserMessage: req.UserMessage,
|
||||
PageOrigin: optionalString(req.PageOrigin),
|
||||
}, req.Context); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||
}
|
||||
|
||||
if err := s.healthGate(ctx); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: "mriver_unreachable",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username := s.usernameFor(ctx, req.UserID)
|
||||
session := s.cfg.Persona + ":" + username
|
||||
primer := s.buildPrimerExchanges(ctx, session, req)
|
||||
|
||||
jwt, err := s.mintJWTIfConfigured(req.UserID)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "jwt_mint_failed")
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: "shim_error",
|
||||
Message: fmt.Sprintf("mint turn jwt: %v", err),
|
||||
})
|
||||
return nil, fmt.Errorf("paliadin: mint turn jwt: %w", err)
|
||||
}
|
||||
|
||||
body := aichatTurnRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: username,
|
||||
UserID: req.UserID.String(),
|
||||
SessionID: req.SessionID,
|
||||
Message: sanitiseForTmux(req.UserMessage),
|
||||
JWT: jwt,
|
||||
Primer: primer,
|
||||
Meta: buildAichatMeta(req),
|
||||
}
|
||||
|
||||
// Stream the upstream call. acc accumulates the full text so we can
|
||||
// persist the row + return a TurnResult on success.
|
||||
var (
|
||||
acc strings.Builder
|
||||
streamMeta trailerMeta
|
||||
convID string
|
||||
paneSpawned bool
|
||||
upstreamDoneMs int64
|
||||
)
|
||||
|
||||
streamErr := s.callStreamingHTTP(ctx, "/chat/turn/stream", body, func(frame streamFrame) {
|
||||
switch {
|
||||
case frame.event == "heartbeat":
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamHeartbeat,
|
||||
ElapsedSeconds: frame.heartbeat.ElapsedSeconds,
|
||||
})
|
||||
case frame.data.Type == "chunk":
|
||||
if frame.data.Content == "" {
|
||||
return
|
||||
}
|
||||
acc.WriteString(frame.data.Content)
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamChunk,
|
||||
Content: frame.data.Content,
|
||||
})
|
||||
case frame.data.Type == "meta":
|
||||
streamMeta = trailerMeta{
|
||||
UsedTools: append([]string(nil), frame.data.UsedTools...),
|
||||
RowsSeen: coerceAichatRowsSeen(frame.data.RowsSeen),
|
||||
ClassifierTag: frame.data.ClassifierTag,
|
||||
}
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamMeta,
|
||||
UsedTools: streamMeta.UsedTools,
|
||||
RowsSeen: streamMeta.RowsSeen,
|
||||
ClassifierTag: streamMeta.ClassifierTag,
|
||||
})
|
||||
case frame.data.Type == "done":
|
||||
if frame.data.ConversationID != "" {
|
||||
convID = frame.data.ConversationID
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamConversation,
|
||||
ConversationID: convID,
|
||||
})
|
||||
}
|
||||
paneSpawned = frame.data.PaneSpawned
|
||||
upstreamDoneMs = frame.data.DurationMs
|
||||
case frame.data.Type == "error":
|
||||
// Forward as a stream error AND mark for non-nil err
|
||||
// propagation via the streamErr captured below.
|
||||
safeSendStream(ctx, events, StreamEvent{
|
||||
Kind: StreamError,
|
||||
Code: frame.data.Code,
|
||||
Message: frame.data.Message,
|
||||
Retryable: frame.data.Retryable,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
cleanBody := acc.String()
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
if upstreamDoneMs > 0 {
|
||||
durationMS = int(upstreamDoneMs)
|
||||
}
|
||||
|
||||
// Persist the conversation id we learned (best-effort — failure here
|
||||
// just means recovery for THIS turn will have to list conversations
|
||||
// rather than fast-path to a single id).
|
||||
if convID != "" {
|
||||
if err := s.setAichatConversationID(ctx, turnID, convID); err != nil {
|
||||
log.Printf("paliadin: persist aichat conversation id %s: %v", convID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if streamErr != nil {
|
||||
// Don't overwrite an existing error_code we may have set above.
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(streamErr))
|
||||
return nil, streamErr
|
||||
}
|
||||
|
||||
// Aichat is stateless on user content; the client owns the primer.
|
||||
if paneSpawned {
|
||||
s.clearPrimed(session)
|
||||
} else {
|
||||
s.markPrimed(session)
|
||||
}
|
||||
|
||||
if cleanBody == "" {
|
||||
// Upstream closed cleanly with no error event but no content
|
||||
// either (unexpected — log + treat as upstream_error so the
|
||||
// handler doesn't ship an empty bubble).
|
||||
_ = s.markTurnError(ctx, turnID, "shim_error")
|
||||
return nil, errors.New("aichat: stream closed with no content and no error")
|
||||
}
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, streamMeta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s: %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: streamMeta.UsedTools,
|
||||
RowsSeen: streamMeta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: streamMeta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamFrame is one decoded SSE event.
|
||||
type streamFrame struct {
|
||||
event string // "" → default (data:) event
|
||||
data streamDataFrame
|
||||
heartbeat streamHeartbeatFrame
|
||||
}
|
||||
|
||||
type streamDataFrame struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
UsedTools []string `json:"used_tools,omitempty"`
|
||||
RowsSeen []string `json:"rows_seen,omitempty"`
|
||||
ClassifierTag string `json:"classifier_tag,omitempty"`
|
||||
TurnID string `json:"turn_id,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
PaneSpawned bool `json:"pane_spawned,omitempty"`
|
||||
Resumed bool `json:"resumed,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
type streamHeartbeatFrame struct {
|
||||
ElapsedSeconds int `json:"elapsed_seconds"`
|
||||
}
|
||||
|
||||
// callStreamingHTTP opens a streaming POST to aichat and invokes `emit`
|
||||
// for each parsed SSE frame. Returns once the stream closes; surfaces
|
||||
// non-2xx responses via decodeAichatError, transport errors via the
|
||||
// underlying http.Client error.
|
||||
//
|
||||
// Tests can override the parsing path by setting streamHook (kept null
|
||||
// in production).
|
||||
func (s *AichatPaliadinService) callStreamingHTTP(ctx context.Context, path string, body any, emit func(streamFrame)) error {
|
||||
if s.streamHook != nil {
|
||||
return s.streamHook(ctx, path, body, emit)
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: encode %s body: %w", path, err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.BaseURL+path, strings.NewReader(string(buf)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: build %s request: %w", path, err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
if s.cfg.BearerToken != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.BearerToken)
|
||||
}
|
||||
|
||||
// Use a dedicated client without the short Timeout — for streaming
|
||||
// we rely on the silence_timeout watch (no events for > 90 s ⇒ fail)
|
||||
// rather than a hard ceiling on the whole turn. The aichat upstream
|
||||
// keeps emitting heartbeats while it's alive, so a true upstream
|
||||
// stall is observable here.
|
||||
client := s.streamingClient()
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: POST %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
return decodeAichatError(resp.StatusCode, respBytes)
|
||||
}
|
||||
|
||||
return parseSSEStream(ctx, resp.Body, emit)
|
||||
}
|
||||
|
||||
// streamingClient returns an HTTP client tuned for streaming — no
|
||||
// per-request Timeout (kills mid-stream), but a long IdleConnTimeout so
|
||||
// the connection stays usable for multi-minute turns.
|
||||
func (s *AichatPaliadinService) streamingClient() *http.Client {
|
||||
if s.cfg.HTTPClient == nil {
|
||||
return &http.Client{Timeout: 0}
|
||||
}
|
||||
c := *s.cfg.HTTPClient
|
||||
c.Timeout = 0
|
||||
return &c
|
||||
}
|
||||
|
||||
// parseSSEStream tokenises an SSE byte stream into streamFrame events
|
||||
// and calls emit for each. Returns nil on clean EOF; returns the read
|
||||
// error otherwise.
|
||||
//
|
||||
// Frame format (per https://html.spec.whatwg.org/multipage/server-sent-events.html):
|
||||
//
|
||||
// event: <name>\n
|
||||
// data: <payload>\n
|
||||
// <blank line>\n
|
||||
//
|
||||
// Multiple `data:` lines per event are concatenated with `\n`. Lines
|
||||
// starting with `:` are comments and ignored.
|
||||
func parseSSEStream(ctx context.Context, r io.Reader, emit func(streamFrame)) error {
|
||||
br := bufio.NewReaderSize(r, 64<<10)
|
||||
var (
|
||||
eventName string
|
||||
dataLines []string
|
||||
)
|
||||
flush := func() {
|
||||
if len(dataLines) == 0 && eventName == "" {
|
||||
return
|
||||
}
|
||||
payload := strings.Join(dataLines, "\n")
|
||||
eventName = strings.TrimSpace(eventName)
|
||||
dataLines = nil
|
||||
eventOut := eventName
|
||||
eventName = ""
|
||||
if eventOut == "heartbeat" {
|
||||
var hb streamHeartbeatFrame
|
||||
if err := json.Unmarshal([]byte(payload), &hb); err != nil {
|
||||
return
|
||||
}
|
||||
emit(streamFrame{event: "heartbeat", heartbeat: hb})
|
||||
return
|
||||
}
|
||||
// Default event (unnamed) — discriminated by `type` field.
|
||||
var d streamDataFrame
|
||||
if err := json.Unmarshal([]byte(payload), &d); err != nil {
|
||||
return
|
||||
}
|
||||
emit(streamFrame{event: "", data: d})
|
||||
}
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
// Final frame may not be terminated by a blank line on
|
||||
// abrupt close — flush whatever we accumulated.
|
||||
if line != "" {
|
||||
processSSELine(line, &eventName, &dataLines)
|
||||
}
|
||||
flush()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("aichat: read sse: %w", err)
|
||||
}
|
||||
// Normalise line endings (some intermediaries send \r\n).
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
processSSELine(line, &eventName, &dataLines)
|
||||
}
|
||||
}
|
||||
|
||||
// processSSELine handles one line of the SSE wire format.
|
||||
func processSSELine(line string, eventName *string, dataLines *[]string) {
|
||||
if strings.HasPrefix(line, ":") {
|
||||
return // comment / keep-alive
|
||||
}
|
||||
if idx := strings.IndexByte(line, ':'); idx >= 0 {
|
||||
field := line[:idx]
|
||||
value := line[idx+1:]
|
||||
if strings.HasPrefix(value, " ") {
|
||||
value = value[1:]
|
||||
}
|
||||
switch field {
|
||||
case "event":
|
||||
*eventName = value
|
||||
case "data":
|
||||
*dataLines = append(*dataLines, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Field with no value (rare). Treat the whole line as field name
|
||||
// per spec.
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AichatRecoverer — late recovery via the conversation API
|
||||
// =============================================================================
|
||||
|
||||
// RecoverTurn asks aichat whether the given paliad turn has a response.
|
||||
// Returns the up-to-date row on success (including a freshly persisted
|
||||
// response when aichat had one), nil + nil when aichat doesn't know
|
||||
// either, or an error on transport / DB failures.
|
||||
func (s *AichatPaliadinService) RecoverTurn(ctx context.Context, callerID, turnID uuid.UUID) (*PaliadinTurn, error) {
|
||||
row, err := s.GetTurn(ctx, callerID, turnID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fast path: the row already has a response (the janitor or a
|
||||
// concurrent stream finished writing). Return it as-is.
|
||||
if row.Response != nil && *row.Response != "" {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
convID, err := s.resolveAichatConversationID(ctx, row)
|
||||
if err != nil {
|
||||
log.Printf("paliadin: recover %s: resolve conversation: %v", turnID, err)
|
||||
return nil, nil
|
||||
}
|
||||
if convID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
turns, err := s.fetchAichatConversationTurns(ctx, convID)
|
||||
if err != nil {
|
||||
log.Printf("paliadin: recover %s: fetch turns: %v", turnID, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
assistantBody := matchAssistantResponse(turns, row.UserMessage)
|
||||
if assistantBody == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(row.StartedAt) / time.Millisecond)
|
||||
tokens := approxTokenCount(assistantBody)
|
||||
chipCount := countChips(assistantBody)
|
||||
|
||||
if err := s.completeTurnLate(ctx, turnID, finished, durationMS, assistantBody, tokens, trailerMeta{}, chipCount); err != nil {
|
||||
log.Printf("paliadin: recover %s: complete late: %v", turnID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-read so the caller gets a row that reflects the late-write.
|
||||
return s.GetTurn(ctx, callerID, turnID)
|
||||
}
|
||||
|
||||
// resolveAichatConversationID returns the conversation the turn lived
|
||||
// in. Fast path: read the column on the row. Fallback: list aichat
|
||||
// conversations for the user+persona and take the most recent.
|
||||
func (s *AichatPaliadinService) resolveAichatConversationID(ctx context.Context, row *PaliadinTurn) (string, error) {
|
||||
stored, err := s.getAichatConversationID(ctx, row.TurnID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if stored != "" {
|
||||
return stored, nil
|
||||
}
|
||||
username := s.usernameFor(ctx, row.UserID)
|
||||
convs, err := s.listAichatConversations(ctx, username, row.UserID.String())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(convs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
// Aichat orders by last_turn_at DESC; the head is the most recently
|
||||
// active conversation, which is the pane the lost turn ran against.
|
||||
return convs[0].ID, nil
|
||||
}
|
||||
|
||||
// matchAssistantResponse walks the aichat turn list and returns the
|
||||
// body of the latest assistant turn whose preceding user-role turn body
|
||||
// matches `userMessage` (verbatim — aichat persists the raw message
|
||||
// the same way paliad does).
|
||||
//
|
||||
// Falls back to "the last assistant body in the conversation" when no
|
||||
// match is found but the conversation has assistant content. This
|
||||
// covers cases where aichat persisted the user turn with envelope
|
||||
// prefixes that don't exactly match our user_message (e.g. an embedded
|
||||
// [ctx …] block).
|
||||
func matchAssistantResponse(turns []aichatConversationTurn, userMessage string) string {
|
||||
wantedNorm := normaliseForMatch(userMessage)
|
||||
|
||||
for i := 0; i < len(turns)-1; i++ {
|
||||
t := turns[i]
|
||||
if t.Role != "user" {
|
||||
continue
|
||||
}
|
||||
if normaliseForMatch(t.Body) != wantedNorm {
|
||||
continue
|
||||
}
|
||||
next := turns[i+1]
|
||||
if next.Role == "assistant" && next.Body != "" {
|
||||
return next.Body
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
t := turns[i]
|
||||
if t.Role == "assistant" && t.Body != "" {
|
||||
return t.Body
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normaliseForMatch lowercases, strips surrounding whitespace, and
|
||||
// collapses internal whitespace runs. Comparison only — no semantic
|
||||
// meaning beyond "did aichat persist the same prompt we sent".
|
||||
func normaliseForMatch(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
for strings.Contains(s, " ") {
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// aichat conversation API client helpers
|
||||
// =============================================================================
|
||||
|
||||
type aichatConversationSummary struct {
|
||||
ID string `json:"id"`
|
||||
Persona string `json:"persona"`
|
||||
LastTurnAt string `json:"last_turn_at"`
|
||||
}
|
||||
|
||||
type aichatListConversationsResponse struct {
|
||||
Conversations []aichatConversationSummary `json:"conversations"`
|
||||
}
|
||||
|
||||
type aichatConversationTurn struct {
|
||||
ID string `json:"id"`
|
||||
Seq int `json:"seq"`
|
||||
Role string `json:"role"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type aichatGetConversationTurnsResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Turns []aichatConversationTurn `json:"turns"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// listAichatConversations calls GET /chat/conversations for the user.
|
||||
func (s *AichatPaliadinService) listAichatConversations(ctx context.Context, username, userID string) ([]aichatConversationSummary, error) {
|
||||
q := url.Values{}
|
||||
q.Set("persona", s.cfg.Persona)
|
||||
q.Set("username", username)
|
||||
q.Set("user_id", userID)
|
||||
q.Set("limit", "5")
|
||||
path := "/chat/conversations?" + q.Encode()
|
||||
var resp aichatListConversationsResponse
|
||||
if err := s.callHTTP(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Conversations, nil
|
||||
}
|
||||
|
||||
// fetchAichatConversationTurns calls GET /chat/conversations/{id}/turns.
|
||||
func (s *AichatPaliadinService) fetchAichatConversationTurns(ctx context.Context, convID string) ([]aichatConversationTurn, error) {
|
||||
q := url.Values{}
|
||||
q.Set("persona", s.cfg.Persona)
|
||||
q.Set("limit", "20")
|
||||
path := "/chat/conversations/" + url.PathEscape(convID) + "/turns?" + q.Encode()
|
||||
var resp aichatGetConversationTurnsResponse
|
||||
if err := s.callHTTP(ctx, http.MethodGet, path, nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Turns, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DB helpers for paliadin_turns.aichat_conversation_id (migration 118)
|
||||
// =============================================================================
|
||||
|
||||
func (s *AichatPaliadinService) setAichatConversationID(ctx context.Context, turnID uuid.UUID, convID string) error {
|
||||
if convID == "" {
|
||||
return nil
|
||||
}
|
||||
convUUID, err := uuid.Parse(convID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid conversation id %q: %w", convID, err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE paliad.paliadin_turns
|
||||
SET aichat_conversation_id = $2
|
||||
WHERE turn_id = $1
|
||||
AND aichat_conversation_id IS DISTINCT FROM $2
|
||||
`, turnID, convUUID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *AichatPaliadinService) getAichatConversationID(ctx context.Context, turnID uuid.UUID) (string, error) {
|
||||
var convID *uuid.UUID
|
||||
err := s.db.QueryRowxContext(ctx,
|
||||
`SELECT aichat_conversation_id FROM paliad.paliadin_turns WHERE turn_id = $1`,
|
||||
turnID).Scan(&convID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if convID == nil {
|
||||
return "", nil
|
||||
}
|
||||
return convID.String(), nil
|
||||
}
|
||||
|
||||
// Compile-time interface conformance — fail the build if a streaming
|
||||
// method drifts off this backend.
|
||||
var _ StreamingPaliadin = (*AichatPaliadinService)(nil)
|
||||
var _ AichatRecoverer = (*AichatPaliadinService)(nil)
|
||||
259
internal/services/aichat_paliadin_stream_test.go
Normal file
259
internal/services/aichat_paliadin_stream_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package services
|
||||
|
||||
// Streaming + recovery tests for AichatPaliadinService (t-paliad-235).
|
||||
//
|
||||
// Like the sync-path tests next door, every test bypasses the HTTP wire
|
||||
// (streamHook / httpHook). DB-write paths in RunTurnStream are out of
|
||||
// scope here for the same reason — paliad has no sqlx mock. We focus
|
||||
// on the SSE parser, the conversation-API client, and the
|
||||
// match-assistant-response helper.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// SSE parser
|
||||
// =============================================================================
|
||||
|
||||
func TestParseSSEStream_DefaultEvents(t *testing.T) {
|
||||
body := `data: {"type":"chunk","content":"Hello "}
|
||||
|
||||
data: {"type":"chunk","content":"world"}
|
||||
|
||||
data: {"type":"meta","used_tools":["search"],"rows_seen":["3"],"classifier_tag":"howto"}
|
||||
|
||||
data: {"type":"done","turn_id":"abc","conversation_id":"11111111-1111-1111-1111-111111111111","duration_ms":1234,"pane_spawned":false,"resumed":false}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 4 {
|
||||
t.Fatalf("got %d frames; want 4 (%+v)", len(frames), frames)
|
||||
}
|
||||
if frames[0].data.Type != "chunk" || frames[0].data.Content != "Hello " {
|
||||
t.Errorf("frame 0 = %+v; want chunk Hello ", frames[0])
|
||||
}
|
||||
if frames[1].data.Type != "chunk" || frames[1].data.Content != "world" {
|
||||
t.Errorf("frame 1 = %+v; want chunk world", frames[1])
|
||||
}
|
||||
if frames[2].data.Type != "meta" || frames[2].data.ClassifierTag != "howto" {
|
||||
t.Errorf("frame 2 = %+v; want meta howto", frames[2])
|
||||
}
|
||||
if frames[3].data.Type != "done" || frames[3].data.ConversationID == "" {
|
||||
t.Errorf("frame 3 = %+v; want done with conversation_id", frames[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_HeartbeatEvent(t *testing.T) {
|
||||
body := `event: heartbeat
|
||||
data: {"elapsed_seconds": 5}
|
||||
|
||||
event: heartbeat
|
||||
data: {"elapsed_seconds": 10}
|
||||
|
||||
data: {"type":"chunk","content":"Hi"}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 3 {
|
||||
t.Fatalf("got %d frames; want 3", len(frames))
|
||||
}
|
||||
if frames[0].event != "heartbeat" || frames[0].heartbeat.ElapsedSeconds != 5 {
|
||||
t.Errorf("frame 0 = %+v; want heartbeat 5s", frames[0])
|
||||
}
|
||||
if frames[1].event != "heartbeat" || frames[1].heartbeat.ElapsedSeconds != 10 {
|
||||
t.Errorf("frame 1 = %+v; want heartbeat 10s", frames[1])
|
||||
}
|
||||
if frames[2].data.Type != "chunk" || frames[2].data.Content != "Hi" {
|
||||
t.Errorf("frame 2 = %+v; want chunk Hi", frames[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_IgnoresComments(t *testing.T) {
|
||||
body := `: keep-alive comment line
|
||||
|
||||
data: {"type":"chunk","content":"x"}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0].data.Content != "x" {
|
||||
t.Errorf("frames = %+v; want 1 chunk", frames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_HandlesCRLF(t *testing.T) {
|
||||
body := "data: {\"type\":\"chunk\",\"content\":\"crlf\"}\r\n\r\n"
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0].data.Content != "crlf" {
|
||||
t.Errorf("frames = %+v; want crlf chunk", frames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEStream_MultilineData(t *testing.T) {
|
||||
// Two data: lines for the same event must concatenate with \n.
|
||||
body := `data: {"type":"chunk",
|
||||
data: "content":"x"}
|
||||
|
||||
`
|
||||
var frames []streamFrame
|
||||
err := parseSSEStream(context.Background(), strings.NewReader(body), func(f streamFrame) {
|
||||
frames = append(frames, f)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseSSEStream: %v", err)
|
||||
}
|
||||
if len(frames) != 1 || frames[0].data.Content != "x" {
|
||||
t.Errorf("frames = %+v; want 1 chunk x", frames)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// matchAssistantResponse
|
||||
// =============================================================================
|
||||
|
||||
func TestMatchAssistantResponse_PrefersUserPrecededAssistant(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "first question"},
|
||||
{Role: "assistant", Body: "first answer"},
|
||||
{Role: "user", Body: "second question"},
|
||||
{Role: "assistant", Body: "second answer"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "second question")
|
||||
if got != "second answer" {
|
||||
t.Errorf("got %q; want %q", got, "second answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_NormaliseCase(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "Hello World"},
|
||||
{Role: "assistant", Body: "hi back"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, " hello world ")
|
||||
if got != "hi back" {
|
||||
t.Errorf("got %q; want %q", got, "hi back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_FallbackToLastAssistant(t *testing.T) {
|
||||
// User message doesn't match (aichat persisted with a different
|
||||
// envelope or wrapper). Fallback: take the last assistant turn.
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "[ctx route=x] my question"},
|
||||
{Role: "assistant", Body: "the answer"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "my question")
|
||||
if got != "the answer" {
|
||||
t.Errorf("got %q; want %q", got, "the answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_NoAssistantTurns(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "lonely"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "lonely")
|
||||
if got != "" {
|
||||
t.Errorf("got %q; want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAssistantResponse_EmptyAssistantSkipped(t *testing.T) {
|
||||
turns := []aichatConversationTurn{
|
||||
{Role: "user", Body: "q1"},
|
||||
{Role: "assistant", Body: ""},
|
||||
{Role: "user", Body: "q2"},
|
||||
{Role: "assistant", Body: "a2"},
|
||||
}
|
||||
got := matchAssistantResponse(turns, "q1")
|
||||
if got != "a2" {
|
||||
// q1's immediate next is the empty-body assistant — we skip it
|
||||
// and fall back to the last non-empty assistant body.
|
||||
t.Errorf("got %q; want %q (fallback)", got, "a2")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Conversation-API HTTP client
|
||||
// =============================================================================
|
||||
|
||||
func TestListAichatConversations_BuildsExpectedQuery(t *testing.T) {
|
||||
var seenPath string
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
seenPath = path
|
||||
if dst, ok := out.(*aichatListConversationsResponse); ok {
|
||||
dst.Conversations = []aichatConversationSummary{{ID: "11111111-1111-1111-1111-111111111111"}}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
got, err := s.listAichatConversations(context.Background(), "alice", "00000000-0000-0000-0000-000000000001")
|
||||
if err != nil {
|
||||
t.Fatalf("listAichatConversations: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID == "" {
|
||||
t.Errorf("got = %+v; want one conversation", got)
|
||||
}
|
||||
for _, want := range []string{"/chat/conversations?", "persona=paliadin", "username=alice", "user_id=00000000-0000-0000-0000-000000000001", "limit=5"} {
|
||||
if !strings.Contains(seenPath, want) {
|
||||
t.Errorf("path %q missing %q", seenPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAichatConversationTurns_BuildsPath(t *testing.T) {
|
||||
var seenPath string
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
seenPath = path
|
||||
if dst, ok := out.(*aichatGetConversationTurnsResponse); ok {
|
||||
dst.Turns = []aichatConversationTurn{{Role: "assistant", Body: "answer"}}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
turns, err := s.fetchAichatConversationTurns(context.Background(), "11111111-1111-1111-1111-111111111111")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAichatConversationTurns: %v", err)
|
||||
}
|
||||
if len(turns) != 1 || turns[0].Body != "answer" {
|
||||
t.Errorf("turns = %+v; want one assistant", turns)
|
||||
}
|
||||
for _, want := range []string{"/chat/conversations/11111111-1111-1111-1111-111111111111/turns", "persona=paliadin", "limit=20"} {
|
||||
if !strings.Contains(seenPath, want) {
|
||||
t.Errorf("path %q missing %q", seenPath, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Interface conformance
|
||||
// =============================================================================
|
||||
|
||||
func TestAichatPaliadinService_ImplementsStreaming(t *testing.T) {
|
||||
var _ StreamingPaliadin = (*AichatPaliadinService)(nil)
|
||||
var _ AichatRecoverer = (*AichatPaliadinService)(nil)
|
||||
}
|
||||
@@ -501,6 +501,7 @@ func TestRunTurn_HappyPath_ViaCallHTTP(t *testing.T) {
|
||||
body := aichatTurnRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: s.usernameFor(context.Background(), uid),
|
||||
UserID: uid.String(),
|
||||
Message: "Hello",
|
||||
JWT: jwtTok,
|
||||
Meta: buildAichatMeta(TurnRequest{PageOrigin: "/dashboard"}),
|
||||
@@ -516,6 +517,12 @@ func TestRunTurn_HappyPath_ViaCallHTTP(t *testing.T) {
|
||||
if captured.Username != "user-aaaaaaaa" {
|
||||
t.Errorf("username = %q; want user-aaaaaaaa (nil DB fallback)", captured.Username)
|
||||
}
|
||||
// Regression for the 2026-05-21 outage: aichat now requires user_id
|
||||
// when a tenant DB is configured; missing → 400 → SSE drop on the
|
||||
// frontend ("Verbindung verloren"). The struct must carry it.
|
||||
if captured.UserID != uid.String() {
|
||||
t.Errorf("user_id = %q; want %q", captured.UserID, uid.String())
|
||||
}
|
||||
if captured.Message != "Hello" {
|
||||
t.Errorf("message = %q; want Hello", captured.Message)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user