Compare commits
27 Commits
mai/icarus
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| f2fbf93adf | |||
| f4dee97493 | |||
| 7aed8e4ec5 | |||
| b429dabf9e | |||
| d3c28009de | |||
| 8be7af7cd6 | |||
| d52995a4d6 | |||
| f0c343c638 | |||
| f11390d18b | |||
| aa2f4aacc6 | |||
| 3d985ef0c2 | |||
| f72e8a7b85 | |||
| 013facb9db | |||
| ff503ffc43 | |||
| 05f7ea2af5 | |||
| df2a1275cb | |||
| 3700d68c68 | |||
| e0c8401482 | |||
| 247e9005db | |||
| e68b800d52 | |||
| 31d78526cf | |||
| a8e2bd8350 | |||
| 8c94dccf83 | |||
| 90f5dd4b1b | |||
| 34e3d7188e | |||
| 24f3baf61f | |||
| 0f2f3e3ea1 |
@@ -42,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).
|
||||
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}`;
|
||||
}
|
||||
@@ -465,7 +465,8 @@ function refreshRuleAutoDisplay(): void {
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
text.textContent = formatRuleLabel(r);
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(r, esc);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
import { formatRuleLabel, formatRuleLabelHTML } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
@@ -192,7 +192,8 @@ function refreshRuleAutoDisplay(): void {
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
||||
text.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
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 {
|
||||
@@ -59,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,13 @@ export interface BarState {
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -192,12 +192,18 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
|
||||
|
||||
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":
|
||||
|
||||
@@ -1117,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").
|
||||
@@ -2703,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",
|
||||
@@ -2791,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",
|
||||
@@ -2840,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",
|
||||
@@ -2865,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",
|
||||
@@ -2895,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",
|
||||
@@ -2911,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",
|
||||
@@ -2929,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",
|
||||
@@ -2948,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",
|
||||
@@ -3027,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: {
|
||||
@@ -4110,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",
|
||||
@@ -5685,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",
|
||||
@@ -5772,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",
|
||||
@@ -5821,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",
|
||||
@@ -5846,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",
|
||||
@@ -5876,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",
|
||||
@@ -5892,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",
|
||||
@@ -5910,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",
|
||||
@@ -5929,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",
|
||||
@@ -6008,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)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -397,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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,14 +155,29 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
|
||||
"rule.name": { de: "Schriftsatz", en: "Submission" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
|
||||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||||
// placeholder names are below; the `rule.*` aliases that follow are
|
||||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||||
// templates and saved drafts authored with the old names keep
|
||||
// merging identically.
|
||||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
@@ -174,14 +189,14 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "rule",
|
||||
label: { de: "Schriftsatz", en: "Submission" },
|
||||
id: "procedural_event",
|
||||
label: { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
keys: [
|
||||
"rule.name",
|
||||
"rule.legal_source_pretty",
|
||||
"rule.primary_party",
|
||||
"rule.event_type",
|
||||
"rule.submission_code",
|
||||
"procedural_event.name",
|
||||
"procedural_event.legal_source_pretty",
|
||||
"procedural_event.primary_party",
|
||||
"procedural_event.event_kind",
|
||||
"procedural_event.code",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -290,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"
|
||||
@@ -1137,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"
|
||||
@@ -1577,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"
|
||||
@@ -1595,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"
|
||||
@@ -2726,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"
|
||||
@@ -2810,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"
|
||||
|
||||
@@ -7690,11 +7690,16 @@ dialog.modal::backdrop {
|
||||
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
|
||||
Replaces the t-paliad-251 catalog dropdown + sort selector with a
|
||||
binary toggle:
|
||||
.rule-mode-auto — read-only display, lime-tint pill + label.
|
||||
.rule-mode-auto — read-only display, lime-tint chip + label.
|
||||
.rule-mode-custom — free-text input, full-width.
|
||||
Toggle button reuses .btn-link-action for the inline link styling. */
|
||||
Toggle button reuses .btn-link-action for the inline link styling.
|
||||
t-paliad-267 / m/paliad#98 — the auto display is now a block-level
|
||||
row of its own so the resolved rule name sits on its own line
|
||||
beneath the toggle, not crammed beside it. Width is content-sized
|
||||
(align-self:flex-start within form-field's block flow keeps the
|
||||
chip from spanning the whole form column gratuitously). */
|
||||
.rule-mode-auto {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
@@ -7702,6 +7707,9 @@ dialog.modal::backdrop {
|
||||
border-left: 2px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
min-height: 2rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.rule-auto-text {
|
||||
color: var(--color-text);
|
||||
@@ -15174,8 +15182,10 @@ dialog.quick-add-sheet::backdrop {
|
||||
* Floating trigger at bottom-right + slide-out drawer from the
|
||||
* right edge. Hidden by default; revealed by paliadin-widget.ts
|
||||
* after /api/me confirms the caller is the Paliadin owner.
|
||||
* Mobile (≤640px): drawer goes full-screen; trigger sits above
|
||||
* the bottom-nav slots.
|
||||
* Mobile (≤640px): drawer goes full-screen.
|
||||
* Phone breakpoint (≤767px, matches .bottom-nav): trigger lifts
|
||||
* above the bottom-nav slots so it doesn't collide with the
|
||||
* navbar on PWA standalone (t-paliad-269).
|
||||
*/
|
||||
|
||||
.paliadin-widget-trigger {
|
||||
@@ -15262,8 +15272,20 @@ dialog.quick-add-sheet::backdrop {
|
||||
.paliadin-widget-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lift the trigger above the BottomNav at the same breakpoint where
|
||||
the nav appears (<768px in global.css ".bottom-nav"). The navbar is
|
||||
--bottom-nav-height tall plus the iOS safe-area inset; 16px gap
|
||||
keeps the bubble clear without crowding the nav slots. Bubble sits
|
||||
at the right edge so the center FAB-circle (margin-top: -10px) is
|
||||
not in its column.
|
||||
t-paliad-269: previously this rule was scoped to <=640px, but the
|
||||
.bottom-nav shows at <=767px, leaving phones in landscape and small
|
||||
tablets with an overlapping bubble. */
|
||||
@media (max-width: 767px) {
|
||||
.paliadin-widget-trigger {
|
||||
bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||
bottom: calc(var(--bottom-nav-height, 56px) + 16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17525,3 +17547,258 @@ dialog.quick-add-sheet::backdrop {
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Date-range picker (t-paliad-248) ------------------------------------
|
||||
Symmetric past/future chip fan around an ALLES centre, in a popover
|
||||
anchored under a closed-state trigger button. Reuses .agenda-chip /
|
||||
.agenda-chip-active for the fan chips so the active state lights up
|
||||
with the same lime accent as every other paliad filter-chip. The
|
||||
popover scaffold reuses .multi-panel for shadow + border + z-index,
|
||||
and .multi-anchor for the top:100% / left:0 positioning anchor. */
|
||||
|
||||
.date-range-anchor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-range-trigger {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.date-range-trigger:hover {
|
||||
background: var(--color-overlay-subtle);
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
.date-range-trigger:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.date-range-trigger[aria-expanded="true"] {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.date-range-trigger-dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-range-trigger-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-trigger-chev {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
.date-range-panel {
|
||||
/* Inherits .multi-panel positioning + border + shadow. Widen it so
|
||||
the symmetric fan + the custom editor have room to breathe. */
|
||||
width: 32rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
top: 100%;
|
||||
left: 0;
|
||||
padding: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-range-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-range-fan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-content: flex-start;
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.date-range-fan--past {
|
||||
/* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.date-range-fan--next {
|
||||
/* Future fan: innermost chip (Morgen / next_1d) leftmost. */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.date-range-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.date-range-center-btn {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
min-width: 4.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.date-range-center-btn:hover {
|
||||
background: var(--color-overlay-subtle);
|
||||
border-color: var(--color-accent-light);
|
||||
}
|
||||
.date-range-center-btn:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.date-range-center-btn--active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.date-range-center-glyph {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.date-range-center-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.date-range-chip {
|
||||
/* .agenda-chip provides bg/border/radius/typography; this modifier
|
||||
only tightens horizontal padding so more chips fit per row. */
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.date-range-chip--custom {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.date-range-custom {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-range-custom-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-range-custom-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.date-range-custom-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #71717a);
|
||||
}
|
||||
|
||||
.date-range-custom-from,
|
||||
.date-range-custom-to {
|
||||
appearance: none;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
color-scheme: light dark;
|
||||
}
|
||||
.date-range-custom-from:focus-visible,
|
||||
.date-range-custom-to:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.date-range-custom-apply,
|
||||
.date-range-custom-cancel {
|
||||
appearance: none;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.date-range-custom-apply {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
.date-range-custom-apply:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.date-range-custom-apply:hover:not(:disabled) {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
.date-range-custom-cancel:hover {
|
||||
background: var(--color-overlay-subtle);
|
||||
}
|
||||
|
||||
.date-range-custom-error {
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-red-fg, #b91c1c);
|
||||
}
|
||||
|
||||
/* Mobile: stack past / centre / next vertically so each fan gets
|
||||
the full popover width. */
|
||||
@media (max-width: 540px) {
|
||||
.date-range-panel {
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
.date-range-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.date-range-fan--past,
|
||||
.date-range-fan--next {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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'));
|
||||
@@ -79,6 +79,24 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
|
||||
// placeholder bag as the universal _skeleton.docx, but additionally
|
||||
// preserves every HL paragraph + character style from the HL Patents
|
||||
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
|
||||
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
|
||||
// (header logo + firm-address footer). Slotted ahead of the universal
|
||||
// skeleton in the fallback chain so any submission_code without a
|
||||
// dedicated per-code template still renders as a real firm-branded
|
||||
// Schriftsatz with variables substituted, rather than a plain skeleton.
|
||||
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
|
||||
firmSkeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
|
||||
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
@@ -87,6 +105,14 @@ var fileRegistry = map[string]fileEntry{
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
|
||||
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
|
||||
// surface as skeletonSubmissionSlug; carries HL paragraph + character
|
||||
// styles from the source .dotm on top. Sits between the per-code
|
||||
// template and the generic universal skeleton in the fallback chain so
|
||||
// codes without a dedicated template still render with firm branding.
|
||||
const firmSkeletonSubmissionSlug = "submission/_firm-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
|
||||
@@ -219,11 +245,28 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
// 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]
|
||||
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
|
||||
// template bytes (HL paragraph/character styles + 48-key placeholder
|
||||
// bag) plus its provenance SHA. Sits between the per-code template and
|
||||
// the generic universal skeleton in resolveSubmissionTemplate's
|
||||
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
|
||||
// as the other Gitea-backed template parts.
|
||||
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
|
||||
// the firm-skeleton and universal-skeleton accessors. Factored out so
|
||||
// the two paths can't drift apart on caching semantics.
|
||||
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
@@ -241,7 +284,7 @@ func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
|
||||
@@ -904,19 +904,25 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
// submission code. Lookup order matches the cronus design fallback chain
|
||||
// §8 plus the t-paliad-259 universal-skeleton slot:
|
||||
// §8 plus the t-paliad-259 universal-skeleton slot and the t-paliad-275
|
||||
// firm-skeleton slot:
|
||||
//
|
||||
// 1. per-firm per-submission_code template registered in
|
||||
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
|
||||
// specific structure plus the full variable bag.
|
||||
// 2. universal _skeleton.docx — same variable bag, no submission_code-
|
||||
// specific prose. Catches every code without a dedicated template
|
||||
// so the editor preview / generate flow still has variables to
|
||||
// substitute instead of falling through to the bare letterhead.
|
||||
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Final fallback when even the skeleton is unreachable
|
||||
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
|
||||
// for resilience.
|
||||
// 2. firm-formatted _firm-skeleton.docx — full HL paragraph + character
|
||||
// styles (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
|
||||
// HLpat-Table-Recitals-*, HLpat-Signature, …) preserved from the
|
||||
// source .dotm, the firm letterhead header/footer, plus the full
|
||||
// 48-key placeholder bag. Catches every code without a dedicated
|
||||
// template so the editor still renders firm-branded output.
|
||||
// 3. universal _skeleton.docx — same variable bag, no firm formatting.
|
||||
// Backstop for when the firm skeleton is unreachable (e.g. a future
|
||||
// firm hasn't authored one yet).
|
||||
// 4. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Final fallback when even both skeletons are
|
||||
// unreachable (mWorkRepo outage etc.). Preserves the
|
||||
// pre-t-paliad-259 behaviour for resilience.
|
||||
//
|
||||
// The returned SHA is the cache entry's commit SHA so the export audit
|
||||
// row can record provenance.
|
||||
@@ -926,6 +932,11 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]by
|
||||
} else if found {
|
||||
return data, sha, nil
|
||||
}
|
||||
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s, falling back to universal skeleton: %v", submissionCode, err)
|
||||
}
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, nil
|
||||
} else {
|
||||
|
||||
@@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
|
||||
}
|
||||
|
||||
// CalculateEndDate applies a single rule's duration + timing to the event date,
|
||||
// then bumps forward off non-working days for the given (country, regime).
|
||||
// Returns (adjusted, original, didAdjust).
|
||||
// then bumps off non-working days for the given (country, regime). For
|
||||
// rules with both a primary and an alt duration (alt_duration_value/_unit)
|
||||
// and a combine_op of 'max' or 'min', both legs are computed independently
|
||||
// and combined per the operator — this implements RoP R.198 / R.213
|
||||
// ("31 days OR 20 working days, whichever is longer") and the equivalent
|
||||
// shape under EPC. Returns (adjusted, original, didAdjust).
|
||||
//
|
||||
// Snap direction follows timing: 'after' snaps forward to the next
|
||||
// working day (RoP R.300.b — period extends to the next working day),
|
||||
// 'before' snaps *backward* to the preceding working day so the
|
||||
// statutory cut-off is not pushed past its hard limit.
|
||||
//
|
||||
// duration_unit='working_days' walks day-by-day via the holiday service
|
||||
// (skipping weekends + court holidays), so its result is always already a
|
||||
// working day — no post-arithmetic snap needed for that leg.
|
||||
//
|
||||
// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md
|
||||
// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds).
|
||||
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
|
||||
endDate := eventDate
|
||||
|
||||
timing := "after"
|
||||
if rule.Timing != nil {
|
||||
timing = *rule.Timing
|
||||
}
|
||||
|
||||
adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime)
|
||||
|
||||
// combine_op + alt_duration_*: compute the alt leg independently,
|
||||
// then pick the later (max) or earlier (min) of the two adjusted
|
||||
// end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar
|
||||
// days vs. 20 working days, whichever is longer).
|
||||
if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil {
|
||||
altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime)
|
||||
switch *rule.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return adjusted, raw, wasAdjusted
|
||||
}
|
||||
|
||||
// computeLeg evaluates a single (value, unit) duration against the event
|
||||
// date in the given timing direction and snap-adjusts the result. Returns
|
||||
// the snap-adjusted end-date, the pre-snap end-date, and whether a snap
|
||||
// occurred. working_days arithmetic never needs a snap (the walker lands
|
||||
// on a working day by construction).
|
||||
func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
|
||||
switch rule.DurationUnit {
|
||||
case "days":
|
||||
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue)
|
||||
case "weeks":
|
||||
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7)
|
||||
case "months":
|
||||
endDate = endDate.AddDate(0, sign*rule.DurationValue, 0)
|
||||
raw = c.addDuration(eventDate, value, unit, sign, country, regime)
|
||||
if unit == "working_days" {
|
||||
return raw, raw, false
|
||||
}
|
||||
if timing == "before" {
|
||||
return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime)
|
||||
}
|
||||
return c.holidays.AdjustForNonWorkingDays(raw, country, regime)
|
||||
}
|
||||
|
||||
original := endDate
|
||||
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
|
||||
return adjusted, original, wasAdjusted
|
||||
// addDuration adds `sign * value` of the given unit to eventDate. For
|
||||
// 'working_days' it walks day-by-day skipping weekends and court
|
||||
// holidays via the holiday service.
|
||||
func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time {
|
||||
switch unit {
|
||||
case "days":
|
||||
return eventDate.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
return eventDate.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
return eventDate.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
return c.addWorkingDays(eventDate, sign*value, country, regime)
|
||||
}
|
||||
return eventDate
|
||||
}
|
||||
|
||||
// addWorkingDays walks `n` business days from `date` (negative `n` walks
|
||||
// backward). The event day itself is never counted; we step first, then
|
||||
// skip past non-working days, repeated n times. Result is always a
|
||||
// working day for the given (country, regime). Matches UPC RoP R.300.b's
|
||||
// "the day on which the event happens shall not be counted" convention
|
||||
// applied to the business-day axis.
|
||||
//
|
||||
// Bound: each business-day step is bounded by a 60-day inner cap so a
|
||||
// misconfigured holiday table can never spin forever. The longest
|
||||
// real-world non-working run between adjacent business days is the
|
||||
// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned.
|
||||
func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time {
|
||||
if n == 0 {
|
||||
return date
|
||||
}
|
||||
step := 1
|
||||
count := n
|
||||
if n < 0 {
|
||||
step = -1
|
||||
count = -n
|
||||
}
|
||||
cur := date
|
||||
for i := 0; i < count; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// CalculateFromRules calculates deadlines for a slice of rules using the
|
||||
|
||||
@@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
|
||||
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
|
||||
// "no later than X before the oral hearing"), a computed cut-off that
|
||||
// lands on a weekend / holiday must snap *backward* to the preceding
|
||||
// working day. Forward snap would push the cut-off past the statutory
|
||||
// limit and miss the deadline. See
|
||||
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
|
||||
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
@@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
DurationUnit: "months",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
|
||||
// Adjust: Sunday → Monday 2026-03-16.
|
||||
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
|
||||
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
|
||||
// in 2026, so no extra holiday in this window).
|
||||
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
|
||||
if !original.Equal(wantOrig) {
|
||||
t.Errorf("original: got %s, want %s", original, wantOrig)
|
||||
}
|
||||
if !adjusted.Equal(wantAdj) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
|
||||
}
|
||||
if !wasAdjusted {
|
||||
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
|
||||
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
|
||||
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
|
||||
// Sat → Karfreitag → Thu 2026-04-02.
|
||||
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "1-month before, Ostern cluster",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
if !wasAdjusted {
|
||||
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
|
||||
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
|
||||
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
|
||||
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
// working_days arithmetic lands on a working day by construction, so the
|
||||
// "snap" reports no adjustment and original == adjusted.
|
||||
if !original.Equal(want) {
|
||||
t.Errorf("original: got %s, want %s", original, want)
|
||||
}
|
||||
if wasAdjusted {
|
||||
t.Error("working_days result should not report a snap adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
|
||||
// 20 working days lands on the Friday four weeks later. Anchor Fri
|
||||
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
|
||||
// window. This exercises the R.198 / R.213 "20 working days" leg.
|
||||
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "20 working days",
|
||||
DurationValue: 20,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
|
||||
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
|
||||
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
|
||||
// (3). Result = Thu 2026-04-09.
|
||||
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "3 working days over Ostern",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
|
||||
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
|
||||
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
|
||||
// Tue 06 (5). Result = Tue 2026-01-06.
|
||||
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days over year-end",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
|
||||
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
|
||||
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
|
||||
// Fri 2026-04-10.
|
||||
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days before",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
|
||||
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
|
||||
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
|
||||
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "3 working days from Saturday",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
|
||||
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
|
||||
// working days, whichever is longer". Anchor Mon 2026-01-12.
|
||||
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
|
||||
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
|
||||
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
|
||||
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
|
||||
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
|
||||
//
|
||||
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
|
||||
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "31d OR 20wd, max",
|
||||
DurationValue: 31,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
|
||||
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
|
||||
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
|
||||
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
|
||||
// where the working-days side overshoots. Anchor over a long-weekend
|
||||
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
|
||||
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
|
||||
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
|
||||
// + snap ≈ 20wd + cluster.
|
||||
//
|
||||
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
|
||||
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
|
||||
// "alt wins", we use a configurable anchor and check the relative order
|
||||
// instead.
|
||||
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
|
||||
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
|
||||
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
|
||||
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
|
||||
// arrives much later. Use 14 days vs 20 working_days to make alt
|
||||
// reliably win on this stretch.
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "14d OR 20wd, max",
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
|
||||
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
|
||||
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
|
||||
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
|
||||
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
|
||||
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
|
||||
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
|
||||
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
|
||||
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
|
||||
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
|
||||
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
|
||||
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
|
||||
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
|
||||
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "14d OR 20wd, min",
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("min"),
|
||||
}
|
||||
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
|
||||
// the primary-only result (defensive: drift in seed data shouldn't crash
|
||||
// the calculator). Same as the basic days test but with combine_op set
|
||||
// and alt fields nil.
|
||||
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "Primary only, stray combine_op",
|
||||
DurationValue: 10,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
@@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
|
||||
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
|
||||
// 2026-08-29") locks the live behaviour.
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@ import (
|
||||
// tree alone is enough to produce a candidate concept set.
|
||||
// - Forums: a list of forum slugs from the v3 bucket map. Translated
|
||||
// to proceeding_type_codes by the search service; trigger-event
|
||||
// pills bypass the forum filter (cross-cutting by design).
|
||||
// pills carry a structured legal_source citation (via mig 123)
|
||||
// and narrow by the per-forum legal-source prefix set instead of
|
||||
// by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
|
||||
// 123 trigger pills bypassed the forum filter unconditionally;
|
||||
// m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
|
||||
// to narrow with the active court-system chip.
|
||||
//
|
||||
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
|
||||
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
|
||||
@@ -74,6 +79,40 @@ var ForumToProceedingCodes = map[string][]string{
|
||||
"dpma": {CodeDPMAOpposition},
|
||||
}
|
||||
|
||||
// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
|
||||
// structured legal_source prefixes that cross-cutting trigger pills
|
||||
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
|
||||
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
|
||||
// have no proceeding context, so the narrowing key is the citation
|
||||
// body itself.
|
||||
//
|
||||
// Mapping mirrors m's spec on the issue:
|
||||
//
|
||||
// - UPC chips → UPC.* (UPC RoP / UPC Agreement / UPC Statute)
|
||||
// - DE LG/OLG/BGH chips → DE.ZPO.* (civil-procedure path)
|
||||
// - DE BPatG chip → DE.PatG.* (national patent path)
|
||||
// - DPMA chip → DE.PatG.* (national patent path)
|
||||
// - EPA chips → EU.EPC* / EU.EPÜ* (EPC / EPÜ citations)
|
||||
//
|
||||
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
|
||||
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
|
||||
// patent jurisdiction. The matching SQL uses startsWith against the
|
||||
// union of the active forums' prefixes, so a chip combination like
|
||||
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
|
||||
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
|
||||
var ForumToLegalSourcePrefixes = map[string][]string{
|
||||
"upc_cfi": {"UPC."},
|
||||
"upc_coa": {"UPC."},
|
||||
"de_lg": {"DE.ZPO."},
|
||||
"de_olg": {"DE.ZPO."},
|
||||
"de_bgh": {"DE.ZPO."},
|
||||
"de_bpatg": {"DE.PatG."},
|
||||
"epa_grant": {"EU.EPC", "EU.EPÜ"},
|
||||
"epa_opp": {"EU.EPC", "EU.EPÜ"},
|
||||
"epa_appeal": {"EU.EPC", "EU.EPÜ"},
|
||||
"dpma": {"DE.PatG."},
|
||||
}
|
||||
|
||||
// SearchOptions carries the optional facet filters from the URL query
|
||||
// string. Empty strings / empty slices mean "no filter on this facet".
|
||||
type SearchOptions struct {
|
||||
@@ -279,8 +318,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
subtree = newSubtreeFilter(outcomes)
|
||||
}
|
||||
|
||||
// v3: translate forum slugs to proceeding_code allow-list.
|
||||
// v3: translate forum slugs to proceeding_code allow-list (rule
|
||||
// pills) and t-paliad-266: parallel legal_source prefix allow-list
|
||||
// for trigger pills. Empty slice for either axis = no narrowing on
|
||||
// that pill kind.
|
||||
forumCodes := translateForums(opts.Forums)
|
||||
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
|
||||
|
||||
if !browseMode && qNorm == "" {
|
||||
return resp, nil
|
||||
@@ -293,11 +336,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
var ranks []rankRow
|
||||
if browseMode {
|
||||
// Browse mode: synthesize ranks from the allow-list directly.
|
||||
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, limit)
|
||||
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
|
||||
} else {
|
||||
qLow := strings.ToLower(qNorm)
|
||||
var err error
|
||||
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, limit)
|
||||
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -310,7 +353,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
|
||||
for i, r := range ranks {
|
||||
conceptIDs[i] = r.ConceptID
|
||||
}
|
||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes)
|
||||
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,6 +461,33 @@ func translateForums(slugs []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
|
||||
// the union of legal_source prefixes those forums admit for trigger
|
||||
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
|
||||
// callers must treat empty as "no trigger narrowing applies" rather
|
||||
// than "match nothing", mirroring translateForums.
|
||||
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
|
||||
if len(slugs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, slug := range slugs {
|
||||
prefixes, ok := ForumToLegalSourcePrefixes[slug]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// browseRanks synthesizes a rank list from a subtree-filter tuple set
|
||||
// (v3 B1 browse mode). No trigram scoring — order is by concept
|
||||
// sort_order then name. Forum filter applies post-hoc to keep concepts
|
||||
@@ -430,6 +500,7 @@ func (s *DeadlineSearchService) browseRanks(
|
||||
subtree *subtreeFilter,
|
||||
party, proc, source *string,
|
||||
forumCodes []string,
|
||||
forumLegalPrefixes []string,
|
||||
limit int,
|
||||
) []rankRow {
|
||||
const sqlText = `
|
||||
@@ -452,8 +523,18 @@ SELECT DISTINCT
|
||||
AND (
|
||||
$6::text[] IS NULL
|
||||
OR cardinality($6::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($6::text[])
|
||||
OR (
|
||||
s.kind = 'rule'
|
||||
AND s.proceeding_code = ANY($6::text[])
|
||||
)
|
||||
OR (
|
||||
s.kind = 'trigger'
|
||||
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM unnest($8::text[]) AS lp
|
||||
WHERE s.legal_source LIKE lp || '%'
|
||||
))
|
||||
)
|
||||
)
|
||||
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
|
||||
LIMIT $7
|
||||
@@ -465,6 +546,7 @@ SELECT DISTINCT
|
||||
party, proc, source,
|
||||
nullableArray(forumCodes),
|
||||
limit,
|
||||
nullableArray(forumLegalPrefixes),
|
||||
); err != nil {
|
||||
// Browse mode failures degrade to empty (taxonomy-driven UX
|
||||
// shouldn't crash on a malformed slug); log via the caller.
|
||||
@@ -490,11 +572,12 @@ func (s *DeadlineSearchService) rankConcepts(
|
||||
party, proc, source *string,
|
||||
subtree *subtreeFilter,
|
||||
forumCodes []string,
|
||||
forumLegalPrefixes []string,
|
||||
limit int,
|
||||
) ([]rankRow, error) {
|
||||
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
|
||||
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
|
||||
// $8 forum_codes text[]? · $9 limit
|
||||
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
|
||||
const sqlText = `
|
||||
WITH matched AS (
|
||||
SELECT
|
||||
@@ -544,8 +627,18 @@ WITH matched AS (
|
||||
AND (
|
||||
$8::text[] IS NULL
|
||||
OR cardinality($8::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($8::text[])
|
||||
OR (
|
||||
s.kind = 'rule'
|
||||
AND s.proceeding_code = ANY($8::text[])
|
||||
)
|
||||
OR (
|
||||
s.kind = 'trigger'
|
||||
AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM unnest($10::text[]) AS lp
|
||||
WHERE s.legal_source LIKE lp || '%'
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
@@ -569,6 +662,7 @@ SELECT
|
||||
cidArg, procArg,
|
||||
nullableArray(forumCodes),
|
||||
limit,
|
||||
nullableArray(forumLegalPrefixes),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("rank concepts: %w", err)
|
||||
}
|
||||
@@ -581,10 +675,11 @@ func (s *DeadlineSearchService) loadPills(
|
||||
party, proc, source *string,
|
||||
subtree *subtreeFilter,
|
||||
forumCodes []string,
|
||||
forumLegalPrefixes []string,
|
||||
) ([]pillRow, error) {
|
||||
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
|
||||
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
|
||||
// $7 forum_codes text[]?
|
||||
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
|
||||
const sqlText = `
|
||||
SELECT
|
||||
s.kind,
|
||||
@@ -627,8 +722,18 @@ SELECT
|
||||
AND (
|
||||
$7::text[] IS NULL
|
||||
OR cardinality($7::text[]) = 0
|
||||
OR s.kind = 'trigger'
|
||||
OR s.proceeding_code = ANY($7::text[])
|
||||
OR (
|
||||
s.kind = 'rule'
|
||||
AND s.proceeding_code = ANY($7::text[])
|
||||
)
|
||||
OR (
|
||||
s.kind = 'trigger'
|
||||
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM unnest($8::text[]) AS lp
|
||||
WHERE s.legal_source LIKE lp || '%'
|
||||
))
|
||||
)
|
||||
)
|
||||
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
|
||||
`
|
||||
@@ -638,6 +743,7 @@ SELECT
|
||||
pq.Array(conceptIDs), party, proc, source,
|
||||
cidArg, procArg,
|
||||
nullableArray(forumCodes),
|
||||
nullableArray(forumLegalPrefixes),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("load pills: %w", err)
|
||||
}
|
||||
|
||||
@@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) {
|
||||
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
||||
})
|
||||
|
||||
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
|
||||
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
|
||||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
|
||||
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
|
||||
// 200..203 from migration 046.
|
||||
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
|
||||
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
|
||||
// 200..203 from mig 046 plus 207 from mig 063.
|
||||
triggerIDs := []int64{}
|
||||
for _, p := range card.Pills {
|
||||
if p.Kind != "trigger" {
|
||||
@@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) {
|
||||
triggerIDs = append(triggerIDs, *p.TriggerEventID)
|
||||
}
|
||||
}
|
||||
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
|
||||
if len(triggerIDs) != 4 {
|
||||
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
|
||||
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
|
||||
if len(triggerIDs) != 5 {
|
||||
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
|
||||
}
|
||||
for _, id := range triggerIDs {
|
||||
if !want[id] {
|
||||
@@ -195,6 +195,107 @@ func TestDeadlineSearch(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// t-paliad-266 / m/paliad#97 — court-system filter narrows
|
||||
// cross-cutting trigger pills via legal_source inference.
|
||||
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
|
||||
// Each pair is (forum slug, expected trigger_event_ids).
|
||||
cases := []struct {
|
||||
name string
|
||||
forum string
|
||||
wantTrigIDs []int64
|
||||
}{
|
||||
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
|
||||
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
|
||||
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
|
||||
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
|
||||
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
|
||||
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
|
||||
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
|
||||
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
|
||||
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
|
||||
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
|
||||
Forums: []string{tc.forum},
|
||||
Limit: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||
got := map[int64]bool{}
|
||||
for _, p := range card.Pills {
|
||||
if p.TriggerEventID != nil {
|
||||
got[*p.TriggerEventID] = true
|
||||
}
|
||||
}
|
||||
want := map[int64]bool{}
|
||||
for _, id := range tc.wantTrigIDs {
|
||||
want[id] = true
|
||||
}
|
||||
for id := range got {
|
||||
if !want[id] {
|
||||
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
|
||||
}
|
||||
}
|
||||
for id := range want {
|
||||
if !got[id] {
|
||||
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
|
||||
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
|
||||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
|
||||
Forums: []string{"upc_cfi", "de_lg"},
|
||||
Limit: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||
got := map[int64]bool{}
|
||||
for _, p := range card.Pills {
|
||||
if p.TriggerEventID != nil {
|
||||
got[*p.TriggerEventID] = true
|
||||
}
|
||||
}
|
||||
want := map[int64]bool{201: true, 207: true}
|
||||
for id := range got {
|
||||
if !want[id] {
|
||||
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
|
||||
}
|
||||
}
|
||||
for id := range want {
|
||||
if !got[id] {
|
||||
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
|
||||
// No forum chips = all 5 triggers stay visible.
|
||||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||||
count := 0
|
||||
for _, p := range card.Pills {
|
||||
if p.Kind == "trigger" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 5 {
|
||||
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
|
||||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
|
||||
if err != nil {
|
||||
|
||||
@@ -122,12 +122,18 @@ type TimeSpec struct {
|
||||
type TimeHorizon string
|
||||
|
||||
const (
|
||||
HorizonNext1d TimeHorizon = "next_1d"
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext14d TimeHorizon = "next_14d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonNextAll TimeHorizon = "next_all"
|
||||
HorizonPast1d TimeHorizon = "past_1d"
|
||||
HorizonPast7d TimeHorizon = "past_7d"
|
||||
HorizonPast14d TimeHorizon = "past_14d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonPastAll TimeHorizon = "past_all"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
@@ -334,8 +340,9 @@ func (s *ScopeSpec) validate() error {
|
||||
|
||||
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
||||
switch t.Horizon {
|
||||
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
||||
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
case HorizonNext1d, HorizonNext7d, HorizonNext14d, HorizonNext30d, HorizonNext90d, HorizonNextAll,
|
||||
HorizonPast1d, HorizonPast7d, HorizonPast14d, HorizonPast30d, HorizonPast90d, HorizonPastAll,
|
||||
HorizonAny:
|
||||
// fine
|
||||
case HorizonAll:
|
||||
// Q26: reject "all" unless scope.projects is explicit. Performance
|
||||
|
||||
@@ -160,6 +160,23 @@ func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-248: the symmetric date-range picker adds six new horizons —
|
||||
// 1d/14d/all on each side. They must round-trip through validate without
|
||||
// requiring scope.explicit (unlike HorizonAll which is a bidirectional-
|
||||
// unbounded substrate scan and stays gated to ScopeExplicit per Q26).
|
||||
func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
|
||||
for _, h := range []TimeHorizon{
|
||||
HorizonNext1d, HorizonNext14d, HorizonNextAll,
|
||||
HorizonPast1d, HorizonPast14d, HorizonPastAll,
|
||||
} {
|
||||
s := validBaseSpec()
|
||||
s.Time.Horizon = h
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("horizon %q must validate against a default scope: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
|
||||
s := validBaseSpec()
|
||||
s.Sources = []DataSource{SourceDeadline}
|
||||
|
||||
@@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string)
|
||||
return h != nil && h.IsClosure
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysBackward is the symmetric counterpart of
|
||||
// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it
|
||||
// lands on a working day for the given (country, regime). Used for
|
||||
// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before
|
||||
// the oral hearing") — when the computed cut-off lands on a weekend or
|
||||
// public holiday, the lawyer must finish *earlier*, not later. Forward
|
||||
// snap would push the cut-off past the statutory limit and cause the
|
||||
// step to be filed too late. Bound by the same 60-iter cap as the
|
||||
// forward variant.
|
||||
func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, -1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays moves the date forward to the next working day for
|
||||
// the given (country, regime). Returns adjusted date, the original
|
||||
// (unmodified) date, and whether any adjustment was made.
|
||||
|
||||
@@ -6,17 +6,28 @@ package services
|
||||
//
|
||||
// Variables span six namespaces:
|
||||
//
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// rule.* paliad.deadline_rules row keyed by submission_code
|
||||
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// procedural_event.* paliad.deadline_rules row keyed by submission_code
|
||||
// — the "what kind of step in the proceeding"
|
||||
// identity (Schriftsatz, Anhörung, Entscheidung,
|
||||
// …). See docs/design-procedural-events-model-
|
||||
// 2026-05-25.md (t-paliad-262 Slice A).
|
||||
// rule.* legacy alias for procedural_event.*; emitted
|
||||
// unconditionally for backward compatibility
|
||||
// with Word templates and saved drafts authored
|
||||
// before the rename. @deprecated — new templates
|
||||
// should use the procedural_event.* form.
|
||||
// deadline.* next open paliad.deadlines row for
|
||||
// (project, procedural_event), if any
|
||||
//
|
||||
// Locale handling: every long-form date string is computed in both DE
|
||||
// and EN; the renderer picks based on the user's lang preference. The
|
||||
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
||||
// procedural-event pretty-printer (legalSourcePretty) also has DE/EN
|
||||
// variants.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
@@ -173,9 +184,12 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the deadline_rule that owns the given
|
||||
// submission_code. Restricts to lifecycle_state='published' so drafts
|
||||
// never end up shaping a real submission.
|
||||
// loadPublishedRule fetches the published procedural-event template
|
||||
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
|
||||
// lifecycle_state='published' so drafts never end up shaping a real
|
||||
// submission. Function name retained for Slice A (prose-only); Slice
|
||||
// B renames it to loadPublishedProceduralEvent when the Go type is
|
||||
// renamed (t-paliad-262 §6).
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
@@ -346,21 +360,55 @@ func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
// addRuleVars populates the procedural-event variable namespace —
|
||||
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
|
||||
//
|
||||
// Two key prefixes are emitted for every value:
|
||||
//
|
||||
// - procedural_event.* — canonical name (t-paliad-262 Slice A,
|
||||
// design docs/design-procedural-events-model-2026-05-25.md).
|
||||
// - rule.* — legacy alias kept forever (m's call,
|
||||
// issue m/paliad#93 Q7); existing Word templates and saved
|
||||
// submission_drafts authored before the rename keep working.
|
||||
//
|
||||
// `procedural_event.event_kind` is the canonical key for the
|
||||
// procedural-event kind (filing|reply|hearing|decision|order). The
|
||||
// legacy `rule.event_type` alias holds the same string. The column
|
||||
// itself stays named `event_type` on `paliad.deadline_rules` — Slice
|
||||
// A is prose-only; the column-level rename to `event_kind` is Slice B.
|
||||
//
|
||||
// Function name stays `addRuleVars` to avoid coupling Slice A to the
|
||||
// Go-type rename which is Slice B (B.5 sub-slice).
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
code := derefString(r.SubmissionCode)
|
||||
var localizedName string
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
localizedName = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
localizedName = r.Name
|
||||
}
|
||||
legalSource := derefString(r.LegalSource)
|
||||
legalSourcePrettyVal := legalSourcePretty(legalSource, lang)
|
||||
primaryParty := derefString(r.PrimaryParty)
|
||||
eventKind := derefString(r.EventType)
|
||||
|
||||
bag["procedural_event.code"] = code
|
||||
bag["procedural_event.name"] = localizedName
|
||||
bag["procedural_event.name_de"] = r.Name
|
||||
bag["procedural_event.name_en"] = r.NameEN
|
||||
bag["procedural_event.legal_source"] = legalSource
|
||||
bag["procedural_event.legal_source_pretty"] = legalSourcePrettyVal
|
||||
bag["procedural_event.primary_party"] = primaryParty
|
||||
bag["procedural_event.event_kind"] = eventKind
|
||||
|
||||
bag["rule.submission_code"] = code
|
||||
bag["rule.name"] = localizedName
|
||||
bag["rule.name_de"] = r.Name
|
||||
bag["rule.name_en"] = r.NameEN
|
||||
bag["rule.legal_source"] = derefString(r.LegalSource)
|
||||
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
||||
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
||||
bag["rule.event_type"] = derefString(r.EventType)
|
||||
bag["rule.legal_source"] = legalSource
|
||||
bag["rule.legal_source_pretty"] = legalSourcePrettyVal
|
||||
bag["rule.primary_party"] = primaryParty
|
||||
bag["rule.event_type"] = eventKind
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
|
||||
153
internal/services/submission_vars_aliases_test.go
Normal file
153
internal/services/submission_vars_aliases_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
// Regression test for the procedural-event placeholder aliases
|
||||
// (t-paliad-262 Slice A, m/paliad#93 Q7).
|
||||
//
|
||||
// The variable bag emits TWO key prefixes for the procedural-event
|
||||
// namespace:
|
||||
//
|
||||
// - procedural_event.* (canonical, post-rename)
|
||||
// - rule.* (legacy, @deprecated)
|
||||
//
|
||||
// m's lock: keep the legacy aliases forever so lawyer-authored Word
|
||||
// templates and existing paliad.submission_drafts rows that already
|
||||
// contain `{{rule.X}}` keep merging correctly.
|
||||
//
|
||||
// This test pins the contract: every (canonical, legacy) pair must
|
||||
// resolve to the same string in the placeholder map, for every value
|
||||
// of (lang, present-vs-NULL columns). Removing the legacy aliases —
|
||||
// or letting them drift in value from the canonical — must light up
|
||||
// here BEFORE the change can land in main.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestAddRuleVars_CanonicalAndLegacyAliasesMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Pairs are (canonical key, legacy key). Order matters only for
|
||||
// the assertion message — the test checks string equality both
|
||||
// ways round.
|
||||
pairs := []struct {
|
||||
canonical string
|
||||
legacy string
|
||||
}{
|
||||
{"procedural_event.code", "rule.submission_code"},
|
||||
{"procedural_event.name", "rule.name"},
|
||||
{"procedural_event.name_de", "rule.name_de"},
|
||||
{"procedural_event.name_en", "rule.name_en"},
|
||||
{"procedural_event.legal_source", "rule.legal_source"},
|
||||
{"procedural_event.legal_source_pretty", "rule.legal_source_pretty"},
|
||||
{"procedural_event.primary_party", "rule.primary_party"},
|
||||
{"procedural_event.event_kind", "rule.event_type"},
|
||||
}
|
||||
|
||||
// Build a fully-populated rule row. Every nullable column has a
|
||||
// distinct non-empty value so missing-value bugs (e.g. the legacy
|
||||
// key copying "" while the canonical key copies the real value)
|
||||
// would surface.
|
||||
code := "dpma.appeal.bgh.begruendung"
|
||||
desc := "Rechtsbeschwerdebegründung — § 102 PatG"
|
||||
party := "both"
|
||||
kind := "filing"
|
||||
legal := "DE.PatG.102"
|
||||
ruleCode := "§ 102 PatG"
|
||||
|
||||
rule := &models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
SubmissionCode: &code,
|
||||
Name: "Rechtsbeschwerdebegründung",
|
||||
NameEN: "Appeal brief",
|
||||
Description: &desc,
|
||||
PrimaryParty: &party,
|
||||
EventType: &kind,
|
||||
LegalSource: &legal,
|
||||
RuleCode: &ruleCode,
|
||||
}
|
||||
|
||||
for _, lang := range []string{"de", "en"} {
|
||||
t.Run(lang, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bag := PlaceholderMap{}
|
||||
addRuleVars(bag, rule, lang)
|
||||
|
||||
for _, p := range pairs {
|
||||
canonicalVal, canonicalOK := bag[p.canonical]
|
||||
legacyVal, legacyOK := bag[p.legacy]
|
||||
|
||||
if !canonicalOK {
|
||||
t.Errorf("canonical key %q missing from bag (lang=%s); "+
|
||||
"Slice A must emit both forms", p.canonical, lang)
|
||||
}
|
||||
if !legacyOK {
|
||||
t.Errorf("legacy alias %q missing from bag (lang=%s); "+
|
||||
"removing legacy aliases would break existing Word "+
|
||||
"templates that paliad doesn't see — keep the "+
|
||||
"emission per m/paliad#93 Q7", p.legacy, lang)
|
||||
}
|
||||
if canonicalVal != legacyVal {
|
||||
t.Errorf("alias drift: %q=%q vs %q=%q (lang=%s)",
|
||||
p.canonical, canonicalVal,
|
||||
p.legacy, legacyVal, lang)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the localized name actually localizes (the
|
||||
// canonical and legacy `name` keys depend on lang). If
|
||||
// this fails the loop above wouldn't catch it (both keys
|
||||
// would agree on the wrong language).
|
||||
localized := bag["procedural_event.name"]
|
||||
if strings.EqualFold(lang, "en") && localized != rule.NameEN {
|
||||
t.Errorf("expected EN localized name=%q, got %q",
|
||||
rule.NameEN, localized)
|
||||
}
|
||||
if strings.EqualFold(lang, "de") && localized != rule.Name {
|
||||
t.Errorf("expected DE localized name=%q, got %q",
|
||||
rule.Name, localized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRuleVars_NullableFieldsEmitEmptyOnBothPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A minimal rule with every optional column NULL. The bag must
|
||||
// still emit every canonical + legacy key — with the empty
|
||||
// string — so downstream merging produces the standard
|
||||
// "[KEIN WERT: ...]" marker rather than a broken template.
|
||||
rule := &models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "Generic step",
|
||||
NameEN: "Generic step",
|
||||
}
|
||||
|
||||
bag := PlaceholderMap{}
|
||||
addRuleVars(bag, rule, "de")
|
||||
|
||||
mustHave := []string{
|
||||
"procedural_event.code", "rule.submission_code",
|
||||
"procedural_event.legal_source", "rule.legal_source",
|
||||
"procedural_event.legal_source_pretty", "rule.legal_source_pretty",
|
||||
"procedural_event.primary_party", "rule.primary_party",
|
||||
"procedural_event.event_kind", "rule.event_type",
|
||||
}
|
||||
for _, key := range mustHave {
|
||||
val, ok := bag[key]
|
||||
if !ok {
|
||||
t.Errorf("key %q missing from bag even with NULL source column; "+
|
||||
"derefString must materialize the empty string so the "+
|
||||
"merger sees the variable and renders the missing-value "+
|
||||
"marker", key)
|
||||
}
|
||||
if val != "" {
|
||||
t.Errorf("key %q = %q, want \"\" (source column was NULL)", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,11 +172,20 @@ type viewSpecBounds struct {
|
||||
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||
now = now.UTC()
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
tomorrow := day.AddDate(0, 0, 1)
|
||||
switch ts.Horizon {
|
||||
case HorizonNext1d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext7d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 7)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext14d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 14)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNext30d:
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 30)
|
||||
@@ -185,18 +194,30 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 90)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonNextAll:
|
||||
// One-sided unbounded — from today onwards, no upper bound.
|
||||
// Distinct from HorizonAll (bidirectional unbounded) and
|
||||
// HorizonAny (no time filter at all).
|
||||
from := day
|
||||
return viewSpecBounds{from: &from}
|
||||
case HorizonPast1d:
|
||||
from := day.AddDate(0, 0, -1)
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast7d:
|
||||
from := day.AddDate(0, 0, -7)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast14d:
|
||||
from := day.AddDate(0, 0, -14)
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast30d:
|
||||
from := day.AddDate(0, 0, -30)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPast90d:
|
||||
from := day.AddDate(0, 0, -90)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
return viewSpecBounds{from: &from, to: &tomorrow}
|
||||
case HorizonPastAll:
|
||||
// One-sided unbounded — up to and including today, no lower bound.
|
||||
return viewSpecBounds{to: &tomorrow}
|
||||
case HorizonAny, HorizonAll:
|
||||
return viewSpecBounds{}
|
||||
case HorizonCustom:
|
||||
|
||||
123
internal/services/view_service_bounds_test.go
Normal file
123
internal/services/view_service_bounds_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package services
|
||||
|
||||
// Pure tests for computeViewSpecBounds — t-paliad-248. Covers every
|
||||
// TimeHorizon constant in the symmetric date-range fan, including the
|
||||
// six new ones added when the picker shipped (next_1d / next_14d /
|
||||
// next_all / past_1d / past_14d / past_all).
|
||||
//
|
||||
// Anchored against a fixed `now` so the assertions never drift with the
|
||||
// wall clock. Each case asserts the bounds shape (open-ended vs.
|
||||
// closed) and the exact offsets from the anchor day.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeViewSpecBounds_Horizons(t *testing.T) {
|
||||
// Anchor: 2026-05-25 14:37:00 UTC. computeViewSpecBounds normalises
|
||||
// to startOfDay UTC, so the wall-clock time within the day is
|
||||
// irrelevant.
|
||||
now := time.Date(2026, 5, 25, 14, 37, 0, 0, time.UTC)
|
||||
day := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
tomorrow := day.AddDate(0, 0, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
horizon TimeHorizon
|
||||
wantFrom *time.Time
|
||||
wantTo *time.Time
|
||||
}{
|
||||
// Future fan.
|
||||
{"next_1d", HorizonNext1d, &day, tptr(day.AddDate(0, 0, 1))},
|
||||
{"next_7d", HorizonNext7d, &day, tptr(day.AddDate(0, 0, 7))},
|
||||
{"next_14d", HorizonNext14d, &day, tptr(day.AddDate(0, 0, 14))},
|
||||
{"next_30d", HorizonNext30d, &day, tptr(day.AddDate(0, 0, 30))},
|
||||
{"next_90d", HorizonNext90d, &day, tptr(day.AddDate(0, 0, 90))},
|
||||
// One-sided unbounded: from today, no upper bound.
|
||||
{"next_all", HorizonNextAll, &day, nil},
|
||||
|
||||
// Past fan — upper bound is tomorrow (exclusive end-of-today).
|
||||
{"past_1d", HorizonPast1d, tptr(day.AddDate(0, 0, -1)), &tomorrow},
|
||||
{"past_7d", HorizonPast7d, tptr(day.AddDate(0, 0, -7)), &tomorrow},
|
||||
{"past_14d", HorizonPast14d, tptr(day.AddDate(0, 0, -14)), &tomorrow},
|
||||
{"past_30d", HorizonPast30d, tptr(day.AddDate(0, 0, -30)), &tomorrow},
|
||||
{"past_90d", HorizonPast90d, tptr(day.AddDate(0, 0, -90)), &tomorrow},
|
||||
// One-sided unbounded: no lower bound, up to and including today.
|
||||
{"past_all", HorizonPastAll, nil, &tomorrow},
|
||||
|
||||
// Bidirectional unbounded — both nil.
|
||||
{"any", HorizonAny, nil, nil},
|
||||
{"all", HorizonAll, nil, nil},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := computeViewSpecBounds(now, TimeSpec{Horizon: tc.horizon})
|
||||
assertBound(t, "from", got.from, tc.wantFrom)
|
||||
assertBound(t, "to", got.to, tc.wantTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeViewSpecBounds_NewHorizonsAreOneSided documents the
|
||||
// semantic distinction between next_all / past_all (one-sided
|
||||
// unbounded, with one bound nil and the other set) and the existing
|
||||
// HorizonAll / HorizonAny (both bounds nil).
|
||||
func TestComputeViewSpecBounds_NewHorizonsAreOneSided(t *testing.T) {
|
||||
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
nextAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonNextAll})
|
||||
if nextAll.from == nil {
|
||||
t.Fatalf("HorizonNextAll: from must be set (today), got nil")
|
||||
}
|
||||
if nextAll.to != nil {
|
||||
t.Fatalf("HorizonNextAll: to must be nil (no upper bound), got %v", *nextAll.to)
|
||||
}
|
||||
|
||||
pastAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonPastAll})
|
||||
if pastAll.from != nil {
|
||||
t.Fatalf("HorizonPastAll: from must be nil (no lower bound), got %v", *pastAll.from)
|
||||
}
|
||||
if pastAll.to == nil {
|
||||
t.Fatalf("HorizonPastAll: to must be set (tomorrow), got nil")
|
||||
}
|
||||
|
||||
any := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonAny})
|
||||
if any.from != nil || any.to != nil {
|
||||
t.Fatalf("HorizonAny: both bounds must be nil, got from=%v to=%v", any.from, any.to)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeViewSpecBounds_CustomRoundTrips makes sure the custom
|
||||
// horizon passes through the caller-supplied from/to verbatim — no
|
||||
// normalisation, no clamping.
|
||||
func TestComputeViewSpecBounds_CustomRoundTrips(t *testing.T) {
|
||||
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
from := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
got := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonCustom, From: &from, To: &to})
|
||||
if got.from == nil || !got.from.Equal(from) {
|
||||
t.Fatalf("custom from: want %v, got %v", from, got.from)
|
||||
}
|
||||
if got.to == nil || !got.to.Equal(to) {
|
||||
t.Fatalf("custom to: want %v, got %v", to, got.to)
|
||||
}
|
||||
}
|
||||
|
||||
func tptr(t time.Time) *time.Time { return &t }
|
||||
|
||||
func assertBound(t *testing.T, name string, got *time.Time, want *time.Time) {
|
||||
t.Helper()
|
||||
switch {
|
||||
case got == nil && want == nil:
|
||||
return
|
||||
case got == nil:
|
||||
t.Fatalf("%s: want %v, got nil", name, *want)
|
||||
case want == nil:
|
||||
t.Fatalf("%s: want nil, got %v", name, *got)
|
||||
case !got.Equal(*want):
|
||||
t.Fatalf("%s: want %v, got %v", name, *want, *got)
|
||||
}
|
||||
}
|
||||
450
scripts/gen-hl-skeleton-template/main.go
Normal file
450
scripts/gen-hl-skeleton-template/main.go
Normal file
@@ -0,0 +1,450 @@
|
||||
// HL-firm skeleton submission template generator (t-paliad-275).
|
||||
//
|
||||
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
|
||||
// macros and template-only artifacts, then emits a clean .docx that:
|
||||
//
|
||||
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
|
||||
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
|
||||
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
|
||||
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
|
||||
// source .dotm untouched.
|
||||
// 2. Preserves the firm letterhead (logo header + firm-address footer)
|
||||
// by keeping word/header[12].xml + word/footer[12].xml and the
|
||||
// sectPr that references them.
|
||||
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
|
||||
// exercises every SubmissionVarsService placeholder (firm.*,
|
||||
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
|
||||
// deadline.*) — applying HL paragraph/character styles to each
|
||||
// section so the rendered output reads as a real HL submission with
|
||||
// variables substituted.
|
||||
//
|
||||
// Drop the output into HL/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
|
||||
//
|
||||
// so paliad's submission generator picks it up via the fallback chain.
|
||||
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
|
||||
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
|
||||
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
|
||||
// (no placeholders). See internal/handlers/submission_drafts.go
|
||||
// resolveSubmissionTemplate.
|
||||
//
|
||||
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
|
||||
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
|
||||
// under the firm-namespaced directory in mWorkRepo so a future firm gets
|
||||
// its own equivalent file generated against its own .dotm.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-hl-skeleton-template \
|
||||
// -in /tmp/hl-patents-style.dotm \
|
||||
// -out /tmp/_firm-skeleton.docx
|
||||
//
|
||||
// Output is byte-stable across runs for a given input (zip mtimes
|
||||
// pinned).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
|
||||
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
if *in == "" {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
srcBytes, err := os.ReadFile(*in)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
docx, err := buildDocx(srcBytes)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
// fixedTime pins every zip entry's mtime so successive runs over the
|
||||
// same .dotm produce byte-stable output. Useful for diffing the
|
||||
// generated file in PR review.
|
||||
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// dropPaths lists zip entries removed during the .dotm → .docx
|
||||
// conversion. VBA macros + their keymap binding + the template-only
|
||||
// glossary parts and ribbon customizations are all dead weight (and
|
||||
// some actively trigger Word's macro-security warning) — none of them
|
||||
// add anything to a placeholder-rich Schriftsatz starter.
|
||||
var dropPaths = map[string]bool{
|
||||
"word/vbaProject.bin": true,
|
||||
"word/vbaData.xml": true,
|
||||
"word/customizations.xml": true,
|
||||
"userCustomization/customUI.xml": true,
|
||||
"customUI/customUI14.xml": true,
|
||||
"word/glossary/document.xml": true,
|
||||
"word/glossary/_rels/document.xml.rels": true,
|
||||
"word/glossary/fontTable.xml": true,
|
||||
"word/glossary/numbering.xml": true,
|
||||
"word/glossary/settings.xml": true,
|
||||
"word/glossary/styles.xml": true,
|
||||
"word/glossary/webSettings.xml": true,
|
||||
}
|
||||
|
||||
// rIdsToDrop names the document-rel ids whose targets are stripped
|
||||
// from the package (vbaProject, customizations.xml, glossary). They
|
||||
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
|
||||
// on a dangling reference.
|
||||
var rIdsToDrop = map[string]bool{
|
||||
"rId1": true, // vbaProject.bin
|
||||
"rId2": true, // customizations.xml (keymap to VBA)
|
||||
"rId21": true, // glossary/document.xml
|
||||
}
|
||||
|
||||
func buildDocx(src []byte) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source zip: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if dropPaths[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "[Content_Types].xml":
|
||||
body = []byte(patchContentTypes(string(body)))
|
||||
case "_rels/.rels":
|
||||
body = []byte(patchRootRels(string(body)))
|
||||
case "word/_rels/document.xml.rels":
|
||||
body = []byte(patchDocumentRels(string(body)))
|
||||
case "word/document.xml":
|
||||
body = []byte(buildDocumentXML())
|
||||
}
|
||||
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// patchContentTypes rewrites the macroEnabledTemplate part type to the
|
||||
// regular wordprocessingml.document type (a .dotm carries the macro
|
||||
// part type even on the body part), and removes Default/Override
|
||||
// entries that target now-deleted parts (vba binary, customizations,
|
||||
// glossary).
|
||||
func patchContentTypes(in string) string {
|
||||
out := in
|
||||
out = strings.ReplaceAll(out,
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
|
||||
|
||||
removals := []string{
|
||||
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
|
||||
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
|
||||
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
|
||||
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
|
||||
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
|
||||
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
|
||||
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
|
||||
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
|
||||
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
|
||||
}
|
||||
for _, r := range removals {
|
||||
out = strings.ReplaceAll(out, r, "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
|
||||
// customUI14 extensibility relationships — both reference VBA-backed
|
||||
// UI we don't ship.
|
||||
func patchRootRels(in string) string {
|
||||
out := in
|
||||
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
|
||||
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
|
||||
return out
|
||||
}
|
||||
|
||||
// patchDocumentRels drops the document-level rels whose targets we
|
||||
// stripped (vbaProject, customizations.xml, glossaryDocument).
|
||||
func patchDocumentRels(in string) string {
|
||||
out := in
|
||||
for rid := range rIdsToDrop {
|
||||
needle := `<Relationship Id="` + rid + `" `
|
||||
out = stripRelByPrefix(out, needle)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripRelByPrefix removes the full <Relationship .../> element whose
|
||||
// open tag starts with the given prefix. Tolerates either a regular
|
||||
// closing tag (</Relationship>) or the more common self-closing form.
|
||||
func stripRelByPrefix(s, prefix string) string {
|
||||
for {
|
||||
start := strings.Index(s, prefix)
|
||||
if start < 0 {
|
||||
return s
|
||||
}
|
||||
// Find end of this element (next "/>"). The .dotm always uses the
|
||||
// self-closing form for Relationship elements.
|
||||
end := strings.Index(s[start:], "/>")
|
||||
if end < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[:start] + s[start+end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
|
||||
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
|
||||
// + the procedural_event.* canonical names + their rule.* legacy
|
||||
// aliases). The structure mirrors a real DE/UPC submission — title
|
||||
// block → court → rubrum → patent reference → submission title →
|
||||
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
|
||||
// signature → locale-variant verification footer.
|
||||
//
|
||||
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving single-run replace) catches every key. HL
|
||||
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
|
||||
// applied via pStyle, character styles via rStyle.
|
||||
//
|
||||
// The sectPr at the bottom is copied verbatim from the source .dotm
|
||||
// so the firm header/footer references (rId16=header1, rId17=footer1,
|
||||
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
|
||||
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
|
||||
// exactly — a lawyer printing this gets the same A4 layout the .dotm
|
||||
// produces.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
skeletonBanner(&b)
|
||||
|
||||
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
|
||||
body0(&b, "Bearbeiter: {{user.display_name}}")
|
||||
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
body0(&b, "{{firm.signature_block}}")
|
||||
|
||||
headerSection(&b, "{{project.court}}")
|
||||
body0(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
body0(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
headerSubsection(&b, "In der Sache")
|
||||
|
||||
recitalsParty(&b, "{{parties.claimant.name}}")
|
||||
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
||||
|
||||
recitalsSequencer(&b, "gegen")
|
||||
|
||||
recitalsParty(&b, "{{parties.defendant.name}}")
|
||||
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
||||
|
||||
recitalsSequencer(&b, "sowie")
|
||||
|
||||
recitalsParty(&b, "{{parties.other.name}}")
|
||||
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
|
||||
recitalsRoles(&b, "— Weitere Beteiligte —")
|
||||
|
||||
headerSubsection(&b, "Betreff")
|
||||
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
|
||||
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
body0(&b, "Projekttitel: {{project.title}}")
|
||||
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
|
||||
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
|
||||
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
|
||||
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
|
||||
|
||||
headerSubsection(&b, "Frist")
|
||||
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
|
||||
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
|
||||
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "II. Anträge")
|
||||
requestsIntro(&b, "Es wird beantragt:")
|
||||
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
|
||||
requestsLevel1(&b, "[Antrag 2]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
|
||||
body0(&b, "[Hier folgen die Rechtsausführungen.]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
|
||||
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
|
||||
|
||||
heading(&b, "HLpat-Heading-H2", "Schlussformel")
|
||||
signature(&b, "{{today.long_de}}")
|
||||
signature(&b, "")
|
||||
signature(&b, "{{user.display_name}}")
|
||||
signature(&b, "{{firm.name}}")
|
||||
|
||||
// Locale-aware verification block — exercises every EN/DE alias the
|
||||
// variable bag carries plus the rule.* legacy aliases so a lawyer
|
||||
// editing the template sees that both surfaces resolve. A real
|
||||
// submission deletes this section after sanity-checking the render.
|
||||
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
|
||||
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
|
||||
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
|
||||
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
|
||||
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
|
||||
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
|
||||
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
|
||||
|
||||
// sectPr — copied verbatim from the source .dotm. Keeps the firm
|
||||
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
|
||||
// and the firm-address footer (rId17, rId19) on every printed page.
|
||||
b.WriteString(sectPrXML)
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sectPrXML matches the source .dotm's section properties exactly so
|
||||
// the firm header/footer refs and A4 page geometry round-trip.
|
||||
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
|
||||
|
||||
func skeletonBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
|
||||
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
|
||||
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
|
||||
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
|
||||
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
|
||||
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
|
||||
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
|
||||
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
|
||||
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
|
||||
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
|
||||
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
|
||||
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
|
||||
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
|
||||
|
||||
// styledPara writes one paragraph with the given pStyle (paragraph
|
||||
// style id) and optional rStyle (character style applied to every run).
|
||||
// Empty style ids drop the corresponding wrapper. Placeholders inside
|
||||
// `text` are split into their own runs so the renderer's pass-1
|
||||
// single-run replace catches each one independently.
|
||||
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if pStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(pStyle)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if rStyle != "" {
|
||||
b.WriteString(`<w:rPr><w:rStyle w:val="`)
|
||||
b.WriteString(rStyle)
|
||||
b.WriteString(`"/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user