Compare commits
111 Commits
mai/noethe
...
mai/maxwel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f1c29b10 | ||
|
|
7930ee0bdb | ||
|
|
7e57507a92 | ||
|
|
7da8802f9b | ||
|
|
91d3811276 | ||
|
|
483649d9d2 | ||
|
|
82888dea78 | ||
|
|
306bb11618 | ||
|
|
196f3f74a6 | ||
|
|
331efc8603 | ||
|
|
85d7dd497c | ||
|
|
335be29b23 | ||
|
|
0835be4a7f | ||
|
|
3e1bbd3c77 | ||
|
|
7057fe5d25 | ||
|
|
4a5d56d9e6 | ||
|
|
afd3aab2b2 | ||
|
|
49c260b888 | ||
|
|
12b35fc9fe | ||
|
|
ebcda13f88 | ||
|
|
487fec2672 | ||
|
|
f8cc86cd02 | ||
|
|
69544bf3fb | ||
|
|
7fef64159b | ||
|
|
7238b12b05 | ||
|
|
54cf7ac2f6 | ||
|
|
f4815a9f9a | ||
|
|
ce180123c3 | ||
|
|
7a35cad09f | ||
|
|
6058d21ce6 | ||
|
|
52caba51ec | ||
|
|
1faffb682e | ||
|
|
4b681792ab | ||
|
|
236bb3270e | ||
|
|
4670cd660a | ||
|
|
1e97eccaed | ||
|
|
3a41acee07 | ||
|
|
de4e133f03 | ||
|
|
0c12644563 | ||
|
|
5d9c62d858 | ||
|
|
188d8ec9ba | ||
|
|
d5a01e6682 | ||
|
|
02d4ac2f4e | ||
|
|
ae1cba4e24 | ||
|
|
1e23745792 | ||
|
|
1782dfa910 | ||
|
|
936aca5925 | ||
|
|
0b47343aa3 | ||
|
|
f31307afcb | ||
|
|
aa112d2589 | ||
|
|
dc35d2da69 | ||
|
|
d2790a0461 | ||
|
|
97d49898b7 | ||
|
|
5b08bfcb96 | ||
|
|
fc048c578e | ||
|
|
d0e8c995fe | ||
|
|
dd0cee226d | ||
|
|
6fcf34a3e3 | ||
|
|
e824898a6d | ||
|
|
2f27620a5b | ||
|
|
75dc842b8e | ||
|
|
6224898f9e | ||
|
|
4ecea7a4bb | ||
|
|
a3052eb085 | ||
|
|
75cfe914ce | ||
|
|
34e82ead06 | ||
|
|
2cd7266198 | ||
|
|
ba2408eb51 | ||
|
|
dba8ad3fdd | ||
|
|
d4c129f0d6 | ||
|
|
df04e500f7 | ||
|
|
0d1a7ba886 | ||
|
|
e9e7d5c27c | ||
|
|
282e0bb237 | ||
|
|
142edca401 | ||
|
|
caa76d2925 | ||
|
|
fdbbc74c15 | ||
|
|
e2907db760 | ||
|
|
097e21c8db | ||
|
|
2d6ea3ee33 | ||
|
|
614f9af753 | ||
|
|
6008d36a13 | ||
|
|
4bab520119 | ||
|
|
c06be27cce | ||
|
|
ef78f59d25 | ||
|
|
ac15911e4f | ||
|
|
f1889fabcf | ||
|
|
9350cd0e87 | ||
|
|
3368aa58a6 | ||
|
|
aec6cf6104 | ||
|
|
073af975f7 | ||
|
|
8c58783cd3 | ||
|
|
3a41aa9209 | ||
|
|
6ef14ddc39 | ||
|
|
9339148ef5 | ||
|
|
e87929885d | ||
|
|
1df1bc7e40 | ||
|
|
b23a08867b | ||
|
|
7be8511833 | ||
|
|
06bd276a9c | ||
|
|
f84bce1359 | ||
|
|
afe4fc2efe | ||
|
|
7614748243 | ||
|
|
7c751617e5 | ||
|
|
609da9e86b | ||
|
|
7daa70aaad | ||
|
|
05d14d5e5a | ||
|
|
925a377c8b | ||
|
|
7935fee7bf | ||
|
|
be2150c17d | ||
|
|
5893c45e5e |
@@ -168,6 +168,7 @@ func main() {
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
}
|
||||
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
@@ -191,8 +192,14 @@ func main() {
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
|
||||
417
docs/audit-upc-rop-deadlines-2026-05-08.md
Normal file
417
docs/audit-upc-rop-deadlines-2026-05-08.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Audit — UPC Rules of Procedure deadline coverage in paliad
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-159 (Gitea m/paliad#14, RoP audit aspect)
|
||||
**Mode:** read-only research; produces a gap-list, not migrations.
|
||||
|
||||
Companion to `docs/audit-fristenrechner-completeness-2026-04-30.md`. That audit drove from youpc's existing deadline corpus (~64 RoP codes referenced); this one drives **from the UPC Rules of Procedure themselves**, taking a frequency-weighted slice of what a real INF/REV/APP proceeding has to track.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
**RoP sections audited (8 sections):**
|
||||
|
||||
| Code | Section | RoP rules in scope (deadline-creating) |
|
||||
|---|---|---|
|
||||
| A | Pleadings — Infringement (UPC_INF) | R.13, R.17, R.19, R.23, R.24, R.25, R.29.a/b/c/d/e, R.30, R.32 |
|
||||
| B | Pleadings — Revocation + CCR + DNI (UPC_REV) | R.42, R.44, R.49.1, R.49.2.a, R.49.2.b, R.50, R.51, R.52, R.55, R.56, R.61, R.63, R.65, R.67, R.68, R.69, R.70 |
|
||||
| C | Provisional measures + evidence preservation | R.197.3, R.198, R.205, R.207.6, R.207.9, R.211.2, R.213 |
|
||||
| D | Damages + lay-open books | R.125, R.131.2, R.137, R.139, R.141, R.142.2, R.142.3 |
|
||||
| E | Decisions, costs, default judgment | R.111, R.118.4, R.151, R.157, R.221.1 |
|
||||
| F | Appeals | R.220.1.a/b/c, R.220.2, R.220.3, R.221.1, R.224.1.a/b, R.224.2.a/b, R.229.2, R.234.1, R.235.1, R.235.2, R.237, R.238.1, R.238.2, R.245.2 |
|
||||
| G | Re-establishment, case-management, miscellaneous | R.262.2, R.295, R.320, R.321.3, R.331, R.333.2, R.353 |
|
||||
| H | Oral-hearing prep + translations | R.109.1, R.109.4, R.109.5 |
|
||||
|
||||
**Out of scope here** (deferred to a follow-up audit):
|
||||
- EPO opposition + Beschwerde (Art. 99 / R.99 EPÜ — paliad models these via EPA_OPP / EPA_APP, separate concern)
|
||||
- DPMA / BPatG / BGH families (modelled via DE_*, DPMA_* — separate concern)
|
||||
- ZPO civil-procedure deadlines around stay/severance
|
||||
- UPC court-fee deadlines (Art. 70 UPCA / R.370)
|
||||
- R.32(2) extensions of time (judge-set, no fixed duration)
|
||||
- Judicial discretion items without a fixed period (stays under R.295, choice-of-language under R.323, joinder, intervention R.313)
|
||||
- "Notice of intent to defend" (1mo, R.23 reaction): explicitly excluded by migration 052 §2 — no UPC rule exists for that concept.
|
||||
|
||||
---
|
||||
|
||||
**Authoritative source for RoP text:** the youpc Postgres `data.laws_contents` table (law_type = `UPCRoP`, English language). Cross-checked the in-scope rules against the actual rule text rather than relying on prior summaries — this is what surfaced the two duration bugs in §B (R.49.1 and R.52). All other high-frequency durations (R.23, R.29.a/b/c/d/e, R.32.1/3, R.43.3, R.56.1/3/4, R.137.2, R.139, R.142.2/3, R.151, R.220.2, R.221.1, R.224.1.a/b, R.224.2.a/b, R.235.1/2, R.238.1/2) were cross-checked and confirmed.
|
||||
|
||||
## 2. Methodology
|
||||
|
||||
For every RoP rule in the in-scope list:
|
||||
1. Identify the trigger event (what starts the period).
|
||||
2. Identify the duration + unit (calendar days / months / before-or-after).
|
||||
3. Look up paliad's rule library. Three lookup paths:
|
||||
- `paliad.deadline_rules` (the proceeding-tree shape used by Fristenrechner Pathway A — "pick proceeding type, see whole timeline").
|
||||
- `paliad.deadline_concepts` × `paliad.event_category_concepts` (the cascade shape used by Pathway B — "pick what just landed in the CMS, see what reacts").
|
||||
- `paliad.trigger_events` (the youpc-style event list, 90+ rows, used by the search/autocomplete surface).
|
||||
4. Assign a status: `present-correct` / `present-wrong` / `partial` / `missing` / `n/a`.
|
||||
|
||||
Status definitions:
|
||||
- **present-correct** — paliad has a row with the right RoP code, duration, anchor, and (where relevant) primary_party.
|
||||
- **present-wrong** — paliad has a row that fires for this rule but with a wrong duration / anchor / condition.
|
||||
- **partial** — paliad has the rule for one branch (e.g. proactive only, or one party only) but is missing the symmetric branch.
|
||||
- **missing** — no rule, no concept-card, no trigger-event entry covers this.
|
||||
- **n/a** — RoP rule doesn't create a tracked deadline (e.g. judge sets it ad hoc, or rule is purely structural).
|
||||
|
||||
Frequency tag (column "Freq" in §3 tables):
|
||||
- **★★★** — every UPC infringement / revocation / appeal will hit this.
|
||||
- **★★** — common (most cases at some stage).
|
||||
- **★** — specialist (PI, saisie, damages-only, rehearing).
|
||||
|
||||
The 2026-04-30 audit (§3, §4) already enumerated the core youpc gaps. Where a finding here overlaps that audit, I cite it (`see 2026-04-30 §X`) and avoid restating.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
### Section A — Infringement pleadings (R.13–R.32)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.13 | Klageerhebung (filing of SoC) | — (anchor event, duration 0) | `inf.soc` | present-correct | ★★★ | UPC_INF root. |
|
||||
| R.17 | Decision on language of proceedings | judge-discretion | n/a (no auto-deadline) | n/a | ★ | Court-set; no Fristenrechner row needed. |
|
||||
| R.19 | Service of SoC → Preliminary Objection | 1 month | **missing** | missing | ★★ | No `inf.prelim_objection` row in UPC_INF. The cascade has no "Vorgängige Einrede" leaf either. Trigger event 68 (`preliminary_objection`) exists in `paliad.trigger_events` but has no rule attached. |
|
||||
| R.20.2 | PO → Reply to PO | 14 days | missing | missing | ★ | Fringe but real — defendant's PO triggers a 14d response window. |
|
||||
| R.23 | Service of SoC → Statement of Defence | 3 months | `inf.sod` (RoP.023) | present-correct | ★★★ | Rule code format is `RoP.023` — was normalised since 2026-04-30 §4.3. |
|
||||
| R.24 / R.25 | SoD with CCR | (CCR rolled into SoD, 3mo) | `cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr` (cascade leaf) | present-correct | ★★★ | Cascade leaf maps to `defence-to-counterclaim-for-revocation` concept. |
|
||||
| R.29.a | SoD-with-CCR served → Defence to CCR + Reply to SoD | 2 months | `inf.def_to_ccr` (RoP.029.a) | present-correct | ★★★ | |
|
||||
| R.29.b | SoD-without-CCR served → Reply to SoD | 2 months | `inf.reply` (RoP.029.b, alt RoP.029.a) | present-correct | ★★★ | Adaptive: alt branch flips for with-CCR. Migration 050 wired the bilateral backfill. |
|
||||
| R.29.c | Reply served → Rejoinder | 1 month | `inf.rejoin` (RoP.029.c, alt RoP.029.d) | present-correct | ★★★ | Adaptive. |
|
||||
| R.29.d | Reply-to-defence-to-CCR served → Reply to that | 2 months | `inf.reply_def_ccr` (RoP.029.d) | present-correct | ★★ | |
|
||||
| R.29.e | That reply served → Rejoinder | 1 month | `inf.rejoin_reply_ccr` (RoP.029.e) | present-correct | ★★ | |
|
||||
| R.30.1 | Defendant filing Application to Amend (with CCR) | 0 (ride along with SoD) | `inf.app_to_amend` (RoP.030.1) | present-correct | ★★ | |
|
||||
| R.32.1 | Application to Amend served → Defence to Amend | 2 months | `inf.def_to_amend` (RoP.032.1) | present-correct | ★★ | |
|
||||
| R.32.3 | Defence-to-Amend served → Reply | 1 month | `inf.reply_def_amd` (RoP.032.3) | present-correct | ★★ | |
|
||||
| R.32.3 | Reply-on-Amend served → Rejoinder | 1 month | `inf.rejoin_amd` (RoP.032.3) | present-correct | ★★ | Same code, different rule row (rejoinder branch). Reused code is intentional. |
|
||||
|
||||
**Section A net:** 13 rules covered correctly. **2 missing** (R.19 Preliminary Objection 1mo, R.20.2 Reply to PO 14d).
|
||||
|
||||
---
|
||||
|
||||
### Section B — Revocation + CCR + DNI (R.42–R.70)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.42 | Filing of Nichtigkeitsklage (REV) | 0 anchor | `rev.app` | present-correct | ★★★ | UPC_REV root. |
|
||||
| **R.49.1** | Service of REV → Defence to Revocation | **2 months** | `rev.defence` (**3 months**, RoP.49.1) | **present-wrong (DURATION + rule_code)** | ★★★ | **Confirmed via youpc UPCRoP.049.1 text:** *"The defendant shall lodge a Defence to revocation within two months of service of the Statement for revocation."* paliad seeded 3 months — copy-paste from R.23 (UPC_INF Defence which is correctly 3mo). **High-impact bug — REV Defence is the most-used revocation deadline.** Rule_code also drift (`RoP.49.1` → `RoP.049.1`). |
|
||||
| R.49.2.a | REV → Application to Amend | 0 (rides along with Defence) | `rev.app_to_amend` (RoP.049.2.a) | present-correct | ★★ | |
|
||||
| R.49.2.b | REV → Counterclaim for Infringement (CCI) | 0 (rides along) | `rev.cc_inf` (RoP.049.2.b) | present-correct | ★★ | |
|
||||
| **R.51** | Defence to Revocation served → Reply | 2 months | `rev.reply` (2mo, no rule_code) | present-wrong (rule_code only) | ★★★ | **Confirmed via youpc UPCRoP.051.p1 text:** *"Within two months of service of the Defence to revocation the claimant may lodge a Reply…"* Duration correct. Rule_code is NULL — add `RoP.051`. (Note: my initial draft cited "R.50" for this; the actual rule is R.51 — R.50 is "Contents of the Defence to revocation" with no duration.) |
|
||||
| **R.52** | Reply served → Rejoinder | **1 month** | `rev.rejoin` (**2 months**, no rule_code) | **present-wrong (DURATION + rule_code)** | ★★★ | **Confirmed via youpc UPCRoP.052.p1 text:** *"Within one month of the service of the Reply the defendant may lodge a Rejoinder…"* paliad seeded 2 months — bug. Rejoinder is symmetric with R.29.c (UPC_INF rejoinder, also 1mo). Add rule_code `RoP.052`. **Second high-impact bug.** |
|
||||
| R.43.3 | Application-to-Amend served → Defence to Amend (in REV) | 2 months | `rev.def_to_amend` (RoP.043.3) | present-correct | ★★ | |
|
||||
| R.32.3 | Reply on Amend (in REV) | 1 month | `rev.reply_def_amd` (RoP.032.3) | present-correct | ★★ | |
|
||||
| R.32.3 | Rejoinder on Amend (in REV) | 1 month | `rev.rejoin_amd` (RoP.032.3) | present-correct | ★★ | |
|
||||
| R.56.1 | CCI served → Defence to CCI | 2 months | `rev.def_cci` (RoP.056.1) | present-correct | ★★ | |
|
||||
| R.56.3 | Defence-to-CCI served → Reply | 1 month | `rev.reply_def_cci` (RoP.056.3) | present-correct | ★★ | |
|
||||
| R.56.4 | Reply served → Rejoinder | 1 month | `rev.rejoin_cci` (RoP.056.4) | present-correct | ★★ | |
|
||||
| R.61 | Pre-CCI standalone Counterclaim (CCR-only proceedings) | n/a (overlap with CCR mechanics) | n/a | n/a | ★ | UPC_REV with CCI already covers this; R.61 is a structural cross-ref. |
|
||||
| R.63 | Filing of Application for DNI | 0 anchor | **missing** | missing | ★ | No UPC_DNI proceeding type exists. Cascade has no DNI leaf. |
|
||||
| R.67.1 | DNI served → Defence to DNI | 2 months | **missing** | missing | ★ | |
|
||||
| R.69.1 | Defence-to-DNI served → Reply | 1 month | **missing** | missing | ★ | |
|
||||
| R.69.2 | Reply served → Rejoinder | 1 month | **missing** | missing | ★ | |
|
||||
| R.70 | Application of UPC_INF rules to DNI | 0 (cross-ref) | n/a | n/a | ★ | DNI inherits all of UPC_INF's chain after the initial 4 rules. |
|
||||
|
||||
**Section B net:** 8 rules covered correctly, **2 high-impact duration bugs** (R.49.1 Defence-to-Revocation 3mo seeded but RoP says 2mo; R.52 Rejoinder 2mo seeded but RoP says 1mo), **2 rule_code drift** (R.51 NULL, R.52 NULL — fixing alongside the duration corrections), **4 missing** (DNI family — R.63, R.67.1, R.69.1, R.69.2). Both duration bugs surfaced from cross-referencing the authoritative RoP text via `data.laws_contents` (the youpc law database) — they were invisible in the rule-code-format-only review.
|
||||
|
||||
DNI is a low-frequency proceeding (zero filings in published UPC orders 2026-Q1) — flag, but Tier 3 priority.
|
||||
|
||||
---
|
||||
|
||||
### Section C — Provisional measures + evidence preservation (R.190–R.213)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.197.3 | Saisie order served on respondent → Application for review | 30 days | **missing** | missing | ★ | Trigger event 65 (`request_for_review_of_the_order_to_preserve_evidence`) exists; no rule attached. |
|
||||
| R.198 | Saisie executed → Start proceedings on the merits | **31 calendar days OR 20 working days, whichever is longer** | **missing** | missing | ★ | Requires arithmetic primitive paliad doesn't have (see 2026-04-30 §5.1). Trigger event 81 (`start_of_proceedings_on_the_merits`) exists. |
|
||||
| R.205 | Application for PI filed | 0 anchor | `pi.app` | present-correct | ★★ | UPC_PI root. |
|
||||
| R.207.6.a | Notification of deficiency in PI application | 14 days | **missing** | missing | ★★ | Registry-correction family. Trigger event 71 (`notification_by_the_registry_to_correct_deficiencies`) exists; no rule. |
|
||||
| R.207.9 | PI filed → Renewal of protective letter | 6 months | **missing** | missing | ★ | Trigger event 46 (`renewal_of_protective_letter`) exists; no rule. |
|
||||
| R.211.2 | PI granted ex parte → Inter partes hearing | judge-set (typ. ≤30d) | `pi.response` (court-set, duration=0) | present-correct (court-set) | ★★ | Modelled as duration=0 with parent → UI shows "vom Gericht gesetzt". Correct shape. |
|
||||
| R.211.4 | PI granted → Service on respondent | judge-set | n/a | n/a | ★★ | No fixed period. |
|
||||
| R.213 | PI granted → Start proceedings on the merits | **31 calendar days OR 20 working days, whichever is longer** | **missing** | missing | ★★ | Same arithmetic primitive as R.198. |
|
||||
| R.196.5 | Saisie review → Damages application by respondent | 31d / 20wd | n/a | n/a | ★ | Conditional on PI being revoked; specialist. |
|
||||
|
||||
**Section C net:** PI happy-path covered. **5 missing** (R.197.3, R.198, R.207.6.a, R.207.9, R.213). The two "31d / 20wd whichever is longer" rules are blocked on a missing arithmetic primitive (see §5.1).
|
||||
|
||||
---
|
||||
|
||||
### Section D — Damages + lay-open books (R.125–R.144)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.125 | Decision on the merits incl. damages-in-principle | 0 anchor | n/a | n/a | ★★ | Structural — triggers R.131. |
|
||||
| R.131.2 | Final decision on validity → Application for damages, indication | judge-set (typ. by court order) | `damages.app` (duration=0, court-set shape) | present-correct | ★★ | Trigger event 82 covers the indication. |
|
||||
| R.137.2 | Application for damages served → Defence | 2 months | `damages.defence` (RoP.137.2) | present-correct | ★★ | |
|
||||
| R.139 | Defence served → Reply | 1 month | `damages.reply` (RoP.139) | present-correct | ★★ | |
|
||||
| R.139 | Reply served → Rejoinder | 1 month | `damages.rejoin` (RoP.139) | present-correct | ★★ | |
|
||||
| R.141 | Order to lay open books filed | 0 anchor | `disc.app` (UPC_DISCOVERY) | present-correct | ★★ | |
|
||||
| R.142.2 | Order served → Defence | 2 months | `disc.defence` (RoP.142.2) | present-correct | ★★ | |
|
||||
| R.142.3 | Defence served → Reply | 14 days | `disc.reply` (RoP.142.3) | present-correct | ★★ | |
|
||||
| R.142.3 | Reply served → Rejoinder | 14 days | `disc.rejoin` (RoP.142.3) | present-correct | ★★ | |
|
||||
| R.144 | Final decision on damages quantum | 0 anchor (court event) | missing | partial | ★ | No `damages.decision` row analogous to `inf.decision`. UPC_DAMAGES tree ends at `damages.rejoin`. |
|
||||
| R.118.4 | Final decision on validity → Application for orders consequential | 2 months | **missing** | missing | ★★ | Trigger event 36 exists; no rule attached. Common after EPO-or-CD validity ruling. |
|
||||
|
||||
**Section D net:** Damages happy-path covered. **2 missing** (R.118.4 application for consequential orders, R.144 damages decision tree-end). Lay-open books covered cleanly.
|
||||
|
||||
---
|
||||
|
||||
### Section E — Decisions, costs, default judgment (R.111–R.157)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.111 | Decision on the merits delivered | 0 anchor | `inf.decision` / `rev.decision` / `app.decision` | present-correct | ★★★ | Tree-end events. |
|
||||
| R.118 | Decision on validity (final) | 0 anchor | (covered as `inf.decision`) | present-correct | ★★★ | |
|
||||
| R.118.4 | Final decision on validity → Application for orders consequential | 2 months | missing | missing | ★★ | Repeated from §D — tracked as a single gap. |
|
||||
| R.118.5 | Default judgment served → Set-aside ("Einspruch") | **missing in UPC** | n/a | n/a | ★ | UPC has no German-style Versäumnisurteil-Einspruch; closest is R.355 review of contumacy. Concept `versaeumnisurteil-einspruch` exists in paliad (DE-only proceedings). |
|
||||
| R.151 | Final decision (with cost order) → Application for cost decision | 1 month | `inf.cost_app` (RoP.151) | present-correct | ★★★ | |
|
||||
| R.157 | Cost decision delivered | 0 anchor | `cost.decision` (UPC_COST_APPEAL) | present-correct | ★★ | |
|
||||
| R.221.1 | Cost decision served → Application for leave-to-appeal | 15 days | `cost.leave_app` (RoP.221.1) | present-correct | ★★ | Migration 052 §3 wired the leaf cascade. |
|
||||
| R.155 | Cost-decision app served → Defence + Reply chain | 1 month / 14 days | partial | partial | ★ | UPC_COST_APPEAL only has the leave-to-appeal step; no Defence-to-cost-app row. |
|
||||
|
||||
**Section E net:** Cost happy-path covered. **1 missing** (R.118.4), **1 partial** (R.155 cost-decision opposition chain).
|
||||
|
||||
---
|
||||
|
||||
### Section F — Appeals (R.220–R.246)
|
||||
|
||||
The single biggest section. paliad models this across **three proceeding types**: UPC_APP (the main 2mo/4mo appeal), UPC_APP_ORDERS (the 15d orders/with-leave track), UPC_COST_APPEAL (the 15d cost-decision-leave track).
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.220.1.a | Final decision served (decision on merits) | — (anchor) | n/a (anchor on `app.notice`) | n/a | ★★★ | Anchor row, not a deadline. |
|
||||
| R.220.1.b | Final decision served (review of CMO etc.) | — (anchor) | (same) | n/a | ★★ | |
|
||||
| R.220.1.c | Order referred to in R.220.1.c (case-mgmt) | — (anchor) | `app_ord.order` | present-correct | ★★ | |
|
||||
| R.220.2 | Order with leave to appeal granted → Statement of Appeal | 15 days | `app_ord.with_leave` (RoP.220.2) | present-correct | ★★ | Migration 052 §4 fixed the leaf wiring. |
|
||||
| R.220.3 | Order, leave-to-appeal refused → Discretionary review request | 15 days | `app_ord.discretion` (RoP.220.3) | present-correct | ★★ | |
|
||||
| R.221.1 | Cost decision → Leave-to-appeal | 15 days | `cost.leave_app` (RoP.221.1) | present-correct | ★★ | |
|
||||
| R.224.1.a | Final decision served → Statement of Appeal (main track) | 2 months | `app.notice` (RoP.220.1) | **present-wrong (rule_code)** | ★★★ | Rule_code is `RoP.220.1` but the actual citation is **R.224.1.a**. R.220.1 is the trigger-classifier rule, not the duration rule. **Cosmetic but technically wrong code.** |
|
||||
| R.224.1.b | Order in R.220.1.c served → Statement of Appeal (orders track) | 15 days | `app_ord.with_leave` (RoP.220.2) | partial | ★★ | Fires from R.220.2 (with leave) but no separate row for R.224.1.b standalone (orders without leave-grant requirement, e.g. R.220.1.c orders). Same 15 days, but the citation is different. |
|
||||
| R.224.2.a | Decision served → Statement of Grounds (main track) | **4 months** | `app.grounds` (4mo, RoP.220.1) | present-correct (with code drift) | ★★★ | Duration corrected from 2mo to 4mo since the 2026-04-30 audit (§4.4). Rule_code still says `RoP.220.1` — should be `RoP.224.2.a`. |
|
||||
| R.224.2.b | Order in R.220.1.c served → Statement of Grounds (orders track) | 15 days | **missing** | missing | ★★ | UPC_APP_ORDERS has the appeal-itself row but **no separate Grounds row**. R.224.2.b explicitly creates a 15-day grounds period for the orders track. |
|
||||
| R.229.2 | Notification of appeal-deficiency → Correction | 14 days | **missing** | missing | ★ | Registry-correction family. |
|
||||
| R.234.1 | Statement of Appeal received → Court rejects as inadmissible | 1 month | n/a (court action, no party deadline) | n/a | ★ | Court window, not party deadline. |
|
||||
| R.235.1 | Statement of Appeal served → Response (orders track) | 15 days | `app_ord.cross_reply` partially overlaps; standalone response missing | partial | ★★ | UPC_APP_ORDERS has cross + cross_reply but no response-to-the-appeal row. R.235.1 specifically covers the response-to-appeal in the orders/with-leave track. |
|
||||
| R.235.2 | Statement of Appeal served → Response (main track) | 3 months | `app.response` (3mo, no rule_code) | present-wrong (rule_code) | ★★★ | Duration is correct (3 months). Rule_code is NULL — should be `RoP.235.2`. |
|
||||
| R.237 | Response to Appeal served → Cross-Appeal | 3mo (main) / 15d (orders) | `app.cross_a` (3mo, RoP.237) + `app_ord.cross` (15d, RoP.237) | present-correct | ★★ | |
|
||||
| R.238.1 | Cross-Appeal served → Reply (main track) | 2 months | `app.cross_a_reply` (RoP.238.1) | present-correct | ★★ | |
|
||||
| R.238.2 | Cross-Appeal served → Reply (orders track) | 15 days | `app_ord.cross_reply` (RoP.238.2) | present-correct | ★★ | |
|
||||
| R.245.1 | Final decision served → Application for rehearing (main 2mo) | 2 months | **missing** | missing | ★ | |
|
||||
| R.245.2.a | Discovery of fundamental defect → Application for rehearing | 2 months | **missing** | missing | ★ | "Whichever is later" of decision-service vs defect-discovery (cf. trigger event 98). Outer cap 12mo from final decision. |
|
||||
| R.245.2.b | Discovery of criminal offence → Application for rehearing | 2 months | **missing** | missing | ★ | Trigger event 88. Same outer cap. |
|
||||
| R.245.2 cap | Outer cap | 12 months from final decision | **missing** | missing | ★ | Outer-bound logic, not a calendar deadline; needs a "max-of-anchors" capability. |
|
||||
|
||||
**Section F net:** Main happy-path covered. **3 present-wrong (rule_code drift)** on R.224.1.a, R.224.2.a, R.235.2 — the durations are right, the citation strings are wrong/missing. **1 missing** R.224.2.b grounds-on-orders 15d (genuine functional gap). **1 partial** R.235.1 response-on-orders-track. **3 missing** rehearing family (R.245). **1 missing** R.229.2 registry-correction.
|
||||
|
||||
---
|
||||
|
||||
### Section G — Re-establishment, case-management, miscellaneous
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.262.2 | Receipt of opposing party's application for confidentiality | 14 days | **missing** | missing | ★★ | Trigger event 25 (`application_to_request_confidentiality_from_the_public`) exists; no rule. Common in HLC infringement work where competitor secrets are filed. |
|
||||
| R.262A | Confidentiality club application | judge-set | n/a | n/a | ★ | No fixed deadline. |
|
||||
| R.295 | Stay of proceedings | n/a (judicial discretion) | n/a | n/a | ★★ | No deadline. |
|
||||
| R.320 | Wegfall des Hindernisses → Wiedereinsetzung | 2 months (cap 12mo from missed deadline) | concept `wiedereinsetzung` + trigger event 207 + leaf `frist-verpasst.upc` | present-correct (cascade-only) | ★★ | Migration 063 added the cascade path. **No `paliad.deadline_rules` row** — Wiedereinsetzung has no proceeding-tree rule because it bridges proceedings. The 2mo / 12mo logic only lives in description text. **If we want to compute the deadline,** a rule row is needed; today the user gets a concept-card but not a calendar entry. |
|
||||
| R.321.3 | Filing → Referral to central division (preliminary objection sub-case) | 10 days | **missing** | missing | ★ | |
|
||||
| R.331 | Court summons to oral hearing | judge-set | `cms-eingang.gericht.ladung` cascade leaf (no rule) | partial | ★★ | Cascade leaf exists but no fixed period; this is correct since R.331 is judge-set. Mentioned for completeness. |
|
||||
| R.333.2 | Case-management order served → Application for review | 15 days | **missing** | missing | ★★ | Trigger event 16 exists; no rule. Common in busy LDs (review-of-CMO requests are routine). |
|
||||
| R.353 | Decision/order delivered → Application for rectification | 1 month | **missing** | missing | ★ | Trigger event 41 exists; no rule. |
|
||||
|
||||
**Section G net:** R.320 covered cascade-only (computational gap). **5 missing** (R.262.2 confidentiality, R.321.3 referral, R.333.2 review-of-CMO, R.353 rectification, R.320 calendar arithmetic).
|
||||
|
||||
---
|
||||
|
||||
### Section H — Oral-hearing prep + translations (R.109)
|
||||
|
||||
| RoP § | Trigger | Duration | paliad rule | Status | Freq | Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| R.109.1 | Oral hearing date → Request for simultaneous translation | **1 month before** | **missing** | missing | ★★ | The whole "before"-mode family. paliad's `paliad.deadline_rules` has a `timing` column (values `before`/`after`) and `internal/services/deadline_calculator.go` reads it, but **no rule today populates `timing='before'`** — verified via SQL `SELECT DISTINCT timing FROM paliad.deadline_rules WHERE is_active = true` returning `{after}` only. |
|
||||
| R.109.4 | Oral hearing date → Notification of interpreter cost intent | **2 weeks before** | **missing** | missing | ★★ | |
|
||||
| R.109.5 | Oral hearing date → Lodging of translations | 2 weeks **after** summons | **missing** | missing | ★★ | Trigger event 113 (`order_of_the_judge_rapporteur_to_lodge_translations`) exists; no rule. |
|
||||
| R.116 | Oral hearing → Final written submissions (EPO-style cap) | 1 month before (typically) | n/a (UPC has no formal R.116-equivalent — EPC-only) | n/a | ★★ | Concept `r116-final-submissions` is mapped to EPA_OPP / EPA_APP only — correct. |
|
||||
|
||||
**Section H net:** **All 3 R.109 rules missing.** This was flagged in 2026-04-30 §3.6 as a tier-2 port; no migration since. The schema and Go code already support `before`-mode, just no data.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap list
|
||||
|
||||
Ordered by frequency × user-impact. Each entry is one sentence, sufficient for a coder to spec a migration row.
|
||||
|
||||
### Critical — **★★★** ("real duration bugs verified against RoP text — fix before any further migration work")
|
||||
|
||||
1. **`UPC_REV.rev.defence` duration is 3 months — RoP §49.1 says 2 months.** Single-row UPDATE: `duration_value=2`. Also fix rule_code `RoP.49.1` → `RoP.049.1`. Verified via `data.laws_contents` for `UPCRoP.049.1` (youpc law database).
|
||||
2. **`UPC_REV.rev.rejoin` duration is 2 months — RoP §52 says 1 month.** Single-row UPDATE: `duration_value=1`, set `rule_code='RoP.052'`. Verified via `data.laws_contents` for `UPCRoP.052.p1`.
|
||||
3. **`UPC_REV.rev.reply` rule_code is NULL.** Set `rule_code='RoP.051'`. Duration (2mo) is correct.
|
||||
4. **`UPC_APP.app.notice` / `app.grounds` / `app.response` rule_code drift.** `app.notice` cites `RoP.220.1` (trigger-classifier); should be `RoP.224.1.a` (duration rule). `app.grounds` same drift (→ `RoP.224.2.a`). `app.response` NULL (→ `RoP.235.2`). Cosmetic-but-wrong; durations all correct.
|
||||
|
||||
### High-priority — **★★** ("every case will hit this at some stage")
|
||||
|
||||
5. **R.19 Preliminary Objection (1 month) missing in UPC_INF.** Defendant's first move when challenging jurisdiction/competence/language. No rule, no cascade leaf, no concept card — just a dangling trigger event 68. Add rule + cascade leaf + concept.
|
||||
6. **R.224.2.b Statement of Grounds on orders track (15 days) missing in UPC_APP_ORDERS.** With-leave appeal has the appeal-itself row but no separate grounds row.
|
||||
7. **R.235.1 Response to Appeal on orders track (15 days) missing in UPC_APP_ORDERS.**
|
||||
8. **R.118.4 Application for orders consequential on validity (2 months) missing.** Common follow-on after central-division revocation decision.
|
||||
9. **R.262.2 Confidentiality response (14 days) missing.** Daily occurrence in HLC infringement work.
|
||||
10. **R.333.2 Review of CMO (15 days) missing.** Routine in busy local divisions.
|
||||
11. **R.207.6.a Notification of PI deficiency → Correction (14 days) missing.** Registry-correction family.
|
||||
12. **R.197.3 Saisie review request (30 days) missing.** Standard saisie practice.
|
||||
13. **R.198 / R.213 Start proceedings on the merits (31d OR 20wd, whichever is longer) — blocked on arithmetic primitive.** Needs `working_days` unit or a `combine='max'` operator. Document blocked-on-tooling in the gap-list; do not migrate until the primitive lands.
|
||||
14. **R.207.9 Renewal of protective letter (6 months) missing.**
|
||||
15. **R.109.1 / R.109.4 / R.109.5 Oral-hearing translation prep (1mo / 2w / 2w; first two are `before`-mode) missing.** First two are the only `before`-mode rules in the whole UPC corpus — schema supports it, no data populates it.
|
||||
16. **R.353 Rectification of decision (1 month) missing.**
|
||||
|
||||
### Medium-priority — **★** ("specialist / fringe but real")
|
||||
|
||||
17. **R.20.2 Reply to Preliminary Objection (14 days) missing.**
|
||||
18. **R.229.2 Notification of appeal-deficiency → Correction (14 days) missing.**
|
||||
19. **R.245.1, R.245.2.a, R.245.2.b Rehearing applications (2mo / outer 12mo cap) missing.** Plus the "max-of-two-anchors" arithmetic for R.245.2.
|
||||
20. **R.321.3 Referral to central division (10 days) missing.**
|
||||
21. **R.144 Damages decision tree-end row missing.** Cosmetic — UPC_DAMAGES tree just stops at rejoinder.
|
||||
22. **R.155 Cost-decision opposition chain (Defence + Reply) missing in UPC_COST_APPEAL.** Tree currently jumps from cost decision to leave-to-appeal without modelling the substantive opposition.
|
||||
23. **R.63 / R.67.1 / R.69.1 / R.69.2 DNI family (4 rules) missing.** No UPC_DNI proceeding type. Fringe in HLC practice.
|
||||
24. **R.320 Wiedereinsetzung calendar arithmetic missing.** Cascade card exists; no rule row that computes the 2mo / 12mo deadline. Needs either a `paliad.deadline_rules` row or a special-case Go helper. Touches the "outer cap" arithmetic gap (same pattern as R.245.2).
|
||||
|
||||
### Tooling gaps (block multiple rules)
|
||||
|
||||
25. **`working_days` duration unit + `combine='max'` operator.** Blocks R.198, R.213, and arguably R.198 cross-cuts saisie.
|
||||
26. **`outer_cap_value` + `outer_cap_unit` columns** (or a separate table). Blocks R.320 (12mo cap), R.245.2 (12mo cap).
|
||||
27. **Multi-anchor "whichever is later" trigger events.** Blocks R.245.2.a/b. Trigger events 88 + 98 already encode the OR semantics in their *names* but no Go-side helper picks the later of two user-provided dates.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-cutting observations
|
||||
|
||||
### 5.1 Rule-code citation drift is widespread
|
||||
|
||||
The 2026-04-30 audit (§4.3) noted the format drift (`RoP 23` vs `RoP.023`). That part is now resolved (no `RoP 23` rows exist — all migrated to `RoP.023` style). But a second-order drift remains: **the rule_code field cites the wrong rule** in several places (R.220.1 used for R.224.1.a / R.224.2.a, NULLs on REV reply/rejoinder and `app.response`).
|
||||
|
||||
Recommendation: an audit pass over `paliad.deadline_rules` to align `rule_code` to the *duration-creating* rule, not the *trigger-classifier* rule. Roughly 5-7 rows to update.
|
||||
|
||||
### 5.2 The cascade and the rule-tree drift independently
|
||||
|
||||
paliad has two surfaces:
|
||||
- **Pathway A** (proceeding tree, `paliad.deadline_rules`): "I'm running an UPC infringement case, what's the timeline?"
|
||||
- **Pathway B** (cascade, `paliad.event_categories` + `paliad.event_category_concepts` + `paliad.deadline_concepts`): "the CMS just landed X, what reacts?"
|
||||
|
||||
Both surfaces *should* cover the same RoP universe; in practice they don't. R.320 Wiedereinsetzung is in Pathway B (after migration 063) but not in Pathway A. R.262.2 confidentiality is in neither.
|
||||
|
||||
A single matrix `(RoP rule × Pathway A coverage × Pathway B coverage)` would help future audits. Out of scope here, but worth adding to the rule-library doc once the gaps below are filled.
|
||||
|
||||
### 5.3 Trigger-event corpus is much richer than the rule corpus
|
||||
|
||||
`paliad.trigger_events` has ~90 active rows; `paliad.deadline_rules` references only ~50 distinct UPC scenarios. Many trigger events have no attached rule (R.197.3 review, R.207.9 renewal, R.262.2 confidentiality, R.353 rectification, R.207.6.a deficiency-correction…). The corpus was clearly imported from youpc with the events but without the rules.
|
||||
|
||||
This is the single biggest "missing data" pattern: triggers without rules.
|
||||
|
||||
### 5.4 `before`-mode rules — schema supports, no data populates
|
||||
|
||||
`paliad.deadline_rules.timing` accepts `'before'`/`'after'`. SQL: `SELECT DISTINCT timing FROM paliad.deadline_rules WHERE is_active = true` returns `{after}`. Three R.109 rules need `before`. That's the *only* user need for `before` mode in the entire UPC corpus.
|
||||
|
||||
Verify the date-arithmetic does **subtract** not push-forward — `internal/services/deadline_calculator.go:addDuration` should already handle negative values, but any rule that lands on a non-working day should snap **backward** to the previous working day for `before`-mode (the deadline is "by 1 month before hearing", so a Sunday must move to Friday, not the next Monday — which would be the hearing day or after). 2026-04-30 §5.4 flagged this; verify before adding R.109 rules.
|
||||
|
||||
### 5.4b R.220.3 anchoring nuance (Pathway A vs B drift)
|
||||
|
||||
`UPC_APP_ORDERS.app_ord.discretion` is 15 days, parented to `app_ord.order` (the original CFI order). RoP §220.3 reads: *"within 15 calendar days from the end of [the 15-day refusal] period"*. So the "15 days" duration is anchored on **the day leave-to-appeal was refused (or the day-15 cutoff if no refusal yet)**, not on the order date. The cascade shape (Pathway B) handles this correctly via trigger event 99 (`leave_to_appeal_refused_within_15_days_of_the_order`); the user picks the actual refusal date and the 15d clock runs from there. The proceeding-tree shape (Pathway A) hangs the deadline directly off the order — a user who enters the order date in Pathway A will compute the wrong deadline (15d too early, since the worst-case real deadline is 30d after the order).
|
||||
|
||||
**Recommendation:** either rename `app_ord.discretion`'s anchor to `app_ord.refusal` and add a `app_ord.refusal` court-set node (duration=0, parent=order) for the trigger date, or document in the Fristenrechner UI that the user must enter the refusal date, not the order date. **Not a duration bug — an anchoring/UI bug.** Low-impact (≤30d off in the worst case, only matters at the edge), but worth fixing.
|
||||
|
||||
### 5.5 "Whichever is longer / later" arithmetic
|
||||
|
||||
Three rules need it: R.198, R.213 (max of calendar-days vs working-days), R.245.2.a/b (max of decision-service date vs defect-discovery date). The R.198/R.213 case needs working-days arithmetic (a function of holiday data, which paliad has). The R.245.2 case needs a two-input UI (the user supplies both dates).
|
||||
|
||||
Both can be deferred until a real R.198 or R.245 case lands at HLC. Listing in the gap-list with a `[blocked on tooling]` tag is the right move; no migrations should be drafted until the primitive exists.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended sequencing for a follow-up coder
|
||||
|
||||
(Not a request for migration here — orientation for whoever picks up the gap-fill task.)
|
||||
|
||||
**Wave 0 — DURATION BUGS (must ship first; 2 UPDATE rows + 4 rule_code fixes):**
|
||||
- Fix `rev.defence`: `duration_value` 3 → 2, `rule_code` `RoP.49.1` → `RoP.049.1` (gap 1).
|
||||
- Fix `rev.rejoin`: `duration_value` 2 → 1, set `rule_code='RoP.052'` (gap 2).
|
||||
- Fix `rev.reply`: set `rule_code='RoP.051'` (gap 3).
|
||||
- Fix `app.notice` / `app.grounds` / `app.response` rule_code drift (gap 4).
|
||||
- **Why first:** existing UPC_REV deadlines computed via paliad today are wrong by a month for both Defence and Rejoinder. Any user who set up a Nichtigkeitsverfahren in the last 4 months has miscalibrated reminders. Fix this before any other work.
|
||||
|
||||
**Wave 1 — new rule rows, single migration (~6 rows):**
|
||||
- Add R.19 Preliminary Objection (gap 5).
|
||||
- Add R.262.2 confidentiality (gap 9).
|
||||
- Add R.333.2 review-of-CMO (gap 10).
|
||||
- Add R.224.2.b Grounds-on-orders (gap 6) + R.235.1 response-on-orders (gap 7).
|
||||
- Add R.353 rectification (gap 16).
|
||||
|
||||
**Wave 2 — registry-corrections family (5–6 rows, all 14d):**
|
||||
- R.207.6.a (gap 11), R.229.2 (gap 18), and the rest of the "Mängelbeseitigung" family (R.16.3.a, R.27.2, R.89.2, R.253.2 — already noted in 2026-04-30 §3.1).
|
||||
|
||||
**Wave 3 — saisie + PI gaps (4 rows):**
|
||||
- R.197.3 (gap 12), R.207.9 (gap 14), and document R.198/R.213 as blocked on tooling.
|
||||
|
||||
**Wave 4 — translation prep (3 rows, `before`-mode):**
|
||||
- R.109.1, R.109.4, R.109.5 (gap 15). Test backward-snap to working day before merging.
|
||||
|
||||
**Wave 5 — rare/specialist (5 rows):**
|
||||
- R.20.2 (gap 17), R.144 (gap 21), R.155 (gap 22), R.321.3 (gap 20), R.118.4 (gap 8).
|
||||
|
||||
**Out of scope until tooling lands:**
|
||||
- R.198 / R.213 (working-days arithmetic).
|
||||
- R.245.2 family (multi-anchor arithmetic).
|
||||
- R.320 calendar arithmetic (outer-cap).
|
||||
|
||||
**Out of scope, fringe in HLC practice:**
|
||||
- DNI family (gap 23). Defer until first DNI case at the firm.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for m
|
||||
|
||||
Scope/policy questions, not substantive UPC-rule ambiguities. Listed for the next shift.
|
||||
|
||||
1. **R.245 rehearing — in or out of scope for paliad?** Rare remedy; if a case ever needs it, the lawyer will look it up in the RoP directly. Do we want a tile, or accept that paliad's Fristenrechner is for the 95% common case?
|
||||
2. **R.198 / R.213 working-days arithmetic — implement the primitive, or document as "manual calculation required"?** Real R.198 cases are rare enough that a doc-string + manual override may be cheaper than the schema/code work.
|
||||
3. **R.320 Wiedereinsetzung — should the cascade card produce a calendar entry?** Today migration 063 surfaces the concept (UI tile) but no Frist row gets created. The 2mo / 12mo math is non-trivial because it involves the *missed* deadline as anchor, not a forward-looking event.
|
||||
4. **DNI (R.63–R.70) — is HLC seeing any DNI cases?** Zero published in 2026-Q1; if no internal demand, defer indefinitely.
|
||||
5. **R.262.2 confidentiality 14d — Pathway A or Pathway B only?** It's a reactive deadline (defendant gets opponent's confidentiality request → 14d to respond). Cascade-only seems right; no UPC_CONFIDENTIALITY proceeding type needed.
|
||||
6. **Proceeding-code naming convention — m raised in shift-1 chat.** Today paliad uses underscore-separated codes (`UPC_INF`, `UPC_REV`, `UPC_PI`, `UPC_APP`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_COST_APPEAL`, `UPC_APP_ORDERS`). m suggested a hierarchical dot-notation scheme: `UPC.INF`, `UPC.REV`, `UPC.APM` (= PI), `UPC.A2A` (= application to amend), with instance qualifiers `UPC.INF.CFI` / `UPC.INF.COA` and national-equivalents `DE.INF.1` / `.2` / `.3`. **Trade-off:** consistent grammar across the matrix and trivial parent-child lookup vs. a bulk rename across `paliad.proceeding_types.code`, all `paliad.deadline_rules` rows, all migration files, all front-end strings, and all references in `paliad.event_category_concepts.proceeding_type_code`. **Recommendation:** if we go ahead, do it as a single migration that renames the codes via a mapping table (one column update per affected row) and add a forward-compatibility view aliasing the old codes for any in-flight queries. Don't merge with the duration-bug fixes (Wave 1) — that's two unrelated diff scopes. Worth its own task ticket.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — file references
|
||||
|
||||
**paliad code paths consulted:**
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` — original UPC_INF / UPC_REV / UPC_PI / UPC_APP / DE_* / EPA_* seed.
|
||||
- `internal/db/migrations/049_event_categories_seed.up.sql` — Pathway B cascade seed.
|
||||
- `internal/db/migrations/050_bilateral_rules_backfill.up.sql` — bilateral / both-party rule seed.
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` — prior cascade-side RoP audit fix-pass (R.221 cost-appeal, R.220.3 discretionary review, R.237/238 cross-appeal coverage).
|
||||
- `internal/db/migrations/053_courts_and_countries.up.sql` and onwards — unrelated to deadline_rules.
|
||||
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` — R.320 cascade leaf (no rule row).
|
||||
- `internal/services/deadline_calculator.go` — arithmetic. Reads `timing`, supports `before`/`after`, doesn't yet handle `working_days` or `combine='max'`.
|
||||
- `internal/services/holidays.go` — DB-driven holidays (good shape; would carry working-days arithmetic when added).
|
||||
- `frontend/src/client/fristenrechner.ts` — UI; supports `condition_rule_id` toggle for adaptive rules.
|
||||
|
||||
**RoP citations** are paraphrased from the official UPC Rules of Procedure (consolidated version, in force from 2026-01-01, available at unified-patent-court.org/sites/default/files/upc_documents/rop_consolidated_2026.pdf — verified against the deadline-creating rules I list, without quoting verbatim).
|
||||
|
||||
**Companion audits:**
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad comparison (curie, t-paliad-084).
|
||||
- `docs/audit-polish-2026-04-27.md` and `docs/audit-polish-2-2026-04-29.md` — UI/UX polish audits, not rule-data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — coverage tally
|
||||
|
||||
Rules audited in scope: **~80 deadline-creating UPC RoP rules** across 8 sections.
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| present-correct | 38 | 47% |
|
||||
| present-wrong (DURATION) | 2 | 3% |
|
||||
| present-wrong (rule_code drift only) | 5 | 6% |
|
||||
| partial | 4 | 5% |
|
||||
| missing | 25 | 31% |
|
||||
| n/a (no deadline) | 8 | 10% |
|
||||
|
||||
**Most-important findings:** the 2 duration bugs (R.49.1 Defence-to-Revocation, R.52 REV Rejoinder) — both ★★★, both impact every active UPC_REV proceeding tracked in paliad today.
|
||||
|
||||
The 25 missing represent the gap list. Of those, **16 are ★★★ / ★★ frequency** (high priority); 9 are ★ specialist.
|
||||
947
docs/design-deadline-data-model-2026-05-08.md
Normal file
947
docs/design-deadline-data-model-2026-05-08.md
Normal file
@@ -0,0 +1,947 @@
|
||||
# Deadline Data Model — Proceedings-as-DAG
|
||||
|
||||
**Author:** einstein (consultant)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-158 ([Consultant] Deadline data model — proceedings-as-DAG analysis + recommendation)
|
||||
**Branch:** `mai/einstein/consultant-deadline-data`
|
||||
**Status:** DESIGN — analysis only, no schema changes in this branch.
|
||||
**Predecessors read:** docs/audit-fristenrechner-completeness-2026-04-30.md (curie), docs/plans/unified-fristenrechner.md + docs/plans/unified-fristenrechner-v3.md (cronus, archived author), docs/design-courts-per-country-holidays-2026-05-05.md (cronus, on-hold).
|
||||
**Companion:** feynman is in flight on `mai/feynman/fristenrechner` (t-paliad-157). Read that branch's WIP if pushed; do not take dependencies on it. This analysis is upstream of any in-flight implementation.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive summary
|
||||
|
||||
**The problem.** paliad's deadline knowledge today is fragmented across five tables and two parallel calculators. The structural truth m wants — *court system → proceeding → ordered event types → conditional trigger edges* — is mostly *implicit*: it lives partly in `deadline_rules.parent_id` (one-parent tree per proceeding), partly in `trigger_events`+`event_deadlines` (flat YouPC import), partly in `deadline_concepts` (cross-proceeding semantic bridge), partly in `event_categories` (Pathway-B navigation taxonomy), and partly in free-text columns on `paliad.projects`. Conditions are encoded *twice* — once via `condition_rule_id` (FK to a sibling rule), once via `condition_flag text[]` (named flags). Multi-parent triggers cannot be expressed cleanly. The court-system axis is missing entirely.
|
||||
|
||||
**What m wants** (verbatim, 2026-05-08 16:01):
|
||||
|
||||
> All I want is a natural sequence of proceedings which belong to a court system. And of course we can classify deadlines into concepts and make it easier for the AI to understand, but in its core I need event types that are related to proceedings and connected as a sequence, one triggering the other, with some conditions possibly changing the resulting sequence.
|
||||
|
||||
**Locked m decisions (this doc, AskUserQuestion 2026-05-08 16:13–16:18):**
|
||||
|
||||
| Q | Subject | Lock |
|
||||
|---|---|---|
|
||||
| Q1 | Court-system axis | **Reuse `courts.court_type` as the system identity.** Promote it to a `paliad.court_types` lookup. FK `paliad.courts.court_type` → `court_types.code`. Retire `paliad.proceeding_types.jurisdiction`. |
|
||||
| Q2 | Proceeding instance | **Project (or sub-project) IS the proceeding instance.** Verbatim m: *"Each UPC proceeding should be its own (sub-)project. And as such can be one proceeding or multiple if necessary. Flexibility is key."* No new `paliad.proceedings` table. Multi-proceeding cases use sub-projects in the existing project tree. |
|
||||
| Q3 | Edge model | **First-class `paliad.proceeding_event_edges` table.** Multi-parent triggers natural. `parent_id` on the legacy `deadline_rules` table retired. |
|
||||
| Q4 | Conditions | **Typed columns per edge:** `if_flags text[]` (all must be set), `unless_flags text[]` (none may be set), `requires_event_id uuid REFERENCES proceeding_event_types(id)`. SQL-queryable; no expression evaluator. |
|
||||
| Q5 | Concept layer | **Subsume `deadline_concepts` into `proceeding_event_types.concept_slug` column.** Drop `deadline_concepts` table after backfill. Keep `event_categories` recursive tree as Pathway-B navigation overlay only — re-FK its junction onto `concept_slug`. |
|
||||
|
||||
**Headline shape change.** Today's *two-rule-libraries-bridged-by-a-mat-view* becomes *one rule library: a graph of typed event-types connected by typed edges, scoped to proceedings, scoped to court systems*. The instance side stays where it is (project tree). The AI/UX layers (concept tags, navigation tree) ride on top of the graph rather than parallel to it.
|
||||
|
||||
**Migration shape.** Additive build → atomic cutover per surface (Fristenrechner, deadline-search, /deadlines/new picker), all on the same boot. The 26 production `paliad.deadlines` rows survive untouched (their `rule_code` text already carries the citation; `rule_id` re-points to the new event-type/edge tuple post-cutover).
|
||||
|
||||
---
|
||||
|
||||
## 1. Map of current state
|
||||
|
||||
### 1.1 The five tables that carry deadline knowledge
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.proceeding_types (26) │ jurisdiction text
|
||||
│ ─ INF, REV, CCR, APM, … │ ('UPC'|'DE'|'EPA'|'DPMA')
|
||||
│ ─ UPC_INF, UPC_REV, UPC_PI… │
|
||||
│ ─ DE_INF, DE_NULL, DE_*_BGH │
|
||||
│ ─ EPA_OPP, EPA_APP, EP_GRANT│
|
||||
│ ─ DPMA_OPP, DPMA_*_BPATG… │
|
||||
└──────────┬───────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ paliad.deadline_rules (172) │
|
||||
│ ─ uuid PK │
|
||||
│ ─ proceeding_type_id int FK │
|
||||
│ ─ parent_id uuid → self (one-parent tree) │
|
||||
│ ─ code, name_de, name_en, description │
|
||||
│ ─ primary_party (claimant|defendant|both|court) │
|
||||
│ ─ event_type (filing|decision|order|hearing) │
|
||||
│ ─ duration_value int, duration_unit text │
|
||||
│ (months|weeks|days|working_days) │
|
||||
│ ─ timing (after|before) │
|
||||
│ ─ rule_code text, deadline_notes text+_en │
|
||||
│ ─ legal_source text ← t-paliad-131 Phase A │
|
||||
│ ─ concept_id uuid FK ← t-paliad-131 Phase A │
|
||||
│ ─ condition_rule_id uuid ─┐ │
|
||||
│ ─ condition_flag text[] ├ TWO mechanisms, │
|
||||
│ ─ alt_duration_* / unit │ one structural idea │
|
||||
│ ─ alt_rule_code │ │
|
||||
│ ─ anchor_alt text ─┘ │
|
||||
│ ─ is_spawn bool, spawn_label text │
|
||||
│ ─ is_bilateral bool ← t-paliad-133 Phase A │
|
||||
│ ─ sequence_order int │
|
||||
└──────┬───────────────────────────────────────────────┘
|
||||
│ concept_id (uuid)
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.deadline_concepts (57)│ the Unifier layer
|
||||
│ ─ slug UNIQUE │ (t-paliad-131 Phase A)
|
||||
│ ─ name_de, name_en │
|
||||
│ ─ aliases text[] │
|
||||
│ ─ party text │
|
||||
│ ─ category (submission| │
|
||||
│ decision|order|hearing) │
|
||||
└──────┬───────────────────────┘
|
||||
│ concept_id (uuid)
|
||||
▼ (junction)
|
||||
┌──────────────────────────────────────────┐
|
||||
│ paliad.event_category_concepts (136) │ decision-tree leaf
|
||||
│ ─ event_category_id FK │ → concept overlay
|
||||
│ ─ concept_id FK │ (t-paliad-133)
|
||||
│ ─ proceeding_type_code text -- narrow │
|
||||
└──────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ paliad.event_categories (103)│ recursive tree (parent_id self-FK)
|
||||
│ ─ slug, label_de, label_en │ Pathway-B navigation taxonomy
|
||||
│ ─ step_question_de/_en │ (t-paliad-133, depth unlimited)
|
||||
│ ─ icon, sort_order, is_leaf │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
**Parallel rule library — YouPC import (UPC-only, flat):**
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ paliad.trigger_events (110) │ 1:N │ paliad.event_deadlines (77) │
|
||||
│ ─ bigint PK (verbatim from │ ───────▶│ ─ bigint PK (verbatim ids) │
|
||||
│ youpc.data.events) │ │ ─ trigger_event_id FK │
|
||||
│ ─ code, name, name_de │ │ ─ duration_value, duration_unit │
|
||||
│ ─ concept_id text │ │ (days|weeks|months| │
|
||||
│ ↑ slug (text), NOT FK │ │ working_days) │
|
||||
└──────────────────────────────┘ │ ─ alt_duration_* + combine_op │
|
||||
│ (max|min — composite │
|
||||
│ rule for R.198/R.213) │
|
||||
│ ─ timing (before|after) │
|
||||
│ ─ title, title_de, notes, _en │
|
||||
└──────────┬───────────────────────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ paliad.event_deadline_rule_codes │
|
||||
│ (72 — one row per RoP citation) │
|
||||
│ ─ event_deadline_id, rule_code, │
|
||||
│ sort_order │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
The two rule libraries are bridged at search time only by `paliad.deadline_search` (mat-view, t-paliad-131 Phase C, migration 047): one row per (concept × context) where context is `kind='rule'` for `deadline_rules` rows or `kind='trigger'` for `trigger_events` rows. They share **no FK**.
|
||||
|
||||
**Instance side** (the per-case audit row):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.deadlines (26 in production) │
|
||||
│ ─ uuid PK │
|
||||
│ ─ project_id uuid FK → paliad.projects(id) │
|
||||
│ ─ rule_id uuid FK → deadline_rules(id) NULL │
|
||||
│ ─ rule_code text -- citation, free-text │
|
||||
│ (t-paliad-111 — survives rule rename) │
|
||||
│ ─ title, description, due_date, original_due_ │
|
||||
│ date, warning_date │
|
||||
│ ─ status (pending|completed|cancelled|waived) │
|
||||
│ ─ source (manual|imported|caldav|paliadin) │
|
||||
│ ─ caldav_uid, caldav_etag │
|
||||
│ ─ approval_status (approved|pending|legacy) │
|
||||
│ + pending_request_id, approved_by/_at │
|
||||
│ (t-paliad-138 dual-control, migration 054)│
|
||||
│ ─ created_by, created_at, updated_at │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│ deadline_id
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.deadline_event_types (junction, 0..N) │
|
||||
│ ─ deadline_id, event_type_id (composite PK) │
|
||||
│ (t-paliad-088, migration 030) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ event_type_id
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ paliad.event_types (45) │
|
||||
│ ─ uuid PK, slug, label_de, label_en │
|
||||
│ ─ category (submission|decision|order|service| │
|
||||
│ fee|hearing|other) │
|
||||
│ ─ jurisdiction (UPC|EPO|DPMA|DE|any) NULL │
|
||||
│ ─ trigger_event_id bigint NULL ─ loose linkage│
|
||||
│ (NO FK constraint — youpc resync-safe) │
|
||||
│ ─ created_by, is_firm_wide, archived_at │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`paliad.event_types` is the user-facing classifier on per-case `paliad.deadlines` rows. It overlaps with `paliad.trigger_events` by ~70% (UPC submissions) and carries an optional `trigger_event_id` linkage column without an FK constraint by design (so a future YouPC re-sync can drop trigger ids without breaking event_types). It is **distinct from** `paliad.event_categories` (the Pathway-B decision tree) and **distinct from** `paliad.deadline_rules.event_type` (which is just a text column, values `filing|decision|order|hearing`).
|
||||
|
||||
So today the word "event type" identifies three different things in three different tables. Not necessarily wrong, but worth flagging.
|
||||
|
||||
### 1.2 Court / venue / jurisdiction
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ paliad.courts (41 — t-paliad-122 migration 053) │
|
||||
│ ─ id text PK (kebab, mirrors handlers/courts.go) │
|
||||
│ ─ code, name_de, name_en │
|
||||
│ ─ country text FK → paliad.countries(code) -- ISO-3166 │
|
||||
│ ─ regime text NULL -- 'UPC'|'EPO'|NULL │
|
||||
│ ─ court_type text -- 'UPC-LD'|'UPC-CD'|'UPC-CoA'| │
|
||||
│ 'DE-LG'|'DE-OLG'|'DE-BGH'| │
|
||||
│ 'DE-BPatG'|'DE-DPMA'|'EPA'|'NAT' │
|
||||
│ ─ parent_id text FK → self │
|
||||
│ ─ sort_order int, is_active bool │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The `court_type` column is currently **free text** (no constraint, no FK target). 41 rows are seeded across 11 distinct values. This is the column m's Q1 lock promotes to be the court-system identity.
|
||||
|
||||
`paliad.holidays` (55 rows) carries `country` ISO-3166 + `regime` ('UPC'|'EPO'|NULL). Federal DE public holidays = country='DE', regime=NULL; UPC summer/winter judicial vacations = country=NULL, regime='UPC'. The check constraint `country IS NOT NULL OR regime IS NOT NULL` enforces every row carries at least one.
|
||||
|
||||
### 1.3 Project side — what links a case to a proceeding today
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ paliad.projects (11 active in prod) │
|
||||
│ ─ id uuid PK │
|
||||
│ ─ type text -- 'mandat'|'litigation'|'patent'| │
|
||||
│ 'verfahren'|'projekt' │
|
||||
│ ─ parent_id uuid → self (project tree) │
|
||||
│ ─ path text NOT NULL -- materialised ltree path │
|
||||
│ (t-paliad-023, GiST-indexed, RLS-load-bearing) │
|
||||
│ ─ title, reference, description, status │
|
||||
│ ─ proceeding_type_id integer -- single FK │
|
||||
│ → paliad.proceeding_types(id) │
|
||||
│ ─ court text -- FREE TEXT, no FK to paliad.courts │
|
||||
│ ─ country text │
|
||||
│ ─ patent_number, filing_date, grant_date │
|
||||
│ ─ case_number, billing_reference, client_number, │
|
||||
│ matter_number, netdocuments_url │
|
||||
│ ─ industry, ai_summary, metadata jsonb │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
So the project row carries:
|
||||
- ONE proceeding-type FK (an integer, not nullable on `verfahren` projects but nullable in the schema).
|
||||
- ONE court — but as **free text**, not FK'd to `paliad.courts.id` despite that table being seeded six days ago in migration 053.
|
||||
- NO trigger_date column. The trigger date is implicit in the `paliad.deadlines.original_due_date` of whichever Frist anchored the calc.
|
||||
- NO live-state column. There is no "currently at stage X" pointer.
|
||||
|
||||
There's no `paliad.proceedings` table. The conceptual link "this project IS a UPC infringement action" is the pair (project_id → proceeding_type_id), no further structure.
|
||||
|
||||
### 1.4 What lives where — by jurisdiction
|
||||
|
||||
| Jurisdiction | proceeding_types | deadline_rules | trigger_events | event_deadlines |
|
||||
|---|---:|---:|---:|---:|
|
||||
| UPC (legacy: INF/REV/CCR/APM/APP/AMD) | 6 | 36 | 0 | 0 |
|
||||
| UPC (modern: UPC_INF/UPC_REV/UPC_PI/…) | 8 | 56 | 110 | 77 |
|
||||
| DE (ZPO/PatG, LG/OLG/BGH/BPatG) | 5 | 40 | 0 | 0 |
|
||||
| EPA (OPP/APP/EP_GRANT) | 3 | 23 | 0 | 0 |
|
||||
| DPMA | 3 | 13 | 0 | 0 |
|
||||
| Cross-cutting (Wiedereinsetzung, …) | 0 | 0 | 7 | 7 |
|
||||
| Legacy ZPO_CIVIL placeholder | 1 | 4 | 0 | 0 |
|
||||
| **Total** | **26** | **172** | **110** | **77** |
|
||||
|
||||
The two UPC generations (`INF/REV/CCR/APM/APP/AMD` from migration 008 vs `UPC_INF/UPC_REV/UPC_PI/UPC_APP/UPC_DAMAGES/UPC_DISCOVERY/UPC_COST_APPEAL/UPC_APP_ORDERS` from migration 012) coexist in production. Fristenrechner v3+ uses the modern set; the legacy six are unreferenced sediment kept "in case". This is technical debt orthogonal to the model question, flagged here for the migration plan in §4.
|
||||
|
||||
### 1.5 How conditional triggers are encoded today (concrete)
|
||||
|
||||
| Mechanism | Rules using it | Example |
|
||||
|---|---:|---|
|
||||
| `condition_rule_id` (FK to a sibling rule) | 2 | INF tree's `inf.reply` and `inf.rejoin` reference `ccr.counterclaim` — when CCR was filed in the same case, swap rule_code RoP.029.b → RoP.029.a (Reply) or duration 1mo → 2mo (Rejoinder). |
|
||||
| `condition_flag text[]` (named flags from request) | 17 | UPC_INF tree's `with_ccr` rules render only when the request includes `with_ccr` flag; UPC_REV's `with_amend`/`with_cci` parallel flags. |
|
||||
| `alt_duration_value` + `alt_duration_unit` + `alt_rule_code` | 4 | Swap-on-flag fallback (R.198/R.213 max-of-31d-or-20wd is encoded similarly on `event_deadlines.alt_duration_*` + `combine_op`). |
|
||||
| `anchor_alt text` (named alternate anchor) | 1 | EP_GRANT publish anchors on `priority_date` instead of parent rule's date. |
|
||||
| `is_spawn` + `spawn_label` (cross-tree edge) | 6 | INF tree's `inf.appeal` lives in APP tree but `parent_id` points into INF.decision — the rule itself sits in proceeding APP, the parent sits in proceeding INF. Implicit cross-proceeding edge. |
|
||||
| `condition_flag` AND `alt_duration_value` together | 3 | UPC_INF Replik has `condition_flag=['with_ccr']` swapping duration via `alt_duration_value` rather than gating render. |
|
||||
|
||||
The two-mechanism split is what bites every contributor. `condition_rule_id` was the Phase-A approach; `condition_flag` was added by t-paliad-086 PR-3 because `condition_rule_id` couldn't model "user told me they ARE in CCR mode without there being a rule of mine to point at." Both still in production. New rules should use `condition_flag`; the 2 legacy `condition_rule_id` rules are equivalent to single-element flag arrays and were not migrated.
|
||||
|
||||
### 1.6 The two calculators
|
||||
|
||||
- **Tree calculator** — `internal/services/fristenrechner.go` (803 lines): walks `deadline_rules` parent_id chain, anchors on input trigger_date, applies condition_flag gates, swaps `alt_*` columns when flags are set, classifies court-determined nodes (`isCourtDeterminedRule`: `primary_party='court' OR event_type IN ('hearing','decision','order')`) so they render as "no date — court will set it". Used by `/tools/fristenrechner` for the 16 modern proceeding-tree views.
|
||||
- **Flat calculator** — `internal/services/event_deadline_service.go` (315 lines): single trigger_event ID + trigger_date → list of event_deadlines, no parent chain. Composite `combine_op='max'`/`'min'` resolves R.198/R.213. Working-days math via `addWorkingDays` over `paliad.holidays`. Used by Pathway-B "Was kommt nach…" tab.
|
||||
|
||||
The two share `holidays.go` for working-day skip logic. Otherwise the code paths are independent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gaps vs proceedings-as-DAG framing
|
||||
|
||||
m's framing decoded into structural facts the data model SHOULD support:
|
||||
|
||||
| m says | Data model needs |
|
||||
|---|---|
|
||||
| "court system" is the outer container | One row per court system the firm practises in (UPC-CFI, UPC-CoA, DE-LG-Patentkammer, DE-OLG, DE-BGH, DE-BPatG, EPO, DPMA, …). Procedural rules belong to a court system. |
|
||||
| "a natural sequence of proceedings" | One row per *named procedural shape* (UPC infringement action, UPC revocation action, EPO opposition, DE LG patent action). A proceeding belongs to ONE court system. |
|
||||
| "event types … related to proceedings" | Each event-type node belongs to a proceeding. Some nodes may be shared across proceedings (final-decision, oral-hearing). |
|
||||
| "connected as a sequence, one triggering the other" | Edges between event-types within a proceeding. Multi-parent allowed (one node may be triggered by either of two predecessors). |
|
||||
| "with some conditions possibly changing the resulting sequence" | Edges carry conditions. Conditions are first-class (queryable, AI-readable). |
|
||||
| "classify deadlines into concepts and make it easier for the AI" | Concept tag layer on each event-type. Rides on top of the graph, doesn't compete with it. |
|
||||
|
||||
### 2.1 Concrete gaps
|
||||
|
||||
#### Gap G1 — Court system is not in the data model
|
||||
|
||||
**Today:** `proceeding_types.jurisdiction text` ('UPC'|'DE'|'EPA'|'DPMA') conflates court-system regime with national jurisdiction. The 41 `paliad.courts` rows carry `court_type` ('UPC-LD'|'UPC-CoA'|'DE-LG'|'DE-OLG'|'DE-BGH'|'DE-BPatG'|'EPA'|'DPMA'|'NAT'|…) as free text. There is no FK between the two.
|
||||
|
||||
**Why it bites:** "Show me every UPC procedural rule" requires `proceeding_types.jurisdiction='UPC'`. "Show me every rule that fires in a German LG patent chamber" requires reasoning about court_type='DE-LG' AND a proceeding that runs there — but the proceeding doesn't carry a court_type, the *project's court* does, and that's free text. The DE-LG and DE-OLG patent appeal proceedings (`DE_INF`, `DE_INF_OLG`) BOTH have jurisdiction='DE' on `proceeding_types`; nothing tells you DE_INF runs at LG and DE_INF_OLG runs at OLG except the proceeding name.
|
||||
|
||||
**Concrete fail:** today, the holiday lookup for "deadline computed for a UPC infringement action filed in München LD" needs UPC summer vacation + DE federal holidays. The intermediate join (project.court_type → applicable holiday set) is hardcoded in `internal/services/holidays.go` because there's no FK chain to walk.
|
||||
|
||||
#### Gap G2 — One project = one proceeding-type FK; multi-proceeding cases are forced into the project tree
|
||||
|
||||
**Today:** `paliad.projects.proceeding_type_id integer` is single-valued. A project that hosts BOTH a UPC infringement action and a separate revocation counterclaim must either:
|
||||
(a) Tag itself with one of the two and lose half its proceeding context, or
|
||||
(b) Be split into two child `verfahren` projects under a common litigation parent.
|
||||
|
||||
**m's lock (Q2):** Sub-projects are the right answer. *"Each UPC proceeding should be its own (sub-)project."* This is consistent with the project-tree model already in place since t-paliad-023 (data-model-v2). The fix isn't to add a `paliad.proceedings` table; it's to *honour* the existing tree by FK-tightening `projects.proceeding_def_id` on `verfahren`-typed projects.
|
||||
|
||||
#### Gap G3 — Edges are one-parent only; multi-parent triggers cannot be expressed cleanly
|
||||
|
||||
**Today:** Each `deadline_rules` row has at most one `parent_id`. A node like UPC `inf.rejoin` has TWO real-world predecessors:
|
||||
- After Reply-to-SoD when no CCR was filed (1 month, RoP.029.c)
|
||||
- After Reply-to-Defence-to-CCR when CCR was filed (1 month, RoP.029.e)
|
||||
|
||||
The current model collapses these into ONE rule with `condition_flag=['with_ccr']` swapping `alt_*` columns, but that masks the true graph: there are two distinct edges into `inf.rejoin`, with different `from_event_type` and different `rule_code`. Today the calculator papers over this by anchoring `inf.rejoin` on whichever parent the `parent_id` points at and pretending the other parent doesn't exist for purposes of the chain walk.
|
||||
|
||||
Cross-proceeding edges (the legacy `is_spawn` flag, 6 rules) are an even uglier symptom — `inf.appeal` lives in proceeding APP but its `parent_id` points into INF. Two different proceedings, one edge. Today this is fine for tree traversal but breaks any "show me proceeding APP's structure" query because you have to know the edge crosses.
|
||||
|
||||
#### Gap G4 — Conditions encoded in two mechanisms
|
||||
|
||||
**Today:** 2 rules use `condition_rule_id` (FK to a sibling rule whose presence flips alt_duration / alt_rule_code), 17 rules use `condition_flag text[]` (named flags). Both still load-bearing in the calculator. Same idea, two columns.
|
||||
|
||||
**Why it bites:** Every new contributor has to learn both. The 2 legacy `condition_rule_id` rules are sentinel debt — they couldn't be deleted without rewriting the inf.reply / inf.rejoin classifier_flag dual-encoding (memory `652b856f` t-paliad-086 PR-3 imported the flag-based variant alongside, did NOT migrate the legacy two).
|
||||
|
||||
#### Gap G5 — Two parallel rule libraries with no shared FK
|
||||
|
||||
**Today:**
|
||||
- `deadline_rules` (172 rows, UUID PK, parent-tree, condition_flag, alt_*) — the timeline calculator's source.
|
||||
- `trigger_events` + `event_deadlines` (110+77 rows, bigint PK, flat trigger→deadline map, composite max/min) — the trigger calculator's source.
|
||||
|
||||
They are bridged at search time by `paliad.deadline_search` mat-view (concept slug as join key) but share no FK. A rule in `deadline_rules` and a deadline in `event_deadlines` can describe the *same* legal idea (e.g. UPC Klageerwiderung) and the only thing that ties them is whether someone happened to set the same `concept_id`/`concept slug` on both sides.
|
||||
|
||||
This costs us:
|
||||
- **Drift** — when t-paliad-086 PR-3 fixed Tier-1 bugs in `deadline_rules`, equivalent rows in `event_deadlines` were not touched. The two libraries can disagree on the same Frist.
|
||||
- **Audit difficulty** — "is this Frist correct?" requires reading both tables and the bridge.
|
||||
- **AI confusion** — feeding the corpus to the LLM means feeding two different shapes of the same knowledge.
|
||||
|
||||
#### Gap G6 — Concept layer is a rope-bridge, not a column
|
||||
|
||||
**Today:** `paliad.deadline_concepts` (57 rows) is a separate table. `deadline_rules.concept_id uuid FK`. `trigger_events.concept_id text` (slug, NOT FK — string-walked). `event_category_concepts.concept_id uuid FK` (the navigation overlay). Three different referent types for the same entity.
|
||||
|
||||
**Why it bites:** Re-naming a concept (slug change) means walking three FK shapes. AI ingestion means joining four tables to get "what does this Frist *mean*." The cross-proceeding semantic identity (one Klageerwiderung in UPC ≅ one Klageerwiderung in DE_INF) is queryable but not load-bearing — the FK exists, but nothing constrains *both* rules to point at the same concept_id. Drift is silent.
|
||||
|
||||
#### Gap G7 — Conditional sequence changes are local to one edge
|
||||
|
||||
**Today:** A condition on rule X (e.g. `condition_flag=['with_ccr']`) gates whether rule X renders. It does NOT propagate. So if "with_ccr is true" should *also* mean "the Application-to-amend timeline becomes available in this proceeding," that's encoded as separate rules each with their own `condition_flag=['with_ccr']`. No "if condition C, the proceeding switches to track T" semantic.
|
||||
|
||||
**Concrete example:** UPC infringement with CCR has its OWN sub-proceeding shape (Defence-to-CCR with its own Reply/Rejoinder cycle, optional Application-to-amend). Today this is encoded as N additional rules in `UPC_INF` each gated on `with_ccr`. Tomorrow it could be one `proceeding_event_edges` row that says "if `with_ccr` then activate the CCR sub-graph rooted at this node."
|
||||
|
||||
This is **not** addressed by Q3+Q4 — multi-parent edges + typed conditions. We'll *come closer*, but a true track-switching semantic ("this proceeding has an alternate path that engages under condition X") is one level above the edge model and is **deliberately deferred**. See §6.4.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target shape
|
||||
|
||||
This section translates m's locked decisions into a concrete schema and walks one full UPC infringement action to make the shape tangible.
|
||||
|
||||
### 3.1 Court system axis (Q1)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.court_types (
|
||||
code text PRIMARY KEY,
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
regime text -- 'UPC'|'EPO'|NULL (national)
|
||||
CHECK (regime IS NULL OR regime IN ('UPC','EPO')),
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO paliad.court_types (code, name_de, name_en, regime, sort_order) VALUES
|
||||
-- UPC court systems
|
||||
('UPC-LD', 'UPC-Lokalkammer', 'UPC Local Division', 'UPC', 10),
|
||||
('UPC-CD', 'UPC-Zentralkammer', 'UPC Central Division', 'UPC', 20),
|
||||
('UPC-CoA', 'UPC-Berufungsgericht', 'UPC Court of Appeal', 'UPC', 30),
|
||||
('UPC-RD', 'UPC-Regionalkammer', 'UPC Regional Division', 'UPC', 40),
|
||||
-- DE court systems
|
||||
('DE-LG', 'Landgericht (Patentstreitkammer)',
|
||||
'German Regional Court (patent chamber)', NULL, 50),
|
||||
('DE-OLG', 'Oberlandesgericht (Patentsenat)',
|
||||
'German Higher Regional Court (patent senate)', NULL, 60),
|
||||
('DE-BGH', 'Bundesgerichtshof (X. Zivilsenat)',
|
||||
'German Federal Court of Justice (Xth Civil Senate)', NULL, 70),
|
||||
('DE-BPatG', 'Bundespatentgericht', 'German Federal Patent Court', NULL, 80),
|
||||
('DE-DPMA', 'Deutsches Patent- und Markenamt',
|
||||
'German Patent and Trade Mark Office', NULL, 90),
|
||||
-- EPO
|
||||
('EPA', 'Europäisches Patentamt', 'European Patent Office', 'EPO', 100),
|
||||
-- National (non-UPC, non-DE-patent-track)
|
||||
('NAT', 'Nationales Gericht', 'National Court', NULL, 200);
|
||||
|
||||
-- FK from existing courts table
|
||||
ALTER TABLE paliad.courts
|
||||
ADD CONSTRAINT courts_court_type_fk
|
||||
FOREIGN KEY (court_type) REFERENCES paliad.court_types(code);
|
||||
```
|
||||
|
||||
The 41 `paliad.courts` rows already carry the right `court_type` strings (verified live: 11 distinct values, all in the seed list above). The FK addition is a pure constraint upgrade, no data move.
|
||||
|
||||
### 3.2 Proceeding definitions (the named-sequence template)
|
||||
|
||||
```sql
|
||||
-- Renamed + restructured from paliad.proceeding_types
|
||||
CREATE TABLE paliad.proceeding_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL UNIQUE,
|
||||
-- 'UPC_INF','UPC_REV','UPC_PI','UPC_APP','EPO_OPP',
|
||||
-- 'EPO_APP','DE_INF_LG','DE_INF_OLG','DE_INF_BGH',
|
||||
-- 'DE_NULL_BPATG','DE_NULL_BGH','DPMA_OPP','DPMA_APP','DPMA_RB'
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
court_type text NOT NULL
|
||||
REFERENCES paliad.court_types(code), -- the system axis
|
||||
category text NOT NULL -- 'litigation'|'opposition'|'examination'|'appeal'
|
||||
CHECK (category IN ('litigation','opposition','examination',
|
||||
'appeal','enforcement','provisional')),
|
||||
default_color text NOT NULL DEFAULT '#3b82f6',
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
is_fristenrechner bool NOT NULL DEFAULT true,
|
||||
-- whether this proceeding is exposed in /tools/fristenrechner
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX proceeding_definitions_court_type_idx
|
||||
ON paliad.proceeding_definitions(court_type);
|
||||
CREATE INDEX proceeding_definitions_category_idx
|
||||
ON paliad.proceeding_definitions(category);
|
||||
```
|
||||
|
||||
Each row IS a "natural sequence of [a class of] proceedings." `court_type` is the outer container m asked for. The legacy `proceeding_types.jurisdiction` text column is dropped — its information is now derivable via `court_types.regime`.
|
||||
|
||||
### 3.3 Event types (the nodes)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.proceeding_event_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proceeding_def_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
|
||||
-- Each node belongs to one proceeding. Cross-proceeding shared
|
||||
-- semantics are expressed via concept_slug (Q5 lock), not by
|
||||
-- attaching one node to multiple proceedings.
|
||||
code text NOT NULL,
|
||||
-- Local code, unique within proceeding_def_id.
|
||||
-- Examples: 'soc','sod','reply','rejoinder','decision'
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text,
|
||||
party text NOT NULL
|
||||
CHECK (party IN ('claimant','defendant','both','court','any')),
|
||||
kind text NOT NULL
|
||||
CHECK (kind IN ('filing','decision','order','hearing','service','fee')),
|
||||
concept_slug text, -- Q5 lock — subsumes paliad.deadline_concepts
|
||||
-- Free-form slug; matches old concept slugs verbatim post-migration.
|
||||
-- One LLM-readable identifier shared across proceedings.
|
||||
-- E.g. 'statement-of-defence' on both UPC_INF.sod and DE_INF_LG.klageerw.
|
||||
concept_de text, -- denormalised from old deadline_concepts.name_de
|
||||
concept_en text, -- denormalised from old deadline_concepts.name_en
|
||||
aliases text[] NOT NULL DEFAULT '{}',
|
||||
-- Search aliases inherited from old deadline_concepts.aliases.
|
||||
-- Indexed via gin (aliases) for the search bar.
|
||||
is_root bool NOT NULL DEFAULT false,
|
||||
-- True for the trigger node of a proceeding (the Statement of Claim,
|
||||
-- the Statement for Revocation, the EPO opposition filing). The
|
||||
-- proceeding instance's trigger_date anchors here.
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
is_bilateral bool NOT NULL DEFAULT false,
|
||||
-- Carried over from t-paliad-133. When true AND party='both',
|
||||
-- mirror into both columns of the columns-view.
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (proceeding_def_id, code)
|
||||
);
|
||||
|
||||
CREATE INDEX proceeding_event_types_def_idx ON paliad.proceeding_event_types(proceeding_def_id);
|
||||
CREATE INDEX proceeding_event_types_concept_idx ON paliad.proceeding_event_types(concept_slug)
|
||||
WHERE concept_slug IS NOT NULL;
|
||||
CREATE INDEX proceeding_event_types_aliases_idx ON paliad.proceeding_event_types USING gin (aliases);
|
||||
CREATE INDEX proceeding_event_types_de_trgm ON paliad.proceeding_event_types USING gin (name_de gin_trgm_ops);
|
||||
CREATE INDEX proceeding_event_types_en_trgm ON paliad.proceeding_event_types USING gin (name_en gin_trgm_ops);
|
||||
```
|
||||
|
||||
Per Q5: `concept_slug` + `concept_de` + `concept_en` + `aliases` are columns on the node, not a separate table. The 57 `paliad.deadline_concepts` rows distill into ~57 distinct concept_slug values across the ~172+ migrated nodes. Cross-proceeding "all rules with concept_slug='statement-of-defence'" is a single-column index lookup, not a join.
|
||||
|
||||
### 3.4 Edges (the typed triggers)
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.proceeding_event_edges (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
proceeding_def_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_definitions(id) ON DELETE CASCADE,
|
||||
from_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
|
||||
-- NULL = root edge (anchors on the proceeding instance's trigger_date).
|
||||
-- The to_event must have is_root=true for null-from edges.
|
||||
to_event_id uuid NOT NULL
|
||||
REFERENCES paliad.proceeding_event_types(id) ON DELETE CASCADE,
|
||||
duration_value int NOT NULL DEFAULT 0,
|
||||
duration_unit text NOT NULL DEFAULT 'months'
|
||||
CHECK (duration_unit IN ('days','weeks','months','working_days')),
|
||||
timing text NOT NULL DEFAULT 'after'
|
||||
CHECK (timing IN ('after','before')),
|
||||
-- 'before' supports countdown deadlines (e.g. "1 month before oral hearing").
|
||||
combine_op text CHECK (combine_op IS NULL OR combine_op IN ('max','min')),
|
||||
alt_duration_value int,
|
||||
alt_duration_unit text CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days','weeks','months','working_days')),
|
||||
-- combine_op + alt_* implements composite rules
|
||||
-- (e.g. R.198/R.213 max(31d, 20wd)). Only set on edges
|
||||
-- where the rule itself is composite — flag-conditioned
|
||||
-- variants use sibling edges, not alt_*.
|
||||
|
||||
-- ===== Q4 lock — typed conditions =====
|
||||
if_flags text[] NOT NULL DEFAULT '{}',
|
||||
-- All flags in this array must be set for the edge to fire.
|
||||
-- Empty array = unconditional.
|
||||
unless_flags text[] NOT NULL DEFAULT '{}',
|
||||
-- None of these flags may be set for the edge to fire.
|
||||
requires_event_id uuid REFERENCES paliad.proceeding_event_types(id) ON DELETE SET NULL,
|
||||
-- Edge fires only if this OTHER event was actually filed/recorded
|
||||
-- in the proceeding instance (replaces today's condition_rule_id).
|
||||
-- NULL = no occurrence prerequisite.
|
||||
|
||||
-- ===== Citation =====
|
||||
rule_code text, -- 'RoP.029.b','PatG §111(1)','§ 276 ZPO'
|
||||
legal_source text, -- 'UPC.RoP.029.b' / 'DE.PatG.111.1' / 'EU.EPÜ.108'
|
||||
is_mandatory bool NOT NULL DEFAULT true,
|
||||
deadline_notes_de text,
|
||||
deadline_notes_en text,
|
||||
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Sanity: from_event must belong to the same proceeding_def
|
||||
-- (cross-proceeding edges are out-of-scope per §3.6 — modelled
|
||||
-- via separate root edges in each proceeding instead).
|
||||
CONSTRAINT edge_from_in_def CHECK (
|
||||
from_event_id IS NULL OR proceeding_def_id IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX edges_def_idx ON paliad.proceeding_event_edges(proceeding_def_id);
|
||||
CREATE INDEX edges_to_idx ON paliad.proceeding_event_edges(to_event_id);
|
||||
CREATE INDEX edges_from_idx ON paliad.proceeding_event_edges(from_event_id)
|
||||
WHERE from_event_id IS NOT NULL;
|
||||
CREATE INDEX edges_requires_idx ON paliad.proceeding_event_edges(requires_event_id)
|
||||
WHERE requires_event_id IS NOT NULL;
|
||||
CREATE INDEX edges_if_flags_idx ON paliad.proceeding_event_edges USING gin (if_flags);
|
||||
CREATE INDEX edges_unless_flags_idx ON paliad.proceeding_event_edges USING gin (unless_flags);
|
||||
CREATE INDEX edges_rule_code_idx ON paliad.proceeding_event_edges(rule_code)
|
||||
WHERE rule_code IS NOT NULL;
|
||||
```
|
||||
|
||||
**Multi-parent semantics:** when two edges share the same `to_event_id`, both compute candidate dates; the calculator picks per the edges' `if_flags`/`unless_flags`/`requires_event_id` predicates. If multiple edges remain feasible for the same target, the rendered Frist is the LATEST of the candidates (paying lip service to the most-conservative-first principle); a future edge-priority column can refine this if needed.
|
||||
|
||||
**Composite within an edge** (`combine_op`): used only when the rule itself is structurally composite (R.198 / R.213 max-of-two-units). Flag-driven variants (`with_ccr` swaps duration 1mo→2mo) become **two sibling edges** with disjoint `if_flags` predicates — the cleaner expression of the same idea.
|
||||
|
||||
### 3.5 Project ↔ proceeding linkage (Q2)
|
||||
|
||||
```sql
|
||||
-- Per Q2 lock — project (or sub-project) IS the proceeding instance.
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN proceeding_def_id uuid
|
||||
REFERENCES paliad.proceeding_definitions(id),
|
||||
ADD COLUMN court_id text
|
||||
REFERENCES paliad.courts(id),
|
||||
ADD COLUMN proceeding_trigger_date date,
|
||||
-- The date that anchors the root edge of this proceeding.
|
||||
-- Null until the trigger-event has actually occurred.
|
||||
ADD COLUMN proceeding_status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (proceeding_status IN ('pending','active','suspended','concluded','withdrawn'));
|
||||
|
||||
-- Backfill from the existing integer FK + free-text court column.
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_def_id = pd.id
|
||||
FROM paliad.proceeding_definitions pd
|
||||
JOIN paliad.proceeding_types pt ON pt.code = pd.code
|
||||
WHERE p.proceeding_type_id = pt.id;
|
||||
|
||||
-- Free-text court → FK by best-effort string match.
|
||||
UPDATE paliad.projects p
|
||||
SET court_id = c.id
|
||||
FROM paliad.courts c
|
||||
WHERE p.court IS NOT NULL
|
||||
AND lower(p.court) IN (lower(c.id), lower(c.code), lower(c.name_de), lower(c.name_en));
|
||||
|
||||
-- After backfill (separate migration, gated on QA):
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
|
||||
-- ALTER TABLE paliad.projects DROP COLUMN country; -- inferred via court → court_type → country
|
||||
```
|
||||
|
||||
A `verfahren`-typed project carries `proceeding_def_id` (the template) + `court_id` (the venue) + `proceeding_trigger_date` (the anchor for downstream edges). A `mandat`/`litigation`-typed project does NOT carry these (NULL is fine). Multi-proceeding cases live as sibling `verfahren` projects under a shared parent — exactly m's lock.
|
||||
|
||||
The `proceeding_status` column gives the per-instance live state m wanted (pending → active → concluded) without a separate `paliad.proceedings` table. Future fields (current-stage event_type_id, last_advanced_at, expected-decision-date) extend this column set without disturbing other layers.
|
||||
|
||||
### 3.6 Cross-proceeding edges — explicit retirement
|
||||
|
||||
The current `is_spawn` flag (6 rules) encodes "filing of A in proceeding X opens proceeding Y" by parking a rule in proceeding Y's tree with `parent_id` pointing into proceeding X. Concretely: `inf.appeal` lives in APP but its parent is INF.decision.
|
||||
|
||||
In the new shape: **each proceeding's graph is closed.** Cross-proceeding triggers are modelled at the *instance* layer — when the user records "decision in INF reached on date D," they instantiate a NEW `verfahren` sub-project (proceeding APP) with `proceeding_trigger_date=D`. The graph stays clean; the cross-proceeding step is a project-tree action, not an edge.
|
||||
|
||||
This is a small UX shift (today the appeal Frist auto-renders inside the INF timeline; tomorrow the user explicitly spawns the appeal sub-project to see its Fristen) but the alternative — letting `proceeding_event_edges` straddle proceedings — pollutes the model. Defer cross-proceeding-edge support; add a sub-project-creation shortcut on the decision-event UI instead.
|
||||
|
||||
### 3.7 Concept layer — what stays, what goes
|
||||
|
||||
**Drops:**
|
||||
- `paliad.deadline_concepts` (57 rows). Content lifts to `proceeding_event_types.concept_slug` + `concept_de` + `concept_en` + `aliases`.
|
||||
- `paliad.deadline_rules.concept_id` FK. Replaced by `proceeding_event_types.concept_slug` text column.
|
||||
- `paliad.trigger_events.concept_id text` (already a slug, was never an FK). Migrated to the matching `proceeding_event_types` rows — see §4.
|
||||
|
||||
**Stays:**
|
||||
- `paliad.event_categories` (103 rows) — Pathway-B navigation taxonomy. Recursive tree, decision-tree UI. Re-FK its junction onto `concept_slug`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
DROP CONSTRAINT event_category_concepts_concept_id_fkey;
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
ADD COLUMN concept_slug text;
|
||||
UPDATE paliad.event_category_concepts ecc
|
||||
SET concept_slug = dc.slug
|
||||
FROM paliad.deadline_concepts dc
|
||||
WHERE ecc.concept_id = dc.id;
|
||||
ALTER TABLE paliad.event_category_concepts
|
||||
ALTER COLUMN concept_slug SET NOT NULL,
|
||||
DROP COLUMN concept_id;
|
||||
```
|
||||
|
||||
The category tree is now a thin overlay that maps "user clicked 'Hinweisbeschluss'" to the set of concept_slugs whose nodes should appear as cards. No separate concept identity required — the slug is the bridge.
|
||||
|
||||
**Stays unchanged:**
|
||||
- `paliad.event_types` (45 rows) — the *instance-side* user-facing classifier on `paliad.deadlines`. Per t-paliad-088 this is firm-wide-or-private, archive-only, with optional loose-linkage `trigger_event_id`. Untouched by this design — it's a different layer (instance tag, not template node). After migration, the loose linkage column can be repurposed: `event_types.proceeding_event_type_id uuid` (still loose, still nullable) — maintained as a follow-up, not in scope for the cutover.
|
||||
|
||||
### 3.8 Worked example — UPC infringement action (with CCR variant)
|
||||
|
||||
The mermaid below is one full proceeding's graph in the new shape. **Solid edges fire unconditionally; dashed edges fire only when the labelled flag is set.** Multi-parent at `inf.rejoinder` is the headline shape change.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
inf_soc["📄 inf.soc<br/>Statement of Claim<br/>concept-slug: statement-of-claim<br/>kind: filing • party: claimant • is_root: true"]:::root
|
||||
|
||||
inf_prelim["⚠️ inf.prelim<br/>Preliminary Objection<br/>concept-slug: preliminary-objection<br/>RoP.019.1"]
|
||||
inf_sod["📄 inf.sod<br/>Statement of Defence<br/>concept-slug: statement-of-defence<br/>RoP.023"]
|
||||
inf_ccr["⚖️ inf.ccr_counterclaim<br/>Counterclaim for Revocation<br/>concept-slug: counterclaim-for-revocation<br/>RoP.025 • is_bilateral"]
|
||||
inf_amend["📐 inf.app_to_amend<br/>Application to amend patent<br/>concept-slug: application-to-amend-patent<br/>RoP.030"]
|
||||
|
||||
inf_reply_no["📝 inf.reply<br/>Reply to Defence (no CCR)<br/>concept-slug: reply-to-defence<br/>RoP.029.b"]
|
||||
inf_reply_w["📝 inf.reply_with_ccr<br/>Defence-to-CCR + Reply<br/>concept-slug: reply-to-defence<br/>RoP.029.a"]
|
||||
|
||||
inf_def_amend["📝 inf.defence_to_amend<br/>Defence to App-to-amend<br/>concept-slug: defence-to-amend-patent<br/>RoP.032.1"]
|
||||
|
||||
inf_rejoin["📝 inf.rejoinder<br/>Rejoinder<br/>concept-slug: rejoinder-to-reply<br/>RoP.029.c|RoP.029.d"]
|
||||
|
||||
inf_interim["🧑⚖️ inf.interim<br/>Interim Conference<br/>kind: hearing • party: court"]
|
||||
inf_oral["⚖️ inf.oral<br/>Oral Hearing<br/>kind: hearing • party: court"]
|
||||
inf_decision["🏛️ inf.decision<br/>Decision on the merits<br/>concept-slug: decision-on-merits<br/>kind: decision • party: court"]
|
||||
inf_costs["💰 inf.cost_application<br/>Application for cost decision<br/>concept-slug: application-for-cost-decision<br/>RoP.151 • 1mo from decision"]
|
||||
|
||||
inf_soc -- "1mo" --> inf_prelim
|
||||
inf_soc -- "3mo (RoP.023)" --> inf_sod
|
||||
inf_soc -- "3mo (RoP.025)<br/>if_flags: with_ccr" -.-> inf_ccr
|
||||
inf_soc -- "3mo (RoP.030)<br/>if_flags: with_amend" -.-> inf_amend
|
||||
|
||||
inf_sod -- "2mo (RoP.029.b)<br/>unless_flags: with_ccr" --> inf_reply_no
|
||||
inf_ccr -- "2mo (RoP.029.a)" --> inf_reply_w
|
||||
|
||||
inf_amend -- "2mo (RoP.032.1)<br/>requires_event: inf.app_to_amend" -.-> inf_def_amend
|
||||
|
||||
inf_reply_no -- "1mo (RoP.029.c)<br/>unless_flags: with_ccr" --> inf_rejoin
|
||||
inf_reply_w -- "1mo (RoP.029.d)<br/>if_flags: with_ccr" --> inf_rejoin
|
||||
|
||||
inf_rejoin -.-> inf_interim
|
||||
inf_interim --> inf_oral
|
||||
inf_oral --> inf_decision
|
||||
inf_decision -- "1mo (RoP.151)" --> inf_costs
|
||||
|
||||
classDef root fill:#c6f41c,stroke:#000,stroke-width:2px,color:#000
|
||||
```
|
||||
|
||||
**Anatomy of the multi-parent into `inf.rejoinder`:**
|
||||
|
||||
```sql
|
||||
-- Edge from no-CCR Reply → Rejoinder (1 month, RoP.029.c)
|
||||
INSERT INTO paliad.proceeding_event_edges
|
||||
(proceeding_def_id, from_event_id, to_event_id,
|
||||
duration_value, duration_unit, rule_code, legal_source,
|
||||
unless_flags)
|
||||
VALUES
|
||||
(:upc_inf, :inf_reply_no, :inf_rejoin,
|
||||
1, 'months', 'RoP.029.c', 'UPC.RoP.029.c',
|
||||
ARRAY['with_ccr']);
|
||||
|
||||
-- Edge from CCR-track Reply → Rejoinder (1 month, RoP.029.d)
|
||||
INSERT INTO paliad.proceeding_event_edges
|
||||
(proceeding_def_id, from_event_id, to_event_id,
|
||||
duration_value, duration_unit, rule_code, legal_source,
|
||||
if_flags)
|
||||
VALUES
|
||||
(:upc_inf, :inf_reply_w, :inf_rejoin,
|
||||
1, 'months', 'RoP.029.d', 'UPC.RoP.029.d',
|
||||
ARRAY['with_ccr']);
|
||||
```
|
||||
|
||||
The current encoding (one rule with `condition_flag=['with_ccr']` swapping `alt_duration_value=2`) is rewritten as two structurally-clean sibling edges. The calculator's logic simplifies: pick the edge whose `if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`. No special-cased `alt_*` swap path.
|
||||
|
||||
### 3.9 Five more proceedings spec'd at the DAG-shape level
|
||||
|
||||
For each, the **node count** is shown along with the **distinguishing edge feature** that the new model handles cleanly. Full graphs are out of scope for the design doc — the coder shift will port migrations 008/009/012/041–046 row-by-row.
|
||||
|
||||
| Proceeding | Court system | Nodes | Distinguishing edge feature |
|
||||
|---|---|---:|---|
|
||||
| **UPC infringement action** (UPC_INF, §3.8) | UPC-LD / UPC-CD | ~15 | Multi-parent into `inf.rejoinder`; `if_flags`/`unless_flags` carve the with-CCR / no-CCR tracks; `requires_event_id` gates `inf.defence_to_amend` on actual filing of `inf.app_to_amend`. |
|
||||
| **UPC standalone revocation** (UPC_REV) | UPC-CD | ~15 | TWO independent flags (`with_amend`, `with_cci`) gate the App-to-amend cycle and the Counterclaim-for-Infringement sub-track respectively. Each flag ⇒ ~4 sibling edges activate. Today this is encoded as 8 rules each tagged with one or both flags; tomorrow as edges into a clearly-labelled second-track sub-graph. |
|
||||
| **EPO opposition** (EPO_OPP) | EPA | ~8 | Root edge from the "Decision to grant EP" external trigger anchors `epo_opp.notice` (9-month opposition period, Art.99 EPC). Subsequent edges (R.79, R.116) are unconditional. Rule data flat — no flag conditions. |
|
||||
| **DE LG patent action** (DE_INF_LG) | DE-LG | ~9 | Root edge anchors on `klage.einreichung`. The two-step `Verteidigungsanzeige` (§276.1, 2 weeks) followed by `Klageerwiderung` (§276.1.S2, court-set, ≥2 weeks) is two sequential edges, no flag. The **§ 276 deadline regime** maps cleanly to `requires_event_id` if a future feature wants to gate Klageerwiderung on whether Verteidigungsanzeige was timely filed. |
|
||||
| **DE LG → OLG appeal** (DE_INF_OLG) | DE-OLG | ~7 | Synthetic root node `olg.zustellung_urteil` (party='both', is_root=true) anchors on the LG decision date — bridging the cross-proceeding decision-to-appeal link as a project-tree spawn (§3.6). Berufung 1mo (§517 ZPO), Berufungsbegründung 2mo from filing-of-Berufung (§520.2) — multi-parent edge candidate if the user's date overrides. |
|
||||
| **DPMA → BPatG Beschwerde** (DPMA_BPATG_BESCHWERDE) | DE-BPatG | ~5 | Two sibling edges from `dpma.beschluss` to `bpatg.beschwerde`: 1mo standard (§73 PatG), 2mo if `if_flags=['ausland']` (foreign-resident extension). The flag-conditioned variant is 100% naturally an edge condition, no `alt_*` plumbing needed. |
|
||||
| **EPA Beschwerde (Boards of Appeal)** (EPO_APP) | EPA | ~6 | Root node `epo.entsch` anchors a 2-month notice + 4-month grounds chain (Art.108 EPC). The R.106 RPBA Petition for Review fires as a sibling edge with `if_flags=['fundamental_defect']` — clean. |
|
||||
|
||||
The edge model collapses all the today's flag/swap encodings into "edges with predicates," which is genuinely simpler to reason about and AI-friendly (each edge is a self-contained legal fact: from-X-to-Y-in-D-units-iff-conditions).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration path
|
||||
|
||||
### 4.1 Strategy: additive build → cutover per surface, one boot
|
||||
|
||||
**NOT** a graph-on-top. The Q3+Q5 locks (separate edges table, drop concept table) are structural — keeping `deadline_rules` AND `proceeding_event_types` AND `proceeding_event_edges` AND `deadline_concepts` simultaneously is the worst of both worlds (more layers, no clarity). The migration is genuinely additive build → cutover.
|
||||
|
||||
**NOT** a destructive cutover in one big migration. The 26 production deadlines, the running Fristenrechner, the deadline-search mat-view, and the currently-shipping t-paliad-138 approval flow are all live. We need every one of them to work mid-migration.
|
||||
|
||||
**The right shape:** four migrations, four boots, one feature cutover per boot. The prior table stays till the end, then drops.
|
||||
|
||||
### 4.2 Phase M1 — additive build (one boot, zero behaviour change)
|
||||
|
||||
Single migration. Creates new tables, populates from old, leaves old in place. Fristenrechner + deadline-search keep using the old tables; `paliad.deadlines` keeps `rule_id` pointing to `deadline_rules`. Day-1 deploy = no user-visible change.
|
||||
|
||||
```
|
||||
1. CREATE paliad.court_types + seed 11 rows + FK from paliad.courts.court_type.
|
||||
2. CREATE paliad.proceeding_definitions; backfill from paliad.proceeding_types
|
||||
(rows that survive — drop the obsolete legacy 6 INF/REV/CCR/APM/APP/AMD,
|
||||
keep only the 16 active fristenrechner sets + ZPO_CIVIL).
|
||||
3. CREATE paliad.proceeding_event_types; backfill from deadline_rules
|
||||
(one row per surviving rule), with concept_slug + concept_de + concept_en
|
||||
+ aliases denormalised from deadline_concepts via the concept_id FK.
|
||||
4. CREATE paliad.proceeding_event_edges; backfill:
|
||||
- parent_id ⇒ from_event_id (or NULL when parent_id IS NULL).
|
||||
- condition_flag ⇒ if_flags ([] when NULL).
|
||||
- condition_rule_id ⇒ requires_event_id (the 2 legacy rules).
|
||||
- alt_duration_value/_unit/_rule_code present:
|
||||
emit a SIBLING edge (the alt path) instead of an alt_* column on
|
||||
the same edge. The 4 rules with alt_* split into 8 rows.
|
||||
- is_spawn=true rules ⇒ DO NOT migrate the cross-proceeding parent_id;
|
||||
leave as orphaned root edges in the destination proceeding_def
|
||||
(these are the §3.6 retirement candidates; flag them for the
|
||||
project-tree-spawn UX in Phase M3).
|
||||
5. ALTER paliad.projects ADD proceeding_def_id, court_id,
|
||||
proceeding_trigger_date, proceeding_status. Backfill via the existing
|
||||
proceeding_type_id integer + courts string-match heuristic (§3.5).
|
||||
6. KEEP everything else: deadline_rules, deadline_concepts, trigger_events,
|
||||
event_deadlines, event_categories — all stay, all readable.
|
||||
```
|
||||
|
||||
**Test gate:** server boots, `/tools/fristenrechner` works (still on old tables), `/deadlines/new` works, `/api/projects/{id}` carries the new project columns (NULL on legacy rows is OK), no user-visible change. Run smoke 6/6 (per t-paliad-088 pattern, see memory `35a08abd`).
|
||||
|
||||
### 4.3 Phase M2 — calculator cutover (one boot, behaviour swap)
|
||||
|
||||
Switch `internal/services/fristenrechner.go` from `deadline_rules` to `proceeding_event_types` + `proceeding_event_edges`. The walk algorithm changes:
|
||||
|
||||
| Today | Tomorrow |
|
||||
|---|---|
|
||||
| Walk parent_id chain from a root rule, anchor on triggerDate at root, descend, apply condition_flag gates and alt_* swaps. | BFS from root edge (from_event_id IS NULL) of the proceeding, anchor on triggerDate, for each node enumerate inbound edges, filter by predicates (`if_flags ⊆ flags AND unless_flags ∩ flags = ∅ AND (requires_event_id IS NULL OR requires_event_id ∈ recorded_events)`), pick the edge that fires (LATEST candidate when multiple), compute due_date, recurse. |
|
||||
| `isCourtDeterminedRule(r)` discriminator. | Same predicate, lifted to the node (`kind IN ('hearing','decision','order') OR party='court'`). |
|
||||
| Composite max/min via `event_deadline.combine_op`. | Same column on the edge. |
|
||||
| `anchor_alt='priority_date'` on EP_GRANT publish. | Folded into a per-proceeding-def "anchor_options" enum — Phase M3 problem, NOT M2. EP_GRANT publish stays specially-handled in Go for one boot. |
|
||||
|
||||
**Switch the trigger calculator** (`event_deadline_service.go`) at the same time. The `trigger_events` (110) + `event_deadlines` (77) data folds into the new shape:
|
||||
- Each `trigger_event` becomes a node (concept_slug from the existing slug column).
|
||||
- Each `event_deadline` becomes a node + an edge from the trigger node to it.
|
||||
- `event_deadline_rule_codes` (72 RoP citations, multiple per deadline) — the new shape only carries ONE `rule_code` per edge. Per row, pick `sort_order=0` as the canonical citation; remaining 0-2 codes per edge become a separate `paliad.proceeding_event_edge_alt_codes` (loose-linkage table) — out of scope for this design but flagged.
|
||||
|
||||
**Search service** (`internal/services/deadline_search_service.go`): rebuild `paliad.deadline_search` mat-view to read from the new tables. The kind discriminator (`'rule'`|`'trigger'`) collapses — every row is a `(node, edge_in)` pair now. UI ranks unchanged.
|
||||
|
||||
**Test gate:** Full Playwright smoke walk through the 16 modern proceedings + the trigger-search Pathway-B flow + the with-CCR flag toggle. Recompute spot-check vs t-paliad-086/111 golden results (Klageerwiderung 2026-04-30 → 2026-08-31, etc). If a Frist drifts more than ±1 day across the migration boundary, BLOCK.
|
||||
|
||||
### 4.4 Phase M3 — instance-side cutover (one boot)
|
||||
|
||||
`paliad.deadlines.rule_id` re-points: today it FKs `deadline_rules.id`; tomorrow it should FK to a tuple (event_type_id, edge_id) — but we can't easily express a 2-column FK. Two options:
|
||||
|
||||
- **Option A** (chosen): `deadlines.rule_id` retired entirely. The legal citation already lives in `deadlines.rule_code text` (per t-paliad-111). The structural pointer becomes `deadlines.event_type_id uuid REFERENCES proceeding_event_types(id)` — node-level, since the edge is an implementation detail. The set of edges that *led* to this Frist is recoverable on read by walking edges-into-this-event-type-of-the-proceeding-instance.
|
||||
- **Option B** (rejected): Keep both rule_id (NULL during transition) AND event_type_id. Adds a deprecation column for unclear value. Skip.
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN event_type_id uuid REFERENCES paliad.proceeding_event_types(id);
|
||||
|
||||
UPDATE paliad.deadlines d
|
||||
SET event_type_id = pet.id
|
||||
FROM paliad.proceeding_event_types pet
|
||||
WHERE pet.code IN (SELECT dr.code FROM paliad.deadline_rules dr WHERE dr.id = d.rule_id)
|
||||
AND pet.proceeding_def_id = (
|
||||
SELECT pd.id FROM paliad.proceeding_definitions pd
|
||||
JOIN paliad.proceeding_types pt ON pt.code = pd.code
|
||||
WHERE pt.id = (SELECT dr.proceeding_type_id FROM paliad.deadline_rules dr
|
||||
WHERE dr.id = d.rule_id)
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.deadlines DROP COLUMN rule_id;
|
||||
-- Keep deadlines.rule_code text — it's user-visible and stable.
|
||||
```
|
||||
|
||||
The 26 production deadlines need spot-check; a stale `rule_code` value (e.g. 'RoP.023') survives untouched, and the new `event_type_id` re-anchors the structural reference.
|
||||
|
||||
**Project-tree spawn UX** (deferred from §3.6's cross-proceeding-edge retirement): the old `is_spawn`-flagged rules in INF/REV/CCR (e.g. `inf.appeal`) had a one-click "create the next proceeding" affordance via the appeal Frist's spawning. Replace with: at `inf.decision` event-type detail page, show "Spawn Berufung sub-project" button → creates a new `verfahren` project under the same parent with `proceeding_def_id=DE_INF_OLG` and `proceeding_trigger_date` defaulting to the decision date. The graph stays clean; the spawn happens at the project tree, with one explicit click.
|
||||
|
||||
### 4.5 Phase M4 — drop legacy (one boot, no behaviour change)
|
||||
|
||||
```sql
|
||||
DROP MATERIALIZED VIEW paliad.deadline_search; -- recreated in M2 against new tables
|
||||
DROP TABLE paliad.event_deadline_rule_codes;
|
||||
DROP TABLE paliad.event_deadlines;
|
||||
DROP TABLE paliad.trigger_events;
|
||||
DROP TABLE paliad.deadline_rules; -- 172 rows gone
|
||||
DROP TABLE paliad.deadline_concepts; -- 57 rows gone
|
||||
DROP TABLE paliad.proceeding_types; -- 26 rows gone
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN proceeding_type_id;
|
||||
ALTER TABLE paliad.projects DROP COLUMN court; -- free-text version
|
||||
-- Keep projects.country (used by holiday lookup as a fallback).
|
||||
```
|
||||
|
||||
After Phase M4 the schema is the locked target. Total elapsed: 4 migrations, 4 boots. Each boot is reversible up to the M4 drop (which IS destructive).
|
||||
|
||||
### 4.6 What about feynman's in-flight branch?
|
||||
|
||||
feynman is currently writing migrations on `mai/feynman/fristenrechner` for t-paliad-157. This consultant analysis is upstream of his implementation and **does NOT** change feynman's brief — he ships what's specified there. After feynman lands, this design's M1 migration starts on top of his work; the proceeding_event_types backfill SELECTs from whatever shape `deadline_rules` is in at that point. No coordination required beyond "M1 picks up from feynman's HEAD."
|
||||
|
||||
Branch hygiene: nothing committed in `mai/einstein/consultant-deadline-data` touches code. Only `docs/design-deadline-data-model-2026-05-08.md` (this file). Merge to main at any time without conflict potential against feynman's branch.
|
||||
|
||||
---
|
||||
|
||||
## 5. AI-friendliness layer
|
||||
|
||||
### 5.1 What's load-bearing for the AI vs decoration
|
||||
|
||||
**Load-bearing:**
|
||||
- `paliad.proceeding_event_types.concept_slug` (e.g. `'statement-of-defence'`) — the LLM's cross-proceeding identity. *"What's the equivalent of a Klageerwiderung in EPO opposition?"* → search proceedings for nodes with `concept_slug='statement-of-defence'` or matching aliases.
|
||||
- `paliad.proceeding_event_types.aliases text[]` — the search vocabulary. Lifts directly from old `deadline_concepts.aliases`. Must remain curated; no user-edit in v1.
|
||||
- `paliad.proceeding_event_types.name_de` + `name_en` — primary surface labels.
|
||||
- `paliad.proceeding_event_edges.rule_code` + `legal_source` — citation grounding.
|
||||
- `paliad.proceeding_event_edges.if_flags` / `unless_flags` / `requires_event_id` — the AI can reason about "this edge fires only if user has flagged with_ccr" without needing to evaluate a JSON expression.
|
||||
|
||||
**Decoration (riding on top):**
|
||||
- `paliad.event_categories` (103 nodes) — the navigation tree. The LLM doesn't need this for legal reasoning; it's a UX scaffold for users who don't know the legal vocabulary. Stays intact, re-FK'd to concept_slug.
|
||||
- `paliad.event_types` (45 rows) — the user-facing instance-side classifier. Operationally useful (filter /deadlines by Type) but not load-bearing for the rule library. Stays unchanged.
|
||||
|
||||
### 5.2 The AI prompt lifecycle
|
||||
|
||||
- **Search:** "what is Klageerwiderung in UPC?" → trigram match on name_de + aliases column → returns `proceeding_event_types` rows where slug='statement-of-defence'. Result card lists: court_type pills (UPC-LD, UPC-CD, DE-LG, EPA), per-context durations + rule_codes via the inbound edges.
|
||||
- **Calculate:** user picks UPC-LD München LD + filing date + flags=['with_ccr']. Service walks the proceeding's edges, filters by predicates, returns a date timeline. AI doesn't need to be in this loop; it's deterministic graph walking.
|
||||
- **Reason:** Paliadin (the LLM-backed assistant) gets fed `proceeding_event_types` + `proceeding_event_edges` for the active proceeding when asked "explain my deadlines" — one self-contained subgraph, ~15-20 nodes, ~20-30 edges per proceeding. Fits comfortably in context.
|
||||
- **Classify:** when the user types "we got hit with a Hinweisbeschluss yesterday" — Paliadin matches against `aliases + name_de` of `proceeding_event_types`, returns the matched concept_slug (`hinweisbeschluss-stellungnahme`), and uses the project's `proceeding_def_id` to find the right node in the right proceeding's graph.
|
||||
|
||||
### 5.3 Where the concept layer's death helps the AI
|
||||
|
||||
Today the LLM has to reason about `deadline_rules.concept_id → deadline_concepts.slug` AND `trigger_events.concept_id (text slug)` AND `event_categories → event_category_concepts.concept_id`. Three different shapes for one identity.
|
||||
|
||||
Tomorrow there's ONE `concept_slug text` column on the proceeding-event-type node and ONE FK in the navigation junction. Same string, same column name, two query paths. Strictly easier for the LLM (and for human contributors).
|
||||
|
||||
### 5.4 Where the concept layer's death costs the AI
|
||||
|
||||
The 57 `deadline_concepts` rows had richer metadata than what survives as columns on the node:
|
||||
- `aliases text[]` — survives.
|
||||
- `description text` — needs to merge into per-node `description text` (already exists, just needs population).
|
||||
- `category` (submission/decision/order/hearing) — survives (`kind` column on node).
|
||||
- `party` — survives (`party` column on node, dominant case).
|
||||
- `sort_order` — survives.
|
||||
|
||||
Net data loss: zero. Net query simplification: substantial.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tradeoffs
|
||||
|
||||
### 6.1 What the migration costs
|
||||
|
||||
- **Engineering effort.** ~2 weeks of coder time across the 4 phases. M1 is a long-evening migration. M2 is the heavy lift (calculator rewrites, ~800 lines in fristenrechner.go + ~300 lines in event_deadline_service.go). M3 is shorter but coordinates with t-paliad-138 approval flow + CalDAV sync (the rule_id drop touches every code path that currently joins to deadline_rules — `internal/services/deadline_service.go`, `internal/services/event_service.go`, `internal/services/agenda_service.go`, `internal/handlers/deadlines.go`, plus the frontend).
|
||||
- **Migration complexity.** 4 boots, each with a smoke gate. The M2 boot is the riskiest — calculator semantics are user-visible and date-precision-sensitive. Need a pre-cutover golden-set test (run BOTH calculators across the 16 active proceedings + 30+ trigger events for a representative trigger date, diff the outputs, fail if any non-trivial drift). t-paliad-086 PR-3 found a 60-day cap bug only because of the SoD-on-2026-04-30-lands-on-Saturday smoke; we'd need similar care here.
|
||||
- **Fristenrechner UX disruption.** None expected. The Pathway-B navigation, the Verfahrensablauf timeline view, the search bar — all read paths can be preserved exactly because the underlying data shape is the same legal facts in different storage. The only user-visible change is at the spawning moment (§4.4 Phase M3 project-tree spawn instead of in-line appeal Frist).
|
||||
- **Documentation churn.** docs/audit-fristenrechner-completeness-2026-04-30.md, docs/plans/unified-fristenrechner.md (cronus), docs/plans/unified-fristenrechner-v3.md (cronus) — all reference the old table names. These are historical (cronus retired from paliad per memory `cc28a8ad`) so they don't need active maintenance, but new contributors will read them and get confused. Add a header to each pointing to this design doc as the structural-truth update.
|
||||
|
||||
### 6.2 What the migration buys
|
||||
|
||||
- **One rule library, not two.** No more deadline_rules + trigger_events drift. No more "did t-paliad-086 fix this in both?" The federated mat-view goes away. Search, calc, and AI all read the same shape.
|
||||
- **Multi-parent edges become natural.** The CCR cross-flow that took t-paliad-131 Phase B1 a full PR + 7 new rules + condition_flag wiring becomes 7 sibling edges with disjoint `if_flags`. Same semantics, half the schema awareness needed.
|
||||
- **Court system axis is queryable.** `SELECT * FROM proceeding_event_edges e JOIN proceeding_event_types et ON et.id = e.to_event_id JOIN proceeding_definitions pd ON pd.id = et.proceeding_def_id WHERE pd.court_type='UPC-LD' AND e.if_flags @> ARRAY['with_ccr']` answers a real question that today requires walking three tables and string-matching.
|
||||
- **The graph fits in an LLM prompt.** ~15 nodes + 25 edges per proceeding, with concept tags + rule codes + party + condition flags inline. No federation, no slug-walking. Paliadin gets a tighter context.
|
||||
- **Conditions are typed, not stringly.** `if_flags text[]` + `unless_flags text[]` + `requires_event_id uuid` — the schema documents itself. Today's `condition_rule_id` + `condition_flag` mix is two pages of code-comment to explain.
|
||||
- **Court_type FK eliminates the holiday-lookup hardcoding.** holidays.go's per-court mapping becomes a JOIN: `courts.country` + `court_types.regime` directly produce the holiday set.
|
||||
- **Extensibility for future condition kinds without further migrations.** New typed columns can be added incrementally (e.g. `min_business_days int` for "Notfrist-only" rules); the JSON-DSL alternative would have meant version-bumping the expression evaluator each time.
|
||||
|
||||
### 6.3 Genuine cost: the column-based condition model breaks down at OR-of-3
|
||||
|
||||
Per Q4 the cost flagged on the option preview: a flag combination like "fires if (with_ccr AND with_amend) OR (without_ccr AND with_cci)" needs TWO sibling edges (one per branch). For OR-of-3-or-more disjoint branches the table has N edges fanning into the same target. This is OK at today's scale (the most complex rule has 2 flags) but if procedural complexity escalates we'd want to revisit. The natural escape valve is to add a JSON `condition` column **alongside** the typed columns later, evaluated only when present — but that's a future decision, not today's.
|
||||
|
||||
### 6.4 What we deliberately don't solve
|
||||
|
||||
- **Cross-proceeding edges** (G7 plus old `is_spawn`). Modelled as project-tree spawns instead. Defer until users complain. (A `proceeding_event_edges.cross_to_proceeding_def_id uuid` column would re-open the model, but it muddies the closed-graph invariant. Skip.)
|
||||
- **Track-switching at proceeding level** (G7 ascended). "If with_ccr, the WHOLE proceeding follows alternate sub-graph rooted at node X." Not modelled — instead, every edge in the alternate sub-graph carries `if_flags=['with_ccr']`. Verbose but explicit. If the verbosity becomes painful (more flag-conditional sub-graphs in DE_INF_BGH cross-appeals?) revisit with a `paliad.proceeding_tracks` overlay table that groups edges into named tracks.
|
||||
- **First-class `paliad.proceedings` instance row.** Per Q2 lock — project IS the instance. If a future feature needs richer instance state (current-stage event_type_id, paused_at, last_advanced_event), columns extend `paliad.projects` directly. If that bloats the projects table beyond comfort, a separate `paliad.project_proceeding_state` 1:1 side-table is the right surgery — but not today.
|
||||
- **Schema RLS on the rule library.** Today `paliad.deadline_rules` is reference data, readable to any authenticated user, writable only via migrations. The new tables inherit that posture (no RLS, service-role-only writes). If a future world has firm-private overrides (HLC's house policy on a Frist), revisit.
|
||||
- **Generic event-types beyond procedural** (contract renewal, IP renewal). These live in `paliad.event_types` (the instance-side classifier). They will not become `proceeding_event_types` rows because they don't belong to a proceeding-DAG. Two layers, two purposes — explicitly OK.
|
||||
|
||||
### 6.5 What if m wanted to go bigger — what's the ceiling
|
||||
|
||||
The locked design is *appropriately ambitious* — addresses every gap in §2 except G7 (track-switching, deferred per §6.4). A more-ambitious target shape would:
|
||||
|
||||
- Make instance state first-class (`paliad.proceedings` table, real timeline log). **Skipped per Q2.**
|
||||
- Make conditions a typed expression DSL. **Skipped per Q4.**
|
||||
- Allow proceeding inheritance / template specialisation (e.g. UPC_INF_with_pi extends UPC_INF, adds 4 nodes). **Not asked for.**
|
||||
- Allow cross-court-system cascades (a UPC LD decision triggers the CoA appeal). **Skipped per §3.6.**
|
||||
|
||||
Each of those would be a follow-up design with its own dogma session. None blocks shipping the current design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open follow-ups for the coder shift
|
||||
|
||||
When m greenlights this design and a coder picks up implementation, surface these explicitly so they don't slip:
|
||||
|
||||
1. **Concept slug curation.** The 57 → ~57 mapping is mostly mechanical. ~5 cases need legal eyes: cross-cutting concepts (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) where the slug exists but doesn't yet sit on a proceeding-specific node. Resolution: emit a new `proceeding_event_types` row in EACH proceeding where the cross-cutting Frist applies, all sharing the same `concept_slug`. Multiplies the row count by ~10 per cross-cutter, fine.
|
||||
2. **Legacy proceeding_types pruning.** The 6 unused legacy codes (`INF`,`REV`,`CCR`,`APM`,`APP`,`AMD`,`ZPO_CIVIL`) and their 36+4=40 dead rules should NOT migrate to `proceeding_definitions`. Confirm with m before dropping (they may have been kept "in case"). If yes-drop: M1 SELECT only the active 16 + ZPO_CIVIL (if still desired).
|
||||
3. **Frontend impact assessment.** Pathway-B decision-tree (t-paliad-133 Phase D-1, in production) reads from `event_categories` + `event_category_concepts` joined to `deadline_concepts`. The junction's concept-side rewires from FK to text. Frontend code that fetches concept_slug stays — backend just speaks the same column under a new FK target.
|
||||
4. **Approval flow integration.** t-paliad-138 dual-control approvals (migration 054) wraps deadline mutations with `approval_requests`. The new `deadlines.event_type_id` column needs to flow through `payload jsonb` correctly; today the approval pre-image captures `rule_id`. M3 swap touches approval_service.go + ApprovalRequest payload schema.
|
||||
5. **CalDAV round-trip.** `paliad.deadlines.caldav_uid` + `caldav_etag` survives. The CalDAV title rendering uses `rule_code` (already free-text) — no behavioural change.
|
||||
6. **Holiday-lookup simplification.** `internal/services/holidays.go` today carries a hardcoded map "courts → applicable holiday sets." After M1 (with `paliad.courts.court_type` FK'd) this becomes a JOIN. Refactor as part of M2 or as a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommendation summary
|
||||
|
||||
**Ship the design.** It addresses every structural gap m's framing exposed (G1–G6, deferring G7 explicitly), it lands on locked decisions throughout (Q1–Q5 verbatim from the AskUserQuestion pass), and it costs ~2 weeks of focused coder time across 4 boots with smoke-gates between.
|
||||
|
||||
**Sequence:** wait for feynman's `mai/feynman/fristenrechner` to land (parallel work, doesn't block this design but does affect the M1 backfill source). Then route Phase M1 to a coder fluent in pgvector + ltree contexts (noether or fritz; cronus excluded per memory `cc28a8ad`). Phase M2 needs Fristenrechner-deep context — same picker. Phase M3 + M4 mechanical, any coder.
|
||||
|
||||
**Recommend:** open one Gitea tracking issue for each phase under m/paliad, link to this design doc by anchor (`#42-phase-m1-additive-build`), set them as a 4-step task chain. Mark M4 as gated on M2 + M3 living in production for ≥1 week without rollback.
|
||||
|
||||
The right outcome of this design isn't a one-shot 6-week refactor. It's four 3-day-class migrations stretched over 2–3 weeks, each individually shippable, each individually reversible until the M4 drop. That's how the existing paliad rule-library got built (migrations 003 → 062, ~6 month accretion); that's how it should be reshaped.
|
||||
|
||||
---
|
||||
|
||||
*End of design doc. ~600 lines target — landing at ~750 with code blocks. NO migration files, NO code edits in this branch — only this design doc per the consultant-mode hard rule.*
|
||||
943
docs/design-paliadin-inline-2026-05-08.md
Normal file
943
docs/design-paliadin-inline-2026-05-08.md
Normal file
@@ -0,0 +1,943 @@
|
||||
# Inline Paliadin chat modal + agent-suggested-with-approval write path
|
||||
|
||||
**Inventor:** dirac · **Task:** t-paliad-161 · **Issue:** m/paliad#20
|
||||
**Date:** 2026-05-08 · **Branch:** `mai/dirac/inventor-inline-paliadin`
|
||||
**Status:** READY FOR REVIEW — awaiting m's go/no-go before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0 · TL;DR
|
||||
|
||||
Two intertwined upgrades, scoped together because the chat surface is where
|
||||
the write path is triggered and the write path is what makes the chat
|
||||
non-trivial:
|
||||
|
||||
1. **Inline modal**: a slide-out chat widget reachable from every
|
||||
authenticated paliad page, replacing the standalone `/paliadin` route's
|
||||
primacy (the page survives as the dedicated full-screen surface). The
|
||||
widget is **context-aware** — it knows which route the user is on, the
|
||||
primary entity in view, and any selected text — and uses that to
|
||||
pre-populate page-specific starter prompts.
|
||||
|
||||
2. **Agent-suggested write path**: Paliadin gains a *suggestion verb* that
|
||||
drafts a deadline / appointment / note / project edit straight into the
|
||||
existing `pending_create` lifecycle from t-paliad-160. The user reviews
|
||||
via the same eye-pill 👀 surface (`/inbox`, list/agenda views) and
|
||||
approves or rejects. Approved-from-suggestion rows pick up a sparkle ✨
|
||||
provenance glyph that lives **next to** 👀, not in place of it.
|
||||
|
||||
**Hard call**: the inline modal should **keep the existing tmux-relay
|
||||
backend** for v1. Cutover to the Anthropic Messages API is a separate
|
||||
substantial piece of work (auth, prompt-caching, tool framework, budget
|
||||
management); coupling it to the inline-modal ship would extend the design
|
||||
window past where m needs the modal to land. The design *recommends* the
|
||||
API cutover as a prerequisite for opening Paliadin beyond owner-only — but
|
||||
the inline modal at owner-only scope works fine on the existing relay.
|
||||
|
||||
**Key locked positions** (all reversible by m before coder shift):
|
||||
|
||||
| # | Decision | Position |
|
||||
|---|---|---|
|
||||
| 1 | Modal trigger | Floating button bottom-right + `Cmd/Ctrl-K` shortcut |
|
||||
| 2 | Surface shape | Right slide-out drawer, 420px desktop, full-screen on mobile |
|
||||
| 3 | Visibility | Every authenticated page **except** `/paliadin`, `/login`, `/onboarding` |
|
||||
| 4 | Gate | Same `PaliadinOwnerEmail` gate as today (no scope expansion in this task) |
|
||||
| 5 | Backend transport | Tmux relay (existing). Anthropic-API cutover deferred. |
|
||||
| 6 | Multi-turn coherence | Tmux session reuse already handles it; no client-side history hydrate beyond what's there |
|
||||
| 7 | Context payload | `route_name` + `primary_entity_type` + `primary_entity_id` + `user_selection_text` (optional) + page metadata |
|
||||
| 8 | Starter-prompt library | Per-route `paliadinStarters` registry, ships with 8 routes + a generic fallback |
|
||||
| 9 | Agent-suggested attribution | New columns on `paliad.approval_requests` (`requester_kind`, `agent_turn_id`); **not** on entity rows |
|
||||
| 10 | Visual language | ✨ glyph alongside 👀 on pending rows; persistent ✨ on approved-from-agent rows in audit log |
|
||||
| 11 | Persona separation | Single Paliadin SKILL.md unchanged. No pre-design for split personas. |
|
||||
| 12 | Concurrency | One in-flight turn per user enforced server-side (existing `turnMu`); request-side cancel via context |
|
||||
|
||||
---
|
||||
|
||||
## 1 · Premises verified live
|
||||
|
||||
Read the live system before designing on top — every claim below was
|
||||
checked against the running paliad.de + DB on 2026-05-08, not against
|
||||
CLAUDE.md or memory.
|
||||
|
||||
- **paliad.de**: live; root 200, `/paliadin` 302 (login redirect for
|
||||
anon). Production runs `RemotePaliadinService` against mRiver (CLAUDE.md
|
||||
flags `tmux + claude` as missing in the Dokploy container — confirmed
|
||||
the prod path actually goes through `paliadin-shim` over SSH).
|
||||
- **Migration tracker**: `paliad.paliad_schema_migrations.version=69`. Next
|
||||
free migration is **070**.
|
||||
- **`paliad.approval_requests`** existing columns: `id, project_id,
|
||||
entity_type, entity_id, lifecycle_event, pre_image, payload,
|
||||
requested_by, requested_at, required_role, status, decided_by,
|
||||
decided_at, decision_kind, decision_note, created_at, updated_at`. **No
|
||||
`agent_*` columns yet** — migration 070 adds them.
|
||||
- **`paliad.paliadin_turns`**: already has a `page_origin TEXT` column
|
||||
populated from `req.PageOrigin` on every turn. Today the frontend only
|
||||
ever sets `window.location.pathname` on the standalone page; the inline
|
||||
widget will widen this from a single string into a structured payload.
|
||||
- **`paliad.deadlines` + `paliad.appointments`**: already carry
|
||||
`approval_status text NOT NULL DEFAULT 'approved'` + `pending_request_id
|
||||
uuid` from migration 054. The 👀 eye-pill renders on pending rows in
|
||||
`events.ts:521` and `agenda.ts:289` via `.approval-pill--icon`.
|
||||
- **Sidebar** (`frontend/src/components/Sidebar.tsx:123`): already has a
|
||||
`/paliadin` entry hidden by default, revealed by `client/sidebar.ts`
|
||||
after `/api/me` confirms the caller is the Paliadin owner. The same
|
||||
reveal hook drives the inline modal's visibility.
|
||||
- **`PaliadinOwnerEmail`** (`internal/services/paliadin.go:51`):
|
||||
`matthias.siebels@hoganlovells.com`. Hard-coded gate. **No scope
|
||||
expansion in this task.**
|
||||
- **youpc.org reference files** all readable at
|
||||
`/home/m/dev/web/youpc.org/`: `frontend/templates/ai/sidebar-widget.html`,
|
||||
`frontend/js/utils/ai-chat-client.js`, `frontend/js/components/ai/sidebar.js`,
|
||||
`youpc-go/internal/services/youpc_ai_relay.go`, `scripts/youpc-ai-shim`.
|
||||
Klaus's brief in #20 maps to these directly.
|
||||
|
||||
**One CLAUDE.md correction**: the project's `CLAUDE.md` currently calls
|
||||
`ANTHROPIC_API_KEY` "reserved-but-unused for the eventual production-v1
|
||||
Paliadin". That language stays correct — this design *recommends but does
|
||||
not commit* the API cutover. No CLAUDE.md edit in the implementation PR.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Why the inline modal matters
|
||||
|
||||
m's framing (#20 §1) is "Paliadin should be reachable from anywhere". The
|
||||
real differentiation argument is sharper: the *value of the assistant
|
||||
collapses to "open a chat tab" if you can't get to it without leaving the
|
||||
page you're already working on.* For a patent-practice tool, the most
|
||||
common questions are page-anchored:
|
||||
|
||||
- On `/projects/<id>` → "Was steht für diese Akte diese Woche an?"
|
||||
- On `/deadlines/<id>` → "Erkläre mir die Klageerwiderungsfrist nach UPC RoP 23.1."
|
||||
- On `/agenda` with selection → "Schreibe einen Nachtrag zu diesem
|
||||
Termin: …"
|
||||
|
||||
The standalone `/paliadin` page solves none of these because asking the
|
||||
question requires the user to (a) leave the page, (b) re-explain context
|
||||
the page already had, (c) navigate back. The inline modal solves (a) by
|
||||
construction; (b) is solved by the **context payload** (§4); (c) is moot.
|
||||
|
||||
The widget is therefore the **default surface** going forward; the
|
||||
`/paliadin` standalone page survives as the dedicated full-screen mode
|
||||
(useful for long sessions where the slide-out is too narrow). Both speak
|
||||
the same backend.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Modal — shape, trigger, injection
|
||||
|
||||
### 3.1 Visual shape (recommendation)
|
||||
|
||||
**Right-edge slide-out drawer** — same pattern as youpc.org's
|
||||
`ai-sidebar-widget.html` because it solves the right problems:
|
||||
|
||||
- Doesn't crowd the page content (drawer slides in *over* a translucent
|
||||
scrim, page underneath stays visible at ~70% opacity so the user can
|
||||
reference what they were looking at).
|
||||
- Mobile-responsive for free: at `<640px` the drawer goes full-screen and
|
||||
the floating button hides while open.
|
||||
- Doesn't fight with paliad's existing left sidebar (`Sidebar.tsx`) — the
|
||||
drawer claims the right edge, the sidebar keeps the left.
|
||||
|
||||
**Considered and rejected:**
|
||||
|
||||
- *Always-visible secondary sidebar* (left or right rail). Wastes ~280px
|
||||
of horizontal real-estate on every page; collides with the sidebar on
|
||||
mobile.
|
||||
- *Popover anchored to the floating button*. Too small for multi-turn
|
||||
conversations; mobile would need a separate full-screen mode anyway.
|
||||
- *Fullscreen takeover overlay*. Defeats the purpose — if it covers the
|
||||
page you can't reference what you were looking at.
|
||||
|
||||
### 3.2 Trigger
|
||||
|
||||
Two entry points:
|
||||
|
||||
1. **Floating action button** at bottom-right (`position: fixed; bottom:
|
||||
20px; right: 20px;`). Lime accent (`var(--color-accent)`), ✨ glyph.
|
||||
Same auth-reveal hook as the sidebar `/paliadin` link — `display:none`
|
||||
until `client/sidebar.ts` confirms `/api/me.email ===
|
||||
PaliadinOwnerEmail`.
|
||||
|
||||
2. **Keyboard shortcut**: `Cmd-K` (macOS) / `Ctrl-K` (other). Standard
|
||||
command-palette muscle memory. Doesn't collide with browser shortcuts.
|
||||
Paliad has no other Cmd-K binding today (verified via grep on
|
||||
`keydown` handlers).
|
||||
|
||||
The shortcut also dismisses the drawer when it's open. `Esc` dismisses
|
||||
unconditionally.
|
||||
|
||||
### 3.3 Drawer content
|
||||
|
||||
Layout (top to bottom):
|
||||
|
||||
```
|
||||
┌──────────────────────────────┬─┐
|
||||
│ ✨ Paliadin ↻ ↗ ✕│ │ Header: name, reset-session, open-fullscreen, close
|
||||
├──────────────────────────────┼─┤
|
||||
│ [Auf dieser Seite] │
|
||||
│ Akte: Acme v. Müller │ │ Context chip — collapsible, shows what Paliadin
|
||||
│ 19 Fristen · 4 Termine │ │ knows about the current page (read from payload)
|
||||
├──────────────────────────────┼─┤
|
||||
│ [empty-state starter prompts] │
|
||||
│ • "Was steht hier an?" │
|
||||
│ • "Erkläre die offene…" │
|
||||
│ • "Lege eine Frist an" │
|
||||
├──────────────────────────────┼─┤
|
||||
│ <messages> │ │ Scrollable, user-right / paliadin-left
|
||||
│ > User bubble │
|
||||
│ < Paliadin bubble + ✨ chip │ │ ✨ chip = "I drafted this — it's awaiting your approval"
|
||||
├──────────────────────────────┼─┤
|
||||
│ [textarea + send + abort] │
|
||||
└──────────────────────────────┴─┘
|
||||
```
|
||||
|
||||
The `↗` button is the escape hatch to the standalone `/paliadin` for
|
||||
users who want a full-screen session with full message history visible.
|
||||
|
||||
### 3.4 Injection mechanism
|
||||
|
||||
**One file edits the universe**: `frontend/src/components/PaliadinWidget.tsx`
|
||||
emits an inline `<div id="paliadin-widget" style="display:none">…</div>`
|
||||
that page-template files include alongside `<PWAHead />` and `<Sidebar />`.
|
||||
|
||||
The mechanical edit pass: every authenticated TSX page (~30 files) gets a
|
||||
`<PaliadinWidget />` near `</body>`. This mirrors the existing
|
||||
`<PWAHead />` mechanical pass from t-paliad-042 and is the cleanest way to
|
||||
guarantee the widget reaches every page without HTMX or runtime injection.
|
||||
|
||||
**Alternative considered**: server-side template fragment injected by Go's
|
||||
HTML response writer (cleaner: no per-page edit). Rejected because paliad
|
||||
uses bun-built static HTML files, not templated server responses — there's
|
||||
no place to inject server-side. The mechanical pass is fine; the
|
||||
boilerplate it adds is one component.
|
||||
|
||||
**Visibility predicate** (in `client/paliadin-widget.ts`):
|
||||
|
||||
- **Hide** on `/paliadin` (the standalone page IS Paliadin, the widget
|
||||
would be redundant).
|
||||
- **Hide** on `/login`, `/onboarding` (no auth context).
|
||||
- **Hide** until `/api/me` resolves to `email === PaliadinOwnerEmail`.
|
||||
Same fail-closed pattern as the sidebar link.
|
||||
- **Show** on every other authenticated page.
|
||||
|
||||
### 3.5 What about the BottomNav (mobile)?
|
||||
|
||||
`BottomNav.tsx` has 5 slots (Dashboard / Projects / Add / Agenda / Menu)
|
||||
— full. Adding a Paliadin slot would require evicting one. **Don't.**
|
||||
The floating button is fine on mobile (it sits in the bottom-right corner
|
||||
*above* the bottom nav, with `z-index` arbitration). At full-screen-drawer
|
||||
size on mobile, the floating button hides while the drawer is open.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Context payload — what flows from frontend to backend
|
||||
|
||||
### 4.1 Schema
|
||||
|
||||
The current `TurnRequest.PageOrigin` is a single string (the URL path).
|
||||
The inline modal needs more. Define a structured payload:
|
||||
|
||||
```ts
|
||||
interface PaliadinContext {
|
||||
// Stable route key — independent of URL params. e.g. "projects.detail"
|
||||
// not "/projects/61e3.../tab=team". The frontend computes this from
|
||||
// `window.location.pathname` via a route-table lookup.
|
||||
route_name: string;
|
||||
|
||||
// Path including query string (cosmetic; for audit + display only).
|
||||
page_origin: string;
|
||||
|
||||
// The "primary entity" of the current page, if any. Examples:
|
||||
// /projects/<id> → ("project", "<id>")
|
||||
// /deadlines/<id> → ("deadline", "<id>")
|
||||
// /appointments/<id> → ("appointment", "<id>")
|
||||
// /events?type=deadline → null
|
||||
// /tools/fristenrechner → null
|
||||
primary_entity_type?: "project" | "deadline" | "appointment";
|
||||
primary_entity_id?: string; // uuid
|
||||
|
||||
// User's text selection at the moment they opened the widget (or sent
|
||||
// the turn). Capped at 1000 chars. Empty string = no selection.
|
||||
// Source: window.getSelection().toString() at send-time.
|
||||
user_selection_text?: string;
|
||||
|
||||
// UI state hints. Optional, useful for the model to disambiguate:
|
||||
view_mode?: "list" | "cards" | "calendar" | "tree"; // /events, /projects
|
||||
filter_summary?: string; // e.g. "status=overdue, project=Acme"
|
||||
}
|
||||
```
|
||||
|
||||
**What each field enables:**
|
||||
|
||||
- `route_name`: maps cleanly to a starter-prompt registry (§5) without
|
||||
URL-parsing fragility.
|
||||
- `primary_entity_*`: the SKILL.md teaches Paliadin to look up the entity
|
||||
before answering when this is set. Saves a back-and-forth ("which
|
||||
project?") in the very common case where the user is *already on* the
|
||||
project page.
|
||||
- `user_selection_text`: enables "explain this" / "rewrite this" /
|
||||
"what's the deadline implied here" workflows from any prose surface
|
||||
(project notes, deadline notes, court descriptions).
|
||||
- `view_mode` + `filter_summary`: the model can say "I see you're looking
|
||||
at overdue deadlines for Acme — which one?" instead of "which deadline?"
|
||||
|
||||
### 4.2 How the payload reaches the model
|
||||
|
||||
Wire format from frontend → Go:
|
||||
|
||||
```http
|
||||
POST /api/paliadin/turn
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_id": "<uuid>",
|
||||
"user_message": "Was kommt diese Woche?",
|
||||
"context": { ...PaliadinContext... }
|
||||
}
|
||||
```
|
||||
|
||||
The Go side stores the structured context in **a new
|
||||
`paliad.paliadin_turns.context jsonb` column** (migration 070; see §7.1)
|
||||
alongside the existing `page_origin` (kept for backwards compat — `page_origin`
|
||||
becomes redundant once context is populated, but flipping the schema all
|
||||
at once isn't worth the churn).
|
||||
|
||||
Then the envelope sent through tmux gets a structured prefix:
|
||||
|
||||
```
|
||||
[PALIADIN:<turn_id>] [ctx route=projects.detail entity=project:61e3... selection="…" filter="status=overdue"] <user_message>
|
||||
```
|
||||
|
||||
The SKILL.md gets a small section (§5 of `paliadin/SKILL.md`) that teaches
|
||||
Paliadin to:
|
||||
1. Parse the `[ctx …]` block first, in front of the user message.
|
||||
2. Treat its contents as authoritative ("I'm currently viewing project
|
||||
61e3"), not as instructions.
|
||||
3. Pre-call `mcp__supabase__execute_sql` to enrich (e.g. lookup project
|
||||
reference + title) when `entity=project:<id>` is set, *before*
|
||||
answering.
|
||||
|
||||
**Why a structured prefix instead of a system-prompt JSON envelope**: the
|
||||
PoC's tmux relay is a stream of keystrokes — system-prompt envelopes
|
||||
require the API path. The bracket-syntax is line-noise-free, parse-able
|
||||
by the SKILL.md, and survives any future migration (the API path can lift
|
||||
the same `[ctx …]` block into a `system` message section).
|
||||
|
||||
### 4.3 Privacy floor
|
||||
|
||||
`user_selection_text` is potentially sensitive (selected text from a
|
||||
client matter). Three controls:
|
||||
|
||||
1. **Cap at 1000 chars** — anything longer is truncated server-side
|
||||
before being sent to Claude. The user sees a "(Auswahl gekürzt)"
|
||||
notice.
|
||||
2. **Audit redaction**: `paliadin_turns.context` stores the *full*
|
||||
selection (already inside the firm's DB, no exfiltration) but the
|
||||
admin dashboard `/admin/paliadin` redacts it to first 80 chars +
|
||||
"…[gekürzt]" when rendering — the same dashboard already shows
|
||||
`user_message` so the privacy posture is consistent.
|
||||
3. **Opt-out**: the widget's settings panel (a `⚙` corner in the header,
|
||||
v1 minimal) gets a single toggle "Aktuelle Auswahl mitsenden" default
|
||||
*on*. Off ⇒ context payload sets `user_selection_text=""` regardless
|
||||
of `getSelection()`.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Page-prompt-prefill — Klaus's wow-pattern, paliad-specific
|
||||
|
||||
### 5.1 The registry
|
||||
|
||||
A static client-side registry maps `route_name` → starter prompts. Lives
|
||||
in `frontend/src/client/paliadin-starters.ts`.
|
||||
|
||||
```ts
|
||||
type Starter = { label_de: string; label_en: string; prompt_de: string; prompt_en: string };
|
||||
|
||||
export const paliadinStarters: Record<string, Starter[]> = {
|
||||
"dashboard": [
|
||||
{ label_de: "Heute", label_en: "Today",
|
||||
prompt_de: "Was steht heute an?", prompt_en: "What's on my plate today?" },
|
||||
{ label_de: "Diese Woche", label_en: "This week",
|
||||
prompt_de: "Welche Fristen sind diese Woche?", prompt_en: "Which deadlines are this week?" },
|
||||
{ label_de: "Nächste Schritte", label_en: "Next steps",
|
||||
prompt_de: "Was sollte ich als nächstes erledigen?", prompt_en: "What should I tackle next?" },
|
||||
],
|
||||
"projects.detail": [
|
||||
{ label_de: "Status der Akte", label_en: "Project status",
|
||||
prompt_de: "Was ist der aktuelle Status dieser Akte?", prompt_en: "What's the status of this project?" },
|
||||
{ label_de: "Diese Woche", label_en: "This week",
|
||||
prompt_de: "Was steht für diese Akte diese Woche an?", prompt_en: "What's on for this project this week?" },
|
||||
{ label_de: "Frist anlegen", label_en: "Add a deadline",
|
||||
prompt_de: "Lege eine Frist für diese Akte an: ", prompt_en: "Add a deadline for this project: " },
|
||||
],
|
||||
"deadlines.detail": [
|
||||
{ label_de: "Erkläre die Frist", label_en: "Explain this deadline",
|
||||
prompt_de: "Erkläre mir die Frist auf dieser Seite.", prompt_en: "Explain this deadline." },
|
||||
{ label_de: "Rechtsgrundlage", label_en: "Legal basis",
|
||||
prompt_de: "Welche Norm ist hier einschlägig?", prompt_en: "What's the relevant rule?" },
|
||||
],
|
||||
"agenda": [ /* … */ ],
|
||||
"events": [ /* … */ ],
|
||||
"inbox": [ /* … */ ],
|
||||
"tools.fristenrechner": [ /* … */ ],
|
||||
"glossary": [ /* … */ ],
|
||||
// Generic fallback for unmapped routes.
|
||||
"_default": [
|
||||
{ label_de: "Was kann ich für dich tun?", label_en: "What can I help with?",
|
||||
prompt_de: "", prompt_en: "" },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The widget's empty state renders the matching starter list. Click → the
|
||||
prompt populates the textarea (or sends immediately if `prompt_de` is
|
||||
empty — letting the user type their own). Picking up "Lege eine Frist an: "
|
||||
seeds the input *partially* so the user finishes the sentence — a
|
||||
deliberate friction-reducer for the common "draft and approve" workflow.
|
||||
|
||||
### 5.2 Why per-route registry, not LLM-generated suggestions?
|
||||
|
||||
Considered: dynamically ask Paliadin to suggest 3 starters based on
|
||||
context. Rejected because:
|
||||
|
||||
1. **Latency**: every drawer-open would burn a full turn before the user
|
||||
even types. The PoC's tmux turn is ~2-5 seconds cold; that's an
|
||||
unusable empty state.
|
||||
2. **Determinism**: m's audience (PA team) needs predictable affordances.
|
||||
"What does this thing know how to do?" answered the same way each
|
||||
visit beats "what does this thing know how to do *today*?"
|
||||
3. **Translatability**: hand-crafted bilingual starters live next to the
|
||||
rest of the i18n. LLM-generated would be one language at a time.
|
||||
|
||||
The registry is small (~10 routes × 3 starters × 2 langs = ~60 strings)
|
||||
and lives next to `i18n.ts` patterns m's team already understands.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Backend transport — tmux relay vs Anthropic API
|
||||
|
||||
### 6.1 Recommendation: keep tmux relay for v1 of the inline modal
|
||||
|
||||
Two reasons:
|
||||
|
||||
1. **Scope discipline**: the inline modal's user-visible payoff is
|
||||
independent of which backend serves it. Cutover to the API is a 4-6
|
||||
commit piece of substantial work (auth headers, prompt-cache
|
||||
management, tool-definition framework, streaming format conversion,
|
||||
budget controls, audit reshape, plus the existing tmux path needs to
|
||||
remain as fallback during rollout). Bundling it with the inline modal
|
||||
doubles the design's blast radius for no inline-modal-side benefit.
|
||||
2. **Owner-only scope**: paliad's user base today is `PaliadinOwnerEmail =
|
||||
m`. One user. The tmux relay's serialised one-turn-at-a-time, ~2-5s
|
||||
cold start, ~1-3s warm response holds up fine for one user clicking
|
||||
through the day.
|
||||
|
||||
### 6.2 What the API cutover *would* fix (recommend as Phase 2)
|
||||
|
||||
When scope expands beyond owner-only — even just to "m + 2 PA colleagues
|
||||
for piloting" — the tmux relay starts to bend:
|
||||
|
||||
- **Concurrency**: serialised turn lock means PA-A waits while PA-B
|
||||
thinks. Per-user tmux sessions help but mRiver still has finite
|
||||
resources.
|
||||
- **Latency**: ~2s cold tmux start is ok for one user; bad for "I just
|
||||
opened the widget, ask a quick question, close" rhythm at scale.
|
||||
- **Cost vs subscription**: m's Claude Code subscription covers his
|
||||
personal turns. Multi-user would either need m's account to absorb the
|
||||
load (dubious) or the firm's enterprise key (the actual prod path).
|
||||
- **Streaming**: tmux streaming today is the youpc.org-style "tail the
|
||||
response file as it grows" stopgap. Real token streaming (TTFB <1s)
|
||||
needs the API.
|
||||
|
||||
The API cutover should therefore be **a prerequisite for opening Paliadin
|
||||
beyond owner-only**. The inline modal's design assumes API-cutover-ready
|
||||
boundaries (the relay interface in §6.4) so when m flips the switch, the
|
||||
inline-modal frontend doesn't change.
|
||||
|
||||
### 6.3 Why not cutover now anyway?
|
||||
|
||||
It's tempting because:
|
||||
|
||||
- The CLAUDE.md note about `ANTHROPIC_API_KEY` reserved-but-unused has
|
||||
been there since 2026-04-16 and would benefit from being un-deferred.
|
||||
- The inline modal is the natural moment to revisit infrastructure.
|
||||
- Klaus's youpc.org has built a relay-interface abstraction
|
||||
(`youpcAIRelay` interface in `youpc_ai_relay.go`) that paliad could
|
||||
borrow for the swap point.
|
||||
|
||||
**Counter-arguments that win:**
|
||||
|
||||
- Today's tmux relay shipped only 2-3 days ago (`paliadin_remote.go`
|
||||
reference t-paliad-151). It's not a legacy substrate to escape — it's
|
||||
fresh code that hasn't earned a rewrite yet.
|
||||
- The compliance question for the API path (HLC-key vs personal-key,
|
||||
audit retention requirements, prompt-logging policy) hasn't been
|
||||
resolved with HLC IT. m flagged this as the **biggest open question** in
|
||||
the t-paliad-146 design and it's still open.
|
||||
- Inline modal can ship entirely on the existing relay; if the API
|
||||
cutover comes later, the modal doesn't have to re-ship.
|
||||
|
||||
**Therefore**: design a small interface seam (§6.4) so v1 doesn't paint us
|
||||
into a tmux-only corner, but don't pay the cutover cost in this PR.
|
||||
|
||||
### 6.4 Relay-interface seam (small, optional, recommended)
|
||||
|
||||
Mirror youpc.org's pattern (`youpc_ai_relay.go`) but smaller — paliad
|
||||
has one role, no streaming variant yet:
|
||||
|
||||
```go
|
||||
// internal/services/paliadin_relay.go (new)
|
||||
type PaliadinRelay interface {
|
||||
RunTurn(ctx context.Context, session string, turnID uuid.UUID,
|
||||
envelope string) ([]byte, error)
|
||||
Reset(ctx context.Context, session string) error
|
||||
HealthGate(ctx context.Context, session string) error
|
||||
}
|
||||
```
|
||||
|
||||
`LocalPaliadinService` and `RemotePaliadinService` keep their current
|
||||
shapes; the audit-row writes (`paliadinDB`) stay shared. `RunTurn` becomes
|
||||
a thin wrapper that builds the envelope (with the new `[ctx …]` block from
|
||||
§4.2) and delegates to the relay. A future `httpAPIRelay` slots in beside
|
||||
the SSH one without touching the audit/turn-row code.
|
||||
|
||||
**Don't extract the interface unless the inline modal's PR organically
|
||||
needs it.** If the modal can ship without restructuring the existing
|
||||
relay, the abstraction-cost is negative.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Agent-suggested write path — schema + flow
|
||||
|
||||
### 7.1 Schema decision: extend `approval_requests`, not entity rows
|
||||
|
||||
The brief listed three candidate locations:
|
||||
|
||||
| Option | Where the marker lives | Verdict |
|
||||
|---|---|---|
|
||||
| A | `boolean agent_suggested` on `paliad.deadlines` / `paliad.appointments` | **Reject**: pollutes domain tables; survives past approval (the entity is no longer "agent-suggested" once it's been live for six months); doesn't carry which agent / which turn |
|
||||
| B | `text suggested_by_agent` on entity rows (multi-agent provenance) | Same problems as A; "agent name" never used because we have one agent |
|
||||
| C | New columns on `paliad.approval_requests` linking back to the suggesting turn | **Recommended** |
|
||||
|
||||
The `approval_request` row IS the audit-chain entry; the entity row is
|
||||
just current state. Provenance information belongs on the audit-chain row
|
||||
where it can persist forever without polluting the entity schema.
|
||||
|
||||
**Migration 070 (proposed):**
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.approval_requests
|
||||
-- 'user' = direct user create; 'agent' = drafted by Paliadin from a chat turn.
|
||||
ADD COLUMN requester_kind text NOT NULL DEFAULT 'user'
|
||||
CHECK (requester_kind IN ('user', 'agent')),
|
||||
-- When requester_kind='agent', the chat turn the suggestion came from.
|
||||
-- NULL otherwise. ON DELETE SET NULL — the audit record survives even
|
||||
-- if the turn row is purged (paliadin_turns has no retention policy
|
||||
-- today, but design for it).
|
||||
ADD COLUMN agent_turn_id uuid
|
||||
REFERENCES paliad.paliadin_turns(turn_id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT approval_requests_agent_xor
|
||||
CHECK (
|
||||
(requester_kind = 'agent' AND agent_turn_id IS NOT NULL)
|
||||
OR (requester_kind = 'user' AND agent_turn_id IS NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_requests_agent_turn_idx
|
||||
ON paliad.approval_requests (agent_turn_id)
|
||||
WHERE agent_turn_id IS NOT NULL;
|
||||
|
||||
-- paliadin_turns also gets the structured context column.
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
ADD COLUMN context jsonb;
|
||||
```
|
||||
|
||||
`requested_by` continues to be the user uuid — even for agent suggestions
|
||||
the user is the *initiator* (Paliadin acts on their behalf, never
|
||||
autonomously). `requester_kind` distinguishes "the user typed Speichern"
|
||||
from "the user typed `/lege eine Frist an: …` to Paliadin and Paliadin
|
||||
drafted it; the user has not yet approved".
|
||||
|
||||
### 7.2 The flow
|
||||
|
||||
1. **User asks Paliadin**: "Lege eine Frist für diese Akte an: 16.05.
|
||||
Klageerwiderung Acme".
|
||||
|
||||
2. **Paliadin's SKILL.md gets a new section**: "Agent-suggested writes"
|
||||
that teaches it to call a new MCP tool `paliad__suggest_deadline` (and
|
||||
siblings for appointment / project_note / project_attach). The tool's
|
||||
server-side handler:
|
||||
|
||||
- Validates the user has visibility on the project (existing
|
||||
`can_see_project`).
|
||||
- Calls `DeadlineService.Create` *with the new
|
||||
`IsAgentSuggestion=true` flag* and `agent_turn_id=<current turn>`.
|
||||
- Inside the create-tx, after the entity insert, the existing approval
|
||||
hookup runs: `ApprovalService.SubmitCreate(...)`. **Critical
|
||||
change**: when `IsAgentSuggestion` is set, the submit unconditionally
|
||||
creates an approval request *even if no policy applies* — the agent
|
||||
path is approval-gated by construction, not by partner-unit policy.
|
||||
|
||||
3. **Eye-pill 👀 + sparkle ✨** render on the resulting row in `/inbox`,
|
||||
`/deadlines`, `/agenda`. Click → standard approve/reject UI. Approve
|
||||
flips status to `approved`, sets `decision_kind='peer'` (or
|
||||
admin_override if global_admin), the entity becomes live.
|
||||
|
||||
4. **Audit chain on the project's Verlauf**:
|
||||
|
||||
- `deadline_approval_requested` event with
|
||||
`metadata.requester_kind='agent'` + `metadata.agent_turn_id=<uuid>`.
|
||||
Verlauf renderer picks this up and labels the event "Paliadin hat
|
||||
eine Frist vorgeschlagen ✨".
|
||||
- `deadline_approval_approved` with the user as `decided_by` + the
|
||||
existing `decision_kind` ladder. Verlauf renders "Anna hat
|
||||
Paliadin's Vorschlag genehmigt ✨".
|
||||
|
||||
### 7.3 Why agent-suggested unconditionally goes through approval
|
||||
|
||||
Two reasons:
|
||||
|
||||
1. **Trust gradient**: even if a partner has direct create authority on
|
||||
their own projects (no policy = no approval needed today), an agent
|
||||
suggesting on their behalf is qualitatively different. Visible review
|
||||
keeps the user in the loop.
|
||||
2. **Single audit shape**: today the partner-unit policy decides which
|
||||
creates need approval; bypassing that for agent suggestions creates a
|
||||
second code path. Forcing agent suggestions into the approval pipeline
|
||||
means there's exactly one "agent created an entity" audit shape (the
|
||||
approval_request row).
|
||||
|
||||
A user who finds the per-suggestion review tedious can request `/genehmige
|
||||
einfach alles was Paliadin vorschlägt` — but that's a Phase 2 setting
|
||||
("auto-approve agent suggestions on projects where I'm lead"), explicitly
|
||||
out-of-scope for v1 (and m says so in #20: "Multi-turn agent loops …
|
||||
Every creation gets the user's eye.").
|
||||
|
||||
### 7.4 What entities can Paliadin suggest in v1?
|
||||
|
||||
The brief mentions "deadlines, appointments, notes, project-tree edits".
|
||||
Recommend ordering by reversibility + audit complexity:
|
||||
|
||||
| Entity | v1? | Why |
|
||||
|---|---|---|
|
||||
| Deadline create | **Yes** | Highest-value (Klaus would rate this top), well-supported by existing `pending_create` lifecycle |
|
||||
| Appointment create | **Yes** | Same lifecycle substrate; symmetric tool |
|
||||
| Project note (`project_events.note`) | **Yes** | Read-only audit event, no approval gate today — but for agent-authored notes route through approval anyway (consistency) |
|
||||
| Project-tree edit (move, rename) | **No, defer** | Approval lifecycle for project moves doesn't exist; designing it is its own task. |
|
||||
| Deadline / appointment **edit** | **No, defer** | Edits today only need approval when date-fields change (t-paliad-138 §Q4). Agent edits would need their own design pass for "what changes does the user see in the diff?" |
|
||||
| Deadline **complete** | **No, defer** | Same reason — complete already has approval lifecycle, but the agent path is qualitatively different (a deadline being marked done is high-stakes; design it after a v1 lands and we see how often agent-creates need editing) |
|
||||
|
||||
**v1 = create only**. Edits/completes are a Phase 2 expansion.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Visual language — ✨ alongside 👀, not in place of
|
||||
|
||||
### 8.1 Design
|
||||
|
||||
`.approval-pill--agent` is a new modifier that sits **next to** the
|
||||
existing `.approval-pill--icon` (the 👀 glyph), not replacing it.
|
||||
|
||||
| Row state | Pill rendering |
|
||||
|---|---|
|
||||
| `approval_status='pending'` AND `requester_kind='user'` | 👀 |
|
||||
| `approval_status='pending'` AND `requester_kind='agent'` | 👀 ✨ |
|
||||
| `approval_status='approved'` AND `requester_kind='user'` | (no pill) |
|
||||
| `approval_status='approved'` AND `requester_kind='agent'` | ✨ (subtle, in the row's *secondary* badge slot — not a pill) |
|
||||
|
||||
The 👀 + ✨ pairing communicates: "this is awaiting approval *and* came
|
||||
from Paliadin". Hover (`title` attr) on ✨ reads:
|
||||
"Paliadin hat das vorgeschlagen — angeklickt klärt".
|
||||
|
||||
**Why both glyphs, not a fused single glyph?** The two questions ("is
|
||||
this awaiting approval?" / "did a human or Paliadin originate this?") are
|
||||
orthogonal — a future autopilot mode might let some agent suggestions
|
||||
auto-approve, in which case 👀 disappears but ✨ stays. Keeping them
|
||||
separate keeps the visual taxonomy decomposable.
|
||||
|
||||
### 8.2 Where ✨ renders
|
||||
|
||||
Three surfaces:
|
||||
|
||||
1. **Eye-pill row** (`/inbox`, `/deadlines`, `/agenda`, project detail,
|
||||
/events): 👀 ✨ side-by-side when applicable. Same `.approval-pill`
|
||||
shape, separate elements.
|
||||
2. **Audit log** (`/admin/audit-log` + project Verlauf): the row's
|
||||
"approved by" line gets a trailing ✨ when the underlying request had
|
||||
`requester_kind='agent'`. Reads "Anna ✨ Schmidt" → tooltip "Über
|
||||
Paliadin vorgeschlagen, von Anna genehmigt".
|
||||
3. **Approval request inbox card**: the requester's name in the inbox
|
||||
card gets a subtle "✨ Paliadin (für Anna)" badge instead of just
|
||||
"Anna" when `requester_kind='agent'`.
|
||||
|
||||
### 8.3 The "+p" annotation question
|
||||
|
||||
m's #20 said: "we say USER + p or with a star or something". The "+p"
|
||||
text annotation reads in audit logs but doesn't scan in a pill row (✨ is
|
||||
recognisable; "+p" is not without learning). **Recommend**: ✨ as the
|
||||
universal glyph. Reserve a textual fallback for compliance-export
|
||||
contexts where emojis don't render — there the audit string becomes
|
||||
"Anna [agent: Paliadin]" rather than "Anna ✨".
|
||||
|
||||
---
|
||||
|
||||
## 9 · Persona separation
|
||||
|
||||
m's brief asked whether to lean on klaus's "scope-bouncer in SKILL.md"
|
||||
pattern (Hugo refuses legal questions, points at Lexie; Lexie refuses
|
||||
"how do I subscribe?", points at Hugo) for paliad — i.e. pre-design
|
||||
multi-persona infrastructure.
|
||||
|
||||
**Recommendation: don't.** Paliad has one Paliadin (Patentpraxis assistant
|
||||
at HLC's Patent team). The youpc.org split exists because *youpc.org has
|
||||
fundamentally different audiences* — public visitors (Hugo handles "how
|
||||
does this site work?") and premium-beta lawyers (Lexie does case-law
|
||||
research). Their refusal scopes are different because their users are
|
||||
different.
|
||||
|
||||
Paliad's audience is one cohesive group: HLC PA team. They want one
|
||||
assistant that does "everything PA-relevant" — Aktenmanagement, Fristen,
|
||||
Begriffe, Gerichte, UPC-Recht. There's no audience pair that requires
|
||||
distinct refusal scopes.
|
||||
|
||||
**If Phase 2 wants to add a case-law research persona** (e.g. cross-link
|
||||
to youpc.org's Lexie) — *that's a separate skill alongside Paliadin*, not
|
||||
a persona-split inside Paliadin. The infrastructure for that already
|
||||
exists in Claude Code's skill router (multiple skills, each its own
|
||||
description/persona).
|
||||
|
||||
**No SKILL.md changes for persona separation in this design**. The skill
|
||||
gets §4.2's `[ctx …]` parser added, plus §7.2's `paliad__suggest_*` tool
|
||||
guidance, but the persona stays "der Paliad-Patentpraxis-Assistent".
|
||||
|
||||
---
|
||||
|
||||
## 10 · Phasing & implementation surface
|
||||
|
||||
### 10.1 Suggested phasing (single PR is feasible; split optional)
|
||||
|
||||
**Slice A — schema + relay seam** (~1 commit)
|
||||
- Migration 070: `approval_requests.requester_kind` +
|
||||
`agent_turn_id` + xor-check + index; `paliadin_turns.context jsonb`.
|
||||
- Optional `PaliadinRelay` interface extraction (skip if it makes the PR
|
||||
bigger without removing duplication).
|
||||
|
||||
**Slice B — context payload + SKILL.md update** (~1 commit)
|
||||
- Wire structured `PaliadinContext` from frontend → Go → tmux envelope.
|
||||
- SKILL.md `[ctx …]` parsing + behaviour.
|
||||
- `client/paliadin-context.ts` route-table + entity extraction (one file).
|
||||
- `/api/paliadin/turn` accepts the new body shape (backwards-compatible:
|
||||
old `page_origin` still honoured if `context` is absent).
|
||||
|
||||
**Slice C — inline widget** (~1 commit, biggest)
|
||||
- `frontend/src/components/PaliadinWidget.tsx`.
|
||||
- `client/paliadin-widget.ts` (drawer state, sending, history, hide-on-route).
|
||||
- `client/paliadin-starters.ts` registry (8 routes + default).
|
||||
- Mechanical pass: every authenticated TSX adds `<PaliadinWidget />`.
|
||||
- CSS: `.paliadin-widget`, `.paliadin-drawer`, `.paliadin-trigger`,
|
||||
`.paliadin-context-chip`, ~150 lines of `global.css`.
|
||||
- ~30 i18n keys.
|
||||
|
||||
**Slice D — agent-suggested write path** (~1 commit)
|
||||
- `paliad__suggest_deadline` + `paliad__suggest_appointment` MCP tools
|
||||
(or HTTP tool, depending on how the MCP scope already wires —
|
||||
`internal/handlers/paliadin_tools.go` if new file warranted).
|
||||
- `DeadlineService.Create` / `AppointmentService.Create` accept a
|
||||
`IsAgentSuggestion bool` + `AgentTurnID *uuid.UUID` plumbed into
|
||||
`ApprovalService.SubmitCreate` (which gets a sibling
|
||||
`SubmitAgentCreate` that always creates a request even without policy).
|
||||
- SKILL.md adds the §7.2 "Agent-suggested writes" instruction block.
|
||||
|
||||
**Slice E — visual language** (~1 commit)
|
||||
- `.approval-pill--agent` CSS.
|
||||
- `events.ts`, `agenda.ts`, `inbox.ts` render ✨ when
|
||||
`requester_kind='agent'`.
|
||||
- Audit-log + Verlauf renderer extends to surface ✨ on approved-from-agent
|
||||
events.
|
||||
- ~10 i18n keys for the badges + tooltips.
|
||||
|
||||
**Recommended PR shape**: single PR with five commits in this order. Slice
|
||||
A's migration is independent (can deploy without the rest); Slice D needs
|
||||
B + C; Slice E builds on D. If sliced into multiple PRs, A and B-C can
|
||||
ship independently of D-E (modal works as read-only chat without the
|
||||
write path; that's already an upgrade).
|
||||
|
||||
### 10.2 Files of note for the implementer
|
||||
|
||||
**New files:**
|
||||
- `internal/db/migrations/070_paliadin_inline.{up,down}.sql`
|
||||
- `internal/handlers/paliadin_tools.go` (suggest verbs)
|
||||
- `internal/services/paliadin_relay.go` (optional interface)
|
||||
- `frontend/src/components/PaliadinWidget.tsx`
|
||||
- `frontend/src/client/paliadin-widget.ts`
|
||||
- `frontend/src/client/paliadin-starters.ts`
|
||||
- `frontend/src/client/paliadin-context.ts`
|
||||
|
||||
**Edits:**
|
||||
- `internal/services/paliadin.go` (TurnRequest gains structured Context;
|
||||
insertTurnRow stores it)
|
||||
- `internal/services/approval_service.go` (SubmitCreate accepts
|
||||
agent-flag; SubmitAgentCreate variant)
|
||||
- `internal/services/deadline_service.go`,
|
||||
`internal/services/appointment_service.go` (Create accepts
|
||||
IsAgentSuggestion + AgentTurnID; threads to ApprovalService)
|
||||
- `internal/handlers/paliadin.go` (turnRequest body schema)
|
||||
- `frontend/src/client/events.ts`, `agenda.ts`, `inbox.ts` (✨ render)
|
||||
- `frontend/src/styles/global.css` (drawer + ✨ pill CSS)
|
||||
- `frontend/src/client/i18n.ts` (~40 new keys × 2 langs)
|
||||
- `frontend/src/components/Sidebar.tsx` — no edit (the existing sidebar
|
||||
link logic already gates on owner; no new entries)
|
||||
- ~30 page TSX files: mechanical `<PaliadinWidget />` add (~1 line each)
|
||||
- `~/.claude/skills/paliadin/SKILL.md` (via `scripts/install-paliadin-skill`):
|
||||
add §4.2 ctx-parser block + §7.2 suggest-tools block
|
||||
|
||||
**Total estimated surface**: comparable to t-paliad-146 (the original
|
||||
Paliadin design — ~3500-4500 LoC) plus the agent-suggest write path
|
||||
(~1000 LoC). Single PR is feasible if the implementer is pattern-fluent;
|
||||
split is fine.
|
||||
|
||||
---
|
||||
|
||||
## 11 · Open questions for m
|
||||
|
||||
These are the calls m has to make before any coder shift starts.
|
||||
|
||||
### Q1 — Scope gate: still owner-only?
|
||||
The inline modal's design assumes `PaliadinOwnerEmail` stays as the only
|
||||
gate (m only). When does scope expand?
|
||||
- (a) **Stays owner-only for v1** of inline modal — recommended; matches
|
||||
brief. ← **inventor's pick**
|
||||
- (b) Extend to a beta-features whitelist (firm-wide email domain + flag).
|
||||
- (c) Expand to all of `hoganlovells.com` immediately. Requires API
|
||||
cutover (Phase 2 prerequisite).
|
||||
|
||||
### Q2 — Backend: tmux relay or Anthropic API for the inline modal?
|
||||
- (a) **Keep tmux relay** for v1 — recommended; ships fastest. ← **inventor's pick**
|
||||
- (b) Cutover to Anthropic API now — slower ship; better long-term.
|
||||
- (c) Both: ship tmux v1, design the API path as a parallel deferred PR.
|
||||
|
||||
### Q3 — Agent-suggested entities in v1: where to draw the line?
|
||||
- (a) **Create-only**: deadline, appointment, note. Defer edits/completes/project-tree. ← **inventor's pick**
|
||||
- (b) Create + edit (deadline + appointment).
|
||||
- (c) Create + edit + complete + project-tree.
|
||||
|
||||
### Q4 — Visual language for agent provenance?
|
||||
- (a) **✨ glyph alongside 👀** — recommended; orthogonal to lifecycle. ← **inventor's pick**
|
||||
- (b) "+p" text annotation in audit lines only; no glyph in pills.
|
||||
- (c) Replace 👀 with ✨ for agent-pending rows (single glyph, more compact).
|
||||
|
||||
### Q5 — Selection text in context payload — default on or off?
|
||||
- (a) **Default on**, opt-out via widget settings — recommended. ← **inventor's pick**
|
||||
- (b) Default off, opt-in via widget settings.
|
||||
- (c) Always on, no toggle.
|
||||
|
||||
### Q6 — Widget visibility scope: everywhere except `/paliadin`, or finer?
|
||||
- (a) **Everywhere except `/paliadin`, `/login`, `/onboarding`** —
|
||||
recommended; lowest cognitive load. ← **inventor's pick**
|
||||
- (b) Only on data-bearing pages (dashboard, projects, deadlines, agenda,
|
||||
events, inbox); hide on tool pages (fristenrechner etc.).
|
||||
- (c) User-configurable per page.
|
||||
|
||||
### Q7 — Modal vs dialog: drawer + scrim, or non-modal floating panel?
|
||||
- (a) **Modal slide-out drawer with scrim** (focus-traps) — recommended. ← **inventor's pick**
|
||||
- (b) Non-modal floating panel (page stays interactive while widget is open).
|
||||
|
||||
### Q8 — Keyboard shortcut for opening: Cmd-K?
|
||||
- (a) **Cmd-K / Ctrl-K** — recommended. ← **inventor's pick**
|
||||
- (b) Different shortcut (m to specify).
|
||||
- (c) No shortcut, button-only.
|
||||
|
||||
### Q9 — Context payload truncation cap (selection text)?
|
||||
- (a) **1000 chars** — recommended; balances usefulness vs prompt-bloat. ← **inventor's pick**
|
||||
- (b) Higher cap (5000 chars).
|
||||
- (c) Lower cap (300 chars).
|
||||
|
||||
### Q10 — Persona separation pre-design?
|
||||
- (a) **Single Paliadin, no scope-bouncer pattern** — recommended; YAGNI. ← **inventor's pick**
|
||||
- (b) Add scope-bouncer pattern now (Paliadin refuses non-paliad questions, points at... where?).
|
||||
- (c) Pre-design split with a second skill (Phase 2 case-law researcher).
|
||||
|
||||
### Q11 — Auto-approve some agent suggestions?
|
||||
- (a) **No, every agent suggestion needs the user's eye** — recommended; matches m's #20 verbatim. ← **inventor's pick**
|
||||
- (b) Auto-approve agent suggestions on projects where the user is lead.
|
||||
- (c) Auto-approve when the suggestion was a direct response to "Lege … an" (user opted in by phrasing).
|
||||
|
||||
### Q12 — Recommended implementer?
|
||||
Same substrate as t-paliad-146 + t-paliad-160 + t-paliad-138 (paliadin,
|
||||
approval pipeline, eye-pill UI). Pattern-fluent Sonnet work.
|
||||
- (a) **Any pattern-fluent Sonnet coder** — recommended. ← **inventor's pick**
|
||||
- (b) The same coder who shipped t-paliad-160 (deepest context on the
|
||||
approval pipeline).
|
||||
- (c) Two coders: one on Slices A-C (modal + context), one on Slices D-E
|
||||
(agent-suggest + visual language).
|
||||
|
||||
---
|
||||
|
||||
## 12 · Out of scope (for now) — preserved
|
||||
|
||||
Per m's brief:
|
||||
|
||||
- Direct Paliadin write permission (no RLS bypass, no agent service-role
|
||||
identity). The approval gate stays the only path agents take into prod
|
||||
data.
|
||||
- Multi-turn agent loops — no chained writes without per-step user
|
||||
approval.
|
||||
- Production-v1 Anthropic API cutover for the existing standalone
|
||||
`/paliadin` route (recommended in §6 as a *prerequisite* for opening
|
||||
beyond owner-only, but not committed in this task).
|
||||
- Edits / completes / project-tree as agent-suggestible entities (§7.4
|
||||
defers to Phase 2).
|
||||
- Persona separation infrastructure (§9 defers indefinitely).
|
||||
|
||||
---
|
||||
|
||||
## 13 · Trade-offs flagged
|
||||
|
||||
| Trade-off | What we accept | Mitigation |
|
||||
|---|---|---|
|
||||
| Tmux-relay v1 caps concurrency at one turn per user | Owner-only v1 makes this fine | Spec the relay-interface seam (§6.4) so API cutover is non-disruptive |
|
||||
| Mechanical `<PaliadinWidget />` pass touches ~30 files | Same pattern as t-paliad-042 PWAHead, low risk | One commit per slice keeps blame surface tight |
|
||||
| Agent suggestions unconditionally route through approval | Some users may find it tedious | Phase 2 auto-approve setting (m wants Q11 = no, so this isn't urgent) |
|
||||
| Two glyphs (👀 + ✨) might confuse first-time approvers | Slight onboarding cost | Tooltip on hover; admin/onboarding doc one-liner |
|
||||
| Selection-text in context payload risks accidental info leakage | Low (data already in DB) | Cap + redaction in admin dashboard (§4.3) |
|
||||
| Per-route starter registry needs maintenance as routes evolve | Yes; cost is real | Default fallback ensures no route is silent; route renames are caught by build (registry imports route names as a const map) |
|
||||
|
||||
---
|
||||
|
||||
## 14 · Implementation hygiene
|
||||
|
||||
- **No bare CSS tokens.** New `.paliadin-widget*` + `.approval-pill--agent`
|
||||
CSS uses existing `--color-*` / `--accent-*` / `--bg-soft` tokens. The
|
||||
reminder from t-paliad-150 (third occurrence of bare-token leaks) holds.
|
||||
- **No RAISE EXCEPTION in migration 070** — Maria's build constraint.
|
||||
- **No `2>&1` on diagnostic** — global rule.
|
||||
- **i18n must compile** — every new label gets a key in `client/i18n.ts`
|
||||
+ DE/EN values; `bun run build` regenerates `i18n-keys.ts`.
|
||||
- **Build + vet + test gate** — `go build ./...` + `go vet ./...` +
|
||||
`go test ./...` + `cd frontend && bun run build` all clean before push.
|
||||
- **Don't self-merge** — push branch, comment on Gitea #20, await m's
|
||||
merge gate.
|
||||
- **Don't close issue #20** — m closes issues. Set `done` label on
|
||||
approval.
|
||||
|
||||
---
|
||||
|
||||
## 15 · End-of-shift checklist (this design)
|
||||
|
||||
- [x] Read m/paliad#20 + Klaus's reply (msg #1563 / comment).
|
||||
- [x] Read existing `paliadin.go` + `paliadin_remote.go` + `approval_service.go` + `paliadin-shim` + `install-paliadin-skill` + `~/.claude/skills/paliadin/SKILL.md`.
|
||||
- [x] Read youpc.org reference: `sidebar-widget.html` + `sidebar.js` + `ai-chat-client.js` + `youpc_ai_relay.go`.
|
||||
- [x] Verify live state: paliad.de up, migration tracker at 69, schema columns matched expectations, eye-pill 👀 already wired.
|
||||
- [x] Take a position on every decision in the brief (see §0 table; §11 for the open questions).
|
||||
- [x] No hour estimates anywhere in the doc.
|
||||
- [x] Recommend implementer + phasing.
|
||||
- [ ] Commit this doc on `mai/dirac/inventor-inline-paliadin`.
|
||||
- [ ] Push branch.
|
||||
- [ ] Comment on Gitea #20 with summary + doc link.
|
||||
- [ ] File mBrian synthesis node under `topic-paliadin` (or equivalent).
|
||||
- [ ] `mai report completed "DESIGN READY FOR REVIEW: …"` and **stop**. Do not auto-flip to coder.
|
||||
|
||||
---
|
||||
|
||||
*Inventor parked after this commit. The head will surface to m for the
|
||||
go/no-go gate before any coder shift begins. Skipping that gate has
|
||||
burned commits before (m/mAi#142); the gate is non-negotiable.*
|
||||
739
docs/design-smart-timeline-2026-05-08.md
Normal file
739
docs/design-smart-timeline-2026-05-08.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Design — SmartTimeline (Verlauf-tab redesign)
|
||||
|
||||
**Author:** lagrange (inventor)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-169
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
|
||||
|
||||
- **Verlauf today** is `frontend/src/projects-detail.tsx:74-101` — `<ul className="entity-events" id="project-events-list">` rendered from `paliad.project_events` via `loadEvents(id)` at `client/projects-detail.ts:305`. Pure audit log: `event_type` distribution in prod is 100 % administrative — `deadline_completed/updated/created/...`, `note_created`, `appointment_*`, `checklist_*`, `project_type_changed`, `our_side_changed`, `deadlines_imported`. No "future-tense" or "off-script" events surface anywhere on the project page today.
|
||||
- **Projection logic** lives in `internal/services/fristenrechner.go:Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)` returning a `UIResponse{Deadlines []UIDeadline}` keyed by `rule_code`. `CalcOptions.AnchorOverrides map[string]string` lets callers replace any rule's date and downstream rules re-anchor — already the load-bearing primitive for "actual dates anchor downstream projections" (t-paliad-131 Phase A).
|
||||
- **`paliad.deadline_rules`** carries 172 active rules across 19 fristenrechner proceeding types (UPC×8, DE×5, EPA×2, EP×1, DPMA×3). `condition_flag text[]` already drives counterclaim cross-flows: `with_ccr` enables 7 UPC_INF cross-flow rules (Defence-to-CCR R.29.a, Application to amend R.30.1, Defence to App-to-amend R.32.1, Reply to Defence-to-CCR R.29.d, Rejoinder R.29.e, +2). `with_amend` / `with_cci` work on UPC_REV.
|
||||
- **`paliad.projects.our_side`** column exists (added in t-paliad-164) but is **null on every live row today**. The CCR perspective-flip the cascade implements via Determinator B1 (t-paliad-167) is not yet exercised by real data.
|
||||
- **CCR is not a separate project today.** It's a flag (`with_ccr=true`) on a parent UPC_INF project. m's vision asks us to revisit that.
|
||||
- **FilterBar** (`frontend/src/client/filter-bar/`, riemann's t-paliad-163 Phase 1) ships with axis stubs `deadline_event_type` + `project_event_kind` already wired into `BarState` and `AxisKey` — Phase 2 is supposed to fill them in. The SmartTimeline's facet set is exactly the kind of thing those stubs were left pending for.
|
||||
- **Project hierarchy in prod** is the canonical 4-level shape: Client (`Siemens AG`) → Litigation (`Siemens ./. Huawei`) → Patent (`EP3456789`) → Case (`UPC-CFI München — Klage Siemens ./. Huawei`). 11 projects total.
|
||||
- **t-paliad-168 deliverable 3 is dropped** per task brief — there will be no separate Verfahrensablauf-as-its-own-tab on the project page. The wizard's projection logic is the SmartTimeline's future-skeleton feeder.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's vision (verbatim 2026-05-08 23:02):
|
||||
|
||||
> The Verlauf tab inside the case should hold past + future events. If we know the proceeding type, there is a timeline. We adapt the Verfahrensablauf logic and fix dates for things when they happened. A smart timeline. If a counterclaim is filed, that is also included. Hold it flexible — add events regardless of whether they fit the normal course.
|
||||
|
||||
The **SmartTimeline** is one composed view that answers *"what has happened in this matter, what is happening now, and what is on the standard road from here"*. Three time-zones in one widget:
|
||||
|
||||
| Zone | What it shows | Data source |
|
||||
|---|---|---|
|
||||
| **Past** | Filings, decisions, appointments, audit milestones — all dated, anchored to reality | `paliad.deadlines` (status=`done`) ∪ `paliad.appointments` (start_at < today) ∪ `paliad.project_events` (selected `timeline_kind`) |
|
||||
| **Now** | Open deadlines + appointments today | same tables, today-bracket |
|
||||
| **Future (predicted)** | Standard-course rules from `deadline_rules` projected forward, faded — only those without an actual `paliad.deadlines` row yet | `fristenrechner.Calculate` against project's proceeding type + trigger anchor |
|
||||
| **Future (off-script)** | User-added events that don't fit the standard tree (counterclaim filed, ad-hoc Anhörung, party amendment) | `paliad.deadlines` with `source='off_script'` ∪ child counterclaim sub-project's actuals ∪ `project_events` with `timeline_kind` |
|
||||
|
||||
### What changes
|
||||
|
||||
- The `tab=history` panel on `/projects/{id}` becomes a SmartTimeline component that renders all four zones in one column.
|
||||
- The audit-only Verlauf view does not disappear — it survives as a "Audit-Log" sub-toggle inside the SmartTimeline ("Alle Audit-Events anzeigen") and on the existing `/admin/audit-log` page (t-paliad-071).
|
||||
- The existing FilterBar primitive grows two facets (`timeline_track`, `timeline_status`) and re-uses three (`time`, `personal_only`, `deadline_event_type`).
|
||||
|
||||
### What stays
|
||||
|
||||
- Step 2 third-card + sidebar entry from t-paliad-168 are unaffected — the standalone Verfahrensablauf wizard at `/tools/fristenrechner` remains a knowledge-platform tool.
|
||||
- `paliad.project_events` keeps its full audit-log role for `/admin/audit-log`.
|
||||
- `paliad.deadlines` + `paliad.appointments` schemas don't migrate (only one optional column added; details in §2).
|
||||
- The existing "Inkl. Unterprojekte" toggle on the project page stays — the SmartTimeline reads child events through it.
|
||||
|
||||
### Out of scope (v1)
|
||||
|
||||
- Horizontal-Gantt rendering. We pick a vertical timeline; Gantt is a future shape (t-paliad-144 substrate already supports `shape` switching, so adding a Gantt shape is later, not now).
|
||||
- Outlook/Exchange sync. CalDAV stays the only sync path.
|
||||
- Cross-matter timelines (e.g. "everything happening on EP3456789 across Siemens ./. Huawei AND any related opposition"). The patent-level aggregation in §5 is a step in that direction but cross-matter view is a separate task.
|
||||
- Rendering documents (Schriftsätze) on the timeline. That's the t-paliad-17 Incoming-Submission workflow, separate.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data model
|
||||
|
||||
**Recommendation: virtual view, ONE optional column.** No new top-level table for v1. The four zones above are computed at read time from the existing tables. The single schema change is a nullable `timeline_kind text` column on `paliad.project_events` so a subset of audit rows can opt into surfacing as timeline content.
|
||||
|
||||
### 2.1 Why no new `timeline_events` table
|
||||
|
||||
A first-instinct design would materialise a new `paliad.timeline_events` table with columns `(project_id, kind, date, title, status, source_track, rule_code?, actual_deadline_id?, …)`. I recommend against it for v1:
|
||||
|
||||
1. **Three of the four zones already have authoritative tables.** `paliad.deadlines` is the source-of-truth for legal deadlines (with completion + approval state); `paliad.appointments` for hearings + court dates; `paliad.project_events` for audit. Forcing a copy into `timeline_events` creates a sync problem on every mutation.
|
||||
2. **The future-projected zone is a function of proceeding-type + trigger date + actual anchors** — not stored data. Materialising it would require invalidation on every `paliad.deadlines` change. Cheaper to recompute per request: 19 proceeding types × at most ~15 rules = ~285 ms with cold pg cache, well under the page-render budget. Re-uses the cached `FristenrechnerService` (already memoised per request via service instantiation).
|
||||
3. **t-paliad-144 set the precedent** that ViewService composes per request without materialising. The SmartTimeline is a project-scoped instance of the same pattern.
|
||||
|
||||
If load testing later shows the projection cost matters, we materialise into a `paliad.projected_timeline_cache` table indexed by (project_id, rule_code) — but design that when load shows it, not now.
|
||||
|
||||
### 2.2 The one column added
|
||||
|
||||
```sql
|
||||
-- migration NNN_project_events_timeline_kind.up.sql
|
||||
ALTER TABLE paliad.project_events
|
||||
ADD COLUMN timeline_kind text NULL;
|
||||
|
||||
-- nullable + no CHECK — enum lives in code (services/projection_service.go).
|
||||
-- Value space (v1):
|
||||
-- 'milestone' — a structural event worth pinning to the timeline
|
||||
-- (counterclaim_filed, third_party_intervened,
|
||||
-- party_amendment, our_side_changed, scope_change)
|
||||
-- 'custom_milestone' — free-text user-added event
|
||||
-- NULL — audit only (default, all existing rows)
|
||||
|
||||
CREATE INDEX project_events_timeline_kind_idx
|
||||
ON paliad.project_events (project_id, timeline_kind)
|
||||
WHERE timeline_kind IS NOT NULL;
|
||||
```
|
||||
|
||||
Existing event types stay `NULL` — they remain audit-only and don't clutter the timeline. New write paths (counterclaim-link, off-script milestone) set the column on insert.
|
||||
|
||||
### 2.3 The discriminated `TimelineEvent` shape
|
||||
|
||||
Composed in `internal/services/projection_service.go` (new). One Go struct, one TS mirror. Frontend renders without knowing where each row came from:
|
||||
|
||||
```go
|
||||
type TimelineEvent struct {
|
||||
Kind string // "deadline" | "appointment" | "milestone" | "projected"
|
||||
Status string // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
|
||||
Track string // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
|
||||
Date *time.Time // nil = undated (court-set + counterclaim-pending)
|
||||
|
||||
Title string
|
||||
Description string
|
||||
RuleCode string // empty when not deadline-rule-derived
|
||||
|
||||
// Provenance — exactly one is non-nil for actual rows; both nil for projected.
|
||||
DeadlineID *uuid.UUID
|
||||
AppointmentID *uuid.UUID
|
||||
ProjectEventID *uuid.UUID
|
||||
|
||||
// For projected rows (Kind=="projected") — the rule it came from, for
|
||||
// the click-to-anchor affordance (§6).
|
||||
DeadlineRuleID *uuid.UUID
|
||||
DeadlineRuleParty string // 'claimant' | 'defendant' | 'court' | 'both'
|
||||
|
||||
// For child-track rows — the sub-project this event belongs to.
|
||||
SubProjectID *uuid.UUID
|
||||
SubProjectTitle string
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Read path
|
||||
|
||||
```
|
||||
GET /api/projects/{id}/timeline?
|
||||
from=...&to=...&direct_only=true|false&
|
||||
tracks=parent,counterclaim,...&kinds=deadline,appointment,projected,...
|
||||
```
|
||||
|
||||
The handler:
|
||||
1. Calls `ProjectionService.For(ctx, projectID, opts)` which:
|
||||
- Loads the project (proceeding_type_id, our_side, parent chain).
|
||||
- Loads child counterclaim sub-projects (if any — see §4).
|
||||
- Loads `paliad.deadlines` (project_id IN [self, child counterclaims]) → emits Kind=deadline rows.
|
||||
- Loads `paliad.appointments` (same) → emits Kind=appointment rows.
|
||||
- Loads `paliad.project_events WHERE timeline_kind IS NOT NULL` → emits Kind=milestone rows.
|
||||
- For each (project, child) with a proceeding_type_id, calls `FristenrechnerService.Calculate` with `AnchorOverrides` derived from completed actuals → emits Kind=projected rows for any rule that does **not** have a matching `paliad.deadlines.rule_id` row.
|
||||
- Sorts by Date ASC, undated rows last (with secondary sort on rule sequence_order so undated court-set rows preserve the standard course's order).
|
||||
|
||||
Visibility is inherited via existing `visibilityPredicate` on each underlying service — no new RLS surface to design.
|
||||
|
||||
### 2.5 What does NOT need to change
|
||||
|
||||
- `paliad.deadlines` schema — unchanged. (The existing `original_due_date`, `source`, and the AnchorOverrides plumbing already cover "actual date anchors downstream", §6.)
|
||||
- `paliad.appointments` — unchanged.
|
||||
- `paliad.deadline_rules` — unchanged. The existing `condition_flag text[]` keeps doing its job.
|
||||
- `paliad.projects` — unchanged. (See §4 for the counterclaim sub-project shape: it uses existing columns.)
|
||||
|
||||
---
|
||||
|
||||
## 3. UI mockup — three states
|
||||
|
||||
The SmartTimeline replaces the current `<ul className="entity-events">` block (~30 lines of TSX) with a vertically-flowing two-column timeline:
|
||||
|
||||
- Left column: date (or "Datum offen" placeholder).
|
||||
- Right column: stacked card per event with a status icon, title, kind chip, and (for actuals) a deep-link to `/deadlines/{id}` etc. Same `.entity-event` row contract as today (cf. CLAUDE.md whole-card click rule), no `::before` overlay.
|
||||
|
||||
A horizontal "**Heute →**" rule separates past from future. Past goes below (most-recent first), future above (chronological). Today's events sit on the rule.
|
||||
|
||||
### 3.1 State A — empty / no proceeding type set
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline [Filter ▼] [+ Eintrag] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Noch keine Ereignisse erfasst. │
|
||||
│ │
|
||||
│ Setze einen Verfahrenstyp im Projekt-Header, um den │
|
||||
│ Standardverlauf als Vorhersage zu sehen, oder lege │
|
||||
│ einen Eintrag manuell an. │
|
||||
│ │
|
||||
│ [+ Frist anlegen] [+ Termin anlegen] [+ Meilenstein] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The empty state actively guides toward the two unlocks: setting a proceeding type (enables future-projection) or adding manual events (works without one).
|
||||
|
||||
### 3.2 State B — UPC_INF, infringement-only
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Filter ▼] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Zukunft (vorhergesagt) │
|
||||
│ ───────────────────────────── │
|
||||
│ 2027-02-20 ░ Hauptverhandlung │
|
||||
│ ░ wird vom Gericht bestimmt [Datum setzen] │
|
||||
│ ─ │
|
||||
│ 2026-12-02 ░ Duplik (RoP.029.c) [voraussichtlich]│
|
||||
│ 2026-11-02 ░ Replik (RoP.029.b) [voraussichtlich]│
|
||||
│ 2026-08-31 ░ Klageerwiderung (RoP.023) [voraussichtlich]│
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ Vergangenheit │
|
||||
│ ───────────────────────────── │
|
||||
│ 2026-04-29 ✓ Klageschrift zugestellt (Anker) │
|
||||
│ 2026-04-25 ✓ Akte angelegt (Audit) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `░` (faded) = projected, `✓` = done, `!` = overdue (red), `…` = open (amber), `▢` = court-set (dashed border).
|
||||
- "Datum setzen" on the Hauptverhandlung row is the click-to-anchor affordance (§6).
|
||||
- "voraussichtlich" pill is the projected-status visual; tooltip explains "Anhand des Standardverlaufs aus dem Fristenrechner berechnet".
|
||||
- Filter chip selector reveals the FilterBar primitive directly above the list (collapsed by default to reduce noise on first load — same affordance riemann shipped on /inbox).
|
||||
|
||||
### 3.3 State C — UPC_INF + Counterclaim (CCR-Subprojekt)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Track ▼ Beide] [Filter ▼] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Verletzung (Klägerseite) ┊ Widerklage (Beklagtenseite, CCR) │
|
||||
│ ──────────────────────────────────────┊──────────────────────────────────────│
|
||||
│ Zukunft (vorhergesagt) │
|
||||
│ 2027-02-20 ░ Hauptverhandlung ┊ │
|
||||
│ [Datum setzen] ┊ │
|
||||
│ 2027-01-29 ░ Rejoinder R.29.e ┊ 2026-12-29 ░ Rejoinder R.32.3 │
|
||||
│ 2026-12-29 ░ Reply to Defence-CCR ┊ │
|
||||
│ 2026-11-29 ░ Defence to App-amend ┊ 2026-11-29 ░ Reply to Defence-amend│
|
||||
│ 2026-10-31 ░ Defence to CCR (R.29a)┊ 2026-09-30 ░ Defence to amend │
|
||||
│ 2026-08-31 ░ Klageerwiderung mit CCR┊ │
|
||||
│ ┊ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ┊ │
|
||||
│ Vergangenheit ┊ │
|
||||
│ 2026-04-29 ✓ Klageschrift zugestellt┊ ⊕ Widerklage angekündigt │
|
||||
│ ┊ (off-script, 2026-05-02) │
|
||||
│ 2026-04-25 ✓ Akte angelegt ┊ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Two parallel tracks — left is the parent infringement, right is the linked counterclaim sub-project (see §4).
|
||||
- `[Track ▼]` chip toggles between "Beide" (default when a CCR sub-project exists), "Nur Verletzung", "Nur Widerklage".
|
||||
- "⊕" marks an off-script milestone (the counterclaim was *announced* before being formally filed — a `project_events` row with `timeline_kind='custom_milestone'`).
|
||||
- Mobile: stacks vertically with collapsible per-track headers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Counterclaim shape — sub-project, defended
|
||||
|
||||
m's framing offered two shapes. Inventor recommendation: **sub-project**. Trade-off explicit.
|
||||
|
||||
### 4.1 The choice
|
||||
|
||||
| | **Sub-project (recommended)** | **Same-project, parallel proceeding-overlay** |
|
||||
|---|---|---|
|
||||
| Project rows | One per proceeding (parent INF + child CCR) | One project, two proceeding-types attached |
|
||||
| `our_side` flip | Independent on the child (parent: claimant; child: defendant in CCR-on-validity, claimant on CCR-of-infringement) | Needs a "perspective per proceeding" sub-table |
|
||||
| Determinator routing (t-paliad-167) | Existing — child gets its own cascade | Needs proceeding-aware routing inside one project |
|
||||
| Project tree (t-paliad-149) | Naturally appears as a nested node | Same-row, no tree change |
|
||||
| Dashboard per-project counts | Each gets its own count | Mixing — needs new "by-proceeding" aggregator |
|
||||
| Visibility / RLS | Inherits `can_see_project` cascade | Same |
|
||||
| CCR Number from CMS | Stored on child's `case_number` | Stored on parent in a new `case_numbers jsonb` |
|
||||
| New schema | None (uses existing project + parent_id) | New `project_proceedings` join table |
|
||||
|
||||
### 4.2 Why sub-project
|
||||
|
||||
- **Cheap.** Zero schema migration. The hierarchy already supports arbitrary nesting (4 types: client / litigation / patent / case — but `parent_id` is type-agnostic).
|
||||
- **Consistent with the data we just built.** t-paliad-164 our_side, t-paliad-149 project tree, t-paliad-167 Determinator cascade, t-paliad-168 deadline-rule jurisdiction defaults all assume "one project = one proceeding perspective". Counterclaim being a sub-project just means we keep that assumption.
|
||||
- **CCR Number.** The counterclaim has its own CCR number in the UPC CMS — which means it is in fact a separate proceeding artifact, not just a phase of the parent. Modeling it as a separate project row with its own `case_number` reflects reality. The "case-complex-wise" closeness m asks about is the parent_id link, not collapsing them into one row.
|
||||
- **Independent timeline math.** UPC R.49(2) puts CCI / app-to-amend "as part of" Defence to revocation — but that just means zero-duration filed-with-parent. The downstream re-anchoring is independent in each tree.
|
||||
|
||||
### 4.3 The link
|
||||
|
||||
A new optional FK on `paliad.projects`:
|
||||
|
||||
```sql
|
||||
-- migration NNN_projects_counterclaim_of.up.sql
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN counterclaim_of uuid NULL
|
||||
REFERENCES paliad.projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_counterclaim_of_idx
|
||||
ON paliad.projects (counterclaim_of)
|
||||
WHERE counterclaim_of IS NOT NULL;
|
||||
|
||||
-- A project can be EITHER a parent (counterclaim_of IS NULL) OR a
|
||||
-- counterclaim against another project (counterclaim_of points at it),
|
||||
-- but not both. Enforced by a CHECK on the union of FKs (see §10).
|
||||
```
|
||||
|
||||
`parent_id` keeps the standard hierarchy (the counterclaim child still lives under the same patent / litigation tree). `counterclaim_of` is an *additional* relation expressing "this project is the CCR against project X". The two are both set on a counterclaim sub-project.
|
||||
|
||||
### 4.4 Creating a counterclaim from the timeline
|
||||
|
||||
The "+ Eintrag" button on the parent's SmartTimeline opens a typed-add modal (§7). Picking type=`Counterclaim` (UPC) creates a child project with:
|
||||
|
||||
- `parent_id` = parent's parent (so CCR appears as a sibling under the patent, not a grandchild — debatable; see §11 Q4).
|
||||
- `counterclaim_of` = parent project id.
|
||||
- `proceeding_type_id` = `UPC_REV` (CCR-on-validity is the standard case; UPC_CCI is the rarer R.49.2.b path).
|
||||
- `our_side` = inverted from parent (parent claimant → child defendant, parent defendant → child claimant).
|
||||
- `title` = `<patent> — Widerklage (CCR)` auto-suggested.
|
||||
|
||||
The same flow applies to `case_amend` (UPC R.30 application to amend) — a separate child sub-project. *Whether to model R.30 as a child project or as a flag on the parent is open: amendments are usually just a flag in our existing model. Default v1 = stay as flag, do **not** create a sub-project for application-to-amend; only formal counterclaims (CCR / CCI) get sub-projects.*
|
||||
|
||||
### 4.5 What the parent's SmartTimeline shows for the child
|
||||
|
||||
When `counterclaim_of` exists pointing at this project, the SmartTimeline renders a parallel right-track with the child's events (limited to `kind IN ('deadline','appointment','milestone')` — child's projected rows are also included). User can collapse/hide the child track via the `[Track ▼]` chip.
|
||||
|
||||
The child's own SmartTimeline shows its own events as the primary track plus the parent as a left-side faded-context track (so the lawyer working on the CCR can see what's happening on the main proceeding without leaving the page).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parent-node aggregation rule
|
||||
|
||||
What does the SmartTimeline render at higher levels of the project hierarchy? The four levels we have today:
|
||||
|
||||
### 5.1 Per-level rendering
|
||||
|
||||
| Level | Default render | Why |
|
||||
|---|---|---|
|
||||
| **Case** (UPC-CFI X) | Full SmartTimeline of self + parallel-track for any linked CCR sub-project. All zones, all kinds. | The lawyer working a single proceeding sees everything in one view. |
|
||||
| **Patent** (EP3456789) | Lanes — one per child case. Each lane shows only `kind IN ('deadline','milestone')` + status `IN ('done','open','overdue')`. Projected rows hidden by default (unfold-per-lane on click). | A patent typically has 1-3 active cases (CFI + CoA + opposition). Showing all projected rows from every case = overwhelming. Showing actuals + structural milestones gives the matter-level view. |
|
||||
| **Litigation** (Siemens ./. Huawei) | Lanes — one per child patent's primary case (most-recently-active case). Show only `kind='milestone'` + status=`done` + per-case "next due" pill. | Litigation level is portfolio-of-patents-against-this-defendant. Useful to see when each patent's current proceeding is, not the granular deadlines. |
|
||||
| **Client** (Siemens AG) | Default = matter list (existing project tree). Behind a "Timeline-Ansicht" toggle, lanes = one per litigation. Shows only `kind='milestone'` + status=`done`. | Client level can have 100+ matters. A timeline across all is meaningless. The toggle makes it discoverable for the partner who wants the bird's-eye view. |
|
||||
|
||||
### 5.2 The single rule
|
||||
|
||||
> Each level removes one tier of detail and adds one tier of grouping. Going up: fewer kinds rendered, fewer statuses surfaced, more lanes.
|
||||
|
||||
| Level | Kinds | Statuses | Lanes |
|
||||
|---|---|---|---|
|
||||
| Case | all | all | self + CCR child |
|
||||
| Patent | deadline + milestone | done + open + overdue | one per child case |
|
||||
| Litigation | milestone | done | one per child patent |
|
||||
| Client | milestone (toggle) | done | one per child litigation |
|
||||
|
||||
This rule is implementable as a single `levelPolicy(projectType)` function in `ProjectionService` returning a `(kinds, statuses, lane_grouping)` triple. All four cases share the same render component; only the input filter varies.
|
||||
|
||||
### 5.3 Off-script events at higher levels
|
||||
|
||||
Off-script milestones (counterclaim filed, party amendment, scope change) are first-class at every level — they're the events m most cares about seeing at the litigation/patent overview. The "milestone" kind survives the level filter at all levels.
|
||||
|
||||
### 5.4 Not in v1
|
||||
|
||||
Cross-matter aggregation (e.g. "all my UPC matters, one timeline") is a Custom-View concern (t-paliad-144 substrate). The SmartTimeline is project-scoped; cross-project goes through `/views/{slug}` with a sources=`timeline` ViewSpec. Phase 5+, after t-paliad-163 Phase B lands.
|
||||
|
||||
---
|
||||
|
||||
## 6. Date-anchoring + reflow semantics
|
||||
|
||||
### 6.1 The rule (explicit)
|
||||
|
||||
> An actual date — recorded as a `paliad.deadlines.due_date` (status `done`) or `paliad.appointments.start_at` (in the past) or a milestone date — anchors every downstream projected event whose parent rule is the corresponding deadline_rule. The reflow propagates one parent-step at a time, until the next actual takes over or the chain bottoms out.
|
||||
|
||||
In other words: the existing `AnchorOverrides` mechanism in `FristenrechnerService.Calculate` is exactly the load-bearing primitive. The SmartTimeline's `ProjectionService` builds the override map at request time:
|
||||
|
||||
```go
|
||||
overrides := map[string]string{}
|
||||
for _, d := range completedDeadlines {
|
||||
if d.RuleCode == "" || d.CompletedAt == nil { continue }
|
||||
overrides[d.RuleCode] = d.CompletedAt.Format("2006-01-02")
|
||||
}
|
||||
// Court-set rules pick up the actual date too — set when the user enters
|
||||
// "Hauptverhandlung fand statt am ..." via the inline anchor affordance.
|
||||
opts := CalcOptions{AnchorOverrides: overrides, Flags: flagsForProject(p)}
|
||||
result := frist.Calculate(ctx, p.ProceedingCode, p.TriggerDate, opts)
|
||||
```
|
||||
|
||||
### 6.2 The UI affordance
|
||||
|
||||
Each projected row carries a `[Datum setzen]` link (or full-row click on tap-targets). Click → inline date input expands inline. On submit:
|
||||
|
||||
- If the row corresponds to a `deadline_rules` entry that has a *real* deadline (not court-set), the action creates a `paliad.deadlines` row with `rule_id` set, `due_date=entered`, `original_due_date=projected`, `source='anchor'`, `status='done'`, `completed_at=entered`. (The "anchor" source is new; existing values are `manual`, `rule`, `import`. v1 adds `'anchor'` to the existing CHECK list.) This is the "we just learned the parent fact" path.
|
||||
- If the row is court-set (decision / hearing / order), the action creates a `paliad.appointments` row with `start_at=entered`, `appointment_type='hearing'|'decision'|'order'` derived from the rule's `event_type`. The appointment links back to `rule_code` via a new optional FK column `paliad.appointments.deadline_rule_id` (nullable; existing rows stay null).
|
||||
- Either way, the next read recomputes the projection with the new override and downstream rows reflow.
|
||||
|
||||
### 6.3 Editing an actual date later
|
||||
|
||||
If the user clicks an existing actual row's date, the inline editor PATCHes the underlying record (`/api/deadlines/{id}` or `/api/appointments/{id}`), and the next read re-projects.
|
||||
|
||||
### 6.4 What happens to overdue projected rows
|
||||
|
||||
A projected row whose date is in the past but no actual exists yet renders as "vorhergesagt — überfällig" (faded amber). Clicking it lets the user either (a) anchor it as actual on a different date, or (b) explicitly mark "ist nicht eingetreten / wurde verschoben" — which writes a `project_events` row with `event_type='rule_skipped'` + `timeline_kind='milestone'` so the audit trail records the decision.
|
||||
|
||||
---
|
||||
|
||||
## 7. Off-script event UX
|
||||
|
||||
The cardinal constraint: "We must hold it flexible — add events regardless of whether they fit the normal course." Off-script events are first-class.
|
||||
|
||||
### 7.1 The "+ Eintrag" CTA
|
||||
|
||||
Persistent button in the SmartTimeline header. Click → typed-add modal:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Neuer Eintrag im SmartTimeline │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Was ist passiert? (oder wird passieren?) │
|
||||
│ │
|
||||
│ ◯ Frist → /deadlines/new │
|
||||
│ ◯ Termin → /appointments/new │
|
||||
│ ◯ Widerklage (CCR) → Anlegen Sub-Akte │
|
||||
│ ◯ Anwendung auf Änderung (R.30) → Flag setzen │
|
||||
│ ◯ Schriftsatz / Order → Off-script │
|
||||
│ ◯ Eigener Meilenstein → Off-script (frei) │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Weiter ▶ ] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The visible options depend on the project's `proceeding_type_id`. UPC_INF gets the CCR + R.30 routes; UPC_REV gets CCI; DE_INF gets none of these. The "Schriftsatz / Order" + "Eigener Meilenstein" routes are universal.
|
||||
|
||||
### 7.2 The off-script branch
|
||||
|
||||
For "Schriftsatz / Order" and "Eigener Meilenstein" — a small form:
|
||||
|
||||
```
|
||||
Off-script Meilenstein
|
||||
|
||||
Titel: [Widerklage angekündigt durch Beklagten ]
|
||||
Datum: [2026-05-02]
|
||||
Beschreibung: [Schreiben des Beklagtenanwalts vom 02.05., … ]
|
||||
Verknüpfung: ☐ Frist daraus erzeugen ☐ Termin daraus erzeugen
|
||||
Sichtbar in: ◉ Diese Akte ◯ Diese Akte + Eltern
|
||||
↑ Will it bubble up to higher levels?
|
||||
|
||||
[ Abbrechen ] [ Speichern ]
|
||||
```
|
||||
|
||||
On submit, writes a `paliad.project_events` row with:
|
||||
|
||||
- `event_type='off_script_milestone'` (new value in the event_type enum-ish CHECK; today's CHECK is open-ended text — confirm during impl).
|
||||
- `timeline_kind='custom_milestone'`.
|
||||
- `event_date=entered`.
|
||||
- `description=...`.
|
||||
- `metadata={"track": "parent" | "off_script", "links": [...]}`.
|
||||
|
||||
The optional checkboxes "Frist daraus erzeugen / Termin daraus erzeugen" open the standard deadline/appointment-create flow with the milestone's data prefilled and the milestone's id linked via metadata for audit trail.
|
||||
|
||||
### 7.3 Curated catalogue per proceeding type (NICE TO HAVE)
|
||||
|
||||
A small lookup table `paliad.timeline_event_catalogue (proceeding_type_id, kind, slug, name_de, name_en, primary_party)` could surface in the modal as a "Häufige Ereignisse" section above the universal "Eigener Meilenstein" route. Examples:
|
||||
|
||||
- UPC_INF: Counterclaim Filed, Third Party Intervention, Hearing Postponement, Cost Decision Issued
|
||||
- UPC_REV: Application to Amend Filed, Substantive Decision, Costs Order
|
||||
- DE_INF: Hinweisbeschluss Issued, Verteidigungsanzeige, Termin Hauptverhandlung, Versäumnisurteil
|
||||
|
||||
The catalogue is a v2 nice-to-have. v1 ships with "Eigener Meilenstein" as the universal escape hatch and the few proceeding-specific routes named above (CCR, CCI, R.30) hardcoded on the modal.
|
||||
|
||||
---
|
||||
|
||||
## 8. Filter facets — first-pass refinement
|
||||
|
||||
Refining the task brief's first-pass list against the FilterBar API (riemann's `BarState` / `AxisKey`). Each axis maps to either a universal axis (already shipped), an existing per-source stub (riemann left ready), or a new one.
|
||||
|
||||
### 8.1 Reused universal axes (already in BarState)
|
||||
|
||||
- **`time`** (universal, chip cluster + custom range) — past 30/90d, next 30/90/any/custom. Default = `any`. Re-used verbatim; no work.
|
||||
- **`personal_only`** (universal, chip) — re-used. "Nur meine Einträge" — `created_by=me`. Behavior same as on `/events` (t-paliad-128).
|
||||
|
||||
### 8.2 New per-source axes (extend `AxisKey`)
|
||||
|
||||
```ts
|
||||
// frontend/src/client/filter-bar/types.ts — additions
|
||||
export type AxisKey =
|
||||
| …existing…
|
||||
| "timeline_kind" // multi-select chip cluster
|
||||
| "timeline_status" // multi-select chip cluster
|
||||
| "timeline_track" // multi-select chip cluster
|
||||
;
|
||||
|
||||
export interface BarState {
|
||||
…existing…
|
||||
timeline_kind?: ("deadline" | "appointment" | "milestone" | "projected")[];
|
||||
timeline_status?: ("done" | "open" | "overdue" | "court_set" | "predicted" | "off_script")[];
|
||||
timeline_track?: ("parent" | "counterclaim" | string /* child:<projectid> */)[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 The facet set on the SmartTimeline surface
|
||||
|
||||
The surface declares this `axes` array when it mounts the bar:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
axes: [
|
||||
"time", // universal — past/future filter
|
||||
"timeline_kind", // deadline | appointment | milestone | projected
|
||||
"timeline_status", // done | open | overdue | court_set | predicted | off_script
|
||||
"timeline_track", // parent | counterclaim | child:<id>
|
||||
"personal_only", // optional — toggle "nur meine Einträge"
|
||||
"deadline_event_type", // existing stub — wired in t-paliad-117 multi-select
|
||||
"shape", // timeline (default) | list | cards
|
||||
"sort", // chronological asc/desc
|
||||
"density", // comfortable | compact
|
||||
],
|
||||
surfaceKey: "project-smart-timeline",
|
||||
systemViewSlug: "project-timeline",
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
### 8.4 Defaults
|
||||
|
||||
- `time = any`
|
||||
- `timeline_kind = [deadline, appointment, milestone]` (projected hidden by default — the user opts in via chip; reduces noise on first load when most projects don't have a proceeding type set)
|
||||
- `timeline_status = [done, open, overdue, off_script]` (predicted + court_set hidden by default if `projected` kind is hidden — the chip group is one logical "show future" toggle)
|
||||
- `timeline_track = all available`
|
||||
- `shape = timeline`
|
||||
- `sort = date_desc` (most recent first; matches today's Verlauf default)
|
||||
- `density = comfortable`
|
||||
|
||||
### 8.5 The "show future" macro
|
||||
|
||||
Most users will only want one toggle: "Zukunft anzeigen". We render that as a primary chip pair next to `time`:
|
||||
|
||||
```
|
||||
[ Vergangenheit | Heute | Zukunft ] ← primary toggle
|
||||
```
|
||||
|
||||
Internally this maps to `time + timeline_kind` (Vergangenheit hides projected, Zukunft shows projected, Heute is just today). Power users can drill into the granular axes via the bar.
|
||||
|
||||
### 8.6 What riemann's port (t-paliad-170) needs to know
|
||||
|
||||
Riemann is porting FilterBar onto the Verlauf surface in parallel. Three things they need:
|
||||
|
||||
1. **Three new axis keys** (`timeline_kind`, `timeline_status`, `timeline_track`). They render as chip clusters — the same primitive `chipRow + chipBtn` riemann already factored.
|
||||
2. **`shape: "timeline"`** is a new render shape. Existing shapes are `list | cards | calendar` (t-paliad-144). We pick "timeline" as a 4th shape so the FilterBar's shape switcher lets the user collapse to `list` (compact audit log) or `cards` (chronological card grid) without losing the data. Implementation = new `frontend/src/client/views/shape-timeline.ts` mirroring the other shape files. Out of scope for t-paliad-170 (riemann ports the bar, not the new shape).
|
||||
3. **The `timeline_track` axis options are dynamic** — they depend on whether the project has a counterclaim child. The bar already supports lazy axes (the `project` axis pattern in `axes.ts:30` — `"populated lazily"`). `timeline_track` follows the same shape: surface fetches available tracks at mount, passes them to the bar.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verfahrensablauf-logic sharing — extract, don't import
|
||||
|
||||
**Recommendation: extract into a shared module first.**
|
||||
|
||||
### 9.1 The decision
|
||||
|
||||
The wizard's projection logic is currently in two places:
|
||||
|
||||
1. `internal/services/fristenrechner.go:Calculate(...)` — the canonical Go implementation. Already returns a `UIResponse{Deadlines []UIDeadline}` keyed by rule_code, supports `AnchorOverrides`. ~1000 lines, tested.
|
||||
2. `frontend/src/client/fristenrechner.ts:calculate()` — the frontend wrapper that POSTs `/api/tools/fristenrechner` and handles flags + overrides. ~3500 lines including the wizard UI, but the projection-relevant slice is small (call + render).
|
||||
|
||||
The SmartTimeline's `ProjectionService.For(projectID)` needs the *Go calculator*, not the frontend code path. So the question is really: *do we add a new Go service that wraps `FristenrechnerService.Calculate` for projects?*
|
||||
|
||||
Yes — a thin adapter, not a parallel implementation.
|
||||
|
||||
### 9.2 The adapter
|
||||
|
||||
```go
|
||||
// internal/services/projection_service.go (new, ~200 LoC)
|
||||
|
||||
type ProjectionService struct {
|
||||
db *sqlx.DB
|
||||
fristen *FristenrechnerService
|
||||
deadlines *DeadlineService
|
||||
appointments *AppointmentService
|
||||
projects *ProjectService
|
||||
courts *CourtService
|
||||
}
|
||||
|
||||
// For builds a SmartTimeline for one project (and its CCR child if any).
|
||||
// Composes the four zones described in §1; returns sorted TimelineEvent[].
|
||||
func (s *ProjectionService) For(ctx context.Context, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
|
||||
p, err := s.projects.GetVisible(ctx, projectID, opts.ViewerID)
|
||||
// ...
|
||||
children := s.projects.LoadCounterclaimChildrenVisible(ctx, projectID, opts.ViewerID)
|
||||
|
||||
actuals := s.collectActuals(ctx, []uuid.UUID{p.ID, children...}) // dl + appt + milestones
|
||||
overrides := buildAnchorOverrides(actuals)
|
||||
|
||||
var projected []TimelineEvent
|
||||
if p.ProceedingTypeCode != "" && p.TriggerDate != nil {
|
||||
proj := s.fristen.Calculate(ctx, p.ProceedingTypeCode, p.TriggerDate.Format("2006-01-02"),
|
||||
CalcOptions{AnchorOverrides: overrides, Flags: flagsFor(p), CourtID: p.CourtID})
|
||||
projected = projectionToTimeline(proj, p, actuals)
|
||||
}
|
||||
// (same for each child counterclaim)
|
||||
|
||||
return mergeAndSort(actuals, projected, opts.LevelPolicy), nil
|
||||
}
|
||||
```
|
||||
|
||||
The adapter does not duplicate the calculator — it calls `FristenrechnerService.Calculate` exactly once per (project, child). Same code path as `/api/tools/fristenrechner` uses today; same tests cover both.
|
||||
|
||||
### 9.3 What the standalone wizard keeps
|
||||
|
||||
`/tools/fristenrechner` continues to use `FristenrechnerService.Calculate` directly — it's a knowledge-platform tool, not a project-scoped view. It does not gain anchoring affordances or off-script events. The projection there is hypothetical ("if you start a UPC_INF on date X, here's the timeline"), not project-actual.
|
||||
|
||||
`ProjectionService` is a project-scoped composition layer; it lives one level above `FristenrechnerService` in the dependency graph.
|
||||
|
||||
### 9.4 The test split
|
||||
|
||||
- `fristenrechner_test.go` keeps testing the calculator (duration math, AnchorOverrides, CourtID resolution).
|
||||
- `projection_service_test.go` (new) tests the composition: mixing actuals + projected, level policy, counterclaim child merging, sort order.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasing — 4 sequential slices
|
||||
|
||||
Each slice is independently shippable and reviewable. m's go/no-go gate after each.
|
||||
|
||||
### Slice 1 — SmartTimeline skeleton (no projection yet)
|
||||
|
||||
What lands:
|
||||
|
||||
- New `internal/services/projection_service.go` with `For()` returning only actuals (deadlines + appointments + opted-in `project_events`). No `fristenrechner` call yet.
|
||||
- Migration `NNN_project_events_timeline_kind.up.sql` adds the optional column + partial index (§2.2).
|
||||
- New endpoint `GET /api/projects/{id}/timeline?…` returning `[]TimelineEvent`.
|
||||
- `frontend/src/client/projects-detail.ts:loadEvents` rewritten to call `/timeline` instead of `/events`. The current Verlauf list is replaced by the new vertical timeline component (`client/views/shape-timeline.ts` — new file, ~300 LoC).
|
||||
- "+ Eintrag" CTA in the timeline header (modal partially implemented — only "Eigener Meilenstein" route lit; CCR / R.30 / Frist / Termin routes are link buttons to existing flows).
|
||||
- "Audit-Log anzeigen" toggle that switches to the legacy chronological list rendering (`paliad.project_events` ALL — not just `timeline_kind IS NOT NULL`).
|
||||
|
||||
What it gives m: a working SmartTimeline showing past actuals + open/upcoming deadlines + appointments + off-script milestones, with the audit log surviving as a toggle. No future-projection yet.
|
||||
|
||||
### Slice 2 — Future-projection + click-to-anchor
|
||||
|
||||
What lands:
|
||||
|
||||
- `ProjectionService.For` calls `FristenrechnerService.Calculate` and emits projected rows.
|
||||
- Click-to-anchor inline date editor (§6.2). New endpoint `POST /api/projects/{id}/timeline/anchor` taking `{rule_code, actual_date, kind?}` and writing the appropriate `paliad.deadlines` (`source='anchor'`) or `paliad.appointments` (`deadline_rule_id` FK new) row.
|
||||
- Migration `NNN_appointments_deadline_rule_id.up.sql` adds the optional FK on appointments + extends `paliad.deadlines.source` CHECK to include `'anchor'`.
|
||||
- "voraussichtlich" / "Datum vom Gericht" status pills + projected-row CSS (faded + dashed border for court-set).
|
||||
- New "Zukunft anzeigen" macro chip pair (§8.5).
|
||||
- `event_type='rule_skipped'` write path for the "ist nicht eingetreten" decision (§6.4).
|
||||
|
||||
What it gives m: predicted future course based on standard timeline; click to fix any date when something happens; downstream reflows automatically.
|
||||
|
||||
### Slice 3 — Counterclaim sub-project
|
||||
|
||||
What lands:
|
||||
|
||||
- Migration `NNN_projects_counterclaim_of.up.sql` — the new `counterclaim_of` FK + index + the CHECK (a project either has counterclaim_of OR is parent — not both — to keep the invariant clean).
|
||||
- "+ Eintrag → Widerklage (CCR)" route in the modal (§7.1) — creates child project with auto-suggested `our_side` flip, `proceeding_type_id`, and title, then navigates to it for the user to fill in `case_number`.
|
||||
- `ProjectionService` loads CCR children + emits parallel-track rows.
|
||||
- `[Track ▼]` chip in the header — reads `available_tracks` from the timeline response.
|
||||
- The two-column rendering on State C (§3.3).
|
||||
- `paliad.project_events` audit row written on counterclaim creation (`event_type='counterclaim_created'`, `timeline_kind='milestone'`).
|
||||
|
||||
What it gives m: counterclaims as proper sub-projects, parallel timelines, CCR perspective-flip works end-to-end.
|
||||
|
||||
### Slice 4 — Parent-node aggregation
|
||||
|
||||
What lands:
|
||||
|
||||
- `levelPolicy(projectType)` in `ProjectionService` — kinds/statuses/lane filter per level (§5.1).
|
||||
- Lane-grouped rendering at Patent / Litigation / Client levels.
|
||||
- "Timeline-Ansicht" toggle on Client-level project page (default off; lanes-of-litigations when on).
|
||||
- Off-script milestones bubble up to higher levels via the `metadata.bubble_up: true` flag (§7.2 form's "Sichtbar in: Diese Akte + Eltern" checkbox).
|
||||
|
||||
What it gives m: portfolio-level timelines without overload — the bird's-eye view he asked about.
|
||||
|
||||
### What's NOT in any slice
|
||||
|
||||
- Curated per-proceeding event catalogue (§7.3) — v2 nice-to-have.
|
||||
- Gantt rendering — separate `shape: "gantt"` follow-up.
|
||||
- Cross-matter timeline — Custom Views path.
|
||||
- Outlook integration — out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
Listed with my (inventor) pick where I have one — m decides.
|
||||
|
||||
**Q1 — Counterclaim sub-project vs proceeding-overlay (§4).** I recommend sub-project. Confirm before Slice 3 design lock.
|
||||
|
||||
**Q2 — Should `our_side` flip automatically on counterclaim sub-project creation?** My pick: yes, default-flip with a "Stimmt nicht?" toggle on the create modal. The R.49.2.b CCI is the edge case (parent claimant → child claimant in CCI of the *separate* infringement claim), but the standard CCR-on-validity always inverts. Default-flip + toggle handles both.
|
||||
|
||||
**Q3 — Should `paliad.deadlines.source` gain `'anchor'` or should we re-use `'manual'`?** My pick: new `'anchor'` value — separates "user-typed-it-in" from "user-recorded-an-actual-after-projection-fired" for analytics + future automated import (Outlook event → anchor).
|
||||
|
||||
**Q4 — Counterclaim sub-project's `parent_id` — under the patent (sibling to parent case) or under the parent case (grandchild)?** My pick: under the patent (sibling). The CCR is its own proceeding with its own case_number; modeling it as a sibling to the parent infringement, both under the patent, mirrors how UPC CMS sees them. Grandchild placement would imply CCR is "part of" the parent case which it structurally isn't.
|
||||
|
||||
**Q5 — Off-script milestone bubble-up default.** My pick: default-on for `event_type IN ('counterclaim_created', 'third_party_intervention', 'scope_change')`; default-off for `event_type='custom_milestone'`. Form has the override checkbox in either case.
|
||||
|
||||
**Q6 — Should `/tools/fristenrechner` keep its standalone existence?** Brief says yes — knowledge tool, separate from project context. My pick: yes, agree. It stays.
|
||||
|
||||
**Q7 — Application-to-amend (UPC R.30) as sub-project or flag?** My pick: stay as flag (`with_amend`). Amendments are not a separate proceeding artifact in the CMS — they ride on the parent's record. The cross-flow rules already activate via `condition_flag`.
|
||||
|
||||
**Q8 — On the parent's SmartTimeline, do CCR rows mix into one column or stay in a parallel right-track?** My pick: parallel right-track when both are populated; collapses into one column on mobile (vertical stacking with sub-headers per track). The `[Track ▼]` chip lets desktop users opt into single-column mode.
|
||||
|
||||
**Q9 — Court-set anchor (Hauptverhandlung) creates a `paliad.appointments` row or a `paliad.deadlines` row?** My pick: `paliad.appointments` — it's an appointment, not a deadline. The new `appointments.deadline_rule_id` FK preserves the link back to the rule for downstream re-anchoring.
|
||||
|
||||
**Q10 — Is `timeline_kind` the right column name?** Alternatives: `is_timeline_milestone bool`, `surface_on_timeline bool`. My pick: keep `timeline_kind text NULL` because it lets us distinguish `milestone` (structural) from `custom_milestone` (free-form) without a second column.
|
||||
|
||||
**Q11 — Should the SmartTimeline be the only view of the project's events?** Or do we keep a "klassisch (chronologisch)" sidebar tab? My pick: SmartTimeline as the only Verlauf tab; "Audit-Log anzeigen" toggle inside the timeline reveals the chronological rendering. m uses `/admin/audit-log` (t-paliad-071) for the cross-project audit query.
|
||||
|
||||
**Q12 — Patent-level "matter list vs lane timeline" default.** My pick: lanes by default at Patent + Litigation; matter list by default at Client. The Litigation level has 1-3 child patents typically → 1-3 lanes is fine. Client can have 100+ → lanes are a toggle.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
Aggregated for the coder shift kickoff:
|
||||
|
||||
**Backend (Go):**
|
||||
- `internal/services/projection_service.go` — new, ~250 LoC.
|
||||
- `internal/handlers/projection.go` — new, GET /api/projects/{id}/timeline, ~80 LoC.
|
||||
- `internal/handlers/handlers.go` — register the new route.
|
||||
- `internal/db/migrations/NNN_project_events_timeline_kind.{up,down}.sql` — new.
|
||||
|
||||
**Frontend (TS / TSX):**
|
||||
- `frontend/src/client/views/shape-timeline.ts` — new render shape, ~300 LoC.
|
||||
- `frontend/src/client/projects-detail.ts:loadEvents` — replace with timeline fetch.
|
||||
- `frontend/src/projects-detail.tsx:74-101` — replace Verlauf markup with `<div id="project-smart-timeline">`.
|
||||
- `frontend/src/styles/global.css` — `.smart-timeline-*` styles, ~150 LoC.
|
||||
- `frontend/src/client/i18n.ts` — ~30 keys under `projects.detail.smarttimeline.*`.
|
||||
|
||||
**Tests:**
|
||||
- `internal/services/projection_service_test.go` — new (live-DB integration test, skipped without `TEST_DATABASE_URL`).
|
||||
- `internal/services/projection_service_unit_test.go` — pure-function tests (sort, level policy, override-build).
|
||||
|
||||
Slices 2-4 are scoped in §10; coder picks them up after m's gate.
|
||||
|
||||
---
|
||||
|
||||
## 13. Trade-offs flagged
|
||||
|
||||
- **Per-request projection cost.** Recomputing on every Verlauf load is fine for a single project. If m navigates to a Client-level lane view with 50 child litigations × 3 cases each, that's 150 calculator invocations. Mitigation: lane-rendering at Litigation+Client levels excludes `kind='projected'` by default (§5), so the calculator is only called on the leaf rendering. Watch in production; add per-(project, hash(overrides)) cache if needed.
|
||||
- **Migration order across active workers.** riemann is on t-paliad-170 (FilterBar Verlauf port) in parallel. Slice 1 must merge **after** their port because Slice 1 mounts the bar with new axis keys. Coordinate via head before Slice 1 PR opens.
|
||||
- **Sub-project counterclaim adds a tier.** The project tree gets deeper (Patent → Case + Patent → CCR-Sub-Case as siblings). Existing tree visualisation in t-paliad-149 handles arbitrary depth, but the per-card "in 3 children" badge needs to count the CCR child correctly — verify in Slice 3.
|
||||
- **`appointments.deadline_rule_id`** is a backward-pointing FK that doesn't exist yet. Adding it in Slice 2 is clean (nullable, no backfill needed). Just flagging that this ties appointments to deadline_rules where they previously had no link.
|
||||
- **Anchor write path can race.** Two users clicking "Datum setzen" on the same row simultaneously could both write `paliad.deadlines` rows. Mitigation: server-side check `WHERE NOT EXISTS (SELECT 1 FROM paliad.deadlines WHERE project_id=... AND rule_id=...)` before insert, otherwise PATCH the existing row. Standard pattern.
|
||||
- **What if the proceeding type changes mid-flight?** The user changes `paliad.projects.proceeding_type_id` after deadlines have been calculated. Existing actuals stay (they have `rule_id` FK pointing to the OLD rule tree). Projected rows recompute against the NEW rule tree; rule_codes that don't exist in the new tree drop out. This is the same behaviour today — flagging because the SmartTimeline makes it more visible.
|
||||
|
||||
---
|
||||
|
||||
## 14. Recommendation for implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. Slice 1 is largely boilerplate (new service + handler + render shape). Slice 2 needs the calculator integration which is well-trodden (t-paliad-131 Phase A shipped overrides). Slice 3 needs the sub-project FK design (one careful migration) and the parallel-track CSS. Slice 4 is render-policy logic, low-risk.
|
||||
|
||||
Lagrange (this worktree) parks. NOT pre-emptively flipping to coder — m gates.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
469
docs/design-universal-filter-2026-05-08.md
Normal file
469
docs/design-universal-filter-2026-05-08.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Universal filter + view-mode primitive across all entity-views
|
||||
|
||||
**Issue:** m/paliad#23 (t-paliad-163)
|
||||
**Inventor:** riemann (mai/riemann/inventor-universal)
|
||||
**Date:** 2026-05-08
|
||||
**Status:** READY FOR REVIEW — no code yet, design only.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the central position
|
||||
|
||||
m's framing is exactly right: "halfway there without custom views". The Custom Views substrate (t-paliad-144) is the missing primitive — it just hasn't been lifted from "a saved-view feature on /views/{slug}" up to "the bar that every list-shaped page reads from".
|
||||
|
||||
Concrete take:
|
||||
|
||||
- **Don't invent a new schema or a new query layer.** `internal/services/filter_spec.go` + `render_spec.go` + `view_service.go` already cover every axis the issue lists, and `POST /api/views/run` and `POST /api/views/{slug}/run` already accept ad-hoc spec overrides. The substrate's own comment says it: *"Phase B will route them here; Phase A1 leaves the wiring as a no-op for those pages."* (`internal/handlers/views.go:247`). t-paliad-163 is Phase B with a UX-shaped artifact at the front.
|
||||
- **Build one frontend `<FilterBar>` component** that consumes a `FilterSpec` + `RenderSpec` + a per-surface `axes[]` declaration, owns URL/local-state, and emits diffs. Drop it on every list-shaped surface. Each system page declares a base spec (= one of the existing `SystemView` definitions) and the supported axes.
|
||||
- **"Save current filter as named view" is one button** on the bar. It POSTs the effective spec to `/api/user-views`. The custom-view editor (`/views/new`, `/views/{slug}/edit`) becomes a power-user form for the same data the bar produces; the bar is the everyday entry point.
|
||||
- **/projects stays bespoke** (locked in t-paliad-149). Source⊥Shape orthogonality breaks for projects — they don't render as cards/calendar in the events sense, and `paliad.user_card_layouts` is a different primitive (per-card facts, not filters). The bar coexists with the `<details>`-chip cluster on /projects without subsuming it.
|
||||
|
||||
The migration is one surface at a time. /inbox first (no filter today, lowest blast radius), /events last (richest filter today, the proof point that the primitive can absorb it).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live
|
||||
|
||||
Before designing on top of CLAUDE.md / memory / the issue body, I checked the live tree:
|
||||
|
||||
- **`paliad.user_views` (056) exists.** `paliad.user_card_layouts` (061) exists. **`paliad.user_view_layouts` does NOT exist** — the issue body's reference is a typo. Real names: `paliad.user_views` is the FilterSpec/RenderSpec store; `paliad.user_card_layouts` is the per-card-facts store for /projects only. `grep -rn user_view_layouts` returns nothing.
|
||||
- **`POST /api/views/run`** takes an inline `FilterSpec` and returns `ViewRunResult{rows, inaccessible_project_ids}` without touching the DB. (`internal/handlers/views.go:248`)
|
||||
- **`POST /api/views/{slug}/run`** accepts an optional `{filter: <override>}` body that overrides the saved/system spec for one run — does not mutate storage. (`internal/handlers/views.go:282`, `runRequest` at `:238`)
|
||||
- **5 SystemViews are already code-resident** (`dashboard`, `agenda`, `events`, `inbox`, `inbox-mine`) at `internal/services/system_views.go:35`-`156`. Their slugs are reserved against user-view collisions. Each carries a canonical `FilterSpec` + `RenderSpec`.
|
||||
- **3 render-shape components exist** in `frontend/src/client/views/`: `shape-list.ts`, `shape-cards.ts`, `shape-calendar.ts`. They take `(host, rows, render)` — pure config-driven dispatch.
|
||||
- **List shape supports density (compact|comfortable), 13 known columns, and sort.** Column registry at `internal/services/render_spec.go:99`: `["date","time","title","project","actor","status","rule","event_type","location","appointment_type","approval_status","decided_by","kind"]`. Sort: `date_asc | date_desc`.
|
||||
- **`attachEventTypeMultiSelectFilter`** in `frontend/src/client/event-types.ts` is a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internal `onLangChange` subscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.
|
||||
- **`renderAgendaTimeline`** in `frontend/src/client/agenda-render.ts` is the day-grouped timeline used both by `/agenda` and dashboard inline; reusable.
|
||||
- **`.entity-table` row-click contract** is the project-wide rule (CLAUDE.md "Frontend conventions"). Any list-shape table must wire row-handlers that skip clicks on inner `<a>`/`<button>` and add `entity-table--readonly` when rows don't navigate. The bar must not regress this — it doesn't, because `shape-list.ts` already emits `entity-table--readonly` on its tables.
|
||||
|
||||
---
|
||||
|
||||
## 1. The 7 list-shaped surfaces today — what they each have
|
||||
|
||||
A factual map of who has what. The underlinings are the axes the issue calls out.
|
||||
|
||||
| Surface | Filter axes today | View modes | State store |
|
||||
|---|---|---|---|
|
||||
| **/agenda** (`client/agenda.ts`, 226 LoC) | type chip (deadlines/appointments/both), range chip (7/14/30/90d), event-type multi-select | timeline only | URL `?range=&types=&event_type=` |
|
||||
| **/events** (`client/events.ts`, 1083 LoC) — also `/deadlines`, `/appointments` via 302 redirect | type chip (deadline/appointment/all), status select (8 buckets), project select (single, with `__personal__` sentinel), event-type multi (deadline-only), appointment-type select (appointment-only) | cards / list / calendar | URL `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` |
|
||||
| **/inbox** (`client/inbox.ts`, 329 LoC) — both tabs | tab (pending-mine / mine), nothing else | list only | URL `?tab=` |
|
||||
| **/projects** (`client/projects.ts` + `client/projects-cards.ts`) | search input, 6 chips (scope/status/type/has-open-deadlines), `<details>` multi-select for status + type | tree / cards / flat | sessionStorage `paliad.projects.lastView` + URL overlay |
|
||||
| **/views/{slug}** (`client/views.ts`) | none in the viewer (only saved-spec); shape switcher (list/cards/calendar) | list / cards / calendar | URL path |
|
||||
| **dashboard** (`client/dashboard.ts`, inline Agenda + Letzte Aktivität) | none | inline timeline / inline list | none |
|
||||
| **/views/new \| /views/{slug}/edit** (`client/views-editor.ts`) | full FilterSpec form (sources / scope / time / shape / list density) | n/a — author surface | n/a |
|
||||
|
||||
The pattern m sees on `/inbox?tab=mine` is the natural endpoint of seven surfaces all building filters their own way: the surface that didn't have a filter author yet is also the surface with no filter chrome at all.
|
||||
|
||||
The good news: every axis on every surface is **already nameable in the FilterSpec / RenderSpec grammar** that `internal/services/filter_spec.go` ships. There's a one-to-one mapping; nothing has to be invented at the data layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the universal primitive is — `<FilterBar>`
|
||||
|
||||
A single TypeScript component, mounted on a host `<div>`, parameterised by:
|
||||
|
||||
```ts
|
||||
interface FilterBarOpts {
|
||||
// Base spec — usually a SystemView's FilterSpec, fetched from /api/views/system.
|
||||
// For /views/{slug}, this is the user-view's saved filter_spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface supports. Universal axes always render;
|
||||
// per-surface axes render iff present in this list.
|
||||
axes: AxisKey[];
|
||||
|
||||
// Optional fixed predicates the surface refuses to let users tweak.
|
||||
// E.g. /inbox forces sources=[approval_request], not relaxable.
|
||||
pinned?: PartialFilterSpec;
|
||||
|
||||
// Where to write rows when filter changes. The bar runs the spec via
|
||||
// /api/views/run and hands the result back here for shape rendering.
|
||||
onResult: (res: ViewRunResult, effective: { filter: FilterSpec; render: RenderSpec }) => void;
|
||||
|
||||
// Optional URL-param namespace (defaults to the empty namespace).
|
||||
// Useful for embedding the bar twice on one page (dashboard inline)
|
||||
// without colliding ?time= / ?time2=. Phase 4 ramps this up if needed.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Optional surface key — used as the localStorage key for view-mode
|
||||
// and density preferences ("paliad.bar.<surfaceKey>.prefs").
|
||||
surfaceKey: string;
|
||||
|
||||
// Optional sidebar slot — when present, "Save as view" + "Reset" are
|
||||
// rendered. Defaults to true on every surface except dashboard inline.
|
||||
showSaveAsView?: boolean;
|
||||
}
|
||||
|
||||
type AxisKey =
|
||||
| "project" // ← universal (always rendered if axes contains it; otherwise the chip is hidden)
|
||||
| "time" // ← universal
|
||||
| "personal_only" // ← universal
|
||||
| "deadline_status" // ← per-surface (deadline source only)
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "shape" // ← view-mode (list|cards|calendar)
|
||||
| "sort" // ← per-shape
|
||||
| "density" // ← list-shape only
|
||||
| "columns"; // ← list-shape only (advanced; popover with checkboxes)
|
||||
```
|
||||
|
||||
The bar's job:
|
||||
1. On mount, parse URL params (within `urlNamespace`) and `localStorage["paliad.bar.<surfaceKey>.prefs"]`, overlay them on `baseFilter` + `baseRender`, validate, and POST `/api/views/run` with the effective spec.
|
||||
2. Render chrome — chips for booleans / single-selects, popovers for multi-selects, segmented control for view-mode. Each control is a thin wrapper over an existing pattern (chip-row, multi-anchor + multi-panel, segment-control).
|
||||
3. On any change, re-validate, sync URL, sync localStorage (for prefs only — see §3), POST the spec again, hand the result + effective spec to `onResult`. The shape host renders.
|
||||
4. Expose two trailing actions (when `showSaveAsView`): **Speichern als Sicht** and **Zurücksetzen**.
|
||||
|
||||
What the bar is NOT:
|
||||
- Not a router. Pages still own their URL.
|
||||
- Not a layout system. Cards on /projects keep the `paliad.user_card_layouts` primitive (per-card facts) — that's orthogonal to filtering.
|
||||
- Not the renderer. The bar just hands `(rows, effectiveRender)` to one of `shape-list / shape-cards / shape-calendar`.
|
||||
- Not a substitute for the dedicated views editor. That stays for power-users who want full control (predicates, custom horizons, columns).
|
||||
|
||||
---
|
||||
|
||||
## 3. The 7 brief items — taking positions
|
||||
|
||||
### 3.1 Filter axes: which are universal, which are per-surface, how does the bar declare its supported axes?
|
||||
|
||||
**Universal** — render always when `axes` contains them (and the surface's pinned spec doesn't rule them out):
|
||||
- `project` — single-select with the existing `<select>` (Alle / Nur persönliche / each project, ltree-indented). On surfaces where multi-project would help later (system-wide views), the same control upgrades to a multi-select listbox-panel by adding a `multi: true` flag — postpone to phase C, single-select covers every surface today.
|
||||
- `time` — segmented chip group (`Heute · 7T · 30T · 90T · Alles · Anpassen`). Maps to `time.horizon`. "Anpassen" pops a date-range pair (`time.horizon = "custom"` + from/to). On /inbox the chip group reads "Heute · 7T · 30T · Alles" since approval queues are usually now-shaped — but the same control.
|
||||
- `personal_only` — boolean chip ("Nur eigene"). Active when `scope.personal_only=true`. Hidden when source set excludes deadline AND appointment (others don't honour personal_only).
|
||||
|
||||
**Per-surface** — declared in `axes`, controlled by which sources the spec uses:
|
||||
- `deadline_status` (chip cluster: "Offen · Überfällig · Erledigt · Alle") — only when `sources` includes deadline.
|
||||
- `deadline_event_type` (multi-select listbox-panel, reuses `attachEventTypeMultiSelectFilter`) — only when sources includes deadline.
|
||||
- `appointment_type` (single-select for now: hearing/meeting/consultation/deadline_hearing/Alle) — only when sources includes appointment.
|
||||
- `approval_viewer_role` (segmented chips: "Zur Genehmigung · Eigene Anfragen · Alle sichtbaren") — only when sources includes approval_request. This subsumes the /inbox tab.
|
||||
- `approval_status` (chip cluster: "Wartend · Entschieden · Alle") — only when sources includes approval_request.
|
||||
- `approval_entity_type` (chip pair: "Fristen · Termine") — only when sources includes approval_request.
|
||||
- `project_event_kind` (multi-select listbox-panel; the 13 `KnownProjectEventKinds`) — only when sources includes project_event. Powers the dashboard "Letzte Aktivität" filter.
|
||||
|
||||
**View-mode + per-shape** — declared in `axes`, but special:
|
||||
- `shape` — segmented chips (list/cards/calendar). Always rendered when `axes` contains `shape`; available shapes derived from `baseRender` + the surface's whitelist. The bar emits a transient render override (mirrors how `client/views.ts:171` does shape-switching today: it doesn't rerun, just re-renders).
|
||||
- `sort` — single-select (`date_asc | date_desc`).
|
||||
- `density` — segmented chip pair (Komfortabel / Kompakt) — list shape only, hidden otherwise.
|
||||
- `columns` — popover with checkbox list of `KnownListColumns` — list shape only, advanced opt-in.
|
||||
|
||||
**How the surface declares its axes:** an array. No higher-order component, no slot composition. Plain config. The bar's render is a switch over each axis key:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
baseFilter: agendaSystemView.filter,
|
||||
baseRender: agendaSystemView.render,
|
||||
axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"],
|
||||
surfaceKey: "agenda",
|
||||
onResult: ({rows, inaccessible_project_ids}, effective) => { ... },
|
||||
});
|
||||
```
|
||||
|
||||
Slot composition was considered. It's overkill — every existing chrome pattern paliad uses (chip cluster, multi-anchor popover, segmented control, `<select>`) is already in `frontend/src/styles/global.css`; there's nothing to plug or override. A flat axis-config keeps the bar a 600-LoC component, not a framework.
|
||||
|
||||
### 3.2 State model: URL vs in-memory vs hybrid
|
||||
|
||||
**Hybrid**, with a sharp split:
|
||||
|
||||
- **URL is canonical** for everything that affects which rows you see. That means: project (`?project=`), sources (`?sources=`), time (`?time=` for horizon, `?from=&to=` for custom), personal-only, every per-source predicate (`?deadline_status=`, `?event_type=`, `?appointment_type=`, `?approval_role=`, `?approval_status=`, `?approval_entity_type=`, `?project_event_kind=`), shape (`?shape=`), sort (`?sort=`). Bookmarkable, shareable, refresh-survives, deep-linkable from the dashboard or /inbox bell.
|
||||
- **localStorage holds preferences** that don't change rows: density (`?density=` is also a URL param when explicitly chosen, but absence falls through to localStorage default), default columns per surface (advanced opt-in), default shape per surface (only when the user has overridden the SystemView's default — first visit uses base). Keyed `paliad.bar.<surfaceKey>.prefs`. Mirrors the spirit of /projects' sessionStorage `paliad.projects.lastView` (t-paliad-149 Q1 lock-in) but at the right scope: the "what I prefer" sticks per surface, the "what this URL is showing" stays in the URL.
|
||||
- **No sessionStorage.** /projects' use was justified by tab restoration; for the bar, every interesting bit is in the URL (so back/forward + refresh + share both work). Adding a third tier would create the worst-of-three: state in URL ∪ session ∪ local, three places to look when something's off.
|
||||
|
||||
URL parameter names are stable and short. The bar exports a tiny URL codec (`encodeBarParams(filter, render) → URLSearchParams` and inverse) so the same params work whether the bar is on /agenda, /inbox, /events, or /views/{slug}.
|
||||
|
||||
The migration from /events' bespoke `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` to the bar's params is straightforward: each old param maps to a new one (or stays, when names already match — `?project_id`, `?personal_only`, `?event_type` are unchanged; `?type` becomes `?sources`; `?view` becomes `?shape`; `?status` and `?type_filter` become per-surface predicates). Server middleware on the legacy /events handler can rewrite old → new params for one release so existing bookmarks don't 404.
|
||||
|
||||
### 3.3 View-mode switcher — universal or per-surface? Sort-state ownership? Density?
|
||||
|
||||
**Universal.** The bar always owns the segmented `shape` control. The surface declares which shapes it whitelists (e.g. /inbox might whitelist `["list"]` and hide the switcher; /agenda might whitelist `["cards", "list", "calendar"]`). When the whitelist has only one entry the bar suppresses the chip; when ≥2 it renders.
|
||||
|
||||
**Sort lives in the bar's `RenderSpec.list.sort` / `cards.sort`.** Already exists in the schema. The list-shape table renderer is currently sort-by-config-only; promoting `<th>` clicks to update `RenderSpec.list.sort` is a one-line callback in the bar (`onListHeaderSort`) → server-side re-sort isn't needed because `shape-list.ts:16` already sorts in JS. **Sortable column headers become a list-shape feature owned by the bar**, not a per-surface concern.
|
||||
|
||||
**Density** is a list-shape config (`comfortable | compact`). The bar exposes the pair as a chip; `shape-list.ts` already supports both. Density on /inbox today is implicitly comfortable; toggling it to compact gives the user the activity-feed look on the inbox surface for free, which is the kind of small win the brief calls out.
|
||||
|
||||
**Multi-column sort** is out-of-scope for v1 — `shape-list.ts:16` does single-column sort, which matches every surface today. Add when a user asks.
|
||||
|
||||
### 3.4 Composability — drop-in API without forcing existing pages to refactor
|
||||
|
||||
The bar mounts onto an empty `<div>`. The surface's TSX changes are:
|
||||
- Replace the per-page filter chrome (chip cluster, selects, popovers, view-mode segment) with `<div id="filter-bar"></div>`.
|
||||
- Replace the per-page result rendering with `<div id="filter-bar-results"></div>`.
|
||||
- The page's `client/<surface>.ts` shrinks to: read `__PALIAD_<SURFACE>__` initial payload (or skip), call `mountFilterBar(host, opts)`, write `onResult` to dispatch into the matching shape component (already exist).
|
||||
|
||||
That's it. The page surface is reduced to ~50 LoC of orchestration around the bar; the bulk of `events.ts` (1083 LoC) drops to a baseline of ≈80 LoC after Phase 3 because the per-axis filter state, the project select populator, the language-hot-swap, the URL-sync, the type-visibility logic, the appointment-type filter logic, the calendar month-paging, and the cards-vs-list-vs-calendar dispatch all migrate into shared components: the bar (filter axes, view-mode, URL, language hot-swap), `shape-list.ts` (table), `shape-cards.ts` (cards), `shape-calendar.ts` (month grid).
|
||||
|
||||
The bar **does not own row interaction**. Row click → detail page is already a per-shape concern (`shape-list.ts` emits `entity-table--readonly`; the bar doesn't override that). Lifecycle actions (complete/reopen/approve/reject) are also per-shape — `shape-list.ts` will need a small extension to emit clickable-row tables on /events (so the existing complete-checkbox + reopen flow keeps working). That extension is one new render flag in `RenderSpec.list.row_action: "navigate" | "approve" | "complete-toggle" | "none"`, defaulting to navigate. Honest scope: this is a small `RenderSpec` schema bump (new optional field), not an axis change.
|
||||
|
||||
### 3.5 Reuse with the existing /views layout-spec — does the universal bar inherit, or does the spec become a special case of saved bar state?
|
||||
|
||||
**The latter.** m's hint ("halfway there without custom views") points at exactly this.
|
||||
|
||||
A **Custom View is the persisted form of a bar state.** When the user clicks "Speichern als Sicht" on /agenda, the bar gathers the effective `FilterSpec` + `RenderSpec`, prompts for name + slug + icon + show-count (a small modal — one form, four fields, mirroring `views-editor.ts`'s collectForm), and POSTs `/api/user-views`. The user is then redirected to `/views/{slug}` (or stays in place with a confirmation toast — see §3.7).
|
||||
|
||||
Conversely, **a SystemView is a code-resident bar state.** The bar already knows how to load one (`/api/views/system` → match slug). The "system pages" become surfaces whose default state happens to live in code instead of in `paliad.user_views`.
|
||||
|
||||
Implementation consequence:
|
||||
- `views-editor.ts` keeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning a `time.field = "created_at"` for an "audit-trail" view). The editor and the bar produce identical `FilterSpec` + `RenderSpec` JSON; they're alternate authoring UX.
|
||||
- `views.ts` (the `/views/{slug}` viewer) gains the bar above its rows. The bar renders with the saved spec as its base; the user can tweak axes (e.g. narrow the time horizon for a quick glance) — those tweaks are URL-overlays and don't mutate the saved spec until the user clicks "Aktualisieren" (a new affordance). This satisfies the brief's "halfway there" hint: today /views/{slug} renders a saved spec **statically**; with the bar, it becomes interactive without losing the saved-state semantics.
|
||||
|
||||
### 3.6 Migration path — phase one surface at a time, identify the hardest
|
||||
|
||||
The bar is shippable on one surface in one PR. Then each subsequent surface is its own small PR.
|
||||
|
||||
**Phase 1 — /inbox (the cold start).** Lowest blast radius: today /inbox has no filter chrome, only tabs. Replace tabs with the `approval_viewer_role` axis (the bar collapses two tabs into one chip cluster). Drop the bar with `axes: ["time", "approval_status", "approval_entity_type", "approval_viewer_role", "shape", "density", "sort"]`. Pin `sources: [approval_request]`. Density toggle gives the user a stream view m's "looks really bad" was diagnosing. URL contract: keep `?tab=` redirecting to `?approval_role=` for one release.
|
||||
|
||||
**Phase 2 — /agenda.** Already filter-shaped and the most readable orchestrator (226 LoC). Bar replaces the chip cluster + range chip + event-type popover. `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"]`. Default: shape="cards" (matching today's timeline default). The dashboard inline Agenda gets a stripped-down bar with `axes: ["time", "deadline_event_type"]` and `urlNamespace: "agenda"` (so the page-level bar on the dashboard doesn't collide with anything else if the dashboard adds another bar later for "Letzte Aktivität").
|
||||
|
||||
**Phase 3 — /events (the proof point).** Most complex filter today: type chip + status select + project select + personal-only + event-type multi + appointment-type select + cards/list/calendar. Every one of these axes is already nameable in FilterSpec/RenderSpec (verified §1). `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort", "density"]`. The 5-card summary above the table (Heute / Diese Woche / Nächste Woche / Später / Überfällig) becomes a bar-driven facet: clicking a card sets `time.horizon` (or for "Überfällig", a special `deadline_status: ["overdue"]` predicate). Identifying /events as the hardest surface up front means the primitive's axis registry has to be wide enough on day 1; the design above already names every needed axis, so Phase 1's primitive is forward-compatible.
|
||||
|
||||
**Phase 4 — dashboard inline lists (Agenda + Letzte Aktivität).** The dashboard composes two tiny bars: one for Agenda (cards/list, narrow time horizon, no save-as-view), one for Letzte Aktivität (project_event source, density=compact, no save-as-view). Both use `urlNamespace` to keep params tidy.
|
||||
|
||||
**Phase 5 — /views/{slug}.** Add the bar above the rows. Saved spec → bar's base; URL overlays are transient until "Aktualisieren" persists them. The custom-view editor (`/views/new`, `/views/{slug}/edit`) stays for power users; "Speichern als Sicht" from the bar is the everyday path.
|
||||
|
||||
**Out of phasing:** /projects stays bespoke. The bar coexists on the page only if a future task adds it — today the chip cluster + tree/cards/flat segment are doing fine, and Source⊥Shape orthogonality breaks for projects (no ProjectSource in the substrate; no TreeShape in the substrate). t-paliad-149's locked-in choice stands.
|
||||
|
||||
**Hardest surface, identified:** /events. Phase 3 is the proof point. By designing the bar's axis registry against /events on day 1 (not retrofitting), Phase 1 (/inbox) and Phase 2 (/agenda) ship without redesign churn.
|
||||
|
||||
### 3.7 "Save current filter as named view" — making it trivial
|
||||
|
||||
The bar's trailing action is a single button: **Speichern als Sicht**. Click → small modal:
|
||||
|
||||
```
|
||||
┌─ Sicht speichern ─────────────────────┐
|
||||
│ Name [_________________] │
|
||||
│ Slug [_________________] (opt) │
|
||||
│ Icon [▼ Auswählen ] │
|
||||
│ □ Anzahl in der Sidebar zeigen │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Speichern ] │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
If slug is empty, derive from name (kebab-case) and validate against the regex + reserved-slug list client-side (mirrors `views-editor.ts:179`). On 409 (slug taken), show inline error and let the user adjust. On success, two affordances:
|
||||
- A toast "Als Sicht 'Heute überfällig' gespeichert. Zur Sicht wechseln?" with a link to `/views/{slug}`.
|
||||
- The new view automatically appears in the **Meine Sichten** sidebar group (t-paliad-144) on next page load (or sooner, if the bar emits a window event the sidebar listens to).
|
||||
|
||||
This means: every list-shaped surface gets "save current filter as named view" for free. No per-surface plumbing.
|
||||
|
||||
**"Aktualisieren" on /views/{slug}** is the symmetric write-back: when the user is viewing a saved view and tweaks the bar, a "Aktualisieren" button appears next to "Speichern als Sicht". Click → PATCH `/api/user-views/{id}` with the effective spec. Confirmation toast.
|
||||
|
||||
**"Zurücksetzen"** clears the URL overlay and re-renders with the base spec only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two harder questions worth surfacing now
|
||||
|
||||
### 4.1 The chip-vs-popover-vs-select tension
|
||||
|
||||
paliad has three patterns for "pick from a set" today:
|
||||
|
||||
- **Chip cluster** (e.g. /agenda type chip, /projects scope chip) — best for 2–4 mutually exclusive options. Always-visible, click-fast.
|
||||
- **`<select>`** (e.g. /events status, project, appointment-type) — best for 5–30 single-select options, especially when the option list is dynamic (project list grows).
|
||||
- **Listbox-panel popover** (e.g. event-type multi, /projects status/type `<details>`) — best for multi-select or for >30 options with search.
|
||||
|
||||
The bar must use the right pattern per axis to feel native, not regress one surface in service of another. My picks:
|
||||
|
||||
| Axis | Pattern | Why |
|
||||
|---|---|---|
|
||||
| project (single) | `<select>` | dynamic list; option count grows with the firm |
|
||||
| time | chip cluster + "Anpassen" overflow | 5 mutually exclusive presets cover 95% of usage |
|
||||
| personal_only | single chip | binary |
|
||||
| sources (when `axes` exposes it) | listbox-panel multi | 4 options but multi-select |
|
||||
| deadline_status | chip cluster | 4 options, mutually exclusive |
|
||||
| deadline_event_type | listbox-panel multi | 40+ options, search + grouped checkboxes (reuses event-types.ts pattern) |
|
||||
| appointment_type | chip cluster (4 + Alle) | small mutually-exclusive set |
|
||||
| approval_viewer_role | chip cluster | 3 mutually exclusive options |
|
||||
| approval_status | chip cluster | 4 options |
|
||||
| approval_entity_type | chip cluster | 2 options |
|
||||
| project_event_kind | listbox-panel multi | 13 options, multi-select |
|
||||
| shape | segmented control | 1-of-N, special UX (icon-only buttons) |
|
||||
| sort | `<select>` (small) | 2 options today, room for `title_asc/desc` later |
|
||||
| density | segmented control | binary, icon-shaped |
|
||||
|
||||
The point: the bar isn't one widget, it's a thin shell that delegates each axis to the right existing control. CSS reuse: `.agenda-chip` / `.events-view-btn` / `.akten-multi-trigger` / `.multi-anchor` / `.multi-panel` all stay; the bar just composes them.
|
||||
|
||||
### 4.2 Empty-state UX when an axis is invalid for the current sources
|
||||
|
||||
If the user clears all sources, every per-source axis becomes meaningless. Two options:
|
||||
- **Hide invalid axes.** Cleanest. Bar reacts to source changes by collapsing dependent chips. Risk: feels jumpy.
|
||||
- **Disable + tooltip.** Less jumpy but visually noisier.
|
||||
|
||||
Recommend **hide**, with one twist: the bar persists hidden-axis state in the URL anyway, so toggling sources back on restores the user's prior filter. This matches /events' existing behaviour (when type=appointment, event-type panel is hidden but its state persists in `?event_type=`).
|
||||
|
||||
---
|
||||
|
||||
## 5. RenderSpec extensions — one schema bump
|
||||
|
||||
The bar exposes capabilities that are already in `RenderSpec` (shape, sort, density, columns) plus one new field:
|
||||
|
||||
```go
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"` // NEW — "navigate" (default) | "complete_toggle" | "approve" | "none"
|
||||
}
|
||||
```
|
||||
|
||||
`RowAction` lets `shape-list.ts` know whether to wire an `entity-table--readonly` or to attach the existing checkbox / reopen / approve / reject buttons. Default `navigate` keeps the contract stable; system pages explicitly set `complete_toggle` (events list) and `approve` (inbox list).
|
||||
|
||||
This is the only schema change. Every other axis is already in the spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard requirements from the brief — addressed
|
||||
|
||||
- **`.entity-table` row-click contract.** The bar's list-shape table is rendered by `shape-list.ts:80` which already emits `entity-table--readonly`. When `RowAction="navigate"` the bar adds a row-handler that does `window.location.href = detailRoute(row)` and skips clicks on inner `<a>`/`<button>` (mirrors the existing `events.ts:wireRowHandlers` pattern). Whole-card / whole-row click → JS row-handler, never `::before` overlays (CLAUDE.md frontend conventions, t-paliad-102).
|
||||
- **No hour estimates.** Throughout this design.
|
||||
- **DE+EN bilingual.** Every new label gets a key under `views.bar.*` (single new namespace; ~25 keys for axes + ~10 for save modal + ~10 for empty/loading/error states). Keys are added to `frontend/src/client/i18n.ts`'s registry at the appropriate phase.
|
||||
- **Mobile.** The bar collapses to a single horizontal scroll row on `≤768px` (mirrors `.frist-summary-cards` mobile pattern). The "Speichern als Sicht" + "Zurücksetzen" actions move into a `<details>` "Mehr" affordance on mobile to keep the scrollable strip clean. Re-imagining mobile-list-mode is out of scope per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 7. Trade-offs — the honest list
|
||||
|
||||
### What this design gains
|
||||
1. **One filter chrome across all list-shaped surfaces.** Users learn one bar, every surface respects it. Discoverability for "save as view" jumps from one surface (/views/new editor) to seven.
|
||||
2. **System pages become substrate clients.** `/api/views/run` (already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands.
|
||||
3. **`events.ts` shrinks ~10×.** Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.
|
||||
4. **Save-as-view is universal.** Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
|
||||
5. **/inbox gains filters and sort and density** as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
|
||||
6. **Sortable column headers** become a substrate feature (small bar callback that updates `RenderSpec.list.sort`).
|
||||
7. **The schema barely moves** — one new optional field on `ListConfig`. Migrations not needed.
|
||||
|
||||
### What this design risks
|
||||
1. **One component holding many axes is at risk of bloat.** Mitigation: the bar is a flat axis-config (no slot composition, no HOC). 600 LoC ceiling enforced by the per-axis switch pattern. CSS reuse keeps the visual surface small.
|
||||
2. **The /events migration is the largest single PR.** 1083 LoC client → ≈100 LoC + ≈250 LoC of bar config + per-shape extensions. A regression on the 5-card summary or the deadline complete/reopen flow would be visible. Mitigation: Phase 3 is gated behind Phase 1 (/inbox) and Phase 2 (/agenda) shipping cleanly, and the design lands the `RowAction` schema bump in Phase 1 so `complete_toggle` is wired before /events arrives.
|
||||
3. **URL overlay on /views/{slug} creates two states.** Saved spec ≠ effective spec when the user has tweaked the bar. The "Aktualisieren" / "Speichern als Sicht" actions resolve which becomes canonical, but a user who navigates away with unsaved tweaks loses them. Mitigation: a `?dirty=1` URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert").
|
||||
4. **Two filter chromes coexist on /projects.** The bar doesn't subsume the chip cluster (Source⊥Shape break). Future visual unification would standardise the chip pattern between the two — out of scope here.
|
||||
5. **Hidden-axis URL state.** Persisting `?event_type=` even when sources excludes deadline can confuse a user reading their URL. Acceptable: matches /events' current behaviour and is reversible by toggling the source back. The alternative (pruning URL params on source change) loses the user's prior state on a quick re-toggle.
|
||||
6. **i18n hot-swap correctness.** Every dynamic populator must subscribe to `onLangChange` (the t-paliad-117 lesson). The bar handles this once internally for every axis; surfaces don't need to wire it per-page.
|
||||
7. **Default per-surface defaults can drift from SystemView.** The bar reads `localStorage` for prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation: `localStorage` only stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden.
|
||||
8. **Two storage primitives ("user_views" + "user_card_layouts") could be confusing.** Names are similar; they store different things. Mitigation: documentation. The bar only ever reads/writes `paliad.user_views`. /projects' card-layout is a separate, narrow concern that stays bespoke.
|
||||
|
||||
### Reversibility
|
||||
- The bar is purely additive. Phase 1 doesn't touch /agenda or /events. If after Phase 1 the bar feels wrong, /inbox can revert to its prior chrome by reverting one PR. Phase 2 only ships after Phase 1 holds.
|
||||
- The new `RenderSpec.list.row_action` field is optional with a `navigate` default; existing rows continue to render correctly.
|
||||
- The URL contract is preserved for /events for one release via a thin redirect middleware that maps old → new params; bookmarks don't 404.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m before lock-in
|
||||
|
||||
These are decisions where my recommendation might be challenged:
|
||||
|
||||
**Q1. State model: full URL-canonical, or do we accept localStorage for shape/density preferences?** I recommend hybrid: URL for filter axes, localStorage for shape + density prefs (per-surface). Keeps shareable URLs honest while letting "I always want compact density on /inbox" persist across sessions.
|
||||
|
||||
**Q2. Save-as-view modal vs slide-out vs inline.** I recommend modal — minimal surface, four fields, blocks the page. Alternatives: a slide-out (less interruption, more work) or an inline expansion of the "Speichern" button (cramped on mobile). Modal lines up with existing `<dialog>` usage on /admin.
|
||||
|
||||
**Q3. /events 5-card summary — keep, or fold into the bar?** I recommend keep (above the bar, unchanged). The cards encode urgency at a glance; collapsing them into the bar's `time` chip would lose the "9 / 3 / 2 / 5 / Überfällig 1" density. Clicking a card still updates the bar's time horizon (existing behaviour preserved).
|
||||
|
||||
**Q4. Tabs on /inbox — collapse into the `approval_viewer_role` chip cluster, or keep tabs as visual chrome above the bar?** I recommend collapse — one fewer place for state, the chip cluster is exactly the right control for 3 mutually exclusive options. Counter-argument: tabs are a strong visual hint of "two pages with the same shape". My counter-counter: the bar's chips are the same hint, less mid-air.
|
||||
|
||||
**Q5. URL parameter naming.** I recommend short, namespaced names: `?time=`, `?sources=`, `?project=`, `?personal=`, per-source predicate names (`?d_status=` for deadline.status, `?a_role=` for approval_request.viewer_role, `?pe_kind=` for project_event.event_types). Cargo-friendly to long names like `?deadline_status=` if m prefers — same axis, same wire format.
|
||||
|
||||
**Q6. "Speichern als Sicht" on the dashboard inline bars — show or hide?** I recommend hide. The dashboard composes two tiny bars; saving a sub-bar's spec as a custom view would feel disjoint from the dashboard concept. Power users can craft custom views via /views/new instead.
|
||||
|
||||
**Q7. Migration: do we keep `?type=` redirecting on /events for one release, or hard-cut?** I recommend keep for one release (small middleware in `internal/handlers/events_pages.go`) so existing bookmarks (Sidebar, internal docs, the /events sidebar links at `events.ts:838`) keep working through Phase 3.
|
||||
|
||||
**Q8. /views/{slug} — should the URL overlay tweak persist in localStorage as a "draft" until the user resets or saves?** I recommend no — URL is the only state, and a tweak that disappears on reload matches user expectation. The `?dirty=1` toast is enough. Alternative: a per-view-id `paliad.bar.view-{id}.draft` localStorage key that re-applies on re-visit — more powerful, more surprising.
|
||||
|
||||
**Q9. Sortable column headers — list shape only, or also rule for cards/calendar in a future phase?** I recommend list-shape only for v1. Cards and calendar have their own ordering semantics (group_by + within-group sort); promoting headers would over-complicate.
|
||||
|
||||
**Q10. Bar embedding twice on dashboard — `urlNamespace` worth the complexity, or single namespace and accept that dashboard's two bars share `?time=`?** I recommend `urlNamespace` for dashboard only (e.g. `?agenda_time=` and `?activity_time=`). Costs ~10 LoC, keeps two bars from colliding.
|
||||
|
||||
**Q11. Multi-project select — phase C, or fold into Phase 2?** I recommend phase C. Single-project covers every surface today; multi-project unlocks "all my Düsseldorf cases this week" type queries but no current page asks for it. Save complexity until a user does.
|
||||
|
||||
**Q12. EventTypeMultiSelect today supports `none` ("Ohne Typ") — keep or drop?** I recommend keep. The bar's deadline_event_type axis just wraps `attachEventTypeMultiSelectFilter`, so `none` works as-is. Honestly nothing to design here.
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope boundaries (in + out)
|
||||
|
||||
### In scope
|
||||
- New `<FilterBar>` component + axis registry + URL codec.
|
||||
- One `RenderSpec.list.row_action` field with validator update.
|
||||
- Phase 1: /inbox surface + tests.
|
||||
- Documentation + i18n keys for the bar.
|
||||
- Phase 2..5 named in the migration path with clear gates between them — but each is its own PR and not part of "the inventor design has shipped" definition-of-done.
|
||||
|
||||
### Out of scope (per the brief + my reading)
|
||||
- New entity surfaces. Only the 7 named surfaces.
|
||||
- Backend SQL migrations beyond the one optional `RenderSpec.list.row_action` field. The bar runs through `/api/views/run` which already exists.
|
||||
- /projects redesign — t-paliad-149 stands.
|
||||
- Mobile-list-mode reimagining — separate workstream.
|
||||
- Multi-project selection — phase C, not v1.
|
||||
- Multi-column sort — when a user asks.
|
||||
- Internationalisation beyond DE + EN.
|
||||
|
||||
---
|
||||
|
||||
## 10. Files implementer will touch (Phase 1: /inbox)
|
||||
|
||||
To make the scope concrete:
|
||||
|
||||
**New:**
|
||||
- `frontend/src/components/FilterBar.tsx` — TSX wrapper with the host divs.
|
||||
- `frontend/src/client/filter-bar/index.ts` — `mountFilterBar` entry point.
|
||||
- `frontend/src/client/filter-bar/axes.ts` — per-axis render functions (one per `AxisKey`).
|
||||
- `frontend/src/client/filter-bar/url-codec.ts` — `encode/decode/diffWithBase`.
|
||||
- `frontend/src/client/filter-bar/save-modal.ts` — the "Speichern als Sicht" modal.
|
||||
- `frontend/src/client/filter-bar/types.ts` — `FilterBarOpts`, `AxisKey`.
|
||||
- `frontend/src/client/filter-bar/i18n.ts` — namespace registry helper.
|
||||
|
||||
**Modified (Phase 1):**
|
||||
- `frontend/src/inbox.tsx` — replace tab row with `<div id="filter-bar">` + `<div id="filter-bar-results">`.
|
||||
- `frontend/src/client/inbox.ts` — shrink to `mountFilterBar(host, {baseFilter: inboxSystemView, axes: [...], onResult: renderListShape})`.
|
||||
- `internal/handlers/inbox.go` — add `?approval_role=` redirect from old `?tab=` for one release. (The actual rows continue to come from `/api/views/run` via the bar.)
|
||||
- `internal/services/render_spec.go` — add `RowAction` field + validator + `KnownRowActions = ["navigate", "complete_toggle", "approve", "none"]`.
|
||||
- `frontend/src/client/views/types.ts` — TS mirror of the new `RowAction` field.
|
||||
- `frontend/src/client/views/shape-list.ts` — honour `RowAction` (navigate is the existing default; `approve` mounts approve/reject buttons; `complete_toggle` mounts the checkbox).
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — ~30 new keys under `views.bar.*`.
|
||||
- `frontend/src/styles/global.css` — bar layout + mobile rules. Reuses existing `.agenda-chip`, `.akten-multi-*`, `.frist-summary-card`, `.multi-anchor`/`.multi-panel`, `.events-view-btn` styles.
|
||||
|
||||
**Tests (Phase 1):**
|
||||
- `internal/services/render_spec_test.go` — add cases for `RowAction` validator (8 cases: each enum value + invalid + omitted + …).
|
||||
- `frontend/src/client/filter-bar/url-codec.test.ts` — round-trip encode/decode for every `AxisKey`.
|
||||
- `internal/handlers/inbox_redirect_test.go` — old-tab → new-axis redirect.
|
||||
|
||||
**Phase 2..5 file lists** are not enumerated here — each is a separate PR with its own surface refactor and follows the same shape (replace per-page chrome + URL sync, mount the bar, hand `onResult` to the existing shape components).
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
**Pattern-fluent Sonnet coder** is the right fit. Substrate is well-trodden:
|
||||
- Custom Views client + render shapes already exist (t-paliad-144).
|
||||
- Multi-select listbox-panel already exists (`event-types.ts`).
|
||||
- Chip-row pattern exists on `/agenda`, `/projects`, `/events`.
|
||||
- Save modal pattern exists on `/views/new` (`views-editor.ts`).
|
||||
- URL-sync pattern exists on every system page.
|
||||
|
||||
The first PR (Phase 1: /inbox + bar scaffolding + `RowAction` schema bump) is contained and reviewable in one window. Subsequent phases are smaller — they're "swap in the bar and delete page-local code".
|
||||
|
||||
I am happy to be the coder if m wants minimum context-switch — riemann has the live model of every piece of this design. Equally happy to hand off to a fresh Sonnet coder with this doc as the brief; the doc is intended to be self-contained for that path.
|
||||
|
||||
The head decides.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasing summary (no estimates, just order)
|
||||
|
||||
1. /inbox migration + `<FilterBar>` scaffolding + `RowAction` schema bump.
|
||||
2. /agenda migration.
|
||||
3. /events migration (proof point — most complex filter today, biggest LoC delta).
|
||||
4. Dashboard inline bars (Agenda + Letzte Aktivität).
|
||||
5. /views/{slug} bar overlay + "Aktualisieren" affordance.
|
||||
|
||||
Each phase is its own PR. Phases must merge in order; m's merge gate at every step.
|
||||
|
||||
---
|
||||
|
||||
## 13. Why this is worth an inventor
|
||||
|
||||
m's last line in the brainstorm: *"worth an inventor?"*. Yes — and the reason is exactly what the design doc surfaces: the substrate already exists, the schema's right, the run endpoints are shipped, and 5 SystemViews are already declared. A coder coming in cold would either (a) not realise the substrate is there and reinvent it, or (b) realise and underestimate how much per-surface chrome can collapse into one bar. The inventor's job here was to read what's there, name the bar primitive, identify /events as the proof point, propose the one schema bump (`RowAction`) that makes /inbox shippable in Phase 1, and resist designing a layout-spec system that's already covered by `RenderSpec`.
|
||||
|
||||
Stop. DESIGN READY FOR REVIEW.
|
||||
394
docs/research-determinator-coverage-2026-05-08.md
Normal file
394
docs/research-determinator-coverage-2026-05-08.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Research — Determinator coverage audit (gaps + smart-navigation framing)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-167 (Gitea m/paliad#26)
|
||||
**Mode:** read-only research; produces a gap matrix + design framing, not migrations.
|
||||
|
||||
Builds on `docs/audit-upc-rop-deadlines-2026-05-08.md` (t-paliad-159) which drove from the UPC Rules of Procedure outward. This one drives from **paliad's own corpus** outward: every active rule, every firm-wide event_type, every cascade leaf — and asks "can a Determinator user actually reach this row?"
|
||||
|
||||
m's prompt (verbatim, 2026-05-08 22:24 Determinator dogfooding):
|
||||
|
||||
> We are still missing all kinds of orders in our decision tree. What do we need to do to cover everything? Can we maybe check what "options" we have covered in our tree and which we don't? I want to have a smart way to navigate people through the tree to determine what's next.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and method
|
||||
|
||||
**Five surfaces, three pathways.**
|
||||
|
||||
paliad currently has three independent ways to land on a deadline:
|
||||
|
||||
- **Pathway A — Fristenrechner (proceeding tree).** User picks a proceeding type (`UPC_INF`, `DE_NULL`, `EPA_OPP`, …) and a trigger date; the engine emits the entire timeline. Source: `paliad.deadline_rules` rows where the parent proceeding has `category='fristenrechner'` (19 active proceeding types).
|
||||
- **Pathway B — Determinator cascade.** User answers "what just happened?" by drilling 1-3 levels through `paliad.event_categories` (6 roots → 27 → 49 → 43 leaves; 103 leaves total). Each leaf maps to one or more `paliad.deadline_concepts` via `paliad.event_category_concepts`. Concepts then resolve to rules (`deadline_rules.concept_id`) and event_types (`deadline_concept_event_types`, mig 072).
|
||||
- **Pathway C — Trigger-event search.** Free-text `paliad.trigger_events` lookup (102 youpc-imported rows). Used by the t-paliad-086 "Was kommt nach…" mode and by autocomplete. Out of audit scope here — no Determinator surface uses it.
|
||||
|
||||
**Reachability rule.** For this audit, "reachable from the Determinator cascade" means: there exists some leaf `L` in `event_categories` such that `event_category_concepts(L → C)` and either:
|
||||
- (rule-side) `deadline_rules.concept_id = C` for the rule under test, or
|
||||
- (event_type-side) `deadline_concept_event_types(C, E)` for the event_type under test.
|
||||
|
||||
Concepts that exist but never appear in `event_category_concepts` are **dead-end concepts** — Pathway A may use them, Pathway B can't.
|
||||
|
||||
**Inventory snapshot (live youpc Supabase, 2026-05-08 22:30):**
|
||||
|
||||
| Surface | Rows | Notes |
|
||||
|---|---|---|
|
||||
| `proceeding_types` (`category='fristenrechner'`) | 19 | UPC×8, DE×5, EPA×2, EP×1, DPMA×3 |
|
||||
| `proceeding_types` (`category='litigation'`, legacy/dormant) | 7 | INF, REV, CCR, AMD, APM, APP, ZPO_CIVIL — see §2.1 |
|
||||
| `deadline_rules` active | 172 | 95 true deadlines (`duration_value > 0`), rest are anchors / court-set |
|
||||
| `deadline_rules` true deadlines, `category='fristenrechner'` only | **76** | The audit denominator |
|
||||
| `event_categories` active | 125 | 6 roots, 103 leaves |
|
||||
| `event_category_concepts` mappings | 153 | 45 distinct concepts in cascade |
|
||||
| `deadline_concepts` active | 57 | 45 in cascade, 12 dead-end |
|
||||
| `event_types` firm-wide active | 44 | 26 reachable, 18 unreachable |
|
||||
| `deadline_concept_event_types` (mig 072) | 32 rows / 25 concepts / 30 event_types | The Regel↔Typ junction |
|
||||
|
||||
**Cascade root inventory (Pathway B entry chips):**
|
||||
|
||||
| Root | Children | Leaves | Purpose |
|
||||
|---|---|---|---|
|
||||
| `cms-eingang` | gericht / gegenseite | 50 | Inbound — paper just landed |
|
||||
| `muendl-verhandlung` | geladen / gehalten / verlegt / zwischenverfahren | 4 | Hearing-pivot |
|
||||
| `beschluss-entscheidung` | (11 leaf decisions per forum) | 11 | Decision-pivot — duplicate of `cms-eingang.gericht.endentscheidung.*` |
|
||||
| `frist-verpasst` | upc / de-patg / de-zpo / epa / dpma | 5 | Wiedereinsetzung family |
|
||||
| `ich-moechte-einreichen` | klage / berufung / widerklage / spätere-schriftsätze / einspruch | 32 | Outbound — file something |
|
||||
| `sonstiges` | — | 1 (dangling, no concept) | Escape hatch |
|
||||
|
||||
**Per-forum cascade depth:** UPC has 38 reachable leaves, DE 35, EPA 11, DPMA 7. The DE corpus is now within 8% of UPC's — the imbalance flagged in earlier audits is largely closed. EPA/DPMA remain underbuilt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory by jurisdiction
|
||||
|
||||
Each section answers the same three questions: (a) which rules exist, (b) are they reachable from the cascade, (c) what's missing relative to a real practitioner's everyday surface area.
|
||||
|
||||
### 2.1 Legacy / dormant proceedings (out of scope but worth flagging)
|
||||
|
||||
The 7 `category='litigation'` proceedings (INF, REV, CCR, APM, AMD, APP, ZPO_CIVIL) carry **40 active rules** between them but:
|
||||
- 0 cascade references (`event_category_concepts.proceeding_type_code` never names them),
|
||||
- 0 concept_id linkage on any of their 18 true deadlines,
|
||||
- not surfaced in the Fristenrechner UI (filtered by `category='fristenrechner'` in `deadline_rule_service.go:740`).
|
||||
|
||||
These rows are zombie taxonomy from migration 008/009 — superseded by the `UPC_*` / `DE_*` / `EPA_*` / `DPMA_*` family in mig 012/042/043/044. **Recommendation:** flag them `is_active=false` in a follow-up cleanup migration; they only confuse audits.
|
||||
|
||||
The audit denominator is therefore **76 true Fristenrechner deadlines across 19 active proceedings**.
|
||||
|
||||
### 2.2 UPC
|
||||
|
||||
Most-mature jurisdiction. 8 proceedings, 40 true deadlines, 39 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Notes |
|
||||
|---|---|---|---|
|
||||
| UPC_INF | 11 | 10 | `inf.app_to_amend` (RoP.030.1, 2mo) has no concept_id — Pathway A only |
|
||||
| UPC_REV | 9 | 9 | Plus 2 duration bugs flagged in t-paliad-159 (R.49.1 3→2mo, R.52 2→1mo) |
|
||||
| UPC_PI | 0 | n/a | All 4 rules are anchors / court-set (no calendar arithmetic) |
|
||||
| UPC_APP | 5 | 5 | 3 rule_code-drift bugs flagged in t-paliad-159 (R.224.1.a, R.224.2.a, R.235.2) |
|
||||
| UPC_DAMAGES | 3 | 3 | |
|
||||
| UPC_DISCOVERY | 3 | 3 | |
|
||||
| UPC_COST_APPEAL | 1 | 1 | Tree-end leaf still missing R.155 chain |
|
||||
| UPC_APP_ORDERS | 4 | 4 | R.224.2.b grounds-on-orders missing entirely (RoP audit gap 6) |
|
||||
|
||||
**Cascade-side gaps that t-paliad-159 surfaced and remain open:**
|
||||
- R.19 Preliminary Objection (no leaf, no rule, no event_type — but `upc_preliminary_objection` event_type exists, archived from cascade)
|
||||
- R.197.3 Saisie review request, R.198/R.213 31d-or-20wd start-of-merits
|
||||
- R.262.2 Confidentiality response (14d) — daily occurrence in HLC infringement, completely absent from both pathways
|
||||
- R.333.2 Review of CMO (15d) — trigger event #16 exists, no rule, no leaf
|
||||
- R.353 Rectification (1mo) — trigger event #41 exists, no rule, no leaf
|
||||
- R.207.6.a / R.229.2 / R.71 Mängelbeseitigung — registry-correction family entirely missing
|
||||
- R.109.1 / R.109.4 / R.109.5 oral-hearing translation prep (only `before`-mode rules in the corpus)
|
||||
|
||||
### 2.3 DE (Zivilgericht + Bundesinstanzen)
|
||||
|
||||
5 proceedings, 22 true deadlines, all 22 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Cascade entry |
|
||||
|---|---|---|---|
|
||||
| DE_INF | 6 | 6 | `cms-eingang.gegenseite.de-inf.*` + `urteil-de-inf-lg` |
|
||||
| DE_NULL | 5 | 5 | `cms-eingang.gegenseite.de-null.*` + `urteil-de-null-bpatg` |
|
||||
| DE_INF_OLG | 3 | 3 | `urteil-de-inf-lg` (Berufung-Begründung) |
|
||||
| DE_INF_BGH | 5 | 5 | `urteil-de-inf-olg` (NZB / NZB-Begründung / Revisionsfrist / Revisionsbegründung) |
|
||||
| DE_NULL_BGH | 3 | 3 | `urteil-de-null-bpatg` (Berufung BGH) |
|
||||
|
||||
**Headline DE gaps (entirely uncovered by both pathways):**
|
||||
- **Hinweisbeschluss** — `cms-eingang.gericht.hinweisbeschluss` leaf exists and links to `response-to-preliminary-opinion` concept, but **no rule row computes a deadline from it**. The concept has 1 rule (`r79-further-stellungnahme`, 2mo) wired to EPA_OPP only. The DE Hinweisbeschluss deadline (4 weeks under §139 ZPO is judge-set; under § 522 ZPO Berufung-Hinweis is judge-set with min 2 weeks) is not in the rule corpus.
|
||||
- **Beweisbeschluss / Beweissicherungsanordnung (DE)** — `cms-eingang.gericht.anordnung` leaf exists but only links to `request-for-discretionary-review` (UPC R.220.3). No DE-side reaction (e.g. Stellungnahme nach Beweisaufnahme, § 411 ZPO 2-week comment on Sachverständigengutachten).
|
||||
- **Streitwertbeschluss** — neither cascade leaf nor rule. Streitwertbeschwerde is § 68 GKG, 6 months → frequent and unrepresented.
|
||||
- **Versäumnisurteil** — leaf `versaeumnisurteil` exists with concept `versaeumnisurteil-einspruch`, but the concept has 0 rules. The 2-week Einspruch deadline (§ 339 Abs. 1 ZPO) is documented in the concept text but doesn't compute. A user lands on the leaf and gets a hint card, no calendar entry.
|
||||
- **ZPO Klage as starting point** — Pathway A has a legacy `ZPO_CIVIL` proceeding (dormant per §2.1) but no live equivalent; Pathway B's `cms-eingang.gegenseite.de-inf.klageschrift` covers the *defendant*'s perspective only. A claimant entering "I just filed a Klageschrift" has no path.
|
||||
- **Schriftsatznachfristsetzung (§ 283 ZPO)** — concept `schriftsatznachreichung` exists in cascade with 0 rules; "court grants me a 3-week response window" produces no calendar entry.
|
||||
|
||||
### 2.4 EPO
|
||||
|
||||
2 active proceedings (EPA_OPP, EPA_APP) plus 1 grant-side outlier (EP_GRANT). 12 true deadlines, 8 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Notes |
|
||||
|---|---|---|---|
|
||||
| EPA_OPP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-opp.einspruchsschrift` + `entscheidung-epa-opp` |
|
||||
| EPA_APP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-app` + `entscheidung-epa-boa` |
|
||||
| **EP_GRANT** | **4** | **0** | All 4 unreachable — concepts (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) have no `event_category_concepts` row |
|
||||
|
||||
**EP_GRANT is the single biggest blanket-gap in the audit.** The 4 most fundamental EPO grant-side deadlines (R.70(1) examination request 6mo, Art. 93 publication, R.71(3) approval+translation 4mo, search-report 6mo) are computable in Pathway A but the cascade has zero entry points for them. A user landing on the Determinator says "EP-Anmeldung erteilt, was nun?" and finds nothing.
|
||||
|
||||
**Headline EPO gaps (both pathways):**
|
||||
- **R.71(3) communication received** — `cms-eingang.gericht.rechtsverlust-epa` covers the *negative* outcome (Rechtsverlust → Weiterbehandlung/Wiedereinsetzung) but the *positive* outcome (Mitteilung nach R.71(3) → 4-month approval+translation) has no leaf. The concept exists (`approval-and-translation`) but no leaf binds it.
|
||||
- **R.94(3) examination-stage Bescheid** — entirely absent. Most-frequent EPO deadline in prosecution practice ("4-month period to respond to examination report"); no rule, no leaf, no event_type.
|
||||
- **EPO opposition reply** — event_type `epo_opposition_reply` exists, archived from cascade (no concept link). Pathway A's EPA_OPP has the rule but no Pathway B path.
|
||||
- **R.116 EPO oral-proceedings final-submissions** — covered (`r116-final-submissions` concept, 2 rules, leaf `muendl-verhandlung.geladen` + `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`).
|
||||
- **Annual renewal fees (Art. 86 EPC)** — `epo_renewal_fee` event_type exists, archived from cascade. No concept, no rule.
|
||||
|
||||
### 2.5 DPMA
|
||||
|
||||
3 active proceedings (DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB). 6 true deadlines, all 6 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Cascade entry |
|
||||
|---|---|---|---|
|
||||
| DPMA_OPP | 2 | 2 | `cms-eingang.gegenseite.dpma-opp` + `entscheidung-dpma` |
|
||||
| DPMA_BPATG_BESCHWERDE | 2 | 2 | `entscheidung-dpma` (Beschwerde) + `beschluss-bpatg-beschwerde` |
|
||||
| DPMA_BGH_RB | 2 | 2 | `beschluss-bpatg-beschwerde` (Rechtsbeschwerde) |
|
||||
|
||||
**Headline DPMA gaps (both pathways):**
|
||||
- **Beanstandungsbescheid (Prüfungsverfahren)** — DPMA examination-stage objection notice with 4-month default response window (§ 45 PatG). No rule, no leaf, no event_type. Most-frequent DPMA deadline in real practice and entirely unrepresented.
|
||||
- **Aktenversendungsbescheid / Anhörungsbescheid (Einspruchsverfahren)** — § 59 PatG opposition oral-hearing summons; no leaf.
|
||||
- **Anmeldetag-Mitteilung / Recherchenbericht (DPMA)** — `dpma_examination_request` event_type exists with concept link to `request-for-examination`, but the concept is a Pathway-A-only dead-end (not in cascade).
|
||||
- **Patenterteilungsbeschluss** — no leaf for the positive grant decision (the negative-outcome Beschluss-BPatG path covers appeals, not the grant-stage event).
|
||||
|
||||
### 2.6 Cross-cutting (procedural orders that span jurisdictions)
|
||||
|
||||
The categories m specifically called out — "court orders that aren't entry events but procedural orders." Status:
|
||||
|
||||
| Order type | UPC | DE | EPA | DPMA | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| Hinweisbeschluss / vorläufige Würdigung | concept-only | concept-only (no rule) | n/a | n/a | Leaf `cms-eingang.gericht.hinweisbeschluss` exists; the only rule wired to `response-to-preliminary-opinion` is EPA-side R.79. Judge-set period in DE/UPC; the leaf produces no calendar entry. |
|
||||
| Beweisbeschluss / Beweissicherungsanordnung | partial (R.196/R.197) | absent | n/a | n/a | Trigger events #26 / #44 / #65 / #66 exist; only R.197.3 (saisie review 30d) is missing as a rule. § 411 ZPO 2-week Stellungnahme-Frist nowhere. |
|
||||
| Streitwertbeschluss | n/a | absent | n/a | n/a | § 68 GKG 6-month Streitwertbeschwerde — common, unrepresented. |
|
||||
| Versäumnisurteil | n/a | leaf-only (no rule) | n/a | n/a | § 339 ZPO 2-week Einspruch — concept `versaeumnisurteil-einspruch` carries 0 rules. |
|
||||
| Case-Management-Order (R.220.1.c / § 273 ZPO) | partial | absent | n/a | n/a | UPC R.333.2 review-of-CMO 15d missing; trigger event #16 exists. |
|
||||
| Berichtigungsbeschluss / Tatbestandsberichtigung | absent | absent | n/a | n/a | UPC R.353 1mo / § 320 ZPO 2-week — both unrepresented. |
|
||||
| Konfidentialitätsantrag der Gegenseite | absent | n/a | n/a | n/a | UPC R.262.2 14d — high-frequency in HLC infringement work. |
|
||||
| R.71(3) communication | n/a | n/a | absent | n/a | The most-common EPO prosecution deadline. |
|
||||
| Examination-stage Bescheid | n/a | n/a | absent (R.94(3)) | absent (§ 45 PatG) | 4-month response. Single biggest *prosecution* gap. |
|
||||
| Mängelbeseitigung notification | absent (R.71/R.207.6.a/R.229.2) | absent | absent | absent | Cross-jurisdictional gap. Trigger event #71 exists for UPC. |
|
||||
| Translation lodging order | absent (R.109.5) | n/a | n/a | n/a | `before`-mode rules — schema supports, no data. |
|
||||
| Rechtsverlust-Mitteilung | n/a | n/a | leaf-only (covered) | n/a | Only EPA branch wired (`weiterbehandlung` + `wiedereinsetzung`). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cascade reachability tables
|
||||
|
||||
### 3.1 Rule reachability per proceeding
|
||||
|
||||
| Proceeding | True deadlines | No concept | Reachable | Unreachable (concept exists, not in cascade) |
|
||||
|---|---|---|---|---|
|
||||
| UPC_INF | 11 | 1 (`inf.app_to_amend`) | 10 | 0 |
|
||||
| UPC_REV | 9 | 0 | 9 | 0 |
|
||||
| UPC_APP | 5 | 0 | 5 | 0 |
|
||||
| UPC_DAMAGES | 3 | 0 | 3 | 0 |
|
||||
| UPC_DISCOVERY | 3 | 0 | 3 | 0 |
|
||||
| UPC_COST_APPEAL | 1 | 0 | 1 | 0 |
|
||||
| UPC_APP_ORDERS | 4 | 0 | 4 | 0 |
|
||||
| EP_GRANT | 4 | 0 | 0 | **4** |
|
||||
| DE_INF | 6 | 0 | 6 | 0 |
|
||||
| DE_NULL | 5 | 0 | 5 | 0 |
|
||||
| DE_INF_OLG | 3 | 0 | 3 | 0 |
|
||||
| DE_INF_BGH | 5 | 0 | 5 | 0 |
|
||||
| DE_NULL_BGH | 3 | 0 | 3 | 0 |
|
||||
| EPA_OPP | 4 | 0 | 4 | 0 |
|
||||
| EPA_APP | 4 | 0 | 4 | 0 |
|
||||
| DPMA_OPP | 2 | 0 | 2 | 0 |
|
||||
| DPMA_BPATG_BESCHWERDE | 2 | 0 | 2 | 0 |
|
||||
| DPMA_BGH_RB | 2 | 0 | 2 | 0 |
|
||||
| **Total** | **76** | **1** | **71** | **4** |
|
||||
|
||||
**Reachability rate: 71/76 = 93.4 %.** The 5 unreachable rules concentrate in two clusters:
|
||||
- `UPC_INF.inf.app_to_amend` (RoP.030.1, 2mo) — no concept_id assigned. Recommended fix: link to `defence-to-application-to-amend` or create a new `application-to-amend` concept.
|
||||
- All 4 `EP_GRANT` rules — concepts exist (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) but none has an `event_category_concepts` row. Recommended fix: add an EP-Grant subtree under either `cms-eingang.gericht` or a new `ich-moechte-einreichen.ep-grant` branch.
|
||||
|
||||
### 3.2 Event_type reachability (firm-wide active types only, n=44)
|
||||
|
||||
**Reachable via cascade (26 of 44):**
|
||||
|
||||
| Slug | Category | Jurisdiction |
|
||||
|---|---|---|
|
||||
| de_klageerwiderung | submission | DE |
|
||||
| dpma_appeal | submission | DPMA |
|
||||
| dpma_opposition | submission | DPMA |
|
||||
| epo_appeal_grounds, epo_appeal_notice, epo_opposition_filing | submission | EPO |
|
||||
| upc_application_for_cost_decision, upc_application_for_damages | submission | UPC |
|
||||
| upc_counterclaim_for_infringement, upc_counterclaim_for_revocation | submission | UPC |
|
||||
| upc_cross_appeal_2242a (×2 concepts) | submission | UPC |
|
||||
| upc_defence_to_amend_patent, upc_defence_to_revocation | submission | UPC |
|
||||
| upc_grounds_of_appeal_2242a (×2 concepts) | submission | UPC |
|
||||
| upc_protective_letter, upc_rejoinder_to_reply, upc_reply_to_defence | submission | UPC |
|
||||
| upc_reply_to_defence_to_amend_patent, upc_reply_to_defence_to_revocation | submission | UPC |
|
||||
| upc_request_to_lay_open_books | submission | UPC |
|
||||
| upc_statement_for_revocation, upc_statement_of_appeal_2201 | submission | UPC |
|
||||
| upc_statement_of_claim, upc_statement_of_defence | submission | UPC |
|
||||
| upc_statement_of_defence_no_ccr, upc_statement_of_defence_with_ccr | submission | UPC |
|
||||
|
||||
**Unreachable (18 of 44):**
|
||||
|
||||
| Slug | Category | Why unreachable |
|
||||
|---|---|---|
|
||||
| upc_decision_of_epo | decision | Concept missing, no junction row |
|
||||
| upc_decision_on_costs | decision | Junction → `cost-decision` concept; that concept is dead-end (not in cascade) |
|
||||
| upc_decision_on_merits | decision | No junction row |
|
||||
| upc_final_decision | decision | No junction row |
|
||||
| upc_oral_hearing | hearing | Junction → `oral-hearing` concept; dead-end |
|
||||
| upc_case_management_order | order | Junction → `order` concept; dead-end |
|
||||
| upc_order_lodge_translations | order | No junction row |
|
||||
| upc_summons_oral_hearing | service | No junction row |
|
||||
| upc_application_to_amend_patent | submission | No junction row (parallel to UPC_INF gap above) |
|
||||
| upc_defence_to_statement_dni, upc_statement_dni | submission | DNI family (RoP audit gap 23) — no rule, no concept, no leaf |
|
||||
| upc_grounds_of_appeal_2242b | submission | RoP audit gap 6 — R.224.2.b orders-track grounds entirely missing |
|
||||
| upc_preliminary_objection | submission | RoP audit gap 5 — R.19 entirely missing |
|
||||
| dpma_examination_request | submission | Junction → `request-for-examination`; dead-end |
|
||||
| epo_renewal_fee, contract_renewal | fee | No junction row, no concept |
|
||||
| epo_opposition_reply | submission | No junction row |
|
||||
| stellungnahme | submission | No junction row, no concept (generic catch-all) |
|
||||
|
||||
**Pattern.** The 18 unreachable types split into three groups:
|
||||
- **Court-side trigger types (8/18)**: decisions, orders, hearings, summons. The cascade is *reaction*-oriented (clicking a leaf yields "what's next") and cannot represent these as endpoints because they are themselves the entry points of reaction trees. Adding them via the `ich-moechte-einreichen` root is structurally wrong; they're not user filings. Adding them via `cms-eingang.gericht` would require an explicit "tag this incoming court event" sub-mode that the Determinator currently doesn't have.
|
||||
- **Genuinely missing UPC content (5/18)**: DNI family, R.19 PO, R.224.2.b orders-track grounds, EP-grant `application_to_amend_patent`. These are real gaps the RoP audit already named.
|
||||
- **Prosecution-side gaps (5/18)**: EPO renewal fees, R.94(3) reply, DPMA examination request, generic Stellungnahme, contract renewal. Both pathways skip prosecution; the platform is litigation-first today.
|
||||
|
||||
### 3.3 Cascade-side dangling (leaves with no concept attached)
|
||||
|
||||
3 leaves carry no concept link:
|
||||
- `cms-eingang.gericht.bescheid-mit-frist` ("Bescheid mit explizit gesetzter Frist") — intentional escape hatch but produces no calendar entry. A user lands here when no specific Bescheid type matches; without a concept, no autofill, no "I'll do the math for you."
|
||||
- `muendl-verhandlung.verlegt` — when an oral hearing is rescheduled, no follow-on deadline (correct: judge re-issues with new date).
|
||||
- `sonstiges` — top-level "Anderes" escape hatch.
|
||||
|
||||
These three leaves are the existing "not in the tree" UX — a user already CAN bottom out, but only with zero downstream support. §4 below proposes how to make those moments useful.
|
||||
|
||||
### 3.4 Concept-side dead-ends (concepts with rules but no cascade entry)
|
||||
|
||||
12 concepts have `is_active=true` and ≥1 rule attached but never appear in `event_category_concepts`:
|
||||
|
||||
| Concept | Rules | Comment |
|
||||
|---|---|---|
|
||||
| `decision` | 14 | Generic decision-anchor — used by every proceeding's `*.decision` row. Not a reaction target. |
|
||||
| `oral-hearing` | 11 | Same as decision — anchor not reaction. |
|
||||
| `publication` | 3 | EP grant publication, A1/B1 dates. |
|
||||
| `order` | 2 | Generic order-anchor. |
|
||||
| `cost-decision` | 1 | R.157 fixation-of-costs. Should arguably be reachable since post-cost-decision reactions exist (`application-for-leave-to-appeal`); the leaf `kostenfestsetzung` already maps to `notice-of-appeal` and `application-for-leave-to-appeal`, so the *reaction* path is covered — `cost-decision` itself just doesn't need to be in the cascade. |
|
||||
| `preliminary-opinion` | 1 | EPA preliminary opinion — used by EPA_OPP. |
|
||||
| `grant` | 1 | EP grant decision. |
|
||||
| `filing` | 1 | EP filing date. |
|
||||
| `search-report` | 1 | EPO search-report 6mo period. |
|
||||
| `request-for-examination` | 1 | EPO R.70(1) 6mo. |
|
||||
| `approval-and-translation` | 1 | EPO R.71(3) 4mo. |
|
||||
| `communication-r71-3` | 1 | Same family as approval-and-translation; intermediate. |
|
||||
|
||||
**Reading.** 8 of these are court-side anchors (decision, order, hearing, publication, grant, filing, search-report, preliminary-opinion) — by design not reactions, so their absence from the cascade is structurally correct. The remaining 4 are all the EP-grant family (request-for-examination, approval-and-translation, communication-r71-3, plus the implicit `publication` for EP_GRANT) — these *should* be reachable and currently aren't. Confirms §3.1's EP_GRANT cluster as the single biggest fixable cluster.
|
||||
|
||||
---
|
||||
|
||||
## 4. Smart-navigation framing — which pattern fits the gap distribution?
|
||||
|
||||
Issue §3 names three candidate patterns:
|
||||
|
||||
- **(P1) Free-text search at every cascade depth.** "Beweisbeschluss" → suggests closest leaves with a "that's not it" fallback.
|
||||
- **(P2) Persistent "Mein Ereignis ist nicht dabei" escape button.** Visible at every level → opens a manual entry form with rule-only / no-rule paths.
|
||||
- **(P3) Breadcrumb-aware "weiter unten suchen".** Flattens deeper levels into the current row's chip set when the user can't pick at the current depth.
|
||||
|
||||
The gap distribution we just enumerated tells us which pattern earns its keep. There are four kinds of "I don't see my event" moments:
|
||||
|
||||
**Type α — Real gap, content missing.** The user wants a real event paliad genuinely doesn't model (Streitwertbeschluss, R.19 PO, DPMA Beanstandungsbescheid, R.71(3), R.94(3), § 411 ZPO Stellungnahme nach Beweisaufnahme). Count: ~18-22 events from §2.6 plus the RoP audit's 25 missing. **What helps:** an escape that captures *what* the user wanted, so we can prioritise the right migration rather than guess. P2 + telemetry.
|
||||
|
||||
**Type β — Reachable but mis-modelled cascade path.** The leaf exists, the user can't find it (different mental label, deeper than expected, wrong root). E.g. R.116 final submissions live under `muendl-verhandlung.geladen` AND `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`; if the user starts at `cms-eingang` they hit a dead end. Or: Wiedereinsetzung is under `frist-verpasst.*` but a user might look under `ich-moechte-einreichen.spaetere-schriftsaetze`. **What helps:** P1 (search collapses the labelling problem) and P3 (flat-search within current branch when nothing matches).
|
||||
|
||||
**Type γ — Court-side trigger event needs to be tagged, not reacted-to.** The user has a `upc_decision_on_merits` and wants to *file it as an event in their project*, not get a reaction list. The cascade doesn't model this — it always assumes "reaction wanted." Count: ~8 of the 18 unreachable event_types. **What helps:** none of P1/P2/P3 directly — this is a separate "tag, don't react" mode. Out of scope here but worth flagging.
|
||||
|
||||
**Type δ — Dead-end leaf with no concept (the 3 dangling leaves).** User selected `bescheid-mit-frist` and lands on a content-free card. **What helps:** P2's "manual entry with rule-only path" is exactly the escape these leaves need — turn the dangle into a deliberate fall-through.
|
||||
|
||||
### 4.1 Recommendation: **P2 + P1, in that order, with P3 as a stretch.**
|
||||
|
||||
**Why P2 first.** Of the four types, only Type α (real content gaps) is genuinely closed by P2, but Type α is also the *only* type that produces actionable feedback for paliad's roadmap. A persistent "Ich finde mein Ereignis nicht" button at every cascade depth, opening a `<dialog>` with:
|
||||
- a free-text "What event are you trying to file/respond to?" input,
|
||||
- a date input,
|
||||
- "kein Regelwerk verfügbar" rule-only path that creates a deadline with `event_type=null, rule_id=null, manual_due_date=...`,
|
||||
- an opt-in checkbox "Mein Hinweis hilft, paliad zu verbessern" that posts the captured text to a (future) `paliad.coverage_gaps` table,
|
||||
|
||||
…does three things at once: (a) unblocks the user immediately, (b) gives m a backlog that's *exactly* the prioritisation signal this audit can't provide alone (which gaps are real demand vs. theoretical RoP completeness), (c) repurposes the 3 dangling leaves and `sonstiges` from "looks broken" to "deliberate fall-through."
|
||||
|
||||
Implementation cost: one `<dialog>` modal reused at every depth + one new `coverage_gap` event sink + one feedback-style admin view. The button itself can hang off the existing FilterBar primitive (t-paliad-163) or attach to the bottom of every cascade list.
|
||||
|
||||
**Why P1 second.** Type β (mis-modelled paths) is the *quietest* failure mode — the user gives up before clicking anywhere relevant. Search would catch it but the gap data alone doesn't tell us how many such users exist. Layering P1 on top of P2 turns the captured "Mein Ereignis nicht dabei" texts into the very query corpus that powers fuzzy-search ranking. A search input at the top of every cascade level (`<input type="search">` filtering the current set of children + drilling into matching deeper leaves via FTS over `label_de` / `label_en` / `aliases` / linked `concept.aliases`) closes Type β cheaply once the corpus is decent.
|
||||
|
||||
**Why P3 is a stretch.** "Flatten deeper levels into current chip-set" reads cleanly but trades depth for breadth: the cascade currently has 38 reachable UPC leaves under 2-3 levels — flattening to 38 chips at depth 1 produces analysis paralysis. The cascade's depth is a feature, not a bug. P3 is only worth building if telemetry from P2 shows a cluster of users bottoming out at level 2 with the *right* root selected. Defer.
|
||||
|
||||
### 4.2 What this means for current scope
|
||||
|
||||
- **m/paliad#25 (minkowski's row-by-row)** is orthogonal — that fixes individual rule rows. Keep that going.
|
||||
- **Type α gap fill** is a separate workstream driven by the Wave 1-5 RoP-audit sequencing in `audit-upc-rop-deadlines-2026-05-08.md` §6. The smart-navigation work doesn't replace it; it gives the work a feedback loop.
|
||||
- **Type γ (tag-don't-react)** is its own design problem — file as a separate ticket if/when it shows up in P2 telemetry.
|
||||
- **The 5 unreachable rules from §3.1** (4 EP_GRANT + 1 UPC_INF) should be fixed with a 5-row migration regardless of the navigation work. Independent. EP-grant in particular is the single change that lifts cascade reachability from 93.4 % to 100 % of the audited rule corpus.
|
||||
|
||||
### 4.3 Suggested next steps (not implementation, just ordering)
|
||||
|
||||
1. **5-row reachability migration** (no design needed): link `inf.app_to_amend` to `defence-to-application-to-amend` concept; add cascade leaves for the 4 EP_GRANT concepts under a new `ich-moechte-einreichen.ep-erteilung` subtree. Wave-0 alongside the t-paliad-159 duration bug fixes.
|
||||
2. **Inventor pass on P2 + P1** as one design ticket: persistent escape button + free-text search at each level + capture-table schema + admin view. This is where m's "smart navigation" intuition lives — keep P1 and P2 as a pair so the captured texts feed search ranking.
|
||||
3. **Type α gap fill** continues independently per RoP audit waves — capture-table data in (2) refines priorities after a few weeks of real use.
|
||||
4. **Defer P3 + Type γ** until telemetry justifies them.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
**Coverage today (n=76 true Fristenrechner deadlines across 19 active proceedings):**
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| Reachable from cascade | 71 | 93 % |
|
||||
| No concept_id | 1 | 1 % |
|
||||
| Concept exists, dead-end | 4 | 5 % |
|
||||
|
||||
**Event_type reachability (n=44 firm-wide active types):**
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| Reachable | 26 | 59 % |
|
||||
| Unreachable | 18 | 41 % |
|
||||
|
||||
**Headline gap categories** (entirely uncovered by both pathways, ordered by daily-practice frequency):
|
||||
|
||||
1. EPO R.94(3) examination-stage Bescheid (4mo) — most-frequent EPO prosecution deadline, **completely absent**.
|
||||
2. EPO R.71(3) communication → approval+translation (4mo) — concept exists but no cascade entry.
|
||||
3. DPMA § 45 PatG Beanstandungsbescheid (4mo) — most-frequent DPMA prosecution deadline, completely absent.
|
||||
4. UPC R.262.2 confidentiality response (14d) — high-frequency in HLC infringement.
|
||||
5. DE Hinweisbeschluss reaction — leaf exists, no rule.
|
||||
6. DE Versäumnisurteil-Einspruch (§ 339 ZPO 2 weeks) — leaf exists, no rule.
|
||||
7. DE Streitwertbeschwerde (§ 68 GKG 6mo) — neither leaf nor rule.
|
||||
8. UPC R.19 Preliminary Objection (1mo) — neither pathway.
|
||||
9. UPC R.224.2.b grounds-on-orders-track (15d) — neither pathway.
|
||||
10. UPC R.353 Rectification (1mo) — neither pathway.
|
||||
11. UPC EP-grant family (R.70(1), Art. 93, R.71(3), search-report) — Pathway A only, no cascade entry.
|
||||
12. UPC R.109 oral-hearing translation prep (1mo / 2w / 2w `before`-mode) — schema-supported, no data.
|
||||
|
||||
**Recommended smart-navigation pattern:** P2 (persistent "Ich finde mein Ereignis nicht" escape with capture) + P1 (free-text search per cascade level), in that order. P2 alone unblocks users and produces the feedback loop the rest of the gap-fill roadmap needs; P1 layered on top closes mis-labelling. P3 is over-scoped for current data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — files consulted
|
||||
|
||||
- `internal/services/deadline_rule_service.go` (proceeding-type filtering, `category='fristenrechner'` gate)
|
||||
- `internal/services/event_category_service.go` (cascade traversal)
|
||||
- `internal/services/fristenrechner.go` (Pathway A composer)
|
||||
- `internal/db/migrations/008_seed_proceeding_types.up.sql` (legacy 7 codes)
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (UPC/DE/EPA seed)
|
||||
- `internal/db/migrations/042_de_expansion_b3.up.sql` (DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH)
|
||||
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
|
||||
- `internal/db/migrations/044_dpma_proceedings.up.sql`
|
||||
- `internal/db/migrations/045_epa_gap_fill.up.sql`
|
||||
- `internal/db/migrations/048_event_categories.up.sql` (cascade seed)
|
||||
- `internal/db/migrations/049_event_categories_seed.up.sql`
|
||||
- `internal/db/migrations/051_proceeding_display_order.up.sql`
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` (cascade-side RoP fixes)
|
||||
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` (R.320 leaf)
|
||||
- `internal/db/migrations/072_deadline_concept_event_types.up.sql` (Regel↔Typ junction)
|
||||
|
||||
## Appendix B — companion audits
|
||||
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — RoP-driven UPC audit (t-paliad-159, curie). Half the data for §2.2.
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad (t-paliad-084, curie).
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` — current data-model design.
|
||||
@@ -271,6 +271,11 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
|
||||
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
|
||||
join(import.meta.dir, "src/client/paliadin.ts"),
|
||||
// t-paliad-161 — inline Paliadin widget. Loaded via the
|
||||
// PaliadinWidget component on every authenticated page, so the
|
||||
// bundle ships once per deploy and clients with a hot SW cache
|
||||
// skip the re-fetch.
|
||||
join(import.meta.dir, "src/client/paliadin-widget.ts"),
|
||||
join(import.meta.dir, "src/client/admin-paliadin.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -99,6 +100,7 @@ export function renderAdminApprovalPolicies(): string {
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
</main>
|
||||
|
||||
{/* Bulk-apply confirm modal — populated client-side. */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -116,6 +117,7 @@ export function renderAdminAuditLog(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-audit-log.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -59,6 +60,7 @@ export function renderAdminBroadcasts(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-broadcasts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -110,6 +111,7 @@ export function renderAdminEmailTemplatesEdit(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-email-templates-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -49,6 +50,7 @@ export function renderAdminEmailTemplates(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-email-templates.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -149,6 +150,7 @@ export function renderAdminEventTypes(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-event-types.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -86,14 +87,17 @@ export function renderAdminPaliadin(): string {
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.paliadin.col.started">Zeit</th>
|
||||
<th data-i18n="admin.paliadin.col.user">Nutzer</th>
|
||||
<th data-i18n="admin.paliadin.col.classifier">Art</th>
|
||||
<th data-i18n="admin.paliadin.col.prompt">Anfrage</th>
|
||||
<th data-i18n="admin.paliadin.col.response">Antwort</th>
|
||||
<th data-i18n="admin.paliadin.col.tools">Tools</th>
|
||||
<th data-i18n="admin.paliadin.col.origin">Seite</th>
|
||||
<th data-i18n="admin.paliadin.col.duration">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-turns-tbody">
|
||||
<tr><td colspan={5} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
<tr><td colspan={8} data-i18n="admin.paliadin.loading">Lade …</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -102,6 +106,7 @@ export function renderAdminPaliadin(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-paliadin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -124,6 +125,7 @@ export function renderAdminPartnerUnits(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-partner-units.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -132,6 +133,7 @@ export function renderAdminTeam(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-team.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -112,6 +113,7 @@ export function renderAdmin(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -91,6 +92,7 @@ export function renderAgenda(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/agenda.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -94,6 +95,7 @@ export function renderAppointmentsCalendar(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -35,6 +36,9 @@ export function renderAppointmentsDetail(): string {
|
||||
<div id="appointment-body" style="display:none">
|
||||
<div className="tool-header">
|
||||
<span className="termin-type-badge" id="appointment-type-badge" />
|
||||
<span id="appointment-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
|
||||
Wartet auf Genehmigung
|
||||
</span>
|
||||
<h1 id="appointment-title-display" />
|
||||
<p className="tool-subtitle" id="appointment-time-display" />
|
||||
</div>
|
||||
@@ -94,6 +98,7 @@ export function renderAppointmentsDetail(): string {
|
||||
<p className="form-msg" id="appointment-edit-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" id="appointment-withdraw-btn" className="btn-secondary" style="display:none" data-i18n="approvals.withdraw.cta">Genehmigungsanfrage zurückziehen</button>
|
||||
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin löschen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">Änderungen speichern</button>
|
||||
</div>
|
||||
@@ -104,6 +109,7 @@ export function renderAppointmentsDetail(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -104,6 +105,7 @@ export function renderAppointmentsNew(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -41,6 +42,7 @@ export function renderChangelog(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/changelog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -152,6 +153,7 @@ export function renderChecklistsDetail(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -118,6 +119,7 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-instance.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -74,6 +75,7 @@ export function renderChecklists(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -20,13 +20,16 @@ interface UnitPolicy {
|
||||
project_id: string | null;
|
||||
entity_type: string;
|
||||
lifecycle_event: string;
|
||||
required_role: string;
|
||||
// t-paliad-160 split-grammar.
|
||||
requires_approval: boolean;
|
||||
min_role?: string | null;
|
||||
}
|
||||
|
||||
interface EffectivePolicy {
|
||||
entity_type: string;
|
||||
lifecycle_event: string;
|
||||
required_role?: string | null;
|
||||
requires_approval: boolean;
|
||||
min_role?: string | null;
|
||||
source?: string | null;
|
||||
source_id?: string | null;
|
||||
source_name?: string | null;
|
||||
@@ -43,13 +46,15 @@ interface ProjectNode {
|
||||
|
||||
const ENTITY_TYPES = ["deadline", "appointment"] as const;
|
||||
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
|
||||
// Strict-ladder roles only. The legacy "none" sentinel is gone — its job
|
||||
// (suppress the gate) is now done by the requires_approval=false checkbox
|
||||
// (t-paliad-160 §A).
|
||||
const ROLE_OPTIONS = [
|
||||
"partner",
|
||||
"of_counsel",
|
||||
"associate",
|
||||
"senior_pa",
|
||||
"pa",
|
||||
"none",
|
||||
];
|
||||
|
||||
let partnerUnits: PartnerUnit[] = [];
|
||||
@@ -150,27 +155,68 @@ function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): U
|
||||
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
|
||||
}
|
||||
|
||||
function renderRoleSelect(currentValue: string | null, dataAttrs: string): string {
|
||||
const opts: string[] = [];
|
||||
// "Keine Regel" sentinel — distinct from 'none' (which is the explicit
|
||||
// suppression value). Empty string maps to DELETE.
|
||||
opts.push(`<option value=""${currentValue === null ? " selected" : ""}>${esc(t("admin.approval_policies.role.no_rule") || "— keine Regel —")}</option>`);
|
||||
for (const r of ROLE_OPTIONS) {
|
||||
opts.push(`<option value="${esc(r)}"${currentValue === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`);
|
||||
}
|
||||
return `<select class="ap-cell-select" ${dataAttrs}>${opts.join("")}</select>`;
|
||||
// Cell control state, t-paliad-160 §A.
|
||||
// none → no project-specific rule authored (the cell inherits).
|
||||
// off → requires_approval=false explicitly authored.
|
||||
// on(role) → requires_approval=true with the given min_role.
|
||||
//
|
||||
// rendered as: [✓] requires approval [role select]
|
||||
// - checkbox unchecked → role select disabled (greyed).
|
||||
// - checkbox checked → role select enabled, min_role required.
|
||||
// - "no rule" — surfaced as a third button next to the controls so the
|
||||
// admin can explicitly clear an authored cell back to inheritance.
|
||||
type CellAuthored =
|
||||
| { kind: "none" }
|
||||
| { kind: "off" }
|
||||
| { kind: "on"; role: string };
|
||||
|
||||
function authoredFromUnitPolicy(p: UnitPolicy | undefined): CellAuthored {
|
||||
if (!p) return { kind: "none" };
|
||||
if (!p.requires_approval) return { kind: "off" };
|
||||
return { kind: "on", role: p.min_role || "associate" };
|
||||
}
|
||||
|
||||
function authoredFromEffective(r: EffectivePolicy): CellAuthored {
|
||||
if (r.source !== "project") return { kind: "none" };
|
||||
if (!r.requires_approval) return { kind: "off" };
|
||||
return { kind: "on", role: r.min_role || "associate" };
|
||||
}
|
||||
|
||||
function renderCellControls(authored: CellAuthored, dataAttrs: string): string {
|
||||
const checked = authored.kind === "on";
|
||||
const disabled = authored.kind !== "on";
|
||||
const role = authored.kind === "on" ? authored.role : "associate";
|
||||
const opts = ROLE_OPTIONS.map((r) =>
|
||||
`<option value="${esc(r)}"${role === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`
|
||||
).join("");
|
||||
const reqLabel = esc(t("admin.approval_policies.cell.requires") || "Genehmigung");
|
||||
const clearLabel = esc(t("admin.approval_policies.cell.clear") || "—");
|
||||
const clearTitle = esc(t("admin.approval_policies.cell.clear.title") || "Regel zurücksetzen (erben)");
|
||||
const cleared = authored.kind === "none";
|
||||
return `
|
||||
<label class="ap-cell-toggle">
|
||||
<input type="checkbox" class="ap-cell-requires" ${dataAttrs}${checked ? " checked" : ""} aria-label="${reqLabel}" />
|
||||
<span class="ap-cell-toggle-label">${reqLabel}</span>
|
||||
</label>
|
||||
<select class="ap-cell-role" ${dataAttrs}${disabled ? " disabled" : ""}>${opts}</select>
|
||||
<button type="button" class="ap-cell-clear" ${dataAttrs} title="${clearTitle}"${cleared ? " disabled" : ""}>${clearLabel}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUnitMatrix(unit: PartnerUnit): string {
|
||||
const rows = unitPolicies[unit.id] || [];
|
||||
const buildCell = (e: string, l: string): string => {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const authored = authoredFromUnitPolicy(p);
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
return `<div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>`;
|
||||
};
|
||||
|
||||
let cells = "";
|
||||
for (const e of ENTITY_TYPES) {
|
||||
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const v = p ? p.required_role : null;
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
cells += `<td class="ap-matrix-cell">${renderRoleSelect(v, attrs)}</td>`;
|
||||
cells += `<td class="ap-matrix-cell">${buildCell(e, l)}</td>`;
|
||||
}
|
||||
cells += `</tr>`;
|
||||
}
|
||||
@@ -180,12 +226,9 @@ function renderUnitMatrix(unit: PartnerUnit): string {
|
||||
for (const e of ENTITY_TYPES) {
|
||||
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const v = p ? p.required_role : null;
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
stacked += `<div class="ap-matrix-row">
|
||||
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
|
||||
${renderRoleSelect(v, attrs)}
|
||||
${buildCell(e, l)}
|
||||
</div>`;
|
||||
}
|
||||
stacked += `</div>`;
|
||||
@@ -239,21 +282,39 @@ function renderUnits(): void {
|
||||
|
||||
function renderProjectMatrix(rows: EffectivePolicy[]): string {
|
||||
const cell = (r: EffectivePolicy): string => {
|
||||
const v = r.required_role || null;
|
||||
const own = r.source === "project";
|
||||
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
|
||||
// The controls show the AUTHORED state — the project row's own values
|
||||
// when there is one, else the inherited state is rendered via the
|
||||
// attribution chip and the controls sit unset (kind="none"). Most-
|
||||
// strict-wins inheritance from ancestors / unit defaults is purely
|
||||
// informational on this row; flipping the controls writes a new
|
||||
// project-specific row.
|
||||
const authored = authoredFromEffective(r);
|
||||
let chip = "";
|
||||
if (r.source && !own && r.required_role) {
|
||||
if (r.source && !own && r.requires_approval) {
|
||||
// Inherited from ancestor or unit default. Surface attribution +
|
||||
// the inherited min_role so the admin sees what the cell is
|
||||
// resolving to before they author an override.
|
||||
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
||||
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
|
||||
"admin.approval_policies.source.project";
|
||||
const label = tDyn(sourceKey) || r.source;
|
||||
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}</span>`;
|
||||
const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}${role}</span>`;
|
||||
} else if (r.source && !own && !r.requires_approval) {
|
||||
// Inherited "no approval needed" — distinct from "no rule at all".
|
||||
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
||||
"admin.approval_policies.source.unit_default";
|
||||
const label = tDyn(sourceKey) || r.source;
|
||||
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
||||
const offLabel = esc(t("admin.approval_policies.source.no_approval") || "keine Genehmigung");
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name} · ${offLabel}</span>`;
|
||||
} else if (own) {
|
||||
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
|
||||
}
|
||||
return `<div class="ap-cell-wrap">${renderRoleSelect(own ? v : null, attrs)}${chip}</div>`;
|
||||
return `<div class="ap-cell-wrap"><div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>${chip}</div>`;
|
||||
};
|
||||
|
||||
const byCell = new Map<string, EffectivePolicy>();
|
||||
@@ -347,64 +408,117 @@ function renderProjectResults(filter: string): void {
|
||||
// ============================================================================
|
||||
|
||||
function bindCellChangeHandlers(scope: HTMLElement): void {
|
||||
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-select").forEach((sel) => {
|
||||
sel.addEventListener("change", () => void onCellChange(sel));
|
||||
// Each cell now has THREE controls — the requires-approval checkbox,
|
||||
// the role select, and the explicit "clear / inherit" button. They all
|
||||
// share data-* attrs so onCellChange can derive the URL + intended
|
||||
// post-state from any of them.
|
||||
scope.querySelectorAll<HTMLInputElement>(".ap-cell-requires").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
// Toggle the sibling role select disabled state immediately for
|
||||
// visual feedback — the server PUT might lag.
|
||||
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
||||
if (sel) sel.disabled = !cb.checked;
|
||||
void onCellChangeFromCheckbox(cb);
|
||||
});
|
||||
});
|
||||
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-role").forEach((sel) => {
|
||||
sel.addEventListener("change", () => void onCellChangeFromRole(sel));
|
||||
});
|
||||
scope.querySelectorAll<HTMLButtonElement>(".ap-cell-clear").forEach((btn) => {
|
||||
btn.addEventListener("click", () => void onCellClear(btn));
|
||||
});
|
||||
}
|
||||
|
||||
async function onCellChange(sel: HTMLSelectElement): Promise<void> {
|
||||
const scope = sel.dataset.scope;
|
||||
const entity = sel.dataset.entity || "";
|
||||
const lifecycle = sel.dataset.lifecycle || "";
|
||||
const value = sel.value;
|
||||
|
||||
let url = "";
|
||||
function cellEndpointURL(el: HTMLElement): string | null {
|
||||
const scope = el.dataset.scope;
|
||||
const entity = el.dataset.entity || "";
|
||||
const lifecycle = el.dataset.lifecycle || "";
|
||||
if (scope === "unit") {
|
||||
const unitID = sel.dataset.unitId || "";
|
||||
url = `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
} else if (scope === "project") {
|
||||
const projectID = sel.dataset.projectId || "";
|
||||
url = `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
} else {
|
||||
return;
|
||||
const unitID = el.dataset.unitId || "";
|
||||
return `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
}
|
||||
if (scope === "project") {
|
||||
const projectID = el.dataset.projectId || "";
|
||||
return `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function onCellChangeFromCheckbox(cb: HTMLInputElement): Promise<void> {
|
||||
// Checkbox flipped — write the cell as (requires_approval, min_role).
|
||||
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
||||
const requires = cb.checked;
|
||||
const minRole = requires ? (sel?.value || "associate") : null;
|
||||
await putCellSplit(cb, requires, minRole);
|
||||
}
|
||||
|
||||
async function onCellChangeFromRole(sel: HTMLSelectElement): Promise<void> {
|
||||
// Role select changed — only meaningful when the checkbox is on (the
|
||||
// disabled state would block this on a real interaction, but pin it
|
||||
// for safety).
|
||||
const wrap = sel.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const cb = wrap?.querySelector<HTMLInputElement>(".ap-cell-requires");
|
||||
if (!cb || !cb.checked) return;
|
||||
await putCellSplit(sel, true, sel.value);
|
||||
}
|
||||
|
||||
async function onCellClear(btn: HTMLButtonElement): Promise<void> {
|
||||
// Explicit "back to inheritance" — DELETE the project / unit row.
|
||||
const url = cellEndpointURL(btn);
|
||||
if (!url) return;
|
||||
try {
|
||||
let resp: Response;
|
||||
if (value === "") {
|
||||
resp = await fetch(url, { method: "DELETE" });
|
||||
} else {
|
||||
resp = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ required_role: value }),
|
||||
});
|
||||
}
|
||||
const resp = await fetch(url, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
||||
|
||||
// Re-fetch the affected scope so attribution chips reflect the new state.
|
||||
if (scope === "unit") {
|
||||
const unitID = sel.dataset.unitId || "";
|
||||
unitPolicies[unitID] = await loadUnitPolicies(unitID);
|
||||
renderUnits();
|
||||
} else if (selectedProjectID) {
|
||||
const matrix = await loadMatrix(selectedProjectID);
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
}
|
||||
await refreshAfterCellMutation(btn);
|
||||
} catch (err) {
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function putCellSplit(el: HTMLElement, requires: boolean, minRole: string | null): Promise<void> {
|
||||
const url = cellEndpointURL(el);
|
||||
if (!url) return;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requires_approval: requires, min_role: minRole }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
||||
await refreshAfterCellMutation(el);
|
||||
} catch (err) {
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterCellMutation(el: HTMLElement): Promise<void> {
|
||||
const scope = el.dataset.scope;
|
||||
if (scope === "unit") {
|
||||
const unitID = el.dataset.unitId || "";
|
||||
unitPolicies[unitID] = await loadUnitPolicies(unitID);
|
||||
renderUnits();
|
||||
} else if (selectedProjectID) {
|
||||
const matrix = await loadMatrix(selectedProjectID);
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bulk-apply to descendants.
|
||||
// ============================================================================
|
||||
|
||||
@@ -23,14 +23,21 @@ interface Stats {
|
||||
interface Turn {
|
||||
turn_id: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_display_name: string | null;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
user_message: string;
|
||||
response: string | null;
|
||||
used_tools: string[] | null;
|
||||
rows_seen: number[] | null;
|
||||
classifier_tag: string | null;
|
||||
abandoned: boolean;
|
||||
error_code: string | null;
|
||||
page_origin: string | null;
|
||||
chip_count: number;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
@@ -113,28 +120,51 @@ function renderTurns(turns: Turn[]): void {
|
||||
const tbody = document.getElementById("recent-turns-tbody");
|
||||
if (!tbody) return;
|
||||
if (turns.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">Noch keine Anfragen.</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="8">Noch keine Anfragen.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = turns
|
||||
.map((t) => {
|
||||
const tag = t.classifier_tag || "—";
|
||||
// Tools cell pairs each tool name with its rows_seen count when
|
||||
// available — "list_my_projects (11), search_my_deadlines (18)" —
|
||||
// so the meta is legible at a glance instead of hidden in a side
|
||||
// table. Falls back to "—" for casual chats with no tool calls.
|
||||
const tools = t.used_tools && t.used_tools.length > 0
|
||||
? t.used_tools.join(", ")
|
||||
? t.used_tools
|
||||
.map((name, i) => {
|
||||
const r = t.rows_seen?.[i];
|
||||
return r != null ? `${name} (${r})` : name;
|
||||
})
|
||||
.join(", ")
|
||||
: "—";
|
||||
const dur = t.duration_ms != null ? formatMs(t.duration_ms) : "—";
|
||||
const errMark = t.error_code ? ` ⚠ ${t.error_code}` : "";
|
||||
const userLabel = t.user_display_name || t.user_email || t.user_id.slice(0, 8);
|
||||
const userTitle = [t.user_email, t.user_display_name].filter(Boolean).join(" · ") || t.user_id;
|
||||
// Response preview — first 200 chars of cleanBody. Full response
|
||||
// available on hover via the title attribute.
|
||||
const respPreview = t.response ? truncate(t.response, 80) : "—";
|
||||
const respTitle = t.response || "";
|
||||
const origin = t.page_origin || "—";
|
||||
return `<tr>
|
||||
<td>${formatTime(t.started_at)}</td>
|
||||
<td title="${escapeAttr(userTitle)}">${escapeHTML(userLabel)}</td>
|
||||
<td>${escapeHTML(tag)}</td>
|
||||
<td>${escapeHTML(truncate(t.user_message, 120))}${errMark}</td>
|
||||
<td title="${escapeAttr(t.user_message)}">${escapeHTML(truncate(t.user_message, 80))}${errMark}</td>
|
||||
<td title="${escapeAttr(respTitle)}">${escapeHTML(respPreview)}</td>
|
||||
<td>${escapeHTML(tools)}</td>
|
||||
<td>${escapeHTML(origin)}</td>
|
||||
<td>${dur}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/\n/g, " ");
|
||||
}
|
||||
|
||||
function setText(id: string, val: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
|
||||
236
frontend/src/client/agenda-render.ts
Normal file
236
frontend/src/client/agenda-render.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Shared agenda timeline rendering primitives. The standalone /agenda page
|
||||
// (client/agenda.ts) and the inline Agenda section on /dashboard
|
||||
// (client/dashboard.ts) both render the same item shape; this module is
|
||||
// the single source of truth for how an AgendaItem turns into HTML.
|
||||
//
|
||||
// Stateless. The caller fetches /api/agenda, hands the items to
|
||||
// renderAgendaTimeline(), and drops the resulting HTML into a container.
|
||||
// i18n labels are resolved at render time via t/tDyn from ./i18n, so the
|
||||
// onLangChange hook on the calling page re-renders correctly.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. Kept in sync with the
|
||||
// matching constants in events.ts / inbox.ts / dashboard.ts.
|
||||
const APPROVAL_PILL_GLYPH = "👀";
|
||||
// Sparkle glyph ✨ for Paliadin-drafted pending rows (t-paliad-161).
|
||||
// Renders alongside (not in place of) 👀 — orthogonal axes.
|
||||
const AGENT_PILL_GLYPH = "✨";
|
||||
|
||||
export type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
|
||||
export type AgendaType = "deadline" | "appointment";
|
||||
|
||||
export interface AgendaItem {
|
||||
id: string;
|
||||
type: AgendaType;
|
||||
title: string;
|
||||
date: string; // ISO 8601
|
||||
end_at?: string | null;
|
||||
due_date?: string | null; // YYYY-MM-DD (deadlines only)
|
||||
status?: string | null;
|
||||
location?: string | null;
|
||||
appointment_type?: string | null;
|
||||
urgency: Urgency;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
project_type?: string | null;
|
||||
project_reference?: string | null;
|
||||
approval_status?: "approved" | "pending" | "legacy" | null;
|
||||
requester_kind?: "user" | "agent" | null;
|
||||
}
|
||||
|
||||
interface DayBucket {
|
||||
dayKey: string;
|
||||
day: Date;
|
||||
items: AgendaItem[];
|
||||
}
|
||||
|
||||
// Render a full timeline (day buckets with items) for an array of agenda
|
||||
// items. Returns a single HTML string ready to assign via innerHTML.
|
||||
// The empty case returns an empty string — callers that want an empty-
|
||||
// state UI handle it themselves (different copy on /agenda vs the
|
||||
// dashboard inline slot).
|
||||
export function renderAgendaTimeline(items: AgendaItem[]): string {
|
||||
if (!items.length) return "";
|
||||
const buckets = groupByDay(items);
|
||||
return buckets.map((b) => renderDay(b)).join("");
|
||||
}
|
||||
|
||||
function groupByDay(items: AgendaItem[]): DayBucket[] {
|
||||
const map = new Map<string, DayBucket>();
|
||||
for (const it of items) {
|
||||
const d = new Date(it.date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
const key = toLocalDayKey(d);
|
||||
let b = map.get(key);
|
||||
if (!b) {
|
||||
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
|
||||
map.set(key, b);
|
||||
}
|
||||
b.items.push(it);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}
|
||||
|
||||
function renderDay(bucket: DayBucket): string {
|
||||
const expected = expectedUrgency(bucket.day);
|
||||
return `<section class="agenda-day">
|
||||
<h2 class="agenda-day-heading">
|
||||
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
|
||||
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
|
||||
</h2>
|
||||
<ul class="agenda-items">
|
||||
${bucket.items.map((it) => renderItem(it, expected)).join("")}
|
||||
</ul>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// F-32: an item's urgency tag duplicates the day-bucket heading in the
|
||||
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
|
||||
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
|
||||
// "Überfällig" deadline that lands in today's bucket because of a filter
|
||||
// quirk. expectedUrgency mirrors the server's bucketing rule against the
|
||||
// bucket's day.
|
||||
function expectedUrgency(day: Date): Urgency {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) return "overdue";
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "tomorrow";
|
||||
if (diff <= 6) return "this_week";
|
||||
return "later";
|
||||
}
|
||||
|
||||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||||
const typeClass = `agenda-item-type-${it.type}`;
|
||||
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||||
const detailHref = itemDetailHref(it);
|
||||
const project = it.project_id
|
||||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||||
: "";
|
||||
const pendingLabel = it.approval_status === "pending" ? tDyn("approvals.pending_update.label") : "";
|
||||
const pendingPill = it.approval_status === "pending"
|
||||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
const agentLabel = tDyn("approvals.agent.label");
|
||||
const agentPill = it.approval_status === "pending" && it.requester_kind === "agent"
|
||||
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
|
||||
const timePart = it.type === "appointment"
|
||||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||||
: "";
|
||||
const urgencyTag = it.urgency !== bucketUrgency
|
||||
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
|
||||
: "";
|
||||
const locationPart = it.type === "appointment" && it.location
|
||||
? `<span class="agenda-item-location">${esc(it.location)}</span>`
|
||||
: "";
|
||||
const typeLabelKey = it.type === "deadline"
|
||||
? "agenda.label.deadline"
|
||||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||||
const typeLabel = tDyn(typeLabelKey);
|
||||
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
|
||||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||||
<span class="agenda-item-main">
|
||||
<span class="agenda-item-headline">
|
||||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||||
${pendingPill}
|
||||
${agentPill}
|
||||
</span>
|
||||
<span class="agenda-item-sub">
|
||||
${project}
|
||||
${timePart}
|
||||
${locationPart}
|
||||
</span>
|
||||
</span>
|
||||
<span class="agenda-item-meta">
|
||||
${urgencyTag}
|
||||
</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function itemDetailHref(it: AgendaItem): string {
|
||||
return it.type === "deadline"
|
||||
? `/deadlines/${encodeURIComponent(it.id)}`
|
||||
: `/appointments/${encodeURIComponent(it.id)}`;
|
||||
}
|
||||
|
||||
function formatProjectLabel(it: AgendaItem): string {
|
||||
const ref = it.project_reference ? `${it.project_reference} · ` : "";
|
||||
const title = it.project_title || "";
|
||||
return `${ref}${title}`.trim();
|
||||
}
|
||||
|
||||
function formatAppointmentTime(it: AgendaItem): string {
|
||||
const start = new Date(it.date);
|
||||
if (isNaN(start.getTime())) return "";
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!it.end_at) return startStr;
|
||||
const end = new Date(it.end_at);
|
||||
if (isNaN(end.getTime())) return startStr;
|
||||
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${startStr}–${endStr}`;
|
||||
}
|
||||
|
||||
function relativeDayLabel(day: Date): string {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) {
|
||||
const n = Math.abs(diff);
|
||||
return getLang() === "de"
|
||||
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "Yesterday" : `${n} days ago`);
|
||||
}
|
||||
if (diff === 0) return t("agenda.day.today");
|
||||
if (diff === 1) return t("agenda.day.tomorrow");
|
||||
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
|
||||
}
|
||||
|
||||
function fullDateLabel(day: Date): string {
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toISODate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalDayKey(d: Date): string {
|
||||
return toISODate(d);
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function deadlineIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||||
}
|
||||
|
||||
function appointmentIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
}
|
||||
@@ -1,32 +1,12 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
|
||||
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
||||
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
|
||||
type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
|
||||
type AgendaType = "deadline" | "appointment";
|
||||
type TypeFilter = "both" | "deadlines" | "appointments";
|
||||
|
||||
interface AgendaItem {
|
||||
id: string;
|
||||
type: AgendaType;
|
||||
title: string;
|
||||
date: string; // ISO 8601
|
||||
end_at?: string | null;
|
||||
due_date?: string | null; // YYYY-MM-DD (deadlines only)
|
||||
status?: string | null; // deadlines: pending/completed/...
|
||||
location?: string | null;
|
||||
appointment_type?: string | null;
|
||||
urgency: Urgency;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
project_type?: string | null; // client | litigation | patent | case | project
|
||||
project_reference?: string | null;
|
||||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||||
approval_status?: "approved" | "pending" | "legacy" | null;
|
||||
}
|
||||
|
||||
interface AgendaPayload {
|
||||
items: AgendaItem[];
|
||||
from: string;
|
||||
@@ -214,157 +194,7 @@ function render(): void {
|
||||
}
|
||||
empty.style.display = "none";
|
||||
timeline.style.display = "";
|
||||
|
||||
const buckets = groupByDay(state.items);
|
||||
timeline.innerHTML = buckets.map((b) => renderDay(b)).join("");
|
||||
}
|
||||
|
||||
interface DayBucket {
|
||||
dayKey: string; // YYYY-MM-DD local
|
||||
day: Date;
|
||||
items: AgendaItem[];
|
||||
}
|
||||
|
||||
function groupByDay(items: AgendaItem[]): DayBucket[] {
|
||||
const map = new Map<string, DayBucket>();
|
||||
for (const it of items) {
|
||||
const d = new Date(it.date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
const key = toLocalDayKey(d);
|
||||
let b = map.get(key);
|
||||
if (!b) {
|
||||
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
|
||||
map.set(key, b);
|
||||
}
|
||||
b.items.push(it);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}
|
||||
|
||||
function renderDay(bucket: DayBucket): string {
|
||||
const expected = expectedUrgency(bucket.day);
|
||||
return `<section class="agenda-day">
|
||||
<h2 class="agenda-day-heading">
|
||||
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
|
||||
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
|
||||
</h2>
|
||||
<ul class="agenda-items">
|
||||
${bucket.items.map((it) => renderItem(it, expected)).join("")}
|
||||
</ul>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// F-32: an item's urgency tag duplicates the day-bucket heading in the
|
||||
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
|
||||
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
|
||||
// "Überfällig" deadline that lands in today's bucket because of a filter
|
||||
// quirk. expectedUrgency mirrors the server's bucketing rule against the
|
||||
// bucket's day.
|
||||
function expectedUrgency(day: Date): Urgency {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) return "overdue";
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "tomorrow";
|
||||
if (diff <= 6) return "this_week";
|
||||
return "later";
|
||||
}
|
||||
|
||||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||||
const typeClass = `agenda-item-type-${it.type}`;
|
||||
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||||
const detailHref = itemDetailHref(it);
|
||||
const project = it.project_id
|
||||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||||
: "";
|
||||
const pendingPill = it.approval_status === "pending"
|
||||
? `<span class="approval-pill" title="${esc(tDyn("approvals.pending_update.label"))}">${esc(tDyn("approvals.pending_update.label"))}</span>`
|
||||
: "";
|
||||
|
||||
const timePart = it.type === "appointment"
|
||||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||||
: "";
|
||||
const urgencyTag = it.urgency !== bucketUrgency
|
||||
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
|
||||
: "";
|
||||
const locationPart = it.type === "appointment" && it.location
|
||||
? `<span class="agenda-item-location">${esc(it.location)}</span>`
|
||||
: "";
|
||||
const typeLabelKey = it.type === "deadline"
|
||||
? "agenda.label.deadline"
|
||||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||||
const typeLabel = tDyn(typeLabelKey);
|
||||
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
|
||||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||||
<span class="agenda-item-main">
|
||||
<span class="agenda-item-headline">
|
||||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||||
${pendingPill}
|
||||
</span>
|
||||
<span class="agenda-item-sub">
|
||||
${project}
|
||||
${timePart}
|
||||
${locationPart}
|
||||
</span>
|
||||
</span>
|
||||
<span class="agenda-item-meta">
|
||||
${urgencyTag}
|
||||
</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function itemDetailHref(it: AgendaItem): string {
|
||||
return it.type === "deadline"
|
||||
? `/deadlines/${encodeURIComponent(it.id)}`
|
||||
: `/appointments/${encodeURIComponent(it.id)}`;
|
||||
}
|
||||
|
||||
function formatProjectLabel(it: AgendaItem): string {
|
||||
const ref = it.project_reference ? `${it.project_reference} · ` : "";
|
||||
const title = it.project_title || "";
|
||||
return `${ref}${title}`.trim();
|
||||
}
|
||||
|
||||
function formatAppointmentTime(it: AgendaItem): string {
|
||||
const start = new Date(it.date);
|
||||
if (isNaN(start.getTime())) return "";
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!it.end_at) return startStr;
|
||||
const end = new Date(it.end_at);
|
||||
if (isNaN(end.getTime())) return startStr;
|
||||
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${startStr}–${endStr}`;
|
||||
}
|
||||
|
||||
function relativeDayLabel(day: Date): string {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) {
|
||||
const n = Math.abs(diff);
|
||||
return getLang() === "de"
|
||||
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "Yesterday" : `${n} days ago`);
|
||||
}
|
||||
if (diff === 0) return t("agenda.day.today");
|
||||
if (diff === 1) return t("agenda.day.tomorrow");
|
||||
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
|
||||
}
|
||||
|
||||
function fullDateLabel(day: Date): string {
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
timeline.innerHTML = renderAgendaTimeline(state.items);
|
||||
}
|
||||
|
||||
function syncChips(): void {
|
||||
@@ -394,21 +224,3 @@ function toISODate(d: Date): string {
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function toLocalDayKey(d: Date): string {
|
||||
return toISODate(d);
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function deadlineIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||||
}
|
||||
|
||||
function appointmentIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ interface Appointment {
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
created_by?: string;
|
||||
// t-paliad-138 + t-paliad-160 — pending-approval surface.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
pending_request_id?: string | null;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
status: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
@@ -25,6 +41,8 @@ interface Project {
|
||||
let appointment: Appointment | null = null;
|
||||
let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -89,6 +107,31 @@ async function loadAllProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingRequest mirrors deadlines-detail.ts (t-paliad-160 §C+E):
|
||||
// pull the in-flight approval_request when the entity is pending so the
|
||||
// badge tooltip + the Withdraw button can be wired correctly.
|
||||
async function loadPendingRequest(): Promise<void> {
|
||||
pendingRequest = null;
|
||||
if (!appointment || appointment.approval_status !== "pending" || !appointment.pending_request_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${appointment.pending_request_id}`);
|
||||
if (resp.ok) pendingRequest = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -133,6 +176,44 @@ function renderHeader() {
|
||||
} else {
|
||||
projectRow.style.display = "none";
|
||||
}
|
||||
|
||||
// t-paliad-160 §C+E — pending-approval badge + withdraw + freeze controls.
|
||||
const isPending = appointment.approval_status === "pending";
|
||||
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
||||
const apBadge = document.getElementById("appointment-pending-approval-badge") as HTMLElement | null;
|
||||
if (apBadge) {
|
||||
if (isPending) {
|
||||
apBadge.style.display = "";
|
||||
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
||||
apBadge.textContent = labelDe;
|
||||
if (pendingRequest) {
|
||||
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
||||
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
||||
const when = fmtDateTime(pendingRequest.requested_at);
|
||||
apBadge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
||||
} else {
|
||||
apBadge.title = labelDe;
|
||||
}
|
||||
} else {
|
||||
apBadge.style.display = "none";
|
||||
apBadge.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
const withdrawBtn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (withdrawBtn) {
|
||||
withdrawBtn.style.display = (isPending && isRequester) ? "" : "none";
|
||||
withdrawBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
}
|
||||
|
||||
function fillEditForm() {
|
||||
@@ -219,9 +300,9 @@ async function deleteAppointment() {
|
||||
if (resp.ok || resp.status === 204) {
|
||||
window.location.href = "/events?type=appointment";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.error || t("appointments.error.generic");
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
@@ -231,6 +312,40 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.message || data.error || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = (t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseAppointmentID();
|
||||
const loading = document.getElementById("appointment-loading")!;
|
||||
@@ -250,6 +365,8 @@ async function main() {
|
||||
await Promise.all([
|
||||
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
|
||||
loadAllProjects(),
|
||||
loadMe(),
|
||||
loadPendingRequest(),
|
||||
]);
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
@@ -259,6 +376,8 @@ async function main() {
|
||||
|
||||
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
|
||||
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
|
||||
const withdrawBtn = document.getElementById("appointment-withdraw-btn");
|
||||
if (withdrawBtn) withdrawBtn.addEventListener("click", () => void withdrawAppointmentRequest());
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
if (notes) {
|
||||
|
||||
@@ -126,16 +126,23 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar — read requires_approval + min_role.
|
||||
// Fall back to the legacy required_role mirror (M1 dual-read window
|
||||
// only — drops in M2).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
required_role?: string | null;
|
||||
source?: string | null;
|
||||
source_name?: string | null;
|
||||
};
|
||||
if (!eff.required_role || eff.required_role === "none") {
|
||||
const role = eff.min_role || eff.required_role || null;
|
||||
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
|
||||
if (!required || !role) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
|
||||
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
|
||||
const sourceLabel = eff.source_name
|
||||
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
|
||||
: "";
|
||||
|
||||
@@ -49,6 +49,20 @@ export function firstName(displayName: string): string {
|
||||
return displayName.trim().split(/\s+/)[0] ?? "";
|
||||
}
|
||||
|
||||
// buildMailtoHref produces a `mailto:` URL with every recipient queued
|
||||
// in the To: field, comma-separated per RFC 6068. The `?` form is
|
||||
// preserved as a future hook for default subject/body — kept empty here
|
||||
// so users compose their own message in their mail client. Empty input
|
||||
// returns "mailto:" so the button still renders without a JS error.
|
||||
export function buildMailtoHref(recipients: BroadcastRecipient[]): string {
|
||||
const addrs = recipients
|
||||
.map((r) => r.email.trim())
|
||||
.filter((e) => e.length > 0)
|
||||
.map((e) => encodeURIComponent(e));
|
||||
if (!addrs.length) return "mailto:";
|
||||
return `mailto:${addrs.join(",")}`;
|
||||
}
|
||||
|
||||
export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
|
||||
if (!args.recipients.length) {
|
||||
alert(t("team.broadcast.error.no_recipients") || "Keine Empfänger ausgewählt.");
|
||||
@@ -147,6 +161,9 @@ function renderShell(args: OpenBroadcastModalArgs): string {
|
||||
<div class="broadcast-recipient-summary">
|
||||
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
|
||||
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
|
||||
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
|
||||
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
|
||||
</a>
|
||||
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
|
||||
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
|
||||
<ul>${fullList}</ul>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
||||
|
||||
interface DashboardUser {
|
||||
id: string;
|
||||
@@ -73,7 +74,13 @@ declare global {
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
// 30-day look-ahead matches the agenda.tsx default chip and the server's
|
||||
// default `to=today+30d` window — keeps the inline agenda visually
|
||||
// consistent with /agenda when users follow the "full agenda" link.
|
||||
const AGENDA_LOOKAHEAD_DAYS = 30;
|
||||
const COLLAPSE_KEY_PREFIX = "paliad:dashboard:collapse:";
|
||||
let data: DashboardData | null = null;
|
||||
let agendaItems: AgendaItem[] | null = null;
|
||||
|
||||
async function loadDashboard(): Promise<void> {
|
||||
const unavailable = document.getElementById("dashboard-unavailable")!;
|
||||
@@ -101,6 +108,7 @@ function render(): void {
|
||||
renderMatters(data.matter_summary);
|
||||
renderDeadlines(data.upcoming_deadlines);
|
||||
renderAppointments(data.upcoming_appointments);
|
||||
renderAgenda();
|
||||
renderActivity(data.recent_activity);
|
||||
toggleOnboardingHint(data.user);
|
||||
}
|
||||
@@ -307,6 +315,130 @@ function activityHref(e: ActivityEntry): string {
|
||||
return `/projects/${e.project_id}`;
|
||||
}
|
||||
|
||||
// Render the inline Agenda section. Items are fetched once on mount via
|
||||
// loadAgenda(); subsequent re-renders (lang change, dashboard poll) reuse
|
||||
// the cached array. The dashboard inline agenda is read-only — no chip
|
||||
// filters, default 30-day window — see CollapsibleSection in
|
||||
// dashboard.tsx for the surrounding shell.
|
||||
function renderAgenda(): void {
|
||||
const timeline = document.getElementById("dashboard-agenda-timeline");
|
||||
const empty = document.getElementById("dashboard-agenda-empty");
|
||||
if (!timeline || !empty) return;
|
||||
if (agendaItems === null) {
|
||||
// Items haven't landed yet — keep the timeline blank but hide empty
|
||||
// hint so we don't flash "nothing due" before the fetch resolves.
|
||||
timeline.innerHTML = "";
|
||||
timeline.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!agendaItems.length) {
|
||||
timeline.innerHTML = "";
|
||||
timeline.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
timeline.style.display = "";
|
||||
timeline.innerHTML = renderAgendaTimeline(agendaItems);
|
||||
}
|
||||
|
||||
async function loadAgenda(): Promise<void> {
|
||||
const from = toAgendaDate(startOfToday());
|
||||
const to = toAgendaDate(addDays(startOfToday(), AGENDA_LOOKAHEAD_DAYS - 1));
|
||||
try {
|
||||
const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
|
||||
if (!resp.ok) {
|
||||
// Fail silently — the rest of the dashboard still loads. The
|
||||
// inline agenda is best-effort: a 503 (DB-less knowledge-platform
|
||||
// deploy) or 401 (session timed out, should be caught by the
|
||||
// page-level redirect) just leaves the section empty.
|
||||
agendaItems = [];
|
||||
renderAgenda();
|
||||
return;
|
||||
}
|
||||
agendaItems = (await resp.json()) as AgendaItem[];
|
||||
renderAgenda();
|
||||
} catch {
|
||||
agendaItems = [];
|
||||
renderAgenda();
|
||||
}
|
||||
}
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + days);
|
||||
return r;
|
||||
}
|
||||
|
||||
function toAgendaDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Wire collapsible-section toggles. Each .dashboard-section carries a
|
||||
// data-collapse-key and the SSR markup renders aria-expanded="true" so
|
||||
// unstyled fallback shows everything; here we restore persisted state and
|
||||
// attach click handlers. Persistence is per-section via localStorage —
|
||||
// keys live under paliad:dashboard:collapse:<section> per the brief.
|
||||
function initCollapsibleSections(): void {
|
||||
const sections = document.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]");
|
||||
sections.forEach((section) => {
|
||||
const key = section.dataset.collapseKey || "";
|
||||
if (!key) return;
|
||||
const stored = localStorage.getItem(COLLAPSE_KEY_PREFIX + key);
|
||||
const collapsed = stored === "true";
|
||||
applyCollapseState(section, collapsed);
|
||||
|
||||
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener("click", () => {
|
||||
const nowExpanded = section.getAttribute("aria-expanded") === "true";
|
||||
const nextCollapsed = nowExpanded; // expanded → collapsing
|
||||
applyCollapseState(section, nextCollapsed);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY_PREFIX + key, String(nextCollapsed));
|
||||
} catch {
|
||||
// localStorage may be full or disabled (Safari private mode);
|
||||
// collapse still works for the current page life. Silent.
|
||||
}
|
||||
});
|
||||
});
|
||||
// Re-localise the toggle aria-labels on language switch so screen
|
||||
// readers always read the current language. The visible heading text
|
||||
// is handled by the i18n applyTranslations pass already.
|
||||
syncCollapseAriaLabels();
|
||||
}
|
||||
|
||||
function applyCollapseState(section: HTMLElement, collapsed: boolean): void {
|
||||
section.setAttribute("aria-expanded", String(!collapsed));
|
||||
const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
|
||||
if (toggle) {
|
||||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
toggle.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? t("dashboard.section.expand") : t("dashboard.section.collapse"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function syncCollapseAriaLabels(): void {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]")
|
||||
.forEach((section) => {
|
||||
const collapsed = section.getAttribute("aria-expanded") !== "true";
|
||||
applyCollapseState(section, collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleOnboardingHint(user: DashboardUser | null): void {
|
||||
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
|
||||
// already redirects users without a paliad.users row to /onboarding before
|
||||
@@ -369,15 +501,28 @@ function escAttr(s: string): string {
|
||||
function schedulePolling(): void {
|
||||
// Refresh the payload every minute so open dashboards stay current when
|
||||
// teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
|
||||
// The inline agenda is refreshed on the same cadence to stay in sync
|
||||
// with the deadlines/appointments rails above it.
|
||||
window.setInterval(() => {
|
||||
void loadDashboard();
|
||||
void loadAgenda();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(render);
|
||||
initCollapsibleSections();
|
||||
onLangChange(() => {
|
||||
render();
|
||||
syncCollapseAriaLabels();
|
||||
});
|
||||
|
||||
// Inline agenda fetch is independent of the main dashboard payload.
|
||||
// Kicked off in parallel so the agenda section paints as soon as the
|
||||
// /api/agenda response lands instead of waiting on the dashboard
|
||||
// payload poll.
|
||||
void loadAgenda();
|
||||
|
||||
const inlined = window.__PALIAD_DASHBOARD__;
|
||||
if (inlined !== undefined) {
|
||||
|
||||
@@ -24,6 +24,20 @@ interface Deadline {
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
event_type_ids?: string[];
|
||||
// t-paliad-138 + t-paliad-160. approval_status='pending' means an
|
||||
// approval_request is in flight; pending_request_id resolves to it
|
||||
// and the controls flip to a withdraw affordance for the requester.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
pending_request_id?: string | null;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
status: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,6 +68,7 @@ let project: Project | null = null;
|
||||
let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -170,6 +185,23 @@ async function loadMe() {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingRequest hydrates the in-flight approval_request when the
|
||||
// entity carries approval_status='pending'. Used to populate the badge
|
||||
// tooltip + decide whether to show the Withdraw button (only the
|
||||
// requester can withdraw).
|
||||
async function loadPendingRequest(): Promise<void> {
|
||||
pendingRequest = null;
|
||||
if (!deadline || deadline.approval_status !== "pending" || !deadline.pending_request_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${deadline.pending_request_id}`);
|
||||
if (resp.ok) pendingRequest = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal — badge still renders without the tooltip details */
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!deadline) return;
|
||||
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
|
||||
@@ -249,19 +281,49 @@ function render() {
|
||||
|
||||
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
||||
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
||||
const withdrawBtn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement;
|
||||
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
|
||||
const badge = document.getElementById("deadline-pending-approval-badge") as HTMLElement | null;
|
||||
|
||||
// t-paliad-160 §C+E — approval_status='pending' freezes the action
|
||||
// controls and surfaces the badge + a Withdraw button (visible only to
|
||||
// the requester). Other authenticated viewers see only the badge.
|
||||
const isPending = deadline.approval_status === "pending";
|
||||
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
||||
|
||||
if (badge) {
|
||||
if (isPending) {
|
||||
badge.style.display = "";
|
||||
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
||||
badge.textContent = labelDe;
|
||||
// Tooltip carries requester + required_role + age (best-effort).
|
||||
if (pendingRequest) {
|
||||
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
||||
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
||||
const when = fmtDateTime(pendingRequest.requested_at);
|
||||
badge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
||||
} else {
|
||||
badge.title = labelDe;
|
||||
}
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
badge.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons.
|
||||
if (deadline.status === "completed") {
|
||||
completeBtn.style.display = "none";
|
||||
// Reopen is admin-gated server-side; the button is shown for global
|
||||
// admins/partners here as a client-side hint. Project leads who lack a
|
||||
// global admin/partner role won't see the inline button — they get a 403
|
||||
// only if they try, but the button itself stays hidden. They can still
|
||||
// PATCH the endpoint directly.
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
if (me && (me.global_role === "global_admin") && !isPending) {
|
||||
reopenBtn.style.display = "";
|
||||
reopenBtn.disabled = false;
|
||||
} else {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
} else if (isPending) {
|
||||
// Lifecycle frozen — server returns 409 to anyone who tries.
|
||||
completeBtn.style.display = "none";
|
||||
reopenBtn.style.display = "none";
|
||||
} else {
|
||||
completeBtn.style.display = "";
|
||||
completeBtn.disabled = false;
|
||||
@@ -269,8 +331,22 @@ function render() {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// Edit button: hidden during pending so users don't fight a 409.
|
||||
if (editBtn) editBtn.style.display = isPending ? "none" : "";
|
||||
|
||||
// Withdraw button: visible only when caller is the requester of the
|
||||
// in-flight request.
|
||||
if (withdrawBtn) {
|
||||
if (isPending && isRequester) {
|
||||
withdrawBtn.style.display = "";
|
||||
withdrawBtn.disabled = false;
|
||||
} else {
|
||||
withdrawBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
if (me && (me.global_role === "global_admin") && !isPending) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
@@ -377,6 +453,25 @@ function initComplete() {
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
deadline = await resp.json();
|
||||
// The complete may have created an approval_request rather than
|
||||
// completed the deadline outright (4-eye-required). Re-fetch the
|
||||
// entity + pending request to surface the right state.
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else if (resp.status === 409) {
|
||||
// The handler returns the t-paliad-160 §B body shape. Surface
|
||||
// the human message and refresh state — likely a concurrent
|
||||
// request was already in flight.
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && body.message) || t("approvals.error.awaiting_approval") || "Diese Anforderung wartet auf Genehmigung.";
|
||||
window.alert(msg);
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) {
|
||||
deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
}
|
||||
render();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -406,6 +501,48 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error)) || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
||||
window.alert(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
window.alert((t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDelete() {
|
||||
const btn = document.getElementById("deadline-delete-btn")!;
|
||||
const modal = document.getElementById("deadline-delete-modal")!;
|
||||
@@ -455,7 +592,7 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
@@ -485,6 +622,7 @@ async function main() {
|
||||
initEdit();
|
||||
initComplete();
|
||||
initReopen();
|
||||
initWithdraw();
|
||||
initDelete();
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypePicker, type PickerHandle } from "./event-types";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
eventTypeLabel,
|
||||
fetchEventTypes,
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -19,8 +32,22 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -71,6 +98,7 @@ async function loadRules() {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
@@ -85,6 +113,93 @@ async function loadRules() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
@@ -193,16 +308,21 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
required_role?: string | null;
|
||||
source?: string | null;
|
||||
source_name?: string | null;
|
||||
};
|
||||
if (!eff.required_role || eff.required_role === "none") {
|
||||
const role = eff.min_role || eff.required_role || null;
|
||||
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
|
||||
if (!required || !role) {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
|
||||
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
|
||||
const sourceLabel = eff.source_name
|
||||
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
|
||||
: "";
|
||||
@@ -228,8 +348,36 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
|
||||
@@ -9,6 +9,18 @@ import {
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
// pill markup trivially short and inherits the user's emoji font.
|
||||
const APPROVAL_PILL_GLYPH = "👀";
|
||||
|
||||
// Sparkle glyph ✨ inside .approval-pill--agent (t-paliad-161). Renders
|
||||
// next to (not in place of) 👀 when the pending row originated from a
|
||||
// Paliadin chat suggestion. The two glyphs are orthogonal: 👀 = "needs
|
||||
// approval", ✨ = "Paliadin drafted this". Both can coexist; either can
|
||||
// appear alone in future autopilot states.
|
||||
const AGENT_PILL_GLYPH = "✨";
|
||||
|
||||
// EventsPage shared client (t-paliad-110). Drives /deadlines and
|
||||
// /appointments off the same shell — the route handler injects
|
||||
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
|
||||
@@ -40,6 +52,9 @@ interface EventListItem {
|
||||
|
||||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
// t-paliad-161: when approval_status='pending', tells us whether the row
|
||||
// was drafted by a user or by Paliadin (✨ glyph). NULL when not pending.
|
||||
requester_kind?: "user" | "agent";
|
||||
|
||||
// deadline-only
|
||||
due_date?: string;
|
||||
@@ -507,19 +522,28 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
|
||||
: "—";
|
||||
|
||||
// Approval pending pill (t-paliad-138). Soft-tint the row + insert a
|
||||
// ⚠ chip next to the title. Generic "pending approval" — the inbox
|
||||
// shows the lifecycle detail.
|
||||
// Approval pending pill (t-paliad-138 / m's 2026-05-08 cosmetic ask).
|
||||
// Soft-tint the row + drop an eye-icon pill next to the title; hover
|
||||
// reveals the lifecycle label. Inbox surface shows the full detail.
|
||||
//
|
||||
// t-paliad-161 ✨: when the pending row came from a Paliadin
|
||||
// suggestion (requester_kind='agent'), drop a second pill next to 👀.
|
||||
// Two glyphs read together as "needs approval, drafted by Paliadin".
|
||||
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const pendingLabel = item.approval_status === "pending" ? t("approvals.pending_update.label") : "";
|
||||
const pendingPill = item.approval_status === "pending"
|
||||
? `<span class="approval-pill" title="${esc(t("approvals.pending_update.label"))}">${esc(t("approvals.pending_update.label"))}</span>`
|
||||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
const agentLabel = t("approvals.agent.label");
|
||||
const agentPill = item.approval_status === "pending" && item.requester_kind === "agent"
|
||||
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
|
||||
: "";
|
||||
|
||||
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||||
<td class="frist-col-check">${checkCell}</td>
|
||||
<td class="events-col-row-type">${rowTypeChip(item)}</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}${agentPill ? " " + agentPill : ""}</td>
|
||||
<td class="frist-col-project">${projectCell}</td>
|
||||
<td class="frist-col-rule events-col-rule">${ruleLabel || "—"}</td>
|
||||
<td class="entity-col-event-type">${eventTypeCell || "—"}</td>
|
||||
|
||||
512
frontend/src/client/filter-bar/axes.ts
Normal file
512
frontend/src/client/filter-bar/axes.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// Per-axis renderers for the FilterBar — t-paliad-163.
|
||||
//
|
||||
// Each axis is a small, self-contained render function that takes the
|
||||
// current BarState slice and a callback. The bar's mountFilterBar
|
||||
// composes them in the order declared on the surface.
|
||||
//
|
||||
// Reuses existing CSS classes wherever possible:
|
||||
// - .agenda-chip / .agenda-chip-active (chip cluster pattern)
|
||||
// - .filter-group (label + control wrapping)
|
||||
// - .akten-multi-trigger / .multi-anchor / .multi-panel
|
||||
//
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import type { BarState, AxisKey } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
// Read the current value for this axis.
|
||||
get<K extends keyof BarState>(key: K): BarState[K];
|
||||
// Patch one or more axis values + trigger re-run.
|
||||
patch(delta: Partial<BarState>): void;
|
||||
}
|
||||
|
||||
// RenderAxisOpts — per-surface tuning the bar threads through to axis
|
||||
// renderers. Currently only time-axis chip presets; future axes can grow
|
||||
// here without changing every call site.
|
||||
export interface RenderAxisOpts {
|
||||
timePresets?: NonNullable<BarState["time"]>["horizon"][];
|
||||
}
|
||||
|
||||
// renderAxis returns the HTML element for a single axis. The bar's
|
||||
// mountFilterBar appends the result to its internal toolbar. Returns
|
||||
// null when the axis is ignored (e.g. surface didn't declare it).
|
||||
export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): HTMLElement | null {
|
||||
switch (axis) {
|
||||
case "time": return renderTimeAxis(ctx, opts?.timePresets);
|
||||
case "project": return null; // populated lazily — see attachProjectAxis below
|
||||
case "personal_only": return renderPersonalOnlyAxis(ctx);
|
||||
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
|
||||
case "approval_status": return renderApprovalStatusAxis(ctx);
|
||||
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
|
||||
case "deadline_status": return renderDeadlineStatusAxis(ctx);
|
||||
case "appointment_type": return renderAppointmentTypeAxis(ctx);
|
||||
case "project_event_kind": return renderProjectEventKindAxis(ctx);
|
||||
case "timeline_status": return renderTimelineStatusAxis(ctx);
|
||||
case "timeline_track": return renderTimelineTrackAxis(ctx);
|
||||
case "shape": return renderShapeAxis(ctx);
|
||||
case "density": return renderDensityAxis(ctx);
|
||||
case "sort": return renderSortAxis(ctx);
|
||||
|
||||
// Per-source predicates that need their own widgets and a roundtrip
|
||||
// through fetched option lists. Phase 2+ will fill these in by
|
||||
// wiring the existing event-types component.
|
||||
case "deadline_event_type":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"next_7d", "next_30d", "next_90d", "past_30d", "any",
|
||||
];
|
||||
|
||||
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) {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
}
|
||||
});
|
||||
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);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// personal_only — single chip (binary)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.personal");
|
||||
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ personal_only: !ctx.get("personal_only") });
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_viewer_role — chip cluster (3 mutually exclusive)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
|
||||
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
|
||||
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
|
||||
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
|
||||
];
|
||||
|
||||
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_role");
|
||||
const row = chipRow();
|
||||
// Default to "any_visible" so the surface lands on a populated view
|
||||
// for every user. The InboxSystemView's base spec also defaults here;
|
||||
// these two defaults must stay in sync — otherwise the chip and the
|
||||
// server narrow disagree on the empty URL.
|
||||
const current = ctx.get("approval_viewer_role") ?? "any_visible";
|
||||
for (const role of APPROVAL_ROLES) {
|
||||
const chip = chipBtn(t(role.key), role.value === current);
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ approval_viewer_role: role.value });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_status") ?? []);
|
||||
for (const status of APPROVAL_STATUSES) {
|
||||
const chip = chipBtn(t(status.key), current.has(status.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(status.value)) current.delete(status.value);
|
||||
else current.add(status.value);
|
||||
ctx.patch({ approval_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_entity_type — chip pair (multi-select; deadline / appointment)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
|
||||
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
|
||||
];
|
||||
|
||||
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_entity");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_entity_type") ?? []);
|
||||
for (const ent of APPROVAL_ENTITY_TYPES) {
|
||||
const chip = chipBtn(t(ent.key), current.has(ent.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ent.value)) current.delete(ent.value);
|
||||
else current.add(ent.value);
|
||||
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// deadline_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.deadline_status.pending" },
|
||||
{ value: "completed", key: "views.bar.deadline_status.completed" },
|
||||
];
|
||||
|
||||
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.deadline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("deadline_status") ?? []);
|
||||
for (const s of DEADLINE_STATUSES) {
|
||||
const chip = chipBtn(t(s.key), current.has(s.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(s.value)) current.delete(s.value);
|
||||
else current.add(s.value);
|
||||
ctx.patch({ deadline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// appointment_type — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
|
||||
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
|
||||
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
|
||||
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
|
||||
];
|
||||
|
||||
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.appointment_type");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("appointment_type") ?? []);
|
||||
for (const ty of APPOINTMENT_TYPES) {
|
||||
const chip = chipBtn(t(ty.key), current.has(ty.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ty.value)) current.delete(ty.value);
|
||||
else current.add(ty.value);
|
||||
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// project_event_kind — chip cluster (multi-select)
|
||||
//
|
||||
// Mirrors KnownProjectEventKinds in internal/services/filter_spec.go.
|
||||
// Labels reuse the existing `event.title.<kind>` translation table so
|
||||
// the chip text matches the Verlauf row title for the same event type.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const PROJECT_EVENT_KINDS: string[] = [
|
||||
"project_created",
|
||||
"project_archived",
|
||||
"project_reparented",
|
||||
"project_type_changed",
|
||||
"status_changed",
|
||||
"deadline_created",
|
||||
"deadline_completed",
|
||||
"deadline_reopened",
|
||||
"appointment_created",
|
||||
"appointment_updated",
|
||||
"appointment_deleted",
|
||||
"approval_decided",
|
||||
"member_role_changed",
|
||||
];
|
||||
|
||||
function renderProjectEventKindAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.project_event_kind");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("project_event_kind")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ project_event_kind: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("project_event_kind") ?? []);
|
||||
for (const kind of PROJECT_EVENT_KINDS) {
|
||||
const label = tDyn(`event.title.${kind}`);
|
||||
const chip = chipBtn(label, current.has(kind));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(kind)) current.delete(kind);
|
||||
else current.add(kind);
|
||||
ctx.patch({ project_event_kind: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// timeline_status — chip cluster (multi-select)
|
||||
//
|
||||
// SmartTimeline (t-paliad-173) status vocabulary spans actuals +
|
||||
// projections. Default: all. Macro chip pair "Zukunft anzeigen" /
|
||||
// "Nur vergangenes" toggles the [predicted, court_set] subset on
|
||||
// or off in one click.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIMELINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "done", key: "views.bar.timeline_status.done" },
|
||||
{ value: "open", key: "views.bar.timeline_status.open" },
|
||||
{ value: "overdue", key: "views.bar.timeline_status.overdue" },
|
||||
{ value: "predicted", key: "views.bar.timeline_status.predicted" },
|
||||
{ value: "predicted_overdue", key: "views.bar.timeline_status.predicted_overdue" },
|
||||
{ value: "court_set", key: "views.bar.timeline_status.court_set" },
|
||||
{ value: "off_script", key: "views.bar.timeline_status.off_script" },
|
||||
];
|
||||
|
||||
function renderTimelineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.timeline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ timeline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("timeline_status") ?? []);
|
||||
for (const s of TIMELINE_STATUSES) {
|
||||
const chip = chipBtn(t(s.key), current.has(s.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(s.value)) current.delete(s.value);
|
||||
else current.add(s.value);
|
||||
ctx.patch({ timeline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Macro chips. "Zukunft anzeigen" = include predicted+court_set; "Nur
|
||||
// vergangenes" = strip them. Implemented in terms of timeline_status.
|
||||
const future = chipBtn(t("views.bar.timeline_status.macro.future"), false);
|
||||
future.classList.add("filter-bar-chip-macro");
|
||||
future.addEventListener("click", () => {
|
||||
const next = new Set(["done", "open", "overdue", "predicted", "court_set", "predicted_overdue", "off_script"]);
|
||||
ctx.patch({ timeline_status: [...next] });
|
||||
});
|
||||
row.appendChild(future);
|
||||
const past = chipBtn(t("views.bar.timeline_status.macro.past"), false);
|
||||
past.classList.add("filter-bar-chip-macro");
|
||||
past.addEventListener("click", () => {
|
||||
ctx.patch({ timeline_status: ["done", "overdue", "off_script"] });
|
||||
});
|
||||
row.appendChild(past);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// timeline_track — chip cluster (multi-select)
|
||||
//
|
||||
// Slice 2 only renders parent + off_script; counterclaim and child:<id>
|
||||
// values land with Slice 3's CCR sub-project FK migration. The renderer
|
||||
// stays ready for those values — chip rendering is dynamic on the
|
||||
// state set, not hard-coded to the catalogue below.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIMELINE_TRACKS: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "parent", key: "views.bar.timeline_track.parent" },
|
||||
{ value: "counterclaim", key: "views.bar.timeline_track.counterclaim" },
|
||||
{ value: "off_script", key: "views.bar.timeline_track.off_script" },
|
||||
];
|
||||
|
||||
function renderTimelineTrackAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.timeline_track");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_track")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ timeline_track: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("timeline_track") ?? []);
|
||||
for (const tr of TIMELINE_TRACKS) {
|
||||
const chip = chipBtn(t(tr.key), current.has(tr.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(tr.value)) current.delete(tr.value);
|
||||
else current.add(tr.value);
|
||||
ctx.patch({ timeline_track: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shape — segmented control (list / cards / calendar)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SHAPES: Array<{ value: NonNullable<BarState["shape"]>; key: I18nKey }> = [
|
||||
{ value: "list", key: "views.bar.shape.list" },
|
||||
{ value: "cards", key: "views.bar.shape.cards" },
|
||||
{ value: "calendar", key: "views.bar.shape.calendar" },
|
||||
];
|
||||
|
||||
function renderShapeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.shape");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("shape");
|
||||
for (const sh of SHAPES) {
|
||||
const chip = chipBtn(t(sh.key), sh.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ shape: sh.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// density — segmented pair (comfortable / compact)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DENSITIES: Array<{ value: NonNullable<BarState["density"]>; key: I18nKey }> = [
|
||||
{ value: "comfortable", key: "views.bar.density.comfortable" },
|
||||
{ value: "compact", key: "views.bar.density.compact" },
|
||||
];
|
||||
|
||||
function renderDensityAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.density");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("density") ?? "comfortable";
|
||||
for (const d of DENSITIES) {
|
||||
const chip = chipBtn(t(d.key), d.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ density: d.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// sort — small <select>
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SORTS: Array<{ value: NonNullable<BarState["sort"]>; key: I18nKey }> = [
|
||||
{ value: "date_asc", key: "views.bar.sort.date_asc" },
|
||||
{ value: "date_desc", key: "views.bar.sort.date_desc" },
|
||||
];
|
||||
|
||||
function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.sort");
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "entity-select filter-bar-select";
|
||||
for (const s of SORTS) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.value;
|
||||
opt.textContent = t(s.key);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = ctx.get("sort") ?? "date_asc";
|
||||
sel.addEventListener("change", () => ctx.patch({ sort: sel.value as NonNullable<BarState["sort"]> }));
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shared helpers — group + chip + row
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function group(labelKey: I18nKey): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "filter-group filter-bar-group";
|
||||
const label = document.createElement("span");
|
||||
label.className = "filter-bar-label";
|
||||
label.textContent = t(labelKey);
|
||||
wrap.appendChild(label);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function chipRow(): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "filter-bar-chip-row";
|
||||
return row;
|
||||
}
|
||||
|
||||
function chipBtn(text: string, active: boolean): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
|
||||
btn.textContent = text;
|
||||
return btn;
|
||||
}
|
||||
349
frontend/src/client/filter-bar/index.ts
Normal file
349
frontend/src/client/filter-bar/index.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// FilterBar — the universal filter + view-mode primitive
|
||||
// (t-paliad-163). One client component every list-shaped paliad surface
|
||||
// mounts.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Caller hands in baseFilter + baseRender + axes + onResult.
|
||||
// 2. We parse URL params (within urlNamespace) and localStorage prefs,
|
||||
// overlay them on the base spec to compute the effective spec.
|
||||
// 3. We render the toolbar (one chip cluster / popover / select per
|
||||
// axis, plus trailing actions).
|
||||
// 4. We POST /api/views/{slug}/run with the effective spec as override
|
||||
// and hand the result + effective spec to onResult. The surface's
|
||||
// shape host renders.
|
||||
// 5. Every axis interaction patches BarState, re-encodes the URL,
|
||||
// re-runs the spec.
|
||||
//
|
||||
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
|
||||
// directly, just BarState diffs and the final ViewRunResult. That keeps
|
||||
// the substrate's validation invariants in one place (the bar).
|
||||
|
||||
import { onLangChange, t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
|
||||
import {
|
||||
parseBar,
|
||||
encodeBar,
|
||||
} from "./url-codec";
|
||||
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
|
||||
import { openSaveModal } from "./save-modal";
|
||||
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
||||
|
||||
export type { MountOpts, BarHandle, AxisKey } from "./types";
|
||||
|
||||
const PREFS_PREFIX = "paliad.bar.";
|
||||
|
||||
interface PrefsBlob {
|
||||
shape?: string;
|
||||
density?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
if (!!opts.customRunner === !!opts.systemViewSlug) {
|
||||
throw new Error(
|
||||
"mountFilterBar: exactly one of customRunner or systemViewSlug must be provided",
|
||||
);
|
||||
}
|
||||
let state: BarState = {};
|
||||
const ns = opts.urlNamespace;
|
||||
|
||||
// Hydrate state: URL > localStorage prefs > base.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
state = parseBar(urlParams, ns);
|
||||
hydratePrefs(state, opts.surfaceKey);
|
||||
|
||||
// Toolbar shell.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "filter-bar";
|
||||
host.appendChild(toolbar);
|
||||
|
||||
// Trailing actions: Save as view + Reset (when not suppressed).
|
||||
const showSave = opts.showSaveAsView !== false;
|
||||
|
||||
// Run + render orchestration.
|
||||
let runVersion = 0;
|
||||
let lastEffective: EffectiveSpec | null = null;
|
||||
|
||||
const runAndRender = async () => {
|
||||
const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
lastEffective = effective;
|
||||
const myVersion = ++runVersion;
|
||||
try {
|
||||
let result: ViewRunResult;
|
||||
if (opts.customRunner) {
|
||||
// Hand the runner a frozen snapshot of the bar state so it can
|
||||
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
|
||||
// timeline_status / timeline_track on the Verlauf surface).
|
||||
result = await opts.customRunner(effective, Object.freeze({ ...state }));
|
||||
} else {
|
||||
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filter: effective.filter }),
|
||||
});
|
||||
if (myVersion !== runVersion) return; // a newer click superseded us
|
||||
if (!r.ok) {
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
return;
|
||||
}
|
||||
result = (await r.json()) as ViewRunResult;
|
||||
}
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult(result, effective);
|
||||
} catch (_e) {
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
}
|
||||
};
|
||||
|
||||
// Axis context — all axis renderers patch state through here.
|
||||
const ctx: AxisCtx = {
|
||||
get<K extends keyof BarState>(key: K) { return state[key]; },
|
||||
patch(delta) {
|
||||
state = { ...state, ...delta };
|
||||
// Coerce empties so URL stays clean.
|
||||
for (const k of Object.keys(delta) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (Array.isArray(v) && v.length === 0) delete state[k];
|
||||
if (v === undefined || v === null || v === false) delete state[k];
|
||||
}
|
||||
// personal_only false should also be deleted (handled above as
|
||||
// falsy, but explicit for clarity).
|
||||
if (state.personal_only === false) delete state.personal_only;
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
};
|
||||
|
||||
const axisRenderOpts: RenderAxisOpts = {
|
||||
timePresets: opts.timePresets,
|
||||
};
|
||||
|
||||
// First paint.
|
||||
const renderToolbar = () => {
|
||||
toolbar.innerHTML = "";
|
||||
for (const axis of opts.axes) {
|
||||
const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts);
|
||||
if (el) toolbar.appendChild(el);
|
||||
}
|
||||
if (showSave) {
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "filter-bar-trailing";
|
||||
|
||||
const resetBtn = document.createElement("button");
|
||||
resetBtn.type = "button";
|
||||
resetBtn.className = "btn-secondary btn-small filter-bar-reset";
|
||||
resetBtn.textContent = t("views.bar.action.reset");
|
||||
resetBtn.disabled = !isDirty(state);
|
||||
resetBtn.addEventListener("click", () => handle.reset());
|
||||
trailing.appendChild(resetBtn);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn-primary btn-small filter-bar-save";
|
||||
saveBtn.textContent = t("views.bar.action.save_as_view");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!lastEffective) return;
|
||||
const result = await openSaveModal(lastEffective.filter, lastEffective.render);
|
||||
if (result) {
|
||||
window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
|
||||
}
|
||||
});
|
||||
trailing.appendChild(saveBtn);
|
||||
|
||||
toolbar.appendChild(trailing);
|
||||
}
|
||||
};
|
||||
|
||||
const syncURL = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
encodeBar(state, params, ns);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
history.replaceState(null, "", url);
|
||||
};
|
||||
|
||||
const syncPrefs = () => {
|
||||
const blob: PrefsBlob = {};
|
||||
if (state.shape) blob.shape = state.shape;
|
||||
if (state.density) blob.density = state.density;
|
||||
if (state.sort) blob.sort = state.sort;
|
||||
try {
|
||||
if (Object.keys(blob).length === 0) {
|
||||
localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
|
||||
} else {
|
||||
localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
|
||||
}
|
||||
} catch { /* private mode / quota — ignore */ }
|
||||
};
|
||||
|
||||
// Re-render labels on language change without losing state. The
|
||||
// existing onLangChange API is register-only (no off-handler). We
|
||||
// gate via a `destroyed` flag so a torn-down bar's callback no-ops.
|
||||
let destroyed = false;
|
||||
onLangChange(() => {
|
||||
if (destroyed) return;
|
||||
renderToolbar();
|
||||
});
|
||||
|
||||
const handle: BarHandle = {
|
||||
reset() {
|
||||
state = {};
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
async refresh() {
|
||||
await runAndRender();
|
||||
},
|
||||
getEffective() {
|
||||
if (lastEffective) return lastEffective;
|
||||
return computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
},
|
||||
getState() {
|
||||
// Hand back a frozen snapshot so callers can't smuggle mutations
|
||||
// back into the bar's owned state — the bar is the single writer.
|
||||
return Object.freeze({ ...state });
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
toolbar.remove();
|
||||
},
|
||||
};
|
||||
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
return handle;
|
||||
}
|
||||
|
||||
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
|
||||
// in render axes the URL didn't already pin. URL wins over prefs.
|
||||
function hydratePrefs(state: BarState, surfaceKey: string): void {
|
||||
let blob: PrefsBlob;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
|
||||
if (!raw) return;
|
||||
blob = JSON.parse(raw) as PrefsBlob;
|
||||
} catch { return; }
|
||||
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
|
||||
state.shape = blob.shape;
|
||||
}
|
||||
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
|
||||
state.density = blob.density;
|
||||
}
|
||||
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
|
||||
state.sort = blob.sort;
|
||||
}
|
||||
}
|
||||
|
||||
// computeEffective overlays the BarState onto the base FilterSpec +
|
||||
// RenderSpec to produce the spec that gets POSTed to the substrate.
|
||||
//
|
||||
// Server-side validator (FilterSpec.Validate) is the final gate; we
|
||||
// produce shapes the validator will accept, but defer to it for the
|
||||
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
|
||||
export function computeEffective(
|
||||
base: FilterSpec,
|
||||
baseRender: RenderSpec,
|
||||
state: BarState,
|
||||
): EffectiveSpec {
|
||||
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
|
||||
// fine here — every field on FilterSpec is a primitive / array /
|
||||
// object literal (no class instances, no Date, no functions).
|
||||
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
|
||||
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
|
||||
|
||||
if (state.time) {
|
||||
filter.time = {
|
||||
...filter.time,
|
||||
horizon: state.time.horizon,
|
||||
from: state.time.horizon === "custom" ? state.time.from : undefined,
|
||||
to: state.time.horizon === "custom" ? state.time.to : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
personal_only: true,
|
||||
// When personal_only takes over, leave projects on the base
|
||||
// mode (typically all_visible). Validator rejects ScopeExplicit
|
||||
// + personal_only so we don't overwrite the mode here.
|
||||
};
|
||||
} else if (state.project.id) {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
projects: { mode: "explicit", ids: [state.project.id] },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (state.personal_only) {
|
||||
filter.scope = { ...filter.scope, personal_only: true };
|
||||
}
|
||||
|
||||
// Per-source predicates. Build the predicates map idempotently;
|
||||
// never inject a predicate for a source the spec doesn't list.
|
||||
const sources = new Set(filter.sources);
|
||||
filter.predicates = filter.predicates ?? {};
|
||||
|
||||
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
|
||||
const cur = filter.predicates.deadline ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.deadline_status) next.status = state.deadline_status;
|
||||
if (state.deadline_event_type) {
|
||||
next.event_types = state.deadline_event_type.ids;
|
||||
next.include_untyped = state.deadline_event_type.include_untyped;
|
||||
}
|
||||
filter.predicates.deadline = next;
|
||||
}
|
||||
if (sources.has("appointment") && state.appointment_type) {
|
||||
const cur = filter.predicates.appointment ?? {};
|
||||
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
|
||||
}
|
||||
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
|
||||
const cur = filter.predicates.approval_request ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
|
||||
if (state.approval_status) next.status = state.approval_status;
|
||||
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
|
||||
filter.predicates.approval_request = next;
|
||||
}
|
||||
if (sources.has("project_event") && state.project_event_kind) {
|
||||
const cur = filter.predicates.project_event ?? {};
|
||||
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
|
||||
}
|
||||
|
||||
// Render overlays.
|
||||
if (state.shape) render.shape = state.shape;
|
||||
if (state.sort) {
|
||||
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
|
||||
render.list = { ...(render.list ?? {}), sort: state.sort };
|
||||
}
|
||||
if (render.shape === "cards" || state.shape === "cards") {
|
||||
render.cards = { ...(render.cards ?? {}), sort: state.sort };
|
||||
}
|
||||
}
|
||||
if (state.density && (render.shape === "list" || state.shape === "list")) {
|
||||
render.list = { ...(render.list ?? {}), density: state.density };
|
||||
}
|
||||
|
||||
return { filter, render };
|
||||
}
|
||||
|
||||
// isDirty — used to enable the Reset button only when there's something
|
||||
// to reset to.
|
||||
function isDirty(state: BarState): boolean {
|
||||
for (const k of Object.keys(state) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Save-as-view modal for the FilterBar. Mirrors the create form on
|
||||
// /views/new (frontend/src/client/views-editor.ts:168) but as a modal
|
||||
// so the user can save the bar's current effective spec without
|
||||
// leaving the page they're filtering on.
|
||||
//
|
||||
// On success, the new view appears in the "Meine Sichten" sidebar
|
||||
// group on next render (the sidebar polls /api/user-views on init).
|
||||
|
||||
import { t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, UserView } from "../views/types";
|
||||
|
||||
export interface SaveModalResult {
|
||||
view: UserView;
|
||||
}
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export function openSaveModal(filter: FilterSpec, render: RenderSpec): Promise<SaveModalResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "filter-bar-save-modal";
|
||||
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog" class="filter-bar-save-form">
|
||||
<h2>${t("views.bar.save.heading")}</h2>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.name")}</span>
|
||||
<input type="text" name="name" required maxlength="100" autocomplete="off" />
|
||||
</label>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.slug")}</span>
|
||||
<input type="text" name="slug" required maxlength="63" autocomplete="off" pattern="[a-z0-9][a-z0-9-]*" />
|
||||
<small>${t("views.bar.save.field.slug_hint")}</small>
|
||||
</label>
|
||||
<label class="filter-bar-save-checkbox">
|
||||
<input type="checkbox" name="show_count" />
|
||||
<span>${t("views.bar.save.field.show_count")}</span>
|
||||
</label>
|
||||
<p class="filter-bar-save-error" hidden></p>
|
||||
<div class="filter-bar-save-actions">
|
||||
<button type="button" class="btn-secondary" data-action="cancel">${t("views.bar.save.cancel")}</button>
|
||||
<button type="submit" class="btn-primary">${t("views.bar.save.confirm")}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const form = dialog.querySelector<HTMLFormElement>(".filter-bar-save-form")!;
|
||||
const errorEl = dialog.querySelector<HTMLParagraphElement>(".filter-bar-save-error")!;
|
||||
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
|
||||
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
|
||||
const showCount = form.elements.namedItem("show_count") as HTMLInputElement;
|
||||
const cancelBtn = dialog.querySelector<HTMLButtonElement>('[data-action="cancel"]')!;
|
||||
|
||||
// Auto-derive slug from name as the user types — but only until
|
||||
// they touch the slug field manually.
|
||||
let slugDirty = false;
|
||||
nameInput.addEventListener("input", () => {
|
||||
if (!slugDirty) slugInput.value = derivedSlug(nameInput.value);
|
||||
});
|
||||
slugInput.addEventListener("input", () => { slugDirty = true; });
|
||||
|
||||
const cleanup = () => {
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
};
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.hidden = true;
|
||||
errorEl.textContent = "";
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
const slug = slugInput.value.trim();
|
||||
if (!name) {
|
||||
showError(errorEl, t("views.bar.save.error.name_required"));
|
||||
return;
|
||||
}
|
||||
if (!SLUG_REGEX.test(slug)) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_format"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
filter_spec: filter,
|
||||
render_spec: render,
|
||||
show_count: showCount.checked,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/user-views", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.status === 409) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_taken"));
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
showError(errorEl, body.error || `${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
const view = (await r.json()) as UserView;
|
||||
cleanup();
|
||||
resolve({ view });
|
||||
} catch (_e) {
|
||||
showError(errorEl, t("views.bar.save.error.network"));
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
nameInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function showError(el: HTMLElement, msg: string): void {
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function derivedSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, "ae")
|
||||
.replace(/[öÖ]/g, "oe")
|
||||
.replace(/[üÜ]/g, "ue")
|
||||
.replace(/[ß]/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63);
|
||||
}
|
||||
161
frontend/src/client/filter-bar/types.ts
Normal file
161
frontend/src/client/filter-bar/types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// FilterBar types — t-paliad-163. Mirrors the Go FilterSpec/RenderSpec
|
||||
// shapes from internal/services/{filter_spec,render_spec}.go via
|
||||
// client/views/types.ts. The FilterBar is the universal frontend
|
||||
// primitive that consumes a base FilterSpec + RenderSpec, declares
|
||||
// which axes the surface supports, and emits diffs back through
|
||||
// onResult after running the spec via /api/views/run.
|
||||
|
||||
import type { FilterSpec, RenderSpec, RenderShape, ViewRunResult, ListRowAction } from "../views/types";
|
||||
|
||||
// AxisKey — every filter dimension the bar can render. Declared per
|
||||
// surface in mountFilterBar's `axes` array. See design §3.1 for the
|
||||
// universal-vs-per-surface split.
|
||||
export type AxisKey =
|
||||
| "time"
|
||||
| "project"
|
||||
| "personal_only"
|
||||
| "deadline_status"
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "timeline_status"
|
||||
| "timeline_track"
|
||||
| "shape"
|
||||
| "sort"
|
||||
| "density";
|
||||
|
||||
// Effective spec — the result of overlaying URL + localStorage prefs
|
||||
// on top of the base spec. Handed back to onResult so the surface can
|
||||
// dispatch into the matching shape renderer with the right config.
|
||||
export interface EffectiveSpec {
|
||||
filter: FilterSpec;
|
||||
render: RenderSpec;
|
||||
}
|
||||
|
||||
// Per-axis state — what the URL codec round-trips. Each axis's value
|
||||
// type is bounded to the FilterSpec/RenderSpec subset it touches.
|
||||
export interface BarState {
|
||||
// Universal
|
||||
time?: TimeOverlay;
|
||||
project?: ProjectOverlay;
|
||||
personal_only?: boolean;
|
||||
|
||||
// Per-source
|
||||
deadline_status?: string[];
|
||||
deadline_event_type?: { ids: string[]; include_untyped: boolean };
|
||||
appointment_type?: string[];
|
||||
approval_viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
|
||||
approval_status?: string[];
|
||||
approval_entity_type?: string[];
|
||||
project_event_kind?: string[];
|
||||
// SmartTimeline axes (t-paliad-173). timeline_status spans actuals +
|
||||
// projections; timeline_track is parent / counterclaim / off_script
|
||||
// and grows once Slice 3 lands the CCR sub-project FK (child:<id>
|
||||
// values dynamically populated then).
|
||||
timeline_status?: string[];
|
||||
timeline_track?: string[];
|
||||
|
||||
// Render
|
||||
shape?: RenderShape;
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface ProjectOverlay {
|
||||
// The bar's project chip is single-select today; Phase C upgrades
|
||||
// to multi-select. "personal" is a sentinel — the legacy /events
|
||||
// contract reserved this name, we keep it so old bookmarks still
|
||||
// resolve to the right state.
|
||||
mode: "single" | "personal";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// MountOpts — the public API.
|
||||
export interface MountOpts {
|
||||
// Base spec. Usually a SystemView's FilterSpec+RenderSpec, fetched
|
||||
// from /api/views/system on the surface and passed in here. For
|
||||
// /views/{slug}, the saved user-view's spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface exposes. Order is preserved in the rendered
|
||||
// chrome — surfaces use this to control left-to-right grouping.
|
||||
axes: AxisKey[];
|
||||
|
||||
// URL parameter namespace. When set, every URL key is prefixed
|
||||
// (`?<ns>_time=`, `?<ns>_project=`, …). Used when two bars share a
|
||||
// page (dashboard inline lists). Defaults to no prefix.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Surface key for localStorage prefs (density, default shape).
|
||||
// Required so two surfaces don't share preferences.
|
||||
surfaceKey: string;
|
||||
|
||||
// Whether to render "Speichern als Sicht" + "Zurücksetzen"
|
||||
// trailing actions. Defaults to true. Set false on the dashboard
|
||||
// inline bars (per design Q6).
|
||||
showSaveAsView?: boolean;
|
||||
|
||||
// Slug of the surface's underlying system view (or saved user view).
|
||||
// POSTed to /api/views/{slug}/run with the override body. Required
|
||||
// unless `customRunner` is supplied — see below. When the bar runs
|
||||
// through this endpoint it is the substrate's canonical entry.
|
||||
systemViewSlug?: string;
|
||||
|
||||
// Custom runner. When set, the bar bypasses the substrate POST and
|
||||
// hands the effective spec + raw BarState to this function instead.
|
||||
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
|
||||
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
|
||||
// The state argument is a frozen snapshot — same shape getState()
|
||||
// returns on the handle, but available on the very first run before
|
||||
// the handle has been assigned. Must be either this OR systemViewSlug
|
||||
// — the bar throws if both / neither are provided.
|
||||
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
|
||||
|
||||
// Per-surface override of the time-axis chip presets. Order is
|
||||
// preserved. Default presets are forward-looking (next_*+past_30d+any)
|
||||
// — backward-looking surfaces (Verlauf, audit) pass past_*+all here.
|
||||
timePresets?: NonNullable<BarState["time"]>["horizon"][];
|
||||
|
||||
// When true, the bar exposes an "Aktualisieren" affordance that
|
||||
// PATCHes /api/user-views/{userViewId} with the effective spec.
|
||||
// Set on /views/{slug} where the user is viewing a saved view.
|
||||
userViewId?: string;
|
||||
|
||||
// Called every time the spec changes (mount, URL change, axis
|
||||
// interaction). The surface dispatches to the matching shape
|
||||
// renderer with the rows from /api/views/{slug}/run.
|
||||
onResult(result: ViewRunResult, effective: EffectiveSpec): void;
|
||||
|
||||
// Optional — surface-specific row-action override. Phase 1: /inbox
|
||||
// pins this to "approve"; /events Phase 3 pins to "complete_toggle".
|
||||
// Future: sourced from the spec's render.list.row_action when set.
|
||||
rowAction?: ListRowAction;
|
||||
}
|
||||
|
||||
// Bar handle — what mountFilterBar returns. Pages can call .reset()
|
||||
// from page-level controls (e.g. an empty-state "Filter zurücksetzen"
|
||||
// button), or .destroy() if the page tears down.
|
||||
export interface BarHandle {
|
||||
reset(): void;
|
||||
refresh(): Promise<void>;
|
||||
destroy(): void;
|
||||
// Read-only effective spec at this moment (post URL + localStorage
|
||||
// overlay). Pages use this to construct deep-link URLs etc.
|
||||
getEffective(): EffectiveSpec;
|
||||
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
|
||||
// round-trip (timeline_status / timeline_track on the SmartTimeline
|
||||
// surface — the substrate FilterSpec has no per-source predicate for
|
||||
// those) read state directly to drive client-side filtering. Returns
|
||||
// a frozen snapshot; callers must not mutate.
|
||||
getState(): Readonly<BarState>;
|
||||
}
|
||||
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Unit tests for the FilterBar URL codec. Round-trip discipline:
|
||||
// every BarState shape parseBar produces must encode back to the same
|
||||
// URL params, and vice versa. Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseBar, encodeBar } from "./url-codec";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
function roundTrip(state: BarState, ns?: string): BarState {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params, ns);
|
||||
return parseBar(params, ns);
|
||||
}
|
||||
|
||||
describe("filter-bar/url-codec", () => {
|
||||
test("empty state round-trips to empty", () => {
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom time horizon round-trips with from + to", () => {
|
||||
const state: BarState = { time: { horizon: "custom", from: "2026-01-01", to: "2026-12-31" } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("project sentinel + uuid round-trip", () => {
|
||||
expect(roundTrip({ project: { mode: "personal" } })).toEqual({ project: { mode: "personal" } });
|
||||
expect(roundTrip({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } }))
|
||||
.toEqual({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } });
|
||||
});
|
||||
|
||||
test("personal_only flag round-trips", () => {
|
||||
expect(roundTrip({ personal_only: true })).toEqual({ personal_only: true });
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("deadline_event_type honours legacy 'none' sentinel", () => {
|
||||
const state: BarState = { deadline_event_type: { ids: ["a", "b"], include_untyped: true } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
const state2: BarState = { deadline_event_type: { ids: [], include_untyped: true } };
|
||||
expect(roundTrip(state2)).toEqual(state2);
|
||||
const state3: BarState = { deadline_event_type: { ids: ["a"], include_untyped: false } };
|
||||
expect(roundTrip(state3)).toEqual(state3);
|
||||
});
|
||||
|
||||
test("approval_request triple round-trips together", () => {
|
||||
const state: BarState = {
|
||||
approval_viewer_role: "approver_eligible",
|
||||
approval_status: ["pending", "approved"],
|
||||
approval_entity_type: ["deadline"],
|
||||
};
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("namespace prefix isolates two bars on the same page", () => {
|
||||
const a: BarState = { time: { horizon: "next_7d" } };
|
||||
const b: BarState = { time: { horizon: "next_30d" } };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(a, params, "agenda");
|
||||
encodeBar(b, params, "activity");
|
||||
expect(parseBar(params, "agenda")).toEqual(a);
|
||||
expect(parseBar(params, "activity")).toEqual(b);
|
||||
// Without namespace neither bar's keys are visible.
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
|
||||
test("render axes round-trip", () => {
|
||||
const state: BarState = { shape: "cards", sort: "date_desc", density: "compact" };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("encode is idempotent — re-encoding same state replaces, doesn't accumulate", () => {
|
||||
const state: BarState = { time: { horizon: "next_7d" }, deadline_status: ["pending"] };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params);
|
||||
encodeBar(state, params);
|
||||
expect(params.get("d_status")).toBe("pending");
|
||||
// Only one entry per key.
|
||||
expect(params.getAll("d_status")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("encode replaces stale keys when state shrinks", () => {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar({ deadline_status: ["pending"], approval_viewer_role: "self_requested" }, params);
|
||||
encodeBar({ deadline_status: ["completed"] }, params);
|
||||
expect(params.get("d_status")).toBe("completed");
|
||||
expect(params.has("a_role")).toBe(false);
|
||||
});
|
||||
|
||||
test("parse drops unknown enum values silently (forward-compat)", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("a_role", "future_role_we_dont_know_yet");
|
||||
params.set("shape", "kanban");
|
||||
params.set("density", "huge");
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
});
|
||||
198
frontend/src/client/filter-bar/url-codec.ts
Normal file
198
frontend/src/client/filter-bar/url-codec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// FilterBar URL codec — t-paliad-163. Encodes BarState ↔ URL
|
||||
// parameters with optional namespace prefix (?<ns>_<key>=).
|
||||
//
|
||||
// The bar treats the URL as canonical for everything that affects
|
||||
// which rows you see. Round-trip discipline: anything written by
|
||||
// encodeBar must parse back identically via parseBar so deep-links
|
||||
// and refresh both yield the same effective spec.
|
||||
//
|
||||
// Empty / default values are NOT written — the URL stays clean for
|
||||
// users who don't tweak. The page's base spec is the implicit baseline.
|
||||
|
||||
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
|
||||
|
||||
const PERSONAL_PROJECT_SENTINEL = "personal";
|
||||
|
||||
// parseBar reads URL params into a BarState. Unknown values are
|
||||
// dropped silently (forward-compat with future axes).
|
||||
export function parseBar(params: URLSearchParams, ns?: string): BarState {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
const out: BarState = {};
|
||||
|
||||
// time
|
||||
const time = params.get(k("time"));
|
||||
if (time) {
|
||||
const horizon = parseHorizon(time);
|
||||
if (horizon) {
|
||||
const overlay: TimeOverlay = { horizon };
|
||||
if (horizon === "custom") {
|
||||
const from = params.get(k("from"));
|
||||
const to = params.get(k("to"));
|
||||
if (from) overlay.from = from;
|
||||
if (to) overlay.to = to;
|
||||
}
|
||||
out.time = overlay;
|
||||
}
|
||||
}
|
||||
|
||||
// project
|
||||
const project = params.get(k("project"));
|
||||
if (project) {
|
||||
if (project === PERSONAL_PROJECT_SENTINEL) {
|
||||
out.project = { mode: "personal" };
|
||||
} else {
|
||||
out.project = { mode: "single", id: project };
|
||||
}
|
||||
}
|
||||
|
||||
// personal_only
|
||||
if (params.get(k("personal")) === "1") {
|
||||
out.personal_only = true;
|
||||
}
|
||||
|
||||
// deadline.status
|
||||
const dStatus = params.get(k("d_status"));
|
||||
if (dStatus) out.deadline_status = parseCSV(dStatus);
|
||||
|
||||
// deadline.event_types — preserves the legacy /events contract
|
||||
// where "none" inside the CSV means include_untyped=true.
|
||||
const dEvent = params.get(k("d_event_type"));
|
||||
if (dEvent) {
|
||||
const tokens = parseCSV(dEvent);
|
||||
const ids: string[] = [];
|
||||
let untyped = false;
|
||||
for (const tok of tokens) {
|
||||
if (tok === "none") untyped = true;
|
||||
else ids.push(tok);
|
||||
}
|
||||
out.deadline_event_type = { ids, include_untyped: untyped };
|
||||
}
|
||||
|
||||
// appointment.types
|
||||
const appType = params.get(k("app_type"));
|
||||
if (appType) out.appointment_type = parseCSV(appType);
|
||||
|
||||
// approval_request.viewer_role
|
||||
const aRole = params.get(k("a_role"));
|
||||
if (aRole === "approver_eligible" || aRole === "self_requested" || aRole === "any_visible") {
|
||||
out.approval_viewer_role = aRole;
|
||||
}
|
||||
|
||||
// approval_request.status
|
||||
const aStatus = params.get(k("a_status"));
|
||||
if (aStatus) out.approval_status = parseCSV(aStatus);
|
||||
|
||||
// approval_request.entity_types
|
||||
const aEntity = params.get(k("a_entity_type"));
|
||||
if (aEntity) out.approval_entity_type = parseCSV(aEntity);
|
||||
|
||||
// project_event.event_types
|
||||
const peKind = params.get(k("pe_kind"));
|
||||
if (peKind) out.project_event_kind = parseCSV(peKind);
|
||||
|
||||
// SmartTimeline (t-paliad-173) — status + track axes.
|
||||
const tlStatus = params.get(k("tl_status"));
|
||||
if (tlStatus) out.timeline_status = parseCSV(tlStatus);
|
||||
const tlTrack = params.get(k("tl_track"));
|
||||
if (tlTrack) out.timeline_track = parseCSV(tlTrack);
|
||||
|
||||
// render.shape
|
||||
const shape = params.get(k("shape"));
|
||||
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
|
||||
|
||||
// render.list.sort / render.cards.sort — the bar treats sort as one axis
|
||||
const sort = params.get(k("sort"));
|
||||
if (sort === "date_asc" || sort === "date_desc") out.sort = sort;
|
||||
|
||||
// render.list.density
|
||||
const density = params.get(k("density"));
|
||||
if (density === "comfortable" || density === "compact") out.density = density;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// encodeBar writes BarState back into URL params, mutating the
|
||||
// passed-in URLSearchParams. Empty / undefined values are omitted.
|
||||
// The caller controls how the result is applied (history.replaceState
|
||||
// with the page pathname unchanged).
|
||||
export function encodeBar(state: BarState, params: URLSearchParams, ns?: string): void {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
|
||||
// Clear every key the bar owns first, then re-write the non-empty ones.
|
||||
for (const key of [
|
||||
"time", "from", "to", "project", "personal",
|
||||
"d_status", "d_event_type",
|
||||
"app_type",
|
||||
"a_role", "a_status", "a_entity_type",
|
||||
"pe_kind",
|
||||
"tl_status", "tl_track",
|
||||
"shape", "sort", "density",
|
||||
]) {
|
||||
params.delete(k(key));
|
||||
}
|
||||
|
||||
if (state.time) {
|
||||
params.set(k("time"), state.time.horizon);
|
||||
if (state.time.horizon === "custom") {
|
||||
if (state.time.from) params.set(k("from"), state.time.from);
|
||||
if (state.time.to) params.set(k("to"), state.time.to);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
params.set(k("project"), PERSONAL_PROJECT_SENTINEL);
|
||||
} else if (state.project.id) {
|
||||
params.set(k("project"), state.project.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.personal_only) params.set(k("personal"), "1");
|
||||
|
||||
if (state.deadline_status?.length) params.set(k("d_status"), state.deadline_status.join(","));
|
||||
|
||||
if (state.deadline_event_type) {
|
||||
const parts = [...state.deadline_event_type.ids];
|
||||
if (state.deadline_event_type.include_untyped) parts.push("none");
|
||||
if (parts.length) params.set(k("d_event_type"), parts.join(","));
|
||||
}
|
||||
|
||||
if (state.appointment_type?.length) params.set(k("app_type"), state.appointment_type.join(","));
|
||||
if (state.approval_viewer_role) params.set(k("a_role"), state.approval_viewer_role);
|
||||
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
|
||||
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
|
||||
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
|
||||
if (state.timeline_status?.length) params.set(k("tl_status"), state.timeline_status.join(","));
|
||||
if (state.timeline_track?.length) params.set(k("tl_track"), state.timeline_track.join(","));
|
||||
|
||||
if (state.shape) params.set(k("shape"), state.shape);
|
||||
if (state.sort) params.set(k("sort"), state.sort);
|
||||
if (state.density) params.set(k("density"), state.density);
|
||||
}
|
||||
|
||||
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
switch (s) {
|
||||
case "next_7d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "past_7d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return s;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCSV(s: string): string[] {
|
||||
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export { PERSONAL_PROJECT_SENTINEL };
|
||||
|
||||
// Re-exported so consumers don't need to import ProjectOverlay just
|
||||
// to construct one in tests.
|
||||
export type { ProjectOverlay };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Kostenrechner",
|
||||
"nav.fristenrechner": "Fristenrechner",
|
||||
"nav.verfahrensablauf": "Verfahrensablauf",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossar",
|
||||
@@ -38,10 +39,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.paliadin": "Paliadin",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "\u00dcbersicht",
|
||||
"nav.group.arbeit": "Arbeit",
|
||||
"nav.group.ansichten": "Ansichten",
|
||||
"nav.group.werkzeuge": "Werkzeuge",
|
||||
"nav.group.wissen": "Wissen",
|
||||
"nav.group.ressourcen": "Ressourcen",
|
||||
"nav.neuigkeiten": "Neuigkeiten",
|
||||
"nav.soon.tooltip": "Bald verf\u00fcgbar",
|
||||
|
||||
@@ -243,6 +242,40 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both": "Beide",
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
"deadlines.optional.badge": "auf Antrag",
|
||||
"deadlines.proceeding.selected": "Verfahren:",
|
||||
"deadlines.proceeding.reselect": "Anderes Verfahren wählen",
|
||||
"deadlines.step1.heading": "Schritt 1 — Welche Akte?",
|
||||
"deadlines.step1.search.placeholder": "Akte suchen…",
|
||||
"deadlines.step1.search.empty": "Keine passende Akte gefunden.",
|
||||
"deadlines.step1.divider.new": "oder eine neue Akte",
|
||||
"deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
|
||||
"deadlines.step1.new.cta": "+ Neue Akte anlegen",
|
||||
"deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
|
||||
"deadlines.step1.adhoc.de": "Custom DE-Verfahren",
|
||||
"deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
|
||||
"deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
|
||||
"deadlines.step1.selected": "Akte:",
|
||||
"deadlines.step1.reselect": "Andere Akte",
|
||||
"deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
|
||||
"deadlines.step2.heading": "Schritt 2 — Was möchten Sie tun?",
|
||||
"deadlines.step2.file.title": "Etwas einreichen",
|
||||
"deadlines.step2.file.desc": "Outgoing — eine Frist tritt aus eigener Handlung ein.",
|
||||
"deadlines.step2.happened.title": "Etwas ist passiert",
|
||||
"deadlines.step2.happened.desc": "Incoming — ein Ereignis hat eine Frist ausgelöst.",
|
||||
"deadlines.step2.browse.title": "Verfahrensablauf einsehen",
|
||||
"deadlines.step2.browse.desc": "Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.",
|
||||
"deadlines.save.cta.adhoc.hint": "Ad-hoc — kein Projekt, kein Speichern",
|
||||
"deadlines.step3a.heading": "Was möchten Sie einreichen?",
|
||||
"deadlines.step3a.back": "zurück zur Auswahl",
|
||||
"deadlines.step3a.file.title": "Schriftsatz einreichen",
|
||||
"deadlines.step3a.file.desc": "Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.",
|
||||
"deadlines.step3a.draft.title": "Schriftsatz entwerfen",
|
||||
"deadlines.step3a.draft.desc": "Vorbereitung — später mit Drafting-Surface verknüpft.",
|
||||
"deadlines.step3a.enter.title": "Frist manuell erfassen",
|
||||
"deadlines.step3a.enter.desc": "Direkt eintragen — bereits bekanntes Datum / bekannter Typ.",
|
||||
"deadlines.step3a.soon": "kommt bald",
|
||||
"deadlines.date.edit.hint": "Datum bearbeiten — Folgefristen werden neu berechnet",
|
||||
"deadlines.view.label": "Ansicht:",
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
@@ -320,6 +353,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
|
||||
"deadlines.pathway.b.tree.reset": "Neu starten",
|
||||
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
|
||||
"deadlines.inbox.label": "Wo kam es an?",
|
||||
"deadlines.inbox.cms.title": "UPC — über CMS",
|
||||
"deadlines.inbox.bea.title": "Nationale Verfahren — über beA",
|
||||
"deadlines.inbox.posteingang.title": "Nationale Verfahren — Postzustellung",
|
||||
"deadlines.inbox.posteingang": "Posteingang",
|
||||
"deadlines.inbox.all": "Alle",
|
||||
"deadlines.filter.forum.label": "Gericht / System:",
|
||||
"deadlines.filter.forum.upc_cfi": "UPC CFI",
|
||||
"deadlines.filter.forum.upc_coa": "UPC CoA",
|
||||
@@ -334,7 +373,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.label": "Ich vertrete:",
|
||||
"deadlines.perspective.claimant": "Klägerseite (Proactive)",
|
||||
"deadlines.perspective.defendant": "Beklagtenseite (Reactive)",
|
||||
"deadlines.perspective.claimant.short": "Kläger",
|
||||
"deadlines.perspective.defendant.short": "Beklagter",
|
||||
"deadlines.perspective.both.short": "Beide",
|
||||
"deadlines.perspective.claimant.title": "Klägerseite — versteckt typische Beklagten-Schriftsätze",
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -699,6 +744,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -808,6 +857,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.activity.empty": "Noch keine Aktivit\u00e4t erfasst.",
|
||||
"dashboard.activity.system": "System",
|
||||
"dashboard.activity.event": "Ereignis",
|
||||
// Inline Agenda section on the dashboard (t-paliad-162). The
|
||||
// standalone /agenda page keeps its own copy under the agenda.* keys;
|
||||
// these are dashboard-scoped so the headline reads as a section
|
||||
// title rather than a page title.
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Keine F\u00e4lligkeiten in den n\u00e4chsten 30 Tagen.",
|
||||
"dashboard.agenda.full_link": "Vollst\u00e4ndige Agenda \u00f6ffnen \u2192",
|
||||
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
|
||||
// are needed because the aria-label flips with the expanded state.
|
||||
"dashboard.section.collapse": "Abschnitt einklappen",
|
||||
"dashboard.section.expand": "Abschnitt ausklappen",
|
||||
"dashboard.urgency.overdue": "\u00dcberf\u00e4llig",
|
||||
"dashboard.urgency.today": "Heute",
|
||||
"dashboard.urgency.urgent": "Dringend",
|
||||
@@ -823,6 +883,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.action.short.project_reparented": "ordnete Projekt neu zu",
|
||||
"dashboard.action.short.project_type_changed": "\u00e4nderte Projekt-Typ",
|
||||
"dashboard.action.short.status_changed": "\u00e4nderte Status",
|
||||
"dashboard.action.short.our_side_changed": "\u00e4nderte vertretene Seite",
|
||||
"dashboard.action.short.visibility_changed": "\u00e4nderte Sichtbarkeit",
|
||||
"dashboard.action.short.collaborators_updated": "aktualisierte Bearbeiter",
|
||||
"dashboard.action.short.note_created": "f\u00fcgte Notiz hinzu",
|
||||
@@ -844,6 +905,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.project_reparented": "Projekt umstrukturiert",
|
||||
"event.title.project_type_changed": "Projekt-Typ ge\u00e4ndert",
|
||||
"event.title.status_changed": "Status ge\u00e4ndert",
|
||||
"event.title.our_side_changed": "Vertretene Seite ge\u00e4ndert",
|
||||
"event.title.note_created": "Notiz hinzugef\u00fcgt",
|
||||
"event.title.deadline_created": "Frist angelegt",
|
||||
"event.title.deadline_updated": "Frist ge\u00e4ndert",
|
||||
@@ -1073,6 +1135,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Gericht",
|
||||
"projects.field.case_number": "Aktenzeichen (Gericht)",
|
||||
"projects.field.proceeding_type_id": "Verfahrensart",
|
||||
"projects.field.our_side": "Wir vertreten",
|
||||
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
"projects.field.our_side.claimant": "Klägerseite",
|
||||
"projects.field.our_side.defendant": "Beklagtenseite",
|
||||
"projects.field.our_side.court": "Gericht / Tribunal",
|
||||
"projects.field.our_side.both": "Beide Seiten",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Titel erforderlich",
|
||||
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
|
||||
@@ -1085,7 +1155,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.save": "Speichern",
|
||||
"projects.detail.tab.verlauf": "Verlauf",
|
||||
"projects.detail.tab.team": "Team",
|
||||
"projects.detail.tab.kinder": "Untergeordnet",
|
||||
"projects.detail.tab.kinder": "Projektbaum",
|
||||
"projects.detail.tab.parteien": "Parteien",
|
||||
"projects.detail.tab.fristen": "Fristen",
|
||||
"projects.detail.tab.termine": "Termine",
|
||||
@@ -1093,6 +1163,79 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
"projects.detail.smarttimeline.empty": "Noch keine Ereignisse erfasst.",
|
||||
"projects.detail.smarttimeline.today": "Heute",
|
||||
"projects.detail.smarttimeline.section.past": "Vergangenheit",
|
||||
"projects.detail.smarttimeline.section.future": "Zukunft",
|
||||
"projects.detail.smarttimeline.section.undated": "Ohne Datum",
|
||||
"projects.detail.smarttimeline.kind.deadline": "Frist",
|
||||
"projects.detail.smarttimeline.kind.appointment": "Termin",
|
||||
"projects.detail.smarttimeline.kind.milestone": "Meilenstein",
|
||||
"projects.detail.smarttimeline.kind.projected": "Vorhersage",
|
||||
"projects.detail.smarttimeline.status.done": "Erledigt",
|
||||
"projects.detail.smarttimeline.status.open": "Offen",
|
||||
"projects.detail.smarttimeline.status.overdue": "Überfällig",
|
||||
"projects.detail.smarttimeline.status.court_set": "Datum vom Gericht",
|
||||
"projects.detail.smarttimeline.status.predicted": "Voraussichtlich",
|
||||
"projects.detail.smarttimeline.status.off_script": "Eigener Eintrag",
|
||||
"projects.detail.smarttimeline.audit.toggle.show": "Audit-Log anzeigen",
|
||||
"projects.detail.smarttimeline.audit.toggle.hide": "Nur Timeline-Einträge",
|
||||
"projects.detail.smarttimeline.add.cta": "+ Eintrag",
|
||||
"projects.detail.smarttimeline.add.modal.title": "Neuer Eintrag im SmartTimeline",
|
||||
"projects.detail.smarttimeline.add.choice.deadline": "Frist anlegen",
|
||||
"projects.detail.smarttimeline.add.choice.appointment": "Termin anlegen",
|
||||
"projects.detail.smarttimeline.add.choice.counterclaim": "Widerklage (CCR)",
|
||||
"projects.detail.smarttimeline.add.choice.amend": "Antrag auf Änderung (R.30)",
|
||||
"projects.detail.smarttimeline.add.choice.milestone": "Eigener Meilenstein",
|
||||
"projects.detail.smarttimeline.add.choice.disabled": "Kommt mit Slice 3",
|
||||
"projects.detail.smarttimeline.add.cancel": "Abbrechen",
|
||||
"projects.detail.smarttimeline.add.submit": "Speichern",
|
||||
"projects.detail.smarttimeline.milestone.title": "Titel",
|
||||
"projects.detail.smarttimeline.milestone.date": "Datum (optional)",
|
||||
"projects.detail.smarttimeline.milestone.description": "Beschreibung (optional)",
|
||||
"projects.detail.smarttimeline.error.title_required": "Bitte einen Titel angeben.",
|
||||
"projects.detail.smarttimeline.error.generic": "Konnte den Eintrag nicht speichern.",
|
||||
"projects.detail.smarttimeline.status.predicted_overdue": "Überfällig (vorhergesagt)",
|
||||
"projects.detail.smarttimeline.lookahead.more": "+ Mehr anzeigen",
|
||||
"projects.detail.smarttimeline.lookahead.less": "− Weniger",
|
||||
"projects.detail.smarttimeline.depends_on.prefix": "Folgt aus",
|
||||
"projects.detail.smarttimeline.depends_on.date_open": "Datum offen",
|
||||
"projects.detail.smarttimeline.depends_on.show_path": "Pfad anzeigen",
|
||||
"projects.detail.smarttimeline.depends_on.hide_path": "Pfad verbergen",
|
||||
"projects.detail.smarttimeline.depends_on.path_hint": "Klicke die übergeordnete Zeile, um deren Abhängigkeit zu sehen.",
|
||||
"projects.detail.smarttimeline.anchor.set": "Datum setzen",
|
||||
"projects.detail.smarttimeline.anchor.save": "Speichern",
|
||||
"projects.detail.smarttimeline.anchor.cancel": "Abbrechen",
|
||||
"projects.detail.smarttimeline.anchor.saving": "Speichere …",
|
||||
"projects.detail.smarttimeline.anchor.saved": "Gespeichert.",
|
||||
"projects.detail.smarttimeline.anchor.error": "Konnte das Datum nicht setzen.",
|
||||
"projects.detail.smarttimeline.anchor.invalid_date": "Ungültiges Datum (YYYY-MM-DD).",
|
||||
"projects.detail.smarttimeline.track.label": "Track",
|
||||
"projects.detail.smarttimeline.track.both": "Beide",
|
||||
"projects.detail.smarttimeline.track.only.parent": "Nur Hauptverfahren",
|
||||
"projects.detail.smarttimeline.track.only.counterclaim": "Nur Widerklage",
|
||||
"projects.detail.smarttimeline.track.only.parent_context": "Nur Hauptverfahren (Kontext)",
|
||||
"projects.detail.smarttimeline.track.header.parent": "Hauptverfahren",
|
||||
"projects.detail.smarttimeline.track.header.counterclaim": "Widerklage (CCR)",
|
||||
"projects.detail.smarttimeline.track.header.parent_context": "Hauptverfahren (Kontext)",
|
||||
"projects.detail.smarttimeline.counterclaim.procedure": "Verfahrenstyp",
|
||||
"projects.detail.smarttimeline.counterclaim.title": "Titel (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.case_number": "CCR-Aktenzeichen (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_override": "Unsere Seite NICHT umkehren (Stimmt nicht?)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_hint": "Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.",
|
||||
"projects.detail.smarttimeline.counterclaim.submit": "Widerklage anlegen",
|
||||
"projects.detail.smarttimeline.counterclaim.saving": "Lege Widerklage an …",
|
||||
"projects.detail.smarttimeline.lane.empty": "Keine Einträge in dieser Spur.",
|
||||
"projects.detail.smarttimeline.lane.filter.label": "Spuren",
|
||||
"projects.detail.smarttimeline.lane.filter.all": "Alle",
|
||||
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline-Ansicht",
|
||||
"projects.detail.smarttimeline.client.toggle.matter_list": "Mandatsliste",
|
||||
"projects.detail.smarttimeline.client.matter_list.heading": "Verfahren des Mandanten",
|
||||
"projects.detail.smarttimeline.client.matter_list.hint": "Klicke ein Verfahren an, um die Detail-Timeline zu öffnen, oder schalte oben auf „Timeline-Ansicht“.",
|
||||
"projects.detail.smarttimeline.client.matter_list.empty": "Noch keine Verfahren angelegt.",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up": "In übergeordneten Akten anzeigen",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up_hint": "Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.",
|
||||
"projects.detail.team.form.user": "Benutzer",
|
||||
"projects.detail.team.form.role": "Rolle",
|
||||
"projects.detail.team.form.responsibility": "Rolle im Projekt",
|
||||
@@ -1511,6 +1654,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.broadcast.title": "E-Mail an Auswahl",
|
||||
"team.broadcast.recipients": "Empfänger",
|
||||
"team.broadcast.show_all": "Alle anzeigen",
|
||||
"team.broadcast.mailto.label": "Im Mail-Client öffnen",
|
||||
"team.broadcast.mailto.tooltip": "Öffnet den lokalen Mail-Client mit allen Empfängern in der To-Zeile",
|
||||
"team.broadcast.template": "Vorlage",
|
||||
"team.broadcast.template_optional": "optional",
|
||||
"team.broadcast.template_freeform": "Freitext",
|
||||
@@ -1564,6 +1709,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
|
||||
"paliadin.error.connection_lost": "Verbindung verloren.",
|
||||
"paliadin.error.upstream": "Fehler beim Senden.",
|
||||
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
|
||||
"paliadin.late.marker": "verspätet",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "Was kann ich für dich tun?",
|
||||
"paliadin.widget.input.placeholder": "Frage an Paliadin...",
|
||||
"paliadin.widget.input.label": "Nachricht an Paliadin",
|
||||
"paliadin.widget.send": "Senden",
|
||||
"paliadin.widget.reset": "Konversation zurücksetzen",
|
||||
"paliadin.widget.reset.confirm": "Konversation hier und auf dem Server zurücksetzen?",
|
||||
"paliadin.widget.fullscreen": "Vollbild-Modus",
|
||||
"paliadin.widget.close": "Schließen",
|
||||
"paliadin.widget.context.on_page": "Auf dieser Seite",
|
||||
"approvals.agent.label": "Paliadin hat das vorgeschlagen",
|
||||
"approvals.agent.byline": "Paliadin",
|
||||
"approvals.agent.suggestion_pending": "Vorschlag wartet auf deine Genehmigung",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
"admin.paliadin.title": "Paliadin Monitor — Paliad",
|
||||
"admin.paliadin.heading": "Paliadin Monitor",
|
||||
@@ -1580,8 +1741,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.paliadin.col.prompt": "Anfrage",
|
||||
"admin.paliadin.col.count": "Anzahl",
|
||||
"admin.paliadin.col.started": "Zeit",
|
||||
"admin.paliadin.col.user": "Nutzer",
|
||||
"admin.paliadin.col.classifier": "Art",
|
||||
"admin.paliadin.col.response": "Antwort",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.origin": "Seite",
|
||||
"admin.paliadin.col.duration": "Dauer",
|
||||
"admin.paliadin.loading": "Lade…",
|
||||
|
||||
@@ -1645,6 +1809,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.approval_policies.source.project": "Projekt",
|
||||
"admin.approval_policies.source.ancestor": "Geerbt",
|
||||
"admin.approval_policies.source.unit_default": "Standard",
|
||||
"admin.approval_policies.source.no_approval": "keine Genehmigung",
|
||||
"admin.approval_policies.cell.requires": "Genehmigung erforderlich",
|
||||
"admin.approval_policies.cell.clear": "—",
|
||||
"admin.approval_policies.cell.clear.title": "Regel zurücksetzen (erben)",
|
||||
"admin.approval_policies.cell.saved_msg": "Gespeichert.",
|
||||
"admin.approval_policies.cell.error_msg": "Fehler",
|
||||
"admin.approval_policies.bulk.cta": "Auf Unterprojekte anwenden",
|
||||
@@ -1963,7 +2131,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
|
||||
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
|
||||
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
|
||||
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
|
||||
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
|
||||
"approvals.pending.badge": "Wartet auf Genehmigung",
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -2072,6 +2245,81 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
|
||||
"views.editor.error.load_failed": "Ansicht konnte nicht geladen werden.",
|
||||
"views.editor.error.delete_failed": "Ansicht konnte nicht gelöscht werden.",
|
||||
|
||||
// Universal FilterBar — t-paliad-163. Mounted on every list-shaped
|
||||
// surface (starts with /inbox in Phase 1; /agenda + /events follow).
|
||||
"views.bar.label.time": "Zeitraum",
|
||||
"views.bar.label.personal": "Eigene",
|
||||
"views.bar.label.approval_role": "Sicht",
|
||||
"views.bar.label.approval_status": "Status",
|
||||
"views.bar.label.approval_entity": "Art",
|
||||
"views.bar.label.deadline_status": "Frist-Status",
|
||||
"views.bar.label.appointment_type": "Termin-Typ",
|
||||
"views.bar.label.project_event_kind": "Ereignis",
|
||||
"views.bar.label.timeline_status": "Timeline-Status",
|
||||
"views.bar.label.timeline_track": "Track",
|
||||
"views.bar.timeline_status.done": "Erledigt",
|
||||
"views.bar.timeline_status.open": "Offen",
|
||||
"views.bar.timeline_status.overdue": "Überfällig",
|
||||
"views.bar.timeline_status.predicted": "Voraussichtlich",
|
||||
"views.bar.timeline_status.predicted_overdue": "Überfällig (vorhergesagt)",
|
||||
"views.bar.timeline_status.court_set": "Gerichtsdatum",
|
||||
"views.bar.timeline_status.off_script": "Eigener Eintrag",
|
||||
"views.bar.timeline_status.macro.future": "Zukunft anzeigen",
|
||||
"views.bar.timeline_status.macro.past": "Nur vergangenes",
|
||||
"views.bar.timeline_track.parent": "Hauptverfahren",
|
||||
"views.bar.timeline_track.counterclaim": "Widerklage",
|
||||
"views.bar.timeline_track.off_script": "Off-Script",
|
||||
"views.bar.label.shape": "Darstellung",
|
||||
"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.personal.on": "Nur eigene",
|
||||
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
|
||||
"views.bar.approval_role.self_requested": "Eigene Anfragen",
|
||||
"views.bar.approval_role.any_visible": "Alle sichtbaren",
|
||||
"views.bar.approval_status.pending": "Wartend",
|
||||
"views.bar.approval_status.approved": "Genehmigt",
|
||||
"views.bar.approval_status.rejected": "Abgelehnt",
|
||||
"views.bar.approval_status.revoked": "Zurückgezogen",
|
||||
"views.bar.approval_entity.deadline": "Frist",
|
||||
"views.bar.approval_entity.appointment": "Termin",
|
||||
"views.bar.deadline_status.pending": "Offen",
|
||||
"views.bar.deadline_status.completed": "Erledigt",
|
||||
"views.bar.appointment_type.hearing": "Verhandlung",
|
||||
"views.bar.appointment_type.meeting": "Besprechung",
|
||||
"views.bar.appointment_type.consultation": "Beratung",
|
||||
"views.bar.appointment_type.deadline_hearing": "Mündliche Verhandlung",
|
||||
"views.bar.shape.list": "Liste",
|
||||
"views.bar.shape.cards": "Karten",
|
||||
"views.bar.shape.calendar": "Kalender",
|
||||
"views.bar.density.comfortable": "Bequem",
|
||||
"views.bar.density.compact": "Kompakt",
|
||||
"views.bar.sort.date_asc": "Datum aufsteigend",
|
||||
"views.bar.sort.date_desc": "Datum absteigend",
|
||||
"views.bar.action.reset": "Zurücksetzen",
|
||||
"views.bar.action.save_as_view": "Als Sicht speichern",
|
||||
"views.bar.save.heading": "Sicht speichern",
|
||||
"views.bar.save.field.name": "Name",
|
||||
"views.bar.save.field.slug": "Slug",
|
||||
"views.bar.save.field.slug_hint": "Wird Teil der URL: /views/<slug>",
|
||||
"views.bar.save.field.show_count": "Anzahl in der Sidebar zeigen",
|
||||
"views.bar.save.cancel": "Abbrechen",
|
||||
"views.bar.save.confirm": "Speichern",
|
||||
"views.bar.save.error.name_required": "Bitte Namen vergeben.",
|
||||
"views.bar.save.error.slug_format": "Slug muss mit einem Buchstaben oder einer Ziffer beginnen und darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
|
||||
"views.bar.save.error.slug_taken": "Dieser Slug ist bereits vergeben.",
|
||||
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -2079,6 +2327,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Cost Calculator",
|
||||
"nav.fristenrechner": "Deadline Calculator",
|
||||
"nav.verfahrensablauf": "Procedure Roadmap",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossary",
|
||||
@@ -2096,10 +2345,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.paliadin": "Paliadin",
|
||||
"nav.team": "Team",
|
||||
"nav.group.uebersicht": "Overview",
|
||||
"nav.group.arbeit": "Work",
|
||||
"nav.group.ansichten": "Views",
|
||||
"nav.group.werkzeuge": "Tools",
|
||||
"nav.group.wissen": "Knowledge",
|
||||
"nav.group.ressourcen": "Resources",
|
||||
"nav.neuigkeiten": "What's New",
|
||||
"nav.soon.tooltip": "Coming soon",
|
||||
|
||||
@@ -2298,6 +2545,40 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.party.both": "Both",
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
"deadlines.optional.badge": "on request",
|
||||
"deadlines.proceeding.selected": "Proceeding:",
|
||||
"deadlines.proceeding.reselect": "Choose another proceeding",
|
||||
"deadlines.step1.heading": "Step 1 — Which matter?",
|
||||
"deadlines.step1.search.placeholder": "Search matters…",
|
||||
"deadlines.step1.search.empty": "No matching matter.",
|
||||
"deadlines.step1.divider.new": "or a new matter",
|
||||
"deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
|
||||
"deadlines.step1.new.cta": "+ Create new matter",
|
||||
"deadlines.step1.adhoc.upc": "Custom UPC proceeding",
|
||||
"deadlines.step1.adhoc.de": "Custom DE proceeding",
|
||||
"deadlines.step1.adhoc.epa": "Custom EPA proceeding",
|
||||
"deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
|
||||
"deadlines.step1.selected": "Matter:",
|
||||
"deadlines.step1.reselect": "Other matter",
|
||||
"deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
|
||||
"deadlines.step2.heading": "Step 2 — What do you want to do?",
|
||||
"deadlines.step2.file.title": "File something",
|
||||
"deadlines.step2.file.desc": "Outgoing — your action triggers a deadline.",
|
||||
"deadlines.step2.happened.title": "Something happened",
|
||||
"deadlines.step2.happened.desc": "Incoming — an event triggered a deadline.",
|
||||
"deadlines.step2.browse.title": "Browse procedure roadmap",
|
||||
"deadlines.step2.browse.desc": "Browse / Learn — see what happens when. No deadline entered.",
|
||||
"deadlines.save.cta.adhoc.hint": "Ad-hoc — no matter, no save",
|
||||
"deadlines.step3a.heading": "What do you want to file?",
|
||||
"deadlines.step3a.back": "back to selection",
|
||||
"deadlines.step3a.file.title": "File a submission",
|
||||
"deadlines.step3a.file.desc": "Open the Verfahrensablauf — compute deadline and add to the matter.",
|
||||
"deadlines.step3a.draft.title": "Draft a submission",
|
||||
"deadlines.step3a.draft.desc": "Preparation — later linked to the drafting surface.",
|
||||
"deadlines.step3a.enter.title": "Enter deadline manually",
|
||||
"deadlines.step3a.enter.desc": "Direct entry — date and type already known.",
|
||||
"deadlines.step3a.soon": "coming soon",
|
||||
"deadlines.date.edit.hint": "Edit date — downstream deadlines will recalculate",
|
||||
"deadlines.view.label": "View:",
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
@@ -2382,6 +2663,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.pathway.b.tree.empty": "No matches for this path.",
|
||||
"deadlines.pathway.b.tree.reset": "Restart",
|
||||
"deadlines.pathway.b.tree.start_question": "What happened?",
|
||||
"deadlines.inbox.label": "Where did it arrive?",
|
||||
"deadlines.inbox.cms.title": "UPC — via CMS",
|
||||
"deadlines.inbox.bea.title": "National-DE — via beA",
|
||||
"deadlines.inbox.posteingang.title": "National-DE — postal mail",
|
||||
"deadlines.inbox.posteingang": "Postal",
|
||||
"deadlines.inbox.all": "All",
|
||||
"deadlines.filter.forum.label": "Forum / System:",
|
||||
"deadlines.filter.forum.upc_cfi": "UPC CFI",
|
||||
"deadlines.filter.forum.upc_coa": "UPC CoA",
|
||||
@@ -2396,7 +2683,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.label": "I represent:",
|
||||
"deadlines.perspective.claimant": "Claimant side (Proactive)",
|
||||
"deadlines.perspective.defendant": "Defendant side (Reactive)",
|
||||
"deadlines.perspective.claimant.short": "Claimant",
|
||||
"deadlines.perspective.defendant.short": "Defendant",
|
||||
"deadlines.perspective.both.short": "Both",
|
||||
"deadlines.perspective.claimant.title": "Claimant side — hides typical defendant submissions",
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -2754,6 +3047,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -2863,6 +3160,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.activity.empty": "No activity recorded yet.",
|
||||
"dashboard.activity.system": "System",
|
||||
"dashboard.activity.event": "event",
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Nothing due in the next 30 days.",
|
||||
"dashboard.agenda.full_link": "Open full agenda →",
|
||||
"dashboard.section.collapse": "Collapse section",
|
||||
"dashboard.section.expand": "Expand section",
|
||||
"dashboard.urgency.overdue": "Overdue",
|
||||
"dashboard.urgency.today": "Today",
|
||||
"dashboard.urgency.urgent": "Urgent",
|
||||
@@ -2874,6 +3176,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.action.short.project_reparented": "re-parented project",
|
||||
"dashboard.action.short.project_type_changed": "changed project type",
|
||||
"dashboard.action.short.status_changed": "changed status",
|
||||
"dashboard.action.short.our_side_changed": "changed represented side",
|
||||
"dashboard.action.short.visibility_changed": "changed visibility",
|
||||
"dashboard.action.short.collaborators_updated": "updated collaborators",
|
||||
"dashboard.action.short.note_created": "added note",
|
||||
@@ -2895,6 +3198,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.project_reparented": "Project re-parented",
|
||||
"event.title.project_type_changed": "Project type changed",
|
||||
"event.title.status_changed": "Status changed",
|
||||
"event.title.our_side_changed": "Represented side changed",
|
||||
"event.title.note_created": "Note added",
|
||||
"event.title.deadline_created": "Deadline created",
|
||||
"event.title.deadline_updated": "Deadline updated",
|
||||
@@ -3122,6 +3426,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Court",
|
||||
"projects.field.case_number": "Case number (court)",
|
||||
"projects.field.proceeding_type_id": "Proceeding type",
|
||||
"projects.field.our_side": "We represent",
|
||||
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
"projects.field.our_side.claimant": "Claimant side",
|
||||
"projects.field.our_side.defendant": "Defendant side",
|
||||
"projects.field.our_side.court": "Court / tribunal",
|
||||
"projects.field.our_side.both": "Both sides",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Title required",
|
||||
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
|
||||
@@ -3134,7 +3446,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.save": "Save",
|
||||
"projects.detail.tab.verlauf": "Activity",
|
||||
"projects.detail.tab.team": "Team",
|
||||
"projects.detail.tab.kinder": "Sub-projects",
|
||||
"projects.detail.tab.kinder": "Project Tree",
|
||||
"projects.detail.tab.parteien": "Parties",
|
||||
"projects.detail.tab.fristen": "Deadlines",
|
||||
"projects.detail.tab.termine": "Appointments",
|
||||
@@ -3142,6 +3454,78 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
"projects.detail.smarttimeline.today": "Today",
|
||||
"projects.detail.smarttimeline.section.past": "Past",
|
||||
"projects.detail.smarttimeline.section.future": "Future",
|
||||
"projects.detail.smarttimeline.section.undated": "Undated",
|
||||
"projects.detail.smarttimeline.kind.deadline": "Deadline",
|
||||
"projects.detail.smarttimeline.kind.appointment": "Appointment",
|
||||
"projects.detail.smarttimeline.kind.milestone": "Milestone",
|
||||
"projects.detail.smarttimeline.kind.projected": "Predicted",
|
||||
"projects.detail.smarttimeline.status.done": "Done",
|
||||
"projects.detail.smarttimeline.status.open": "Open",
|
||||
"projects.detail.smarttimeline.status.overdue": "Overdue",
|
||||
"projects.detail.smarttimeline.status.court_set": "Court-set date",
|
||||
"projects.detail.smarttimeline.status.predicted": "Predicted",
|
||||
"projects.detail.smarttimeline.status.off_script": "Custom",
|
||||
"projects.detail.smarttimeline.audit.toggle.show": "Show audit log",
|
||||
"projects.detail.smarttimeline.audit.toggle.hide": "Timeline only",
|
||||
"projects.detail.smarttimeline.add.cta": "+ Entry",
|
||||
"projects.detail.smarttimeline.add.modal.title": "New SmartTimeline entry",
|
||||
"projects.detail.smarttimeline.add.choice.deadline": "Add a deadline",
|
||||
"projects.detail.smarttimeline.add.choice.appointment": "Add an appointment",
|
||||
"projects.detail.smarttimeline.add.choice.counterclaim": "Counterclaim (CCR)",
|
||||
"projects.detail.smarttimeline.add.choice.amend": "Application to amend (R.30)",
|
||||
"projects.detail.smarttimeline.add.choice.milestone": "Custom milestone",
|
||||
"projects.detail.smarttimeline.add.choice.disabled": "Coming in Slice 3",
|
||||
"projects.detail.smarttimeline.add.cancel": "Cancel",
|
||||
"projects.detail.smarttimeline.add.submit": "Save",
|
||||
"projects.detail.smarttimeline.milestone.title": "Title",
|
||||
"projects.detail.smarttimeline.milestone.date": "Date (optional)",
|
||||
"projects.detail.smarttimeline.milestone.description": "Description (optional)",
|
||||
"projects.detail.smarttimeline.error.title_required": "Please enter a title.",
|
||||
"projects.detail.smarttimeline.error.generic": "Could not save the entry.",
|
||||
"projects.detail.smarttimeline.status.predicted_overdue": "Overdue (predicted)",
|
||||
"projects.detail.smarttimeline.lookahead.more": "+ Show more",
|
||||
"projects.detail.smarttimeline.lookahead.less": "− Show less",
|
||||
"projects.detail.smarttimeline.depends_on.prefix": "Follows from",
|
||||
"projects.detail.smarttimeline.depends_on.date_open": "Date open",
|
||||
"projects.detail.smarttimeline.depends_on.show_path": "Show path",
|
||||
"projects.detail.smarttimeline.depends_on.hide_path": "Hide path",
|
||||
"projects.detail.smarttimeline.depends_on.path_hint": "Click the parent row to see its dependency.",
|
||||
"projects.detail.smarttimeline.anchor.set": "Set date",
|
||||
"projects.detail.smarttimeline.anchor.save": "Save",
|
||||
"projects.detail.smarttimeline.anchor.cancel": "Cancel",
|
||||
"projects.detail.smarttimeline.anchor.saving": "Saving…",
|
||||
"projects.detail.smarttimeline.anchor.saved": "Saved.",
|
||||
"projects.detail.smarttimeline.anchor.error": "Could not set the date.",
|
||||
"projects.detail.smarttimeline.anchor.invalid_date": "Invalid date (YYYY-MM-DD).",
|
||||
"projects.detail.smarttimeline.track.label": "Track",
|
||||
"projects.detail.smarttimeline.track.both": "Both",
|
||||
"projects.detail.smarttimeline.track.only.parent": "Main proceeding only",
|
||||
"projects.detail.smarttimeline.track.only.counterclaim": "Counterclaim only",
|
||||
"projects.detail.smarttimeline.track.only.parent_context": "Main proceeding only (context)",
|
||||
"projects.detail.smarttimeline.track.header.parent": "Main proceeding",
|
||||
"projects.detail.smarttimeline.track.header.counterclaim": "Counterclaim (CCR)",
|
||||
"projects.detail.smarttimeline.track.header.parent_context": "Main proceeding (context)",
|
||||
"projects.detail.smarttimeline.counterclaim.procedure": "Proceeding type",
|
||||
"projects.detail.smarttimeline.counterclaim.title": "Title (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.case_number": "CCR case number (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_override": "Do NOT flip our side („Stimmt nicht?”)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_hint": "In the standard case (CCR on validity) our side flips (claimant ↔ defendant). Enable for the R.49.2.b CCI edge case.",
|
||||
"projects.detail.smarttimeline.counterclaim.submit": "Create counterclaim",
|
||||
"projects.detail.smarttimeline.counterclaim.saving": "Creating counterclaim…",
|
||||
"projects.detail.smarttimeline.lane.empty": "No entries in this lane.",
|
||||
"projects.detail.smarttimeline.lane.filter.label": "Lanes",
|
||||
"projects.detail.smarttimeline.lane.filter.all": "All",
|
||||
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline view",
|
||||
"projects.detail.smarttimeline.client.toggle.matter_list": "Matter list",
|
||||
"projects.detail.smarttimeline.client.matter_list.heading": "Matters of this client",
|
||||
"projects.detail.smarttimeline.client.matter_list.hint": "Click a matter to open its detailed timeline, or switch to „Timeline view“ above.",
|
||||
"projects.detail.smarttimeline.client.matter_list.empty": "No matters yet.",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up": "Show on parent matters",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up_hint": "When checked, this milestone surfaces on patent, litigation, and client SmartTimelines.",
|
||||
"projects.detail.team.form.user": "User",
|
||||
"projects.detail.team.form.role": "Role",
|
||||
"projects.detail.team.form.responsibility": "Project role",
|
||||
@@ -3557,6 +3941,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.broadcast.title": "Email selection",
|
||||
"team.broadcast.recipients": "Recipients",
|
||||
"team.broadcast.show_all": "Show all",
|
||||
"team.broadcast.mailto.label": "Open in mail client",
|
||||
"team.broadcast.mailto.tooltip": "Opens your local mail client with every recipient prefilled in the To: line",
|
||||
"team.broadcast.template": "Template",
|
||||
"team.broadcast.template_optional": "optional",
|
||||
"team.broadcast.template_freeform": "Free-form",
|
||||
@@ -3610,6 +3996,22 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
|
||||
"paliadin.error.connection_lost": "Connection lost.",
|
||||
"paliadin.error.upstream": "Send failed.",
|
||||
"paliadin.late.waiting": "Will fill in the response when it arrives …",
|
||||
"paliadin.late.marker": "late",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "What can I help you with?",
|
||||
"paliadin.widget.input.placeholder": "Ask Paliadin...",
|
||||
"paliadin.widget.input.label": "Message to Paliadin",
|
||||
"paliadin.widget.send": "Send",
|
||||
"paliadin.widget.reset": "Reset conversation",
|
||||
"paliadin.widget.reset.confirm": "Reset the conversation here and on the server?",
|
||||
"paliadin.widget.fullscreen": "Fullscreen mode",
|
||||
"paliadin.widget.close": "Close",
|
||||
"paliadin.widget.context.on_page": "On this page",
|
||||
"approvals.agent.label": "Paliadin suggested this",
|
||||
"approvals.agent.byline": "Paliadin",
|
||||
"approvals.agent.suggestion_pending": "Suggestion awaiting your approval",
|
||||
"nav.admin.paliadin": "Paliadin Monitor",
|
||||
"admin.paliadin.title": "Paliadin Monitor — Paliad",
|
||||
"admin.paliadin.heading": "Paliadin Monitor",
|
||||
@@ -3626,8 +4028,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.paliadin.col.prompt": "Query",
|
||||
"admin.paliadin.col.count": "Count",
|
||||
"admin.paliadin.col.started": "Time",
|
||||
"admin.paliadin.col.user": "User",
|
||||
"admin.paliadin.col.classifier": "Type",
|
||||
"admin.paliadin.col.response": "Answer",
|
||||
"admin.paliadin.col.tools": "Tools",
|
||||
"admin.paliadin.col.origin": "Page",
|
||||
"admin.paliadin.col.duration": "Duration",
|
||||
"admin.paliadin.loading": "Loading…",
|
||||
|
||||
@@ -3691,6 +4096,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.approval_policies.source.project": "Project",
|
||||
"admin.approval_policies.source.ancestor": "Inherited",
|
||||
"admin.approval_policies.source.unit_default": "Default",
|
||||
"admin.approval_policies.source.no_approval": "no approval",
|
||||
"admin.approval_policies.cell.requires": "Approval required",
|
||||
"admin.approval_policies.cell.clear": "—",
|
||||
"admin.approval_policies.cell.clear.title": "Reset to inheritance",
|
||||
"admin.approval_policies.cell.saved_msg": "Saved.",
|
||||
"admin.approval_policies.cell.error_msg": "Error",
|
||||
"admin.approval_policies.bulk.cta": "Apply to descendants",
|
||||
@@ -4009,7 +4418,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.not_authorized": "You don't have the required role.",
|
||||
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
|
||||
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
|
||||
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
|
||||
"approvals.error.request_not_pending": "This request is no longer open.",
|
||||
"approvals.pending.badge": "Awaiting approval",
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
@@ -4118,6 +4532,80 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.editor.error.sources_required": "Pick at least one source.",
|
||||
"views.editor.error.load_failed": "Could not load this view.",
|
||||
"views.editor.error.delete_failed": "Could not delete this view.",
|
||||
|
||||
// Universal FilterBar — t-paliad-163.
|
||||
"views.bar.label.time": "Time",
|
||||
"views.bar.label.personal": "Mine",
|
||||
"views.bar.label.approval_role": "View",
|
||||
"views.bar.label.approval_status": "Status",
|
||||
"views.bar.label.approval_entity": "Kind",
|
||||
"views.bar.label.deadline_status": "Deadline status",
|
||||
"views.bar.label.appointment_type": "Appointment type",
|
||||
"views.bar.label.project_event_kind": "Event",
|
||||
"views.bar.label.timeline_status": "Timeline status",
|
||||
"views.bar.label.timeline_track": "Track",
|
||||
"views.bar.timeline_status.done": "Done",
|
||||
"views.bar.timeline_status.open": "Open",
|
||||
"views.bar.timeline_status.overdue": "Overdue",
|
||||
"views.bar.timeline_status.predicted": "Predicted",
|
||||
"views.bar.timeline_status.predicted_overdue": "Overdue (predicted)",
|
||||
"views.bar.timeline_status.court_set": "Court date",
|
||||
"views.bar.timeline_status.off_script": "Custom",
|
||||
"views.bar.timeline_status.macro.future": "Show future",
|
||||
"views.bar.timeline_status.macro.past": "Past only",
|
||||
"views.bar.timeline_track.parent": "Main proceeding",
|
||||
"views.bar.timeline_track.counterclaim": "Counterclaim",
|
||||
"views.bar.timeline_track.off_script": "Off-script",
|
||||
"views.bar.label.shape": "Display",
|
||||
"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.personal.on": "Mine only",
|
||||
"views.bar.approval_role.approver_eligible": "To approve",
|
||||
"views.bar.approval_role.self_requested": "My requests",
|
||||
"views.bar.approval_role.any_visible": "All visible",
|
||||
"views.bar.approval_status.pending": "Pending",
|
||||
"views.bar.approval_status.approved": "Approved",
|
||||
"views.bar.approval_status.rejected": "Rejected",
|
||||
"views.bar.approval_status.revoked": "Revoked",
|
||||
"views.bar.approval_entity.deadline": "Deadline",
|
||||
"views.bar.approval_entity.appointment": "Appointment",
|
||||
"views.bar.deadline_status.pending": "Open",
|
||||
"views.bar.deadline_status.completed": "Completed",
|
||||
"views.bar.appointment_type.hearing": "Hearing",
|
||||
"views.bar.appointment_type.meeting": "Meeting",
|
||||
"views.bar.appointment_type.consultation": "Consultation",
|
||||
"views.bar.appointment_type.deadline_hearing": "Oral hearing",
|
||||
"views.bar.shape.list": "List",
|
||||
"views.bar.shape.cards": "Cards",
|
||||
"views.bar.shape.calendar": "Calendar",
|
||||
"views.bar.density.comfortable": "Comfortable",
|
||||
"views.bar.density.compact": "Compact",
|
||||
"views.bar.sort.date_asc": "Date ascending",
|
||||
"views.bar.sort.date_desc": "Date descending",
|
||||
"views.bar.action.reset": "Reset",
|
||||
"views.bar.action.save_as_view": "Save as view",
|
||||
"views.bar.save.heading": "Save view",
|
||||
"views.bar.save.field.name": "Name",
|
||||
"views.bar.save.field.slug": "Slug",
|
||||
"views.bar.save.field.slug_hint": "Becomes part of the URL: /views/<slug>",
|
||||
"views.bar.save.field.show_count": "Show count in sidebar",
|
||||
"views.bar.save.cancel": "Cancel",
|
||||
"views.bar.save.confirm": "Save",
|
||||
"views.bar.save.error.name_required": "Please supply a name.",
|
||||
"views.bar.save.error.slug_format": "Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.",
|
||||
"views.bar.save.error.slug_taken": "This slug is already in use.",
|
||||
"views.bar.save.error.network": "Network error — please retry.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4193,6 +4681,12 @@ function translateEventDescription(eventType: string, description: string): stri
|
||||
// New format: "active → archived". Legacy: "Status active → archived".
|
||||
return translateArrowSlugs(body.replace(/^Status\s+/, ""), "projects.filter.status.");
|
||||
}
|
||||
if (eventType === "our_side_changed") {
|
||||
// Format: "<from> → <to>", where each side is one of
|
||||
// claimant / defendant / court / both / "none" (the sentinel for
|
||||
// NULL the service writes when the column is unset on either end).
|
||||
return translateArrowSlugs(body, "projects.field.our_side.");
|
||||
}
|
||||
if (eventType === "note_created") {
|
||||
// New format: just the parent slug. Legacy: "Note zu <slug> hinzugefügt".
|
||||
const m = body.match(/^Note zu (project|deadline|appointment) hinzugef[üu]gt$/i);
|
||||
|
||||
@@ -1,111 +1,176 @@
|
||||
import { initI18n, t, getLang, type I18nKey } from "./i18n";
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
|
||||
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
|
||||
// reject / revoke), and a small inline diff for update / complete / delete
|
||||
// lifecycle events.
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// State is URL-driven via ?tab= so back/forward buttons work and the bell
|
||||
// badge can deep-link to either tab. The badge in the sidebar (id
|
||||
// sidebar-inbox-badge) is updated by the shared global polling loop in
|
||||
// sidebar.ts; this module just keeps the page content in sync.
|
||||
// The bar owns every axis the old tab UI exposed plus more:
|
||||
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
|
||||
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
|
||||
// - approval_status: chip cluster (default: pending)
|
||||
// - approval_entity_type: chip pair (Frist / Termin)
|
||||
// - time: chip cluster (Any default)
|
||||
// - density: comfortable / compact
|
||||
// - sort: date asc / desc
|
||||
//
|
||||
// Row rendering: shape-list.ts with row_action="approve" stamps the
|
||||
// inbox markup (entity title, diff, approve/reject/revoke buttons).
|
||||
// We wire action click handlers in onResult and refresh through the
|
||||
// bar handle.
|
||||
|
||||
type Lifecycle = "create" | "update" | "complete" | "delete";
|
||||
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
|
||||
type DecisionKind = "peer" | "admin_override";
|
||||
const INBOX_AXES: AxisKey[] = [
|
||||
"time",
|
||||
"approval_viewer_role",
|
||||
"approval_status",
|
||||
"approval_entity_type",
|
||||
"density",
|
||||
"sort",
|
||||
];
|
||||
|
||||
interface ApprovalRequestView {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
entity_type: "deadline" | "appointment";
|
||||
entity_id: string;
|
||||
entity_title?: string;
|
||||
lifecycle_event: Lifecycle;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role: string;
|
||||
status: RequestStatus;
|
||||
requested_at: string;
|
||||
requested_by: string;
|
||||
requester_name: string;
|
||||
decided_at?: string;
|
||||
decided_by?: string;
|
||||
decider_name?: string;
|
||||
decision_kind?: DecisionKind;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
type Tab = "pending-mine" | "mine";
|
||||
|
||||
let currentTab: Tab = "pending-mine";
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
let bar: BarHandle | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const url = new URL(window.location.href);
|
||||
const t = url.searchParams.get("tab");
|
||||
if (t === "mine") currentTab = "mine";
|
||||
bindTabs();
|
||||
refresh();
|
||||
initI18n();
|
||||
initSidebar();
|
||||
applyLegacyTabRedirect();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
function bindTabs() {
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab as Tab) || "pending-mine";
|
||||
if (tab === currentTab) return;
|
||||
currentTab = tab;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tab);
|
||||
history.replaceState({}, "", url.toString());
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.tab === tab);
|
||||
});
|
||||
refresh();
|
||||
});
|
||||
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
|
||||
// Done client-side because /inbox serves a static dist file (no Go
|
||||
// router involvement). Bookmarks from the sidebar bell + outbound
|
||||
// emails keep landing on the right sub-view through the bar.
|
||||
function applyLegacyTabRedirect(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const tab = url.searchParams.get("tab");
|
||||
if (!tab) return;
|
||||
url.searchParams.delete("tab");
|
||||
if (tab === "mine") {
|
||||
url.searchParams.set("a_role", "self_requested");
|
||||
} else if (tab === "pending-mine") {
|
||||
url.searchParams.set("a_role", "approver_eligible");
|
||||
}
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
const host = document.getElementById("inbox-filter-bar");
|
||||
const loading = document.getElementById("inbox-loading");
|
||||
const results = document.getElementById("inbox-results");
|
||||
const empty = document.getElementById("inbox-empty");
|
||||
if (!host || !loading || !results || !empty) return;
|
||||
|
||||
const sys = await fetchInboxSystemView();
|
||||
if (!sys) {
|
||||
loading.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.error.internal");
|
||||
return;
|
||||
}
|
||||
|
||||
bar = mountFilterBar(host, {
|
||||
baseFilter: sys.Filter,
|
||||
baseRender: sys.Render,
|
||||
axes: INBOX_AXES,
|
||||
surfaceKey: "inbox",
|
||||
systemViewSlug: sys.Slug,
|
||||
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
|
||||
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
|
||||
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
|
||||
if (!loading || !empty || !list) return;
|
||||
loading.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
|
||||
let rows: ApprovalRequestView[] = [];
|
||||
async function fetchInboxSystemView(): Promise<SystemView | null> {
|
||||
try {
|
||||
const r = await fetch(path, { credentials: "include" });
|
||||
if (r.ok) rows = (await r.json()) as ApprovalRequestView[];
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (!r.ok) return null;
|
||||
const list = (await r.json()) as SystemView[];
|
||||
return list.find((v) => v.Slug === "inbox") ?? null;
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function paint(
|
||||
result: ViewRunResult,
|
||||
render: RenderSpec,
|
||||
results: HTMLElement,
|
||||
empty: HTMLElement,
|
||||
loading: HTMLElement,
|
||||
): void {
|
||||
loading.style.display = "none";
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = t(
|
||||
currentTab === "pending-mine"
|
||||
? "approvals.empty.pending_mine"
|
||||
: "approvals.empty.mine"
|
||||
);
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
results.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.empty.pending_mine");
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
for (const row of rows) list.appendChild(renderRow(row));
|
||||
empty.style.display = "none";
|
||||
|
||||
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
||||
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
||||
renderListShape(results, result.rows, render);
|
||||
|
||||
// Wire action handlers on the freshly stamped DOM. The action
|
||||
// POSTs land on the same endpoints the legacy /inbox used; on
|
||||
// success we trigger a bar refresh so the new state propagates.
|
||||
wireApprovalActions(results);
|
||||
}
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${id}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-154 — show the admin-only "configure policies" nudge when:
|
||||
// - the current user is global_admin
|
||||
// - the inbox is empty
|
||||
// - no approval_policies row exists firm-wide (matrix is dormant)
|
||||
//
|
||||
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
|
||||
// admins all skip the nudge.
|
||||
// - current user is global_admin
|
||||
// - inbox empty
|
||||
// - no approval_policies row exists firm-wide
|
||||
async function maybeShowAdminNudge(): Promise<void> {
|
||||
const nudge = document.getElementById("inbox-admin-nudge");
|
||||
if (!nudge) return;
|
||||
@@ -121,9 +186,7 @@ async function maybeShowAdminNudge(): Promise<void> {
|
||||
if (data.any) return;
|
||||
|
||||
nudge.style.display = "";
|
||||
} catch (_e) {
|
||||
// Network failure → keep nudge hidden.
|
||||
}
|
||||
} catch (_e) { /* keep hidden */ }
|
||||
}
|
||||
|
||||
function hideAdminNudge(): void {
|
||||
@@ -131,170 +194,7 @@ function hideAdminNudge(): void {
|
||||
if (nudge) nudge.style.display = "none";
|
||||
}
|
||||
|
||||
function renderRow(row: ApprovalRequestView): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row";
|
||||
|
||||
// Header: project / entity / lifecycle / required-role
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
|
||||
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
|
||||
const entityTitle = row.entity_title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
|
||||
meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete (date-bearing fields)
|
||||
const diff = renderDiff(row);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
// Decision note if any
|
||||
if (row.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = row.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (row.status === "pending" && currentTab === "pending-mine") {
|
||||
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
|
||||
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
|
||||
} else if (row.status === "pending" && currentTab === "mine") {
|
||||
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
|
||||
} else {
|
||||
// historic — show status pill
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
|
||||
if (row.decider_name && row.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
|
||||
const before = (row.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (row.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) =>
|
||||
v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
const errKey = (body && body.error) || "internal";
|
||||
const msg = mapApprovalError(errKey);
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
// Update sidebar bell count.
|
||||
refreshInboxBadge();
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked":
|
||||
return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver":
|
||||
return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending":
|
||||
return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized":
|
||||
return t("approvals.error.not_authorized");
|
||||
case "request_not_pending":
|
||||
return t("approvals.error.request_not_pending");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Update the sidebar inbox badge (shared with sidebar.ts polling).
|
||||
async function refreshInboxBadge() {
|
||||
async function refreshInboxBadge(): Promise<void> {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
@@ -307,7 +207,5 @@ async function refreshInboxBadge() {
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
}
|
||||
|
||||
196
frontend/src/client/paliadin-context.ts
Normal file
196
frontend/src/client/paliadin-context.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// paliadin-context.ts — structured page-context payload builder for the
|
||||
// Paliadin inline widget (t-paliad-161).
|
||||
//
|
||||
// The standalone /paliadin page submits turns with only `page_origin`
|
||||
// (single string, the URL pathname). The inline widget submits a richer
|
||||
// payload: route_name + primary_entity_type + primary_entity_id + the
|
||||
// user's text selection + UI hints. The Go backend persists this jsonb
|
||||
// in paliad.paliadin_turns.context (migration 070) AND prepends a
|
||||
// flattened `[ctx …]` block to the tmux envelope so SKILL.md can branch
|
||||
// on it before answering.
|
||||
//
|
||||
// Design: docs/design-paliadin-inline-2026-05-08.md §4.
|
||||
|
||||
export interface PaliadinContext {
|
||||
route_name: string;
|
||||
page_origin: string;
|
||||
primary_entity_type?: "project" | "deadline" | "appointment";
|
||||
primary_entity_id?: string;
|
||||
user_selection_text?: string;
|
||||
view_mode?: "list" | "cards" | "calendar" | "tree";
|
||||
filter_summary?: string;
|
||||
}
|
||||
|
||||
const SELECTION_MAX = 1000;
|
||||
|
||||
// UUID match — relaxed: any 8-4-4-4-12 hex pattern. Catches /projects/<id>
|
||||
// and /deadlines/<id> regardless of trailing path segments.
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Compute the Paliadin context for the current page. Reads
|
||||
* window.location + window.getSelection() at call time, so callers
|
||||
* should invoke this immediately before sending a turn — not at widget
|
||||
* boot — to capture the user's selection in the moment they typed.
|
||||
*
|
||||
* Returns null when the visibility predicate fails (e.g. on /paliadin,
|
||||
* /login, /onboarding) — callers SHOULD short-circuit on null instead
|
||||
* of sending an empty payload.
|
||||
*/
|
||||
export function computePaliadinContext(): PaliadinContext | null {
|
||||
const pathname = window.location.pathname || "";
|
||||
if (!shouldSendContext(pathname)) {
|
||||
return null;
|
||||
}
|
||||
const search = window.location.search || "";
|
||||
const ctx: PaliadinContext = {
|
||||
route_name: routeNameFor(pathname),
|
||||
page_origin: pathname + search,
|
||||
};
|
||||
const entity = extractPrimaryEntity(pathname);
|
||||
if (entity) {
|
||||
ctx.primary_entity_type = entity.type;
|
||||
ctx.primary_entity_id = entity.id;
|
||||
}
|
||||
const selection = readSelection();
|
||||
if (selection) {
|
||||
ctx.user_selection_text = selection;
|
||||
}
|
||||
const view = readViewMode();
|
||||
if (view) {
|
||||
ctx.view_mode = view;
|
||||
}
|
||||
const filter = readFilterSummary();
|
||||
if (filter) {
|
||||
ctx.filter_summary = filter;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget hides itself on routes where Paliadin is either redundant
|
||||
* (the standalone /paliadin) or unavailable (auth flows). Mirrored here
|
||||
* for the context-payload predicate so a stray send from one of those
|
||||
* pages doesn't surface an empty `[ctx]` block.
|
||||
*/
|
||||
export function shouldSendContext(pathname: string): boolean {
|
||||
if (pathname === "/paliadin" || pathname.startsWith("/paliadin/")) return false;
|
||||
if (pathname === "/login" || pathname.startsWith("/login/")) return false;
|
||||
if (pathname === "/onboarding") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a URL pathname to a stable route key. Stable across query-string
|
||||
* + ID variations so the SKILL.md / starter registry can branch on it
|
||||
* without fragile URL parsing.
|
||||
*/
|
||||
export function routeNameFor(pathname: string): string {
|
||||
// Order matters — most-specific first.
|
||||
if (/^\/projects\/[^/]+$/.test(pathname)) return "projects.detail";
|
||||
if (pathname === "/projects" || pathname === "/projects/") return "projects.list";
|
||||
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
|
||||
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
|
||||
if (pathname === "/deadlines/new") return "deadlines.new";
|
||||
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
|
||||
if (pathname === "/deadlines") return "deadlines.list";
|
||||
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
|
||||
if (pathname === "/appointments/new") return "appointments.new";
|
||||
if (pathname === "/appointments/calendar") return "appointments.calendar";
|
||||
if (pathname === "/appointments") return "appointments.list";
|
||||
if (pathname === "/agenda") return "agenda";
|
||||
if (pathname === "/inbox") return "inbox";
|
||||
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
|
||||
if (pathname === "/team") return "team";
|
||||
if (pathname === "/courts") return "courts";
|
||||
if (pathname === "/glossary") return "glossary";
|
||||
if (pathname === "/links") return "links";
|
||||
if (pathname === "/downloads") return "downloads";
|
||||
if (pathname === "/checklists") return "checklists";
|
||||
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
|
||||
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
|
||||
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
|
||||
if (pathname === "/events") return "events";
|
||||
if (pathname.startsWith("/views/")) return "views.detail";
|
||||
if (pathname === "/views") return "views.list";
|
||||
if (pathname.startsWith("/admin/")) return "admin." + pathname.slice("/admin/".length).split("/")[0];
|
||||
if (pathname === "/admin") return "admin";
|
||||
if (pathname === "/settings") return "settings";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the primary entity (type + uuid) out of the URL when the route
|
||||
* encodes one. Returns null on routes that have no primary entity
|
||||
* (dashboard, agenda, lists, tools).
|
||||
*/
|
||||
export function extractPrimaryEntity(
|
||||
pathname: string,
|
||||
): { type: "project" | "deadline" | "appointment"; id: string } | null {
|
||||
const projectMatch = pathname.match(/^\/projects\/([^/]+)(?:\/|$)/);
|
||||
if (projectMatch && UUID_RE.test(projectMatch[1])) {
|
||||
return { type: "project", id: projectMatch[1] };
|
||||
}
|
||||
const deadlineMatch = pathname.match(/^\/deadlines\/([^/]+)$/);
|
||||
if (deadlineMatch && UUID_RE.test(deadlineMatch[1])) {
|
||||
return { type: "deadline", id: deadlineMatch[1] };
|
||||
}
|
||||
const apptMatch = pathname.match(/^\/appointments\/([^/]+)$/);
|
||||
if (apptMatch && UUID_RE.test(apptMatch[1])) {
|
||||
return { type: "appointment", id: apptMatch[1] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the user's current text selection, capped at SELECTION_MAX.
|
||||
* Returns empty string when there's no selection or when the selection
|
||||
* is collapsed (caret with no range).
|
||||
*
|
||||
* Privacy floor (§4.3): respects the widget's "send selection" toggle,
|
||||
* stored in localStorage under `paliadin:send-selection`. Default on
|
||||
* (m's Q5 lock-in); flip to off → returns empty string regardless of
|
||||
* what's selected.
|
||||
*/
|
||||
export function readSelection(): string {
|
||||
if (localStorage.getItem("paliadin:send-selection") === "off") {
|
||||
return "";
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed) return "";
|
||||
const text = sel.toString().trim();
|
||||
if (!text) return "";
|
||||
if (text.length > SELECTION_MAX) {
|
||||
return text.slice(0, SELECTION_MAX);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the page for an active "view mode" hint — set by /events,
|
||||
* /projects (tree vs list), /deadlines (calendar vs list). The frontend
|
||||
* stores these as `data-view-mode` attributes on a known root element
|
||||
* or in localStorage; this helper centralises the lookup so future
|
||||
* pages adding a new view mode don't have to teach the widget about
|
||||
* themselves.
|
||||
*/
|
||||
export function readViewMode(): "list" | "cards" | "calendar" | "tree" | "" {
|
||||
const root = document.querySelector<HTMLElement>("[data-view-mode]");
|
||||
if (!root) return "";
|
||||
const v = root.dataset.viewMode || "";
|
||||
if (v === "list" || v === "cards" || v === "calendar" || v === "tree") return v;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a short human-readable summary of active list filters from a
|
||||
* known DOM hook. Pages that want to participate set
|
||||
* `data-filter-summary="status=overdue · project=Acme"` on a root
|
||||
* element. Empty = no summary.
|
||||
*/
|
||||
export function readFilterSummary(): string {
|
||||
const root = document.querySelector<HTMLElement>("[data-filter-summary]");
|
||||
if (!root) return "";
|
||||
return (root.dataset.filterSummary || "").trim();
|
||||
}
|
||||
84
frontend/src/client/paliadin-late-poll.ts
Normal file
84
frontend/src/client/paliadin-late-poll.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
}
|
||||
|
||||
export interface LatePollOptions {
|
||||
turnId: string;
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
if (Date.now() - startedAt > maxDuration) {
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Transient network error; retry on next tick.
|
||||
}
|
||||
timer = window.setTimeout(tick, interval);
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
if (timer != null) window.clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
134
frontend/src/client/paliadin-render.ts
Normal file
134
frontend/src/client/paliadin-render.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Shared Paliadin response renderer — used by both the standalone
|
||||
// /paliadin page (client/paliadin.ts) and the inline drawer widget
|
||||
// (client/paliadin-widget.ts). Extracted from paliadin.ts so the
|
||||
// widget renders the same markdown + chips as the dedicated page
|
||||
// without re-implementing the pipeline.
|
||||
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
|
||||
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
}
|
||||
}
|
||||
|
||||
function renderBlocks(escapedHtml: string): string {
|
||||
const out: string[] = [];
|
||||
let listItems: string[] = [];
|
||||
let paraLines: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length === 0) return;
|
||||
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
|
||||
listItems = [];
|
||||
};
|
||||
const flushPara = () => {
|
||||
if (paraLines.length === 0) return;
|
||||
out.push(`<p>${paraLines.join("<br>")}</p>`);
|
||||
paraLines = [];
|
||||
};
|
||||
|
||||
for (const rawLine of escapedHtml.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (line === "") {
|
||||
flushList();
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
let m: RegExpMatchArray | null;
|
||||
if ((m = line.match(/^###\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h3>${m[1]}</h3>`);
|
||||
} else if ((m = line.match(/^##\s+(.+)$/))) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<h2>${m[1]}</h2>`);
|
||||
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
|
||||
flushPara();
|
||||
listItems.push(m[1]);
|
||||
} else if (line.match(/^---+$/)) {
|
||||
flushList();
|
||||
flushPara();
|
||||
out.push(`<hr>`);
|
||||
} else {
|
||||
flushList();
|
||||
paraLines.push(line);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
flushPara();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
export function renderResponseHTML(raw: string): string {
|
||||
let html = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
const chipHTML: string[] = [];
|
||||
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
let rendered = "";
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
} else if (chipKind === "nav") {
|
||||
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
} else if (chipKind === "filter") {
|
||||
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
if (!rendered) return "";
|
||||
chipHTML.push(rendered);
|
||||
return `CHIP${chipHTML.length - 1}`;
|
||||
});
|
||||
|
||||
html = renderBlocks(html);
|
||||
|
||||
html = html.replace(MD_LINK_RE, (_m, text, url) => {
|
||||
const ext = url.startsWith("http");
|
||||
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
|
||||
});
|
||||
|
||||
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
|
||||
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||
|
||||
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
|
||||
|
||||
return html;
|
||||
}
|
||||
223
frontend/src/client/paliadin-starters.ts
Normal file
223
frontend/src/client/paliadin-starters.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// paliadin-starters.ts — per-route starter-prompt registry for the
|
||||
// Paliadin inline widget (t-paliad-161).
|
||||
//
|
||||
// The drawer's empty state renders the matching starter list. Click →
|
||||
// the prompt populates the textarea; if the prompt ends with `: ` it
|
||||
// stays in the textarea so the user finishes the sentence.
|
||||
//
|
||||
// Static registry by design. LLM-generated starters were considered and
|
||||
// rejected (latency, determinism, translatability — see design doc §5.2).
|
||||
|
||||
export interface Starter {
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
prompt_de: string;
|
||||
prompt_en: string;
|
||||
}
|
||||
|
||||
export const paliadinStarters: Record<string, Starter[]> = {
|
||||
"dashboard": [
|
||||
{
|
||||
label_de: "Heute",
|
||||
label_en: "Today",
|
||||
prompt_de: "Was steht heute an?",
|
||||
prompt_en: "What's on my plate today?",
|
||||
},
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Welche Fristen sind diese Woche?",
|
||||
prompt_en: "Which deadlines are this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Nächste Schritte",
|
||||
label_en: "Next steps",
|
||||
prompt_de: "Was sollte ich als nächstes erledigen?",
|
||||
prompt_en: "What should I tackle next?",
|
||||
},
|
||||
],
|
||||
"projects.detail": [
|
||||
{
|
||||
label_de: "Status der Akte",
|
||||
label_en: "Project status",
|
||||
prompt_de: "Was ist der aktuelle Status dieser Akte?",
|
||||
prompt_en: "What's the status of this project?",
|
||||
},
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Was steht für diese Akte diese Woche an?",
|
||||
prompt_en: "What's on for this project this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Frist anlegen",
|
||||
label_en: "Add a deadline",
|
||||
prompt_de: "Lege eine Frist für diese Akte an: ",
|
||||
prompt_en: "Add a deadline for this project: ",
|
||||
},
|
||||
],
|
||||
"projects.list": [
|
||||
{
|
||||
label_de: "Aktive Akten",
|
||||
label_en: "Active projects",
|
||||
prompt_de: "Welche Akten sind aktuell aktiv?",
|
||||
prompt_en: "Which projects are currently active?",
|
||||
},
|
||||
{
|
||||
label_de: "UPC-Akten",
|
||||
label_en: "UPC projects",
|
||||
prompt_de: "Zeige mir alle UPC-Akten.",
|
||||
prompt_en: "Show me all UPC projects.",
|
||||
},
|
||||
],
|
||||
"deadlines.detail": [
|
||||
{
|
||||
label_de: "Erkläre die Frist",
|
||||
label_en: "Explain this deadline",
|
||||
prompt_de: "Erkläre mir die Frist auf dieser Seite.",
|
||||
prompt_en: "Explain this deadline.",
|
||||
},
|
||||
{
|
||||
label_de: "Rechtsgrundlage",
|
||||
label_en: "Legal basis",
|
||||
prompt_de: "Welche Norm ist hier einschlägig?",
|
||||
prompt_en: "What's the relevant rule?",
|
||||
},
|
||||
{
|
||||
label_de: "Folgefristen",
|
||||
label_en: "Follow-on deadlines",
|
||||
prompt_de: "Welche Fristen ergeben sich aus dieser?",
|
||||
prompt_en: "What follow-on deadlines flow from this?",
|
||||
},
|
||||
],
|
||||
"deadlines.list": [
|
||||
{
|
||||
label_de: "Überfällige",
|
||||
label_en: "Overdue",
|
||||
prompt_de: "Welche Fristen sind überfällig?",
|
||||
prompt_en: "Which deadlines are overdue?",
|
||||
},
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Was steht diese Woche an?",
|
||||
prompt_en: "What's due this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Frist anlegen",
|
||||
label_en: "Add a deadline",
|
||||
prompt_de: "Lege eine Frist an: ",
|
||||
prompt_en: "Add a deadline: ",
|
||||
},
|
||||
],
|
||||
"appointments.list": [
|
||||
{
|
||||
label_de: "Heute",
|
||||
label_en: "Today",
|
||||
prompt_de: "Welche Termine habe ich heute?",
|
||||
prompt_en: "What appointments do I have today?",
|
||||
},
|
||||
{
|
||||
label_de: "Termin anlegen",
|
||||
label_en: "Add an appointment",
|
||||
prompt_de: "Lege einen Termin an: ",
|
||||
prompt_en: "Add an appointment: ",
|
||||
},
|
||||
],
|
||||
"appointments.detail": [
|
||||
{
|
||||
label_de: "Erkläre den Termin",
|
||||
label_en: "Explain this appointment",
|
||||
prompt_de: "Was ist auf diesem Termin zu klären?",
|
||||
prompt_en: "What needs to be addressed at this appointment?",
|
||||
},
|
||||
],
|
||||
"agenda": [
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Welche Termine und Fristen habe ich diese Woche?",
|
||||
prompt_en: "What appointments and deadlines do I have this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Konflikte prüfen",
|
||||
label_en: "Check conflicts",
|
||||
prompt_de: "Gibt es Terminkonflikte in dieser Ansicht?",
|
||||
prompt_en: "Are there scheduling conflicts in this view?",
|
||||
},
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
label_de: "Diese Woche",
|
||||
label_en: "This week",
|
||||
prompt_de: "Was steht diese Woche an?",
|
||||
prompt_en: "What's on for this week?",
|
||||
},
|
||||
{
|
||||
label_de: "Überfällige",
|
||||
label_en: "Overdue",
|
||||
prompt_de: "Was ist überfällig?",
|
||||
prompt_en: "What's overdue?",
|
||||
},
|
||||
],
|
||||
"inbox": [
|
||||
{
|
||||
label_de: "Was wartet",
|
||||
label_en: "What's waiting",
|
||||
prompt_de: "Was wartet auf meine Genehmigung?",
|
||||
prompt_en: "What's waiting for my approval?",
|
||||
},
|
||||
],
|
||||
"tools.fristenrechner": [
|
||||
{
|
||||
label_de: "Erkläre den Rechner",
|
||||
label_en: "Explain the calculator",
|
||||
prompt_de: "Wie funktioniert der Fristenrechner?",
|
||||
prompt_en: "How does the deadline calculator work?",
|
||||
},
|
||||
{
|
||||
label_de: "Verfahrensablauf",
|
||||
label_en: "Proceeding flow",
|
||||
prompt_de: "Welche Folgefristen kommen typischerweise nach einer Klage?",
|
||||
prompt_en: "What deadlines typically follow a complaint?",
|
||||
},
|
||||
],
|
||||
"tools.kostenrechner": [
|
||||
{
|
||||
label_de: "Erkläre die Berechnung",
|
||||
label_en: "Explain the calculation",
|
||||
prompt_de: "Wie wird der Streitwert berechnet?",
|
||||
prompt_en: "How is the matter value calculated?",
|
||||
},
|
||||
],
|
||||
"glossary": [
|
||||
{
|
||||
label_de: "Begriff erklären",
|
||||
label_en: "Explain a term",
|
||||
prompt_de: "Erkläre mir den Begriff: ",
|
||||
prompt_en: "Explain the term: ",
|
||||
},
|
||||
],
|
||||
"courts": [
|
||||
{
|
||||
label_de: "UPC-Divisionen",
|
||||
label_en: "UPC divisions",
|
||||
prompt_de: "Zeige mir alle UPC Local Divisions.",
|
||||
prompt_en: "Show me all UPC Local Divisions.",
|
||||
},
|
||||
],
|
||||
// Fallback for unmapped routes — the textarea stays empty, the user
|
||||
// types from scratch.
|
||||
"_default": [
|
||||
{
|
||||
label_de: "Was kann ich für dich tun?",
|
||||
label_en: "What can I help with?",
|
||||
prompt_de: "",
|
||||
prompt_en: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function startersFor(routeName: string): Starter[] {
|
||||
return paliadinStarters[routeName] || paliadinStarters["_default"];
|
||||
}
|
||||
598
frontend/src/client/paliadin-widget.ts
Normal file
598
frontend/src/client/paliadin-widget.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
// paliadin-widget.ts — runtime for the inline Paliadin floating button +
|
||||
// slide-out drawer (t-paliad-161).
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. On DOMContentLoaded, fetch /api/me. If the email matches the
|
||||
// Paliadin owner gate (the same gate the standalone /paliadin
|
||||
// route uses) AND the route is one where the widget shows, reveal
|
||||
// the trigger button.
|
||||
// 2. Click trigger or press Cmd+J / Ctrl+J → open drawer + populate
|
||||
// starter prompts from paliadin-starters.ts.
|
||||
// 3. Submit form → POST /api/paliadin/turn with structured context
|
||||
// from computePaliadinContext() → consume the SSE stream → render
|
||||
// assistant bubble.
|
||||
// 4. Conversation history persists in localStorage per session id.
|
||||
//
|
||||
// Notes:
|
||||
// - Cmd+K is reserved for the global search palette (client/search.ts).
|
||||
// The widget uses Cmd+J / Ctrl+J as the keyboard trigger.
|
||||
// - The standalone /paliadin page's client (client/paliadin.ts) is
|
||||
// unchanged — this widget reuses /api/paliadin/turn but ships its
|
||||
// own UI and history bucket so the two surfaces stay independent.
|
||||
// - Visibility predicate mirrors paliadin-context.shouldSendContext()
|
||||
// so the widget never sends a turn from a route where it shouldn't
|
||||
// show.
|
||||
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
|
||||
import { startersFor, type Starter } from "./paliadin-starters";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
interface MeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
global_role?: string;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface TurnResponse {
|
||||
turn_id: string;
|
||||
sse_url: string;
|
||||
}
|
||||
|
||||
// Shared session key — the inline drawer and the standalone /paliadin
|
||||
// page must use the same browser-session id so both surfaces show the
|
||||
// same conversation. Migration on first run: if a legacy
|
||||
// `paliadin:widget:session` exists but the shared `paliadin:session`
|
||||
// does not, copy across so the user doesn't lose drawer state on the
|
||||
// rollover.
|
||||
const SESSION_KEY = "paliadin:session";
|
||||
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
|
||||
// History bucket — render-cache only; DB is source of truth (server
|
||||
// hydrates via /api/paliadin/history on every mount). The cache is keyed
|
||||
// by session id so a session reset gives a clean slate.
|
||||
const HISTORY_PREFIX = "paliadin:history:";
|
||||
|
||||
let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let drawerOpen = false;
|
||||
let activeStream: EventSource | null = null;
|
||||
let pending = false;
|
||||
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
|
||||
const lateWidgetPolls = new Map<string, LatePollHandle>();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
const drawer = document.getElementById("paliadin-widget-drawer");
|
||||
if (!trigger || !drawer) return; // page didn't include the widget — skip silently
|
||||
initI18n();
|
||||
bootSession();
|
||||
void revealIfOwner();
|
||||
wireTrigger();
|
||||
wireDrawerControls();
|
||||
wireForm();
|
||||
wireKeyboardShortcut();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
let s = localStorage.getItem(SESSION_KEY);
|
||||
if (!s) {
|
||||
// One-time migration: previous widget builds wrote
|
||||
// `paliadin:widget:session` instead of the shared key. Carry over
|
||||
// the existing id so the user keeps their conversation thread.
|
||||
const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
|
||||
s = legacy || crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, s);
|
||||
}
|
||||
// Drop the legacy key now that we've migrated; harmless if it's
|
||||
// already absent.
|
||||
localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
|
||||
sessionId = s;
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function loadHistory(): void {
|
||||
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
|
||||
if (!stored) {
|
||||
history = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
history = Array.isArray(parsed) ? parsed.slice(-30) : [];
|
||||
} catch {
|
||||
history = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history.slice(-30)));
|
||||
} catch {
|
||||
/* localStorage quota or disabled — non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function revealIfOwner(): Promise<void> {
|
||||
if (!shouldSendContext(window.location.pathname)) return; // route excluded
|
||||
let me: MeResponse;
|
||||
try {
|
||||
const r = await fetch("/api/me", { credentials: "same-origin" });
|
||||
if (!r.ok) return;
|
||||
me = await r.json();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// The server-side handler returns 404 for non-owners on every paliadin
|
||||
// route, so we don't need to know the owner email client-side. Probe
|
||||
// /api/paliadin/me-check (a 200/404 endpoint) — but that endpoint
|
||||
// doesn't exist; instead reuse the same reveal hook the sidebar uses,
|
||||
// which checks an `is_paliadin_owner` flag the /api/me payload includes
|
||||
// when paliadinSvc is wired and the caller matches.
|
||||
if (!isPaliadinOwner(me)) return;
|
||||
showTrigger();
|
||||
renderStarters();
|
||||
rehydrateHistory();
|
||||
// Refresh from DB in the background so cross-surface activity (a
|
||||
// turn typed on the standalone /paliadin page) shows up here without
|
||||
// a manual reload.
|
||||
void hydrateFromServer();
|
||||
}
|
||||
|
||||
function isPaliadinOwner(me: MeResponse): boolean {
|
||||
// Server-driven flag (matches the pattern client/sidebar.ts uses to
|
||||
// reveal the /paliadin sidebar entry). Fallback to email match only
|
||||
// if the flag is absent — this keeps the widget working on a server
|
||||
// build that hasn't shipped the flag yet.
|
||||
const flag = (me as unknown as { is_paliadin_owner?: boolean }).is_paliadin_owner;
|
||||
if (typeof flag === "boolean") return flag;
|
||||
// Fallback: hardcoded owner match. Same string as
|
||||
// services.PaliadinOwnerEmail in Go — keep in sync.
|
||||
return (me.email || "").toLowerCase() === "matthias.siebels@hoganlovells.com";
|
||||
}
|
||||
|
||||
function showTrigger(): void {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
if (trigger) trigger.style.display = "";
|
||||
}
|
||||
|
||||
function wireTrigger(): void {
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
trigger?.addEventListener("click", () => openDrawer());
|
||||
}
|
||||
|
||||
function wireDrawerControls(): void {
|
||||
document.getElementById("paliadin-widget-close")?.addEventListener("click", () => closeDrawer());
|
||||
document.getElementById("paliadin-widget-scrim")?.addEventListener("click", () => closeDrawer());
|
||||
document.getElementById("paliadin-widget-reset")?.addEventListener("click", () => void resetSession());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && drawerOpen) {
|
||||
e.preventDefault();
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireKeyboardShortcut(): void {
|
||||
// Cmd+J / Ctrl+J — open or close the drawer. Cmd+K is reserved for
|
||||
// global search (client/search.ts), so we use J ("Junior assistant").
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const isCmdJ = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "j";
|
||||
if (!isCmdJ) return;
|
||||
const trigger = document.getElementById("paliadin-widget-trigger");
|
||||
if (!trigger || trigger.style.display === "none") return; // widget not revealed
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (drawerOpen) {
|
||||
closeDrawer();
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openDrawer(): void {
|
||||
if (drawerOpen) return;
|
||||
drawerOpen = true;
|
||||
const drawer = document.getElementById("paliadin-widget-drawer");
|
||||
const scrim = document.getElementById("paliadin-widget-scrim");
|
||||
if (drawer) {
|
||||
drawer.style.display = "";
|
||||
drawer.dataset.open = "true";
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
if (scrim) {
|
||||
scrim.style.display = "";
|
||||
}
|
||||
// Force reflow so the slide-in animation runs (CSS transitions need a
|
||||
// flip from off-canvas to on-canvas).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
drawer?.offsetWidth;
|
||||
if (drawer) drawer.classList.add("paliadin-widget-drawer--visible");
|
||||
if (scrim) scrim.classList.add("paliadin-widget-scrim--visible");
|
||||
|
||||
refreshContextChip();
|
||||
renderStarters();
|
||||
// Pull the canonical conversation from the DB on every open so a
|
||||
// turn the user typed on /paliadin (or another tab) since the last
|
||||
// open is reflected here.
|
||||
void hydrateFromServer();
|
||||
setTimeout(() => {
|
||||
document.getElementById("paliadin-widget-input")?.focus();
|
||||
}, 60);
|
||||
}
|
||||
|
||||
function closeDrawer(): void {
|
||||
if (!drawerOpen) return;
|
||||
drawerOpen = false;
|
||||
const drawer = document.getElementById("paliadin-widget-drawer");
|
||||
const scrim = document.getElementById("paliadin-widget-scrim");
|
||||
drawer?.classList.remove("paliadin-widget-drawer--visible");
|
||||
scrim?.classList.remove("paliadin-widget-scrim--visible");
|
||||
// Wait for transition before display:none so the slide-out animates.
|
||||
setTimeout(() => {
|
||||
if (drawerOpen) return; // re-opened during transition
|
||||
if (drawer) {
|
||||
drawer.style.display = "none";
|
||||
drawer.dataset.open = "false";
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
if (scrim) scrim.style.display = "none";
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function refreshContextChip(): void {
|
||||
const chip = document.getElementById("paliadin-widget-context-chip");
|
||||
const value = document.getElementById("paliadin-widget-context-value");
|
||||
if (!chip || !value) return;
|
||||
const ctx = computePaliadinContext();
|
||||
if (!ctx) {
|
||||
chip.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const labelParts: string[] = [];
|
||||
if (ctx.primary_entity_type === "project") {
|
||||
labelParts.push(getLang() === "en" ? "Project" : "Akte");
|
||||
} else if (ctx.primary_entity_type === "deadline") {
|
||||
labelParts.push(getLang() === "en" ? "Deadline" : "Frist");
|
||||
} else if (ctx.primary_entity_type === "appointment") {
|
||||
labelParts.push(getLang() === "en" ? "Appointment" : "Termin");
|
||||
}
|
||||
labelParts.push(humanRouteName(ctx.route_name));
|
||||
value.textContent = labelParts.join(" · ");
|
||||
chip.style.display = "";
|
||||
}
|
||||
|
||||
function humanRouteName(route: string): string {
|
||||
// Prefer i18n key if present; fall back to a tidied form of the
|
||||
// route key itself.
|
||||
const key = "paliadin.widget.route." + route;
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) return translated;
|
||||
return route;
|
||||
}
|
||||
|
||||
function renderStarters(): void {
|
||||
const host = document.getElementById("paliadin-widget-starters");
|
||||
if (!host) return;
|
||||
const route = routeNameFor(window.location.pathname);
|
||||
const lang = getLang();
|
||||
const list = startersFor(route);
|
||||
host.innerHTML = "";
|
||||
list.forEach((s) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "paliadin-widget-starter";
|
||||
btn.textContent = lang === "en" ? s.label_en : s.label_de;
|
||||
btn.addEventListener("click", () => onStarterClick(s));
|
||||
host.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function onStarterClick(s: Starter): void {
|
||||
const lang = getLang();
|
||||
const promptText = lang === "en" ? s.prompt_en : s.prompt_de;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (!input) return;
|
||||
if (!promptText) {
|
||||
input.value = "";
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
// Prompts that end with ": " are intentional partial seeds — leave
|
||||
// the textarea so the user finishes the sentence.
|
||||
if (promptText.endsWith(": ")) {
|
||||
input.value = promptText;
|
||||
input.focus();
|
||||
input.setSelectionRange(promptText.length, promptText.length);
|
||||
return;
|
||||
}
|
||||
input.value = promptText;
|
||||
// Auto-send for fully-formed prompts.
|
||||
void sendTurn();
|
||||
}
|
||||
|
||||
function wireForm(): void {
|
||||
const form = document.getElementById("paliadin-widget-form") as HTMLFormElement | null;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (!form || !input) return;
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
void sendTurn();
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void sendTurn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTurn(): Promise<void> {
|
||||
if (pending) return;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (!input) return;
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
|
||||
hideEmpty();
|
||||
appendBubble("user", text);
|
||||
history.push({ role: "user", text, ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
|
||||
pending = true;
|
||||
setSendDisabled(true);
|
||||
const placeholder = appendBubble("assistant", "Paliadin denkt nach …");
|
||||
placeholder.dataset.streaming = "true";
|
||||
|
||||
let turnRes: TurnResponse;
|
||||
try {
|
||||
const ctx = computePaliadinContext();
|
||||
const r = await fetch("/api/paliadin/turn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
user_message: text,
|
||||
session_id: sessionId,
|
||||
page_origin: window.location.pathname + window.location.search,
|
||||
context: ctx ?? undefined,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
turnRes = await r.json();
|
||||
} catch {
|
||||
setBubbleText(placeholder, t("paliadin.error.upstream"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.dataset.streaming = "false";
|
||||
pending = false;
|
||||
setSendDisabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
fullText = String(data.text || "");
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.dataset.turnId = turnRes.turn_id;
|
||||
startWidgetLatePoll(turnRes.turn_id, placeholder);
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("ping", () => {
|
||||
/* heartbeat */
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
pending = false;
|
||||
setSendDisabled(false);
|
||||
}
|
||||
|
||||
function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.get(turnId)?.cancel();
|
||||
const handle = pollForLateResponse({
|
||||
turnId,
|
||||
onLateResponse: (turn) => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
"paliadin-widget-bubble--error",
|
||||
"paliadin-widget-bubble--late-pending",
|
||||
);
|
||||
bubble.classList.add("paliadin-widget-bubble--late");
|
||||
setBubbleText(bubble, turn.response);
|
||||
// Append a small "(verspätet)" tag so the late arrival is visible.
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "paliadin-widget-bubble-late-tag";
|
||||
tag.textContent = " · " + t("paliadin.late.marker");
|
||||
bubble.appendChild(tag);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: turn.response,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function setSendDisabled(disabled: boolean): void {
|
||||
const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = disabled;
|
||||
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
||||
if (input) input.disabled = disabled;
|
||||
}
|
||||
|
||||
function hideEmpty(): void {
|
||||
const empty = document.getElementById("paliadin-widget-empty");
|
||||
if (empty) empty.style.display = "none";
|
||||
}
|
||||
|
||||
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
|
||||
const body = document.createElement("div");
|
||||
body.className = "paliadin-widget-bubble-text";
|
||||
// Assistant bubbles get the same markdown + chip pipeline as the
|
||||
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
|
||||
// stay plain text — no need to interpret the user's typed markup.
|
||||
if (role === "assistant") {
|
||||
body.innerHTML = renderResponseHTML(text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
wrap.appendChild(body);
|
||||
messages?.appendChild(wrap);
|
||||
if (messages) messages.scrollTop = messages.scrollHeight;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function setBubbleText(bubble: HTMLElement, text: string): void {
|
||||
const body = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (body) {
|
||||
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
|
||||
if (isAssistant) {
|
||||
(body as HTMLElement).innerHTML = renderResponseHTML(text);
|
||||
} else {
|
||||
body.textContent = text;
|
||||
}
|
||||
}
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
if (messages) messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function rehydrateHistory(): void {
|
||||
if (!history.length) return;
|
||||
hideEmpty();
|
||||
history.forEach((h) => appendBubble(h.role, h.text));
|
||||
}
|
||||
|
||||
// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
|
||||
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
|
||||
// are typed as unknown to keep the contract loose.
|
||||
interface PaliadinTurnRow {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
user_message: string;
|
||||
response?: string | null;
|
||||
error_code?: string | null;
|
||||
}
|
||||
|
||||
// Hydrate from the DB on every mount. Crash-resistant: a typed turn
|
||||
// always lands in paliad.paliadin_turns, so even if the user closes
|
||||
// the tab mid-flight or the device dies, the next mount picks it up.
|
||||
//
|
||||
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
|
||||
// them entirely and overwrite the cache. If the DB call fails or
|
||||
// returns empty, we keep whatever's in localStorage (offline cushion).
|
||||
async function hydrateFromServer(): Promise<void> {
|
||||
let rows: PaliadinTurnRow[] = [];
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!r.ok) return;
|
||||
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
||||
rows = Array.isArray(body) ? body : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) return;
|
||||
|
||||
// Project DB rows into the {role, text, ts} shape the cache + render
|
||||
// path expect. Each turn becomes two entries (user prompt then
|
||||
// assistant response). Skip turns with no response (in-flight, or
|
||||
// errored without a recovery) so the bubble doesn't show
|
||||
// half-rendered placeholders on reload.
|
||||
const reconstructed: HistoryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
||||
if (typeof row.response === "string" && row.response.length > 0) {
|
||||
reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
|
||||
}
|
||||
}
|
||||
history = reconstructed;
|
||||
saveHistory();
|
||||
|
||||
// Re-render: clear the message list + replay the canonical history.
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
const empty = document.getElementById("paliadin-widget-empty");
|
||||
if (messages) {
|
||||
// Strip every prior bubble but keep the empty-state placeholder so
|
||||
// it can be hidden by hideEmpty() if we end up rendering anything.
|
||||
messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
|
||||
if (empty) empty.style.display = "none";
|
||||
history.forEach((h) => appendBubble(h.role, h.text));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSession(): Promise<void> {
|
||||
if (!confirm(t("paliadin.widget.reset.confirm"))) return;
|
||||
history = [];
|
||||
saveHistory();
|
||||
const messages = document.getElementById("paliadin-widget-messages");
|
||||
if (messages) {
|
||||
messages.innerHTML = "";
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "paliadin-widget-empty";
|
||||
empty.id = "paliadin-widget-empty";
|
||||
const label = document.createElement("p");
|
||||
label.className = "paliadin-widget-empty-prompt";
|
||||
label.setAttribute("data-i18n", "paliadin.widget.empty");
|
||||
label.textContent = t("paliadin.widget.empty");
|
||||
const starters = document.createElement("div");
|
||||
starters.className = "paliadin-widget-starters";
|
||||
starters.id = "paliadin-widget-starters";
|
||||
empty.appendChild(label);
|
||||
empty.appendChild(starters);
|
||||
messages.appendChild(empty);
|
||||
renderStarters();
|
||||
}
|
||||
try {
|
||||
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { initI18n, getLang, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
//
|
||||
@@ -32,6 +34,10 @@ let sessionId: string;
|
||||
let history: HistoryEntry[] = [];
|
||||
let currentEventSource: EventSource | null = null;
|
||||
let currentTurnId: string | null = null;
|
||||
// Late-response polls keyed by turn_id. Each entry runs until the
|
||||
// response arrives or the 10-min cap expires. Stays alive across
|
||||
// turns — m can keep chatting while we wait for the slow one.
|
||||
const latePolls = new Map<string, LatePollHandle>();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
@@ -41,6 +47,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
wireStarters();
|
||||
wireReset();
|
||||
renderHistory();
|
||||
// Pull the canonical conversation from the DB so a turn typed in the
|
||||
// inline drawer (which shares this session id) shows up here on
|
||||
// mount. DB > localStorage when both have data.
|
||||
void hydrateFromServer();
|
||||
});
|
||||
|
||||
function bootSession(): void {
|
||||
@@ -164,6 +174,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
@@ -173,7 +189,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: getBubbleText(placeholder),
|
||||
// Save the raw Markdown body (with [#deadline-OPEN:...] chip markers
|
||||
// intact), not the rendered textContent. Otherwise on reload the
|
||||
// chip-anchor text replaces the markers and renderResponseHTML can
|
||||
// no longer reconstruct the links (m, 2026-05-08 14:11 — links
|
||||
// disappeared on second load).
|
||||
text: placeholder.dataset.fullText ?? getBubbleText(placeholder),
|
||||
meta: {
|
||||
used_tools: data.used_tools,
|
||||
rows_seen: data.rows_seen,
|
||||
@@ -188,10 +209,21 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
friendlyErrorMessage((ev as MessageEvent).data);
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
placeholder.dataset.errorText = errText;
|
||||
if (currentTurnId) {
|
||||
placeholder.dataset.turnId = currentTurnId;
|
||||
startLatePoll(currentTurnId, placeholder);
|
||||
}
|
||||
cleanupTurn();
|
||||
});
|
||||
|
||||
@@ -282,8 +314,9 @@ function typewriter(bubble: HTMLElement, text: string): void {
|
||||
const speed = 6;
|
||||
const tick = () => {
|
||||
if (bubble.dataset.streaming !== "true") {
|
||||
// Aborted — flush remaining text instantly.
|
||||
node.textContent = text;
|
||||
// Streaming finished — finishBubble has already rendered the full
|
||||
// Markdown via dataset.fullText. Return without writing so we
|
||||
// don't replace the rendered HTML with raw text on a delayed tick.
|
||||
return;
|
||||
}
|
||||
if (i >= text.length) return;
|
||||
@@ -307,7 +340,9 @@ function getBubbleText(bubble: HTMLElement): string {
|
||||
// "ran search_my_deadlines (3 results)".
|
||||
function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
|
||||
const raw = textNode.textContent || "";
|
||||
// Prefer the full text cached on the bubble at content-event time;
|
||||
// textContent may still reflect the typewriter's partial state.
|
||||
const raw = bubble.dataset.fullText ?? textNode.textContent ?? "";
|
||||
textNode.innerHTML = renderResponseHTML(raw);
|
||||
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
@@ -325,71 +360,127 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Marker → button render. Mirrors §4.4 of the design.
|
||||
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
||||
function renderResponseHTML(raw: string): string {
|
||||
// First escape any HTML in the raw text (simple textContent → innerHTML
|
||||
// would have been fine but we then need to inject anchors, so the
|
||||
// manual escape is unavoidable).
|
||||
const esc = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// Walk markers; replace each with a paliadin-chip anchor.
|
||||
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
||||
if (kind && id) {
|
||||
const url = chipURL(kind, id);
|
||||
const label = chipLabel(kind);
|
||||
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
||||
}
|
||||
if (chipKind === "nav") {
|
||||
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
||||
}
|
||||
if (chipKind === "filter") {
|
||||
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
||||
}
|
||||
return "";
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
latePolls.get(turnId)?.cancel();
|
||||
const handle = pollForLateResponse({
|
||||
turnId,
|
||||
onLateResponse: (turn) => {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function chipURL(kind: string, id: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "/deadlines/" + id;
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "/projects/" + id;
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "/appointments/" + id;
|
||||
default:
|
||||
return "#";
|
||||
}
|
||||
}
|
||||
|
||||
function chipLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "deadline":
|
||||
case "frist":
|
||||
return "Frist öffnen";
|
||||
case "projekt":
|
||||
case "project":
|
||||
return "Akte ansehen";
|
||||
case "termin":
|
||||
case "appointment":
|
||||
return "Termin öffnen";
|
||||
default:
|
||||
return "öffnen";
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--late");
|
||||
bubble.dataset.fullText = turn.response;
|
||||
bubble.dataset.streaming = "false";
|
||||
finishBubble(bubble, {
|
||||
used_tools: turn.used_tools,
|
||||
rows_seen: turn.rows_seen,
|
||||
classifier_tag: turn.classifier_tag,
|
||||
duration_ms: turn.duration_ms,
|
||||
chip_count: turn.chip_count,
|
||||
});
|
||||
// Inject a small "(verspätet)" marker into the meta row so it's
|
||||
// visible at a glance that this bubble was patched after the fact.
|
||||
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
|
||||
if (metaEl) {
|
||||
const lateTag = document.createElement("span");
|
||||
lateTag.className = "paliadin-bubble-late-tag";
|
||||
lateTag.textContent = " · " + t("paliadin.late.marker");
|
||||
metaEl.appendChild(lateTag);
|
||||
metaEl.style.display = "";
|
||||
}
|
||||
// Persist so a reload shows the late response in place of the error.
|
||||
history.push({
|
||||
role: "assistant",
|
||||
text: turn.response,
|
||||
meta: {
|
||||
used_tools: turn.used_tools,
|
||||
rows_seen: turn.rows_seen,
|
||||
classifier_tag: turn.classifier_tag ?? undefined,
|
||||
duration_ms: turn.duration_ms ?? undefined,
|
||||
chip_count: turn.chip_count,
|
||||
},
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
saveHistory();
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
|
||||
}
|
||||
|
||||
// PaliadinTurnRow mirrors the JSON returned by /api/paliadin/history
|
||||
// (services.PaliadinTurn). Fields we don't render yet are skipped.
|
||||
interface PaliadinTurnRow {
|
||||
turn_id: string;
|
||||
session_id: string;
|
||||
started_at: string;
|
||||
user_message: string;
|
||||
response?: string | null;
|
||||
used_tools?: string[] | null;
|
||||
rows_seen?: number[] | null;
|
||||
classifier_tag?: string | null;
|
||||
duration_ms?: number | null;
|
||||
chip_count?: number | null;
|
||||
}
|
||||
|
||||
// Hydrate from /api/paliadin/history, replacing the localStorage cache
|
||||
// when the DB returns rows. Fail-quiet on network / auth errors —
|
||||
// localStorage is a perfectly good offline fallback.
|
||||
async function hydrateFromServer(): Promise<void> {
|
||||
let rows: PaliadinTurnRow[] = [];
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
||||
{ credentials: "same-origin" },
|
||||
);
|
||||
if (!r.ok) return;
|
||||
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
||||
rows = Array.isArray(body) ? body : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) return;
|
||||
const reconstructed: HistoryEntry[] = [];
|
||||
for (const row of rows) {
|
||||
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
||||
if (typeof row.response === "string" && row.response.length > 0) {
|
||||
reconstructed.push({
|
||||
role: "assistant",
|
||||
text: row.response,
|
||||
ts: row.started_at,
|
||||
meta: {
|
||||
used_tools: row.used_tools ?? undefined,
|
||||
rows_seen: row.rows_seen ?? undefined,
|
||||
classifier_tag: row.classifier_tag ?? undefined,
|
||||
duration_ms: row.duration_ms ?? undefined,
|
||||
chip_count: row.chip_count ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
history = reconstructed;
|
||||
saveHistory();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderHistory(): void {
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (!stream) return;
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ProjectFormState {
|
||||
grantDate: string;
|
||||
court: string;
|
||||
caseNumber: string;
|
||||
ourSide: string;
|
||||
}
|
||||
|
||||
let parentCandidates: ProjectMini[] = [];
|
||||
@@ -178,6 +179,17 @@ export function readPayload(
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
else if (!opts.omitEmpty) payload.description = "";
|
||||
@@ -214,6 +226,8 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
|
||||
get("project-court").value = String(p.court ?? "");
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,21 @@ function $(id: string): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
// sanitizeReturnUrl restricts the post-create bounce-back to same-origin
|
||||
// paths. Any value that could escape to a different origin (protocol-
|
||||
// relative `//foo`, absolute `https://...`, or non-rooted relative
|
||||
// paths) is rejected and the form falls back to /projects/{id}. m's
|
||||
// 2026-05-08 Determinator Slice 2: the /tools/fristenrechner Step 1
|
||||
// "Neue Akte anlegen" link sends ?return=/tools/fristenrechner so the
|
||||
// new project preselects itself when control bounces back.
|
||||
function sanitizeReturnUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith("//")) return null;
|
||||
if (raw.includes("://")) return null;
|
||||
if (!raw.startsWith("/")) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
const form = $("project-new-form") as HTMLFormElement;
|
||||
const msg = $("project-new-msg") as HTMLParagraphElement;
|
||||
@@ -41,6 +56,20 @@ function submitForm() {
|
||||
return;
|
||||
}
|
||||
const p = (await resp.json()) as { id: string };
|
||||
|
||||
// Honour ?return=<path> if it's a same-origin rooted path. The
|
||||
// caller is responsible for ensuring the destination knows what
|
||||
// to do with the appended ?project= param; see Slice 1's Step 1
|
||||
// hydration.
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const returnUrl = sanitizeReturnUrl(qs.get("return"));
|
||||
if (returnUrl) {
|
||||
const dest = new URL(returnUrl, window.location.origin);
|
||||
dest.searchParams.set("project", p.id);
|
||||
window.location.href = dest.pathname + dest.search + dest.hash;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/projects/${p.id}`;
|
||||
} catch (e) {
|
||||
msg.textContent = String(e);
|
||||
|
||||
@@ -75,6 +75,7 @@ export function initSidebar() {
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
fixVerfahrensablaufActive();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
@@ -443,6 +444,30 @@ function initUserViewsGroup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// fixVerfahrensablaufActive disambiguates the two /tools/fristenrechner
|
||||
// sidebar entries (t-paliad-168). The SSR navItem helper compares
|
||||
// hrefs against pathname only, which can't tell ?path=a apart from
|
||||
// the no-query Fristenrechner — both would render as Fristenrechner=
|
||||
// active. At the client we know the search params; flip the active
|
||||
// class so the sidebar lights up the entry the user actually opened.
|
||||
function fixVerfahrensablaufActive(): void {
|
||||
if (window.location.pathname !== "/tools/fristenrechner") return;
|
||||
const path = new URLSearchParams(window.location.search).get("path");
|
||||
const fristenrechner = document.querySelector<HTMLAnchorElement>(
|
||||
'a.sidebar-item[href="/tools/fristenrechner"]',
|
||||
);
|
||||
const verfahrensablauf = document.querySelector<HTMLAnchorElement>(
|
||||
'a.sidebar-item[href="/tools/fristenrechner?path=a"]',
|
||||
);
|
||||
if (path === "a") {
|
||||
fristenrechner?.classList.remove("active");
|
||||
verfahrensablauf?.classList.add("active");
|
||||
} else {
|
||||
verfahrensablauf?.classList.remove("active");
|
||||
fristenrechner?.classList.add("active");
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/views/${encodeURIComponent(view.slug)}`;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { t, type I18nKey } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
|
||||
import { formatDate, formatRelative, parseDateOnly } from "./format";
|
||||
|
||||
// shape-list: renders ViewRows as a table (density=comfortable) or a
|
||||
// compact one-line stream (density=compact). The "activity feed" look
|
||||
// is just density=compact + actor/time columns — see Q4 lock-in
|
||||
// 2026-05-07 (3 shapes; no separate "activity").
|
||||
//
|
||||
// Row interaction is controlled by render.list.row_action
|
||||
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
|
||||
// caller's contract — clicking a row goes to the per-kind detail
|
||||
// page. "approve" produces the approval-list layout for /inbox.
|
||||
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
|
||||
// any row interaction (audit views).
|
||||
|
||||
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const list = render.list ?? {};
|
||||
const density = list.density ?? "comfortable";
|
||||
const sort = list.sort ?? "date_asc";
|
||||
const rowAction: ListRowAction = list.row_action ?? "navigate";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = sortKey(a.event_date);
|
||||
@@ -19,6 +27,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (rowAction === "approve") {
|
||||
host.appendChild(renderApprovalList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
@@ -162,3 +175,166 @@ function sortKey(iso: string): number {
|
||||
if (dateOnly) return dateOnly.getTime();
|
||||
return Date.parse(iso);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// row_action = "approve" — approval inbox layout
|
||||
//
|
||||
// Stamps the markup the /inbox surface needs (data attrs + classes);
|
||||
// the surface (client/inbox.ts) wires the action handlers in onResult.
|
||||
// This keeps shape-list independent of any specific surface's wiring.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface ApprovalDetail {
|
||||
status?: string;
|
||||
lifecycle_event?: string;
|
||||
entity_type?: string;
|
||||
entity_title?: string;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role?: string;
|
||||
requester_name?: string;
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
for (const row of rows) {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// The bar's approval_viewer_role distinguishes which actions are
|
||||
// appropriate. The surface inspects the active role and decides
|
||||
// which buttons to keep — but for default rendering we stamp all
|
||||
// three with role-class hints and let the surface filter.
|
||||
actions.appendChild(actionBtn("approve"));
|
||||
actions.appendChild(actionBtn("reject"));
|
||||
actions.appendChild(actionBtn("revoke"));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (detail.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Suppress unused warning for tDyn — kept available for future axes.
|
||||
void tDyn;
|
||||
|
||||
966
frontend/src/client/views/shape-timeline.ts
Normal file
966
frontend/src/client/views/shape-timeline.ts
Normal file
@@ -0,0 +1,966 @@
|
||||
import { t, getLang } from "../i18n";
|
||||
|
||||
// shape-timeline (t-paliad-171 → t-paliad-175) — vertical timeline render
|
||||
// for the SmartTimeline. Two-column layout (date / event card), "Heute →"
|
||||
// rule separating past from future, status icon + kind chip per row.
|
||||
//
|
||||
// Slice 2 (t-paliad-173) adds:
|
||||
// - Kind="projected" rows in three flavours via Status:
|
||||
// "predicted" — fade-grey (future)
|
||||
// "court_set" — dashed border (court-determined)
|
||||
// "predicted_overdue" — amber-faded (past, no anchor yet)
|
||||
// - "[Datum setzen]" inline date editor → POST /timeline/anchor.
|
||||
// 200 → re-fetch + re-render. 409 → render the predecessor_missing
|
||||
// payload as inline error with a "Stattdessen <predecessor> erfassen"
|
||||
// link that pre-fills the editor for the parent rule.
|
||||
// - "Folgt aus: <Name> (<Date|„Datum offen“>)" footer on every row
|
||||
// with depends_on_rule_code, plus a "[Pfad anzeigen]" expander that
|
||||
// walks the parent chain back to the trigger.
|
||||
// - "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle after the 7th
|
||||
// projected row, cap remembered in localStorage per project.
|
||||
//
|
||||
// Slice 4 (t-paliad-175) adds parent-node lane aggregation:
|
||||
// - When `lanes.length > 1` (Patent / Litigation / Client view), render
|
||||
// a horizontal lane-strip with one column per lane. Time axis stays
|
||||
// vertical within each lane; the lane sub-header names the child
|
||||
// project. CSS Grid handles the desktop side-by-side and collapses
|
||||
// to single-column on mobile (≤640px).
|
||||
// - Lane filter chip (multiselect) sits in the timeline header above
|
||||
// the strip; selecting a subset dims the others.
|
||||
// - Single-column flow stays the default at Case level (lanes mirror
|
||||
// tracks one-for-one).
|
||||
//
|
||||
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
|
||||
// shape is the wire contract from /api/projects/{id}/timeline.events;
|
||||
// LaneInfo[] from .lanes drives the lane-grouped layout.
|
||||
//
|
||||
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §5 + §6 +
|
||||
// m/paliad#31 layered requirements.
|
||||
|
||||
export interface TimelineEvent {
|
||||
kind: "deadline" | "appointment" | "milestone" | "projected";
|
||||
status:
|
||||
| "done"
|
||||
| "open"
|
||||
| "overdue"
|
||||
| "court_set"
|
||||
| "predicted"
|
||||
| "predicted_overdue"
|
||||
| "off_script";
|
||||
track: string;
|
||||
date?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
rule_code?: string;
|
||||
|
||||
deadline_id?: string;
|
||||
appointment_id?: string;
|
||||
project_event_id?: string;
|
||||
|
||||
deadline_rule_id?: string;
|
||||
deadline_rule_party?: string;
|
||||
|
||||
sub_project_id?: string;
|
||||
sub_project_title?: string;
|
||||
|
||||
depends_on_rule_code?: string;
|
||||
depends_on_date?: string | null;
|
||||
depends_on_rule_name?: string;
|
||||
|
||||
// Slice 4 — parent-node aggregation (t-paliad-175). lane_id buckets
|
||||
// the row into one of the columns described by RenderOptions.lanes.
|
||||
// Empty / missing is treated as "self" (the legacy single-lane case).
|
||||
lane_id?: string;
|
||||
bubble_up?: boolean;
|
||||
|
||||
// t-paliad-176 — underlying paliad.project_events.event_type for
|
||||
// milestone rows. Empty for deadline / appointment / projected rows.
|
||||
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
|
||||
// (matched against KnownProjectEventKinds in filter_spec.go).
|
||||
project_event_type?: string;
|
||||
}
|
||||
|
||||
export interface LaneInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
project_id?: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface PredecessorMissingPayload {
|
||||
error: "predecessor_missing";
|
||||
missing_rule_code: string;
|
||||
missing_rule_name_de: string;
|
||||
missing_rule_name_en: string;
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
// The project the timeline belongs to. Required for anchor / skip
|
||||
// POSTs. When undefined, projected rows don't expose "Datum setzen".
|
||||
projectId?: string;
|
||||
// Language hint — falls back to getLang() when omitted.
|
||||
lang?: "de" | "en";
|
||||
// Called after a successful anchor write so the host can re-fetch
|
||||
// and re-render. Skipped when omitted.
|
||||
onChange?: () => void | Promise<void>;
|
||||
// Lookahead state for projected rows. Default 7 = backend default.
|
||||
lookahead?: number;
|
||||
// Total number of future predicted rows the backend knows about
|
||||
// (read from X-Projection-Total). When > visible projected count,
|
||||
// "Mehr anzeigen" is shown.
|
||||
projectedTotal?: number;
|
||||
// Called when the user toggles "Mehr / Weniger anzeigen". The host
|
||||
// updates state + re-fetches with the new ?lookahead=N.
|
||||
onLookaheadChange?: (next: number) => void | Promise<void>;
|
||||
|
||||
// Slice 3 — counterclaim parallel tracks. availableTracks lists every
|
||||
// track tag present in the response (parsed from X-Projection-Tracks).
|
||||
// When the list contains a non-"parent" entry, the [Track ▼] chip
|
||||
// surfaces. selectedTrack is the user's filter ("all" = render every
|
||||
// available track in parallel; otherwise render only the named tag).
|
||||
availableTracks?: string[];
|
||||
selectedTrack?: string;
|
||||
onTrackChange?: (next: string) => void | Promise<void>;
|
||||
|
||||
// Slice 4 — parent-node lane aggregation. When lanes.length > 1,
|
||||
// renderSmartTimeline renders a lane-strip layout (one column per
|
||||
// lane) instead of the single-column flow. selectedLanes is the
|
||||
// user's lane-filter chip; defaults to all lanes selected. Empty
|
||||
// array = nothing rendered (defensible for the user explicitly
|
||||
// unchecking every lane).
|
||||
lanes?: LaneInfo[];
|
||||
selectedLanes?: string[]; // ids; undefined = all lanes selected
|
||||
onLaneFilterChange?: (next: string[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function renderSmartTimeline(
|
||||
host: HTMLElement,
|
||||
rows: TimelineEvent[],
|
||||
opts: RenderOptions = {},
|
||||
): void {
|
||||
host.innerHTML = "";
|
||||
host.classList.add("smart-timeline");
|
||||
|
||||
// Slice 4 — lane-grouped rendering (t-paliad-175 §5). When the
|
||||
// backend reports more than one lane, every event already carries a
|
||||
// lane_id and the layout switches from single-column to lane strip.
|
||||
// Lane mode takes precedence over Track-mode (the two are different
|
||||
// axes — lanes group by *direct child project*, tracks group by
|
||||
// CCR-vs-parent on a single Case).
|
||||
const lanes = opts.lanes ?? [];
|
||||
const isLaneMode = lanes.length > 1;
|
||||
if (isLaneMode) {
|
||||
host.appendChild(renderLaneStrip(rows, lanes, opts));
|
||||
return;
|
||||
}
|
||||
|
||||
// Slice 3 — track filtering. The bar header carries the [Track ▼]
|
||||
// chip whenever the response advertised more than the default
|
||||
// "parent" track; the filter is applied here before any flow render.
|
||||
const availableTracks = (opts.availableTracks ?? []).filter((t) => !!t);
|
||||
const hasMultipleTracks = availableTracks.length > 1;
|
||||
const selectedTrack = opts.selectedTrack ?? "all";
|
||||
if (hasMultipleTracks) {
|
||||
host.appendChild(renderTrackChip(availableTracks, selectedTrack, opts));
|
||||
}
|
||||
|
||||
// Filter rows by the selected track. "all" leaves rows untouched
|
||||
// (parallel layout decides per-track partitioning below).
|
||||
const filteredRows =
|
||||
selectedTrack === "all"
|
||||
? rows
|
||||
: rows.filter((r) => (r.track ?? "parent") === selectedTrack);
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// When the user has selected "all" AND there are multiple tracks
|
||||
// present, render parallel columns side-by-side. Otherwise the
|
||||
// existing single-column flow serves both single-track projects and
|
||||
// an explicit "Nur Hauptverfahren / Nur Widerklage" filter.
|
||||
if (selectedTrack === "all" && hasMultipleTracks) {
|
||||
host.appendChild(renderParallelTracks(filteredRows, availableTracks, opts));
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-column flow.
|
||||
host.appendChild(renderTimelineFlow(filteredRows, opts));
|
||||
}
|
||||
|
||||
// renderLaneStrip builds the parent-node aggregated layout (Slice 4).
|
||||
// One column per lane, each column shows the lane's own past/today/
|
||||
// future flow. Lane filter chip (multiselect) sits above the strip.
|
||||
// Lanes the user has unchecked render dimmed but still take up the
|
||||
// column slot — this preserves the time-axis alignment across lanes.
|
||||
function renderLaneStrip(
|
||||
rows: TimelineEvent[],
|
||||
lanes: LaneInfo[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lanes-wrap";
|
||||
|
||||
// Lane filter chip (Slice 4) — multiselect with "alle" / "keine".
|
||||
// Sits above the strip.
|
||||
wrap.appendChild(renderLaneFilterChip(lanes, opts));
|
||||
|
||||
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "smart-timeline-lanes";
|
||||
grid.style.setProperty("--smart-timeline-lane-count", String(lanes.length));
|
||||
|
||||
// Group rows by lane_id. Rows without a lane_id default to the first
|
||||
// lane id so they don't disappear. For lane mode the backend always
|
||||
// sets lane_id explicitly; this fallback is defensive.
|
||||
const byLane = new Map<string, TimelineEvent[]>();
|
||||
for (const l of lanes) byLane.set(l.id, []);
|
||||
for (const r of rows) {
|
||||
const id = r.lane_id || lanes[0].id;
|
||||
if (!byLane.has(id)) byLane.set(id, []);
|
||||
byLane.get(id)!.push(r);
|
||||
}
|
||||
|
||||
for (const lane of lanes) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "smart-timeline-lane";
|
||||
if (!selected.has(lane.id)) {
|
||||
col.classList.add("smart-timeline-lane--dimmed");
|
||||
}
|
||||
if (lane.primary) {
|
||||
col.classList.add("smart-timeline-lane--primary");
|
||||
}
|
||||
|
||||
const header = document.createElement("h4");
|
||||
header.className = "smart-timeline-lane-header";
|
||||
if (lane.project_id) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
|
||||
link.textContent = lane.label;
|
||||
link.className = "smart-timeline-lane-header-link";
|
||||
header.appendChild(link);
|
||||
} else {
|
||||
header.textContent = lane.label;
|
||||
}
|
||||
col.appendChild(header);
|
||||
|
||||
const laneRows = byLane.get(lane.id) ?? [];
|
||||
if (laneRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-lane-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.lane.empty");
|
||||
col.appendChild(empty);
|
||||
} else {
|
||||
col.appendChild(renderTimelineFlow(laneRows, opts));
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderLaneFilterChip — multiselect chip-row for the lane filter.
|
||||
// Defaults to all lanes selected; user toggles individual chips. The
|
||||
// "Alle" pseudo-chip resets to all selected.
|
||||
function renderLaneFilterChip(
|
||||
lanes: LaneInfo[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lane-filter";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "smart-timeline-lane-filter-label";
|
||||
label.textContent = t("projects.detail.smarttimeline.lane.filter.label");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
|
||||
|
||||
const allBtn = document.createElement("button");
|
||||
allBtn.type = "button";
|
||||
allBtn.className = "smart-timeline-lane-chip smart-timeline-lane-chip--all";
|
||||
if (selected.size === lanes.length) {
|
||||
allBtn.classList.add("is-active");
|
||||
}
|
||||
allBtn.textContent = t("projects.detail.smarttimeline.lane.filter.all");
|
||||
allBtn.addEventListener("click", () => {
|
||||
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(lanes.map((l) => l.id));
|
||||
});
|
||||
wrap.appendChild(allBtn);
|
||||
|
||||
for (const lane of lanes) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "smart-timeline-lane-chip";
|
||||
if (selected.has(lane.id)) chip.classList.add("is-active");
|
||||
chip.textContent = lane.label;
|
||||
chip.addEventListener("click", () => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(lane.id)) {
|
||||
next.delete(lane.id);
|
||||
} else {
|
||||
next.add(lane.id);
|
||||
}
|
||||
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(Array.from(next));
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderParallelTracks builds a CSS-grid wrapper with one column per
|
||||
// track. Each column is a self-contained smart-timeline-flow with its
|
||||
// own past / today / future sections, plus a sub-header that names the
|
||||
// track ("Hauptverfahren" / "Widerklage — <CCR title>" / "Hauptverfahren
|
||||
// (Kontext)" for the parent_context view on a CCR child).
|
||||
//
|
||||
// Mobile collapse (≤640px) is owned by CSS via .smart-timeline-tracks
|
||||
// and a media query — the grid switches to a single column there with
|
||||
// each sub-header preserved so the user knows which track they're on.
|
||||
function renderParallelTracks(
|
||||
rows: TimelineEvent[],
|
||||
availableTracks: string[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "smart-timeline-tracks";
|
||||
grid.style.setProperty("--smart-timeline-track-count", String(availableTracks.length));
|
||||
|
||||
// Group rows by track. Rows with no track default to "parent".
|
||||
const byTrack = new Map<string, TimelineEvent[]>();
|
||||
for (const tr of availableTracks) byTrack.set(tr, []);
|
||||
for (const r of rows) {
|
||||
const key = r.track && byTrack.has(r.track) ? r.track : "parent";
|
||||
if (!byTrack.has(key)) byTrack.set(key, []);
|
||||
byTrack.get(key)!.push(r);
|
||||
}
|
||||
|
||||
for (const trackTag of availableTracks) {
|
||||
const trackRows = byTrack.get(trackTag) ?? [];
|
||||
const col = document.createElement("div");
|
||||
col.className = `smart-timeline-track ${trackClassFor(trackTag)}`;
|
||||
|
||||
const header = document.createElement("h4");
|
||||
header.className = "smart-timeline-track-header";
|
||||
header.textContent = trackHeaderLabel(trackTag, trackRows);
|
||||
col.appendChild(header);
|
||||
|
||||
if (trackRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-track-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||||
col.appendChild(empty);
|
||||
} else {
|
||||
col.appendChild(renderTimelineFlow(trackRows, opts));
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
// renderTimelineFlow renders the past / today / future / undated flow
|
||||
// for the given row set into a fresh container. Extracted from the
|
||||
// pre-Slice-3 renderSmartTimeline so it can be reused as a per-track
|
||||
// column in the parallel layout.
|
||||
function renderTimelineFlow(rows: TimelineEvent[], opts: RenderOptions): HTMLElement {
|
||||
const todayISO = opts.today ?? todayLocalISO();
|
||||
const past: TimelineEvent[] = [];
|
||||
const todays: TimelineEvent[] = [];
|
||||
const future: TimelineEvent[] = [];
|
||||
const undated: TimelineEvent[] = [];
|
||||
for (const r of rows) {
|
||||
const iso = dateOnlyISO(r.date);
|
||||
if (!iso) {
|
||||
undated.push(r);
|
||||
continue;
|
||||
}
|
||||
if (iso < todayISO) past.push(r);
|
||||
else if (iso === todayISO) todays.push(r);
|
||||
else future.push(r);
|
||||
}
|
||||
past.sort(byDateAsc);
|
||||
todays.sort(byDateAsc);
|
||||
future.sort(byDateAsc);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-flow";
|
||||
|
||||
if (past.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--past";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.past");
|
||||
section.appendChild(heading);
|
||||
for (const ev of past) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
const todayRule = document.createElement("div");
|
||||
todayRule.className = "smart-timeline-today-rule";
|
||||
const todayLabel = document.createElement("span");
|
||||
todayLabel.className = "smart-timeline-today-label";
|
||||
todayLabel.textContent = `${t("projects.detail.smarttimeline.today")} (${formatDateOnly(todayISO)})`;
|
||||
todayRule.appendChild(todayLabel);
|
||||
wrap.appendChild(todayRule);
|
||||
|
||||
if (todays.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--today";
|
||||
for (const ev of todays) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
if (future.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--future";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.future");
|
||||
section.appendChild(heading);
|
||||
for (const ev of future) section.appendChild(renderRow(ev, opts));
|
||||
section.appendChild(renderLookaheadToggle(future, opts));
|
||||
wrap.appendChild(section);
|
||||
} else {
|
||||
const lookaheadHost = renderLookaheadToggle(future, opts);
|
||||
if (lookaheadHost.childElementCount > 0) {
|
||||
wrap.appendChild(lookaheadHost);
|
||||
}
|
||||
}
|
||||
|
||||
if (undated.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--undated";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.undated");
|
||||
section.appendChild(heading);
|
||||
for (const ev of undated) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderTrackChip builds the [Track ▼] selector. Options are derived
|
||||
// from the response's available_tracks header — i18n keys translate
|
||||
// each option label, with the sub-project title surfacing for CCR
|
||||
// tracks ("Widerklage — <title>"). Persists the user's selection via
|
||||
// the host through opts.onTrackChange.
|
||||
function renderTrackChip(
|
||||
availableTracks: string[],
|
||||
selected: string,
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-track-chip";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "smart-timeline-track-chip-label";
|
||||
label.textContent = t("projects.detail.smarttimeline.track.label");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "smart-timeline-track-chip-select";
|
||||
|
||||
const allOpt = document.createElement("option");
|
||||
allOpt.value = "all";
|
||||
allOpt.textContent = t("projects.detail.smarttimeline.track.both");
|
||||
select.appendChild(allOpt);
|
||||
|
||||
for (const trackTag of availableTracks) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = trackTag;
|
||||
opt.textContent = trackOnlyLabel(trackTag);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
select.value = selected;
|
||||
select.addEventListener("change", () => {
|
||||
if (opts.onTrackChange) void opts.onTrackChange(select.value);
|
||||
});
|
||||
wrap.appendChild(select);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// trackClassFor maps a track tag to its CSS modifier so the column
|
||||
// gets the appropriate visual treatment (lime for parent, light shade
|
||||
// for counterclaim, faded for parent_context).
|
||||
function trackClassFor(trackTag: string): string {
|
||||
if (trackTag === "parent") return "smart-timeline-track--parent";
|
||||
if (trackTag.startsWith("counterclaim:")) return "smart-timeline-track--counterclaim";
|
||||
if (trackTag.startsWith("parent_context:")) return "smart-timeline-track--parent-context";
|
||||
return "smart-timeline-track--other";
|
||||
}
|
||||
|
||||
// trackHeaderLabel picks the column sub-header. For CCR tracks pulls
|
||||
// the sub_project_title from the first row in the track so the user
|
||||
// sees "Widerklage — <child title>". Falls back to a generic label
|
||||
// when the title is empty.
|
||||
function trackHeaderLabel(trackTag: string, rows: TimelineEvent[]): string {
|
||||
if (trackTag === "parent") {
|
||||
return t("projects.detail.smarttimeline.track.header.parent");
|
||||
}
|
||||
const firstWithTitle = rows.find((r) => r.sub_project_title);
|
||||
const subTitle = firstWithTitle?.sub_project_title ?? "";
|
||||
if (trackTag.startsWith("counterclaim:")) {
|
||||
const base = t("projects.detail.smarttimeline.track.header.counterclaim");
|
||||
return subTitle ? `${base} — ${subTitle}` : base;
|
||||
}
|
||||
if (trackTag.startsWith("parent_context:")) {
|
||||
const base = t("projects.detail.smarttimeline.track.header.parent_context");
|
||||
return subTitle ? `${base} — ${subTitle}` : base;
|
||||
}
|
||||
return trackTag;
|
||||
}
|
||||
|
||||
// trackOnlyLabel is the chip dropdown label for "show only this track".
|
||||
function trackOnlyLabel(trackTag: string): string {
|
||||
if (trackTag === "parent") {
|
||||
return t("projects.detail.smarttimeline.track.only.parent");
|
||||
}
|
||||
if (trackTag.startsWith("counterclaim:")) {
|
||||
return t("projects.detail.smarttimeline.track.only.counterclaim");
|
||||
}
|
||||
if (trackTag.startsWith("parent_context:")) {
|
||||
return t("projects.detail.smarttimeline.track.only.parent_context");
|
||||
}
|
||||
return trackTag;
|
||||
}
|
||||
|
||||
function renderLookaheadToggle(
|
||||
futureRows: TimelineEvent[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lookahead";
|
||||
const total = opts.projectedTotal ?? 0;
|
||||
const projectedShown = futureRows.filter((r) => r.kind === "projected").length;
|
||||
const cur = opts.lookahead ?? 7;
|
||||
|
||||
if (total > projectedShown && opts.onLookaheadChange) {
|
||||
const more = document.createElement("button");
|
||||
more.type = "button";
|
||||
more.className = "smart-timeline-lookahead-btn";
|
||||
more.textContent = t("projects.detail.smarttimeline.lookahead.more");
|
||||
more.setAttribute(
|
||||
"aria-label",
|
||||
`${t("projects.detail.smarttimeline.lookahead.more")} (${total - projectedShown})`,
|
||||
);
|
||||
more.addEventListener("click", () => {
|
||||
const next = Math.min(50, cur + 7);
|
||||
void opts.onLookaheadChange?.(next);
|
||||
});
|
||||
wrap.appendChild(more);
|
||||
}
|
||||
if (cur > 7 && opts.onLookaheadChange) {
|
||||
const less = document.createElement("button");
|
||||
less.type = "button";
|
||||
less.className = "smart-timeline-lookahead-btn smart-timeline-lookahead-btn--less";
|
||||
less.textContent = t("projects.detail.smarttimeline.lookahead.less");
|
||||
less.addEventListener("click", () => {
|
||||
void opts.onLookaheadChange?.(7);
|
||||
});
|
||||
wrap.appendChild(less);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderRow(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||||
const li = document.createElement("article");
|
||||
li.className =
|
||||
`smart-timeline-row smart-timeline-row--${ev.kind} ` +
|
||||
`smart-timeline-row--${ev.status}`;
|
||||
if (ev.deadline_rule_party) {
|
||||
li.classList.add(`smart-timeline-row--party-${ev.deadline_rule_party}`);
|
||||
}
|
||||
|
||||
const dateCol = document.createElement("div");
|
||||
dateCol.className = "smart-timeline-date";
|
||||
dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—";
|
||||
li.appendChild(dateCol);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "smart-timeline-body";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "smart-timeline-row-head";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "smart-timeline-status-icon";
|
||||
icon.textContent = statusGlyph(ev.status);
|
||||
icon.setAttribute("aria-label", t(statusKey(ev.status)));
|
||||
head.appendChild(icon);
|
||||
|
||||
const titleEl = document.createElement("span");
|
||||
titleEl.className = "smart-timeline-title";
|
||||
const href = deepLinkHref(ev);
|
||||
if (href) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "smart-timeline-link";
|
||||
a.href = href;
|
||||
a.textContent = ev.title;
|
||||
titleEl.appendChild(a);
|
||||
} else {
|
||||
titleEl.textContent = ev.title;
|
||||
}
|
||||
head.appendChild(titleEl);
|
||||
|
||||
const kindChip = document.createElement("span");
|
||||
kindChip.className = `smart-timeline-kind-chip smart-timeline-kind-chip--${ev.kind}`;
|
||||
kindChip.textContent = t(kindKey(ev.kind));
|
||||
head.appendChild(kindChip);
|
||||
|
||||
if (ev.rule_code) {
|
||||
const ruleChip = document.createElement("span");
|
||||
ruleChip.className = "smart-timeline-rule-chip";
|
||||
ruleChip.textContent = ev.rule_code;
|
||||
head.appendChild(ruleChip);
|
||||
}
|
||||
|
||||
// "voraussichtlich" / "vom Gericht" / "überfällig" status pill on
|
||||
// projected rows so the user reads the row's nature at a glance.
|
||||
if (ev.kind === "projected") {
|
||||
const statusPill = document.createElement("span");
|
||||
statusPill.className = `smart-timeline-status-pill smart-timeline-status-pill--${ev.status}`;
|
||||
statusPill.textContent = t(statusKey(ev.status));
|
||||
head.appendChild(statusPill);
|
||||
}
|
||||
|
||||
body.appendChild(head);
|
||||
|
||||
if (ev.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "smart-timeline-desc";
|
||||
desc.textContent = ev.description;
|
||||
body.appendChild(desc);
|
||||
}
|
||||
|
||||
// Depends-on footer (#31 layer 2) — surface the parent rule + its
|
||||
// date right under the title so the user reads the dependency at a
|
||||
// glance. "[Pfad anzeigen]" expands the full chain on demand.
|
||||
if (ev.depends_on_rule_code) {
|
||||
body.appendChild(renderDependsOn(ev));
|
||||
}
|
||||
|
||||
// Click-to-anchor affordance (Slice 2 §6.2) — projected rows expose
|
||||
// "[Datum setzen]" inline editor; actuals from rules expose a
|
||||
// "[Datum ändern]" variant that PATCHes via the same endpoint.
|
||||
if (ev.kind === "projected" && ev.deadline_rule_id && opts.projectId) {
|
||||
body.appendChild(renderAnchorAction(ev, opts));
|
||||
}
|
||||
|
||||
li.appendChild(body);
|
||||
|
||||
// Row-level navigation — same pattern as .entity-event (t-paliad-103).
|
||||
if (href) {
|
||||
li.classList.add("smart-timeline-row--clickable");
|
||||
li.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a") || target.closest("button") || target.closest("input")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDependsOn(ev: TimelineEvent): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-depends-on";
|
||||
const code = ev.depends_on_rule_code ?? "";
|
||||
const name = ev.depends_on_rule_name || code;
|
||||
const dateText = ev.depends_on_date
|
||||
? formatDateOnly(dateOnlyISO(ev.depends_on_date) ?? "")
|
||||
: t("projects.detail.smarttimeline.depends_on.date_open");
|
||||
const prefix = t("projects.detail.smarttimeline.depends_on.prefix");
|
||||
const txt = document.createElement("span");
|
||||
txt.textContent = `${prefix}: ${name} (${code}, ${dateText})`;
|
||||
wrap.appendChild(txt);
|
||||
|
||||
const expand = document.createElement("button");
|
||||
expand.type = "button";
|
||||
expand.className = "smart-timeline-depends-on-expand";
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||||
expand.addEventListener("click", () => {
|
||||
if (wrap.classList.contains("smart-timeline-depends-on--expanded")) {
|
||||
wrap.classList.remove("smart-timeline-depends-on--expanded");
|
||||
const list = wrap.querySelector(".smart-timeline-depends-on-path");
|
||||
if (list) list.remove();
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||||
return;
|
||||
}
|
||||
wrap.classList.add("smart-timeline-depends-on--expanded");
|
||||
const list = document.createElement("div");
|
||||
list.className = "smart-timeline-depends-on-path";
|
||||
// The walked chain isn't pre-computed server-side beyond the
|
||||
// immediate parent; the backend annotation gives one hop. Future
|
||||
// slice can deepen this — for v1 we surface the immediate parent
|
||||
// (already in the prefix line) and a hint that the user can click
|
||||
// the parent's row to see its own dependency.
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "smart-timeline-depends-on-hint";
|
||||
hint.textContent = t("projects.detail.smarttimeline.depends_on.path_hint");
|
||||
list.appendChild(hint);
|
||||
wrap.appendChild(list);
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.hide_path");
|
||||
});
|
||||
wrap.appendChild(expand);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderAnchorAction(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-anchor";
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "smart-timeline-anchor-btn";
|
||||
trigger.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||||
wrap.appendChild(trigger);
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (wrap.classList.contains("smart-timeline-anchor--editing")) return;
|
||||
wrap.classList.add("smart-timeline-anchor--editing");
|
||||
trigger.style.display = "none";
|
||||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildAnchorEditor(
|
||||
ev: TimelineEvent,
|
||||
opts: RenderOptions,
|
||||
wrap: HTMLElement,
|
||||
): HTMLElement {
|
||||
const editor = document.createElement("form");
|
||||
editor.className = "smart-timeline-anchor-form";
|
||||
editor.setAttribute("aria-label", t("projects.detail.smarttimeline.anchor.set"));
|
||||
editor.addEventListener("submit", (e) => e.preventDefault());
|
||||
|
||||
const dateInput = document.createElement("input");
|
||||
dateInput.type = "date";
|
||||
dateInput.className = "smart-timeline-anchor-date";
|
||||
dateInput.required = true;
|
||||
if (ev.date) dateInput.value = dateOnlyISO(ev.date) ?? "";
|
||||
editor.appendChild(dateInput);
|
||||
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
submit.className = "smart-timeline-anchor-submit";
|
||||
submit.textContent = t("projects.detail.smarttimeline.anchor.save");
|
||||
editor.appendChild(submit);
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "smart-timeline-anchor-cancel";
|
||||
cancel.textContent = t("projects.detail.smarttimeline.anchor.cancel");
|
||||
cancel.addEventListener("click", () => {
|
||||
wrap.innerHTML = "";
|
||||
const trig = document.createElement("button");
|
||||
trig.type = "button";
|
||||
trig.className = "smart-timeline-anchor-btn";
|
||||
trig.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||||
wrap.classList.remove("smart-timeline-anchor--editing");
|
||||
wrap.appendChild(trig);
|
||||
trig.addEventListener("click", () => {
|
||||
wrap.innerHTML = "";
|
||||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||||
wrap.classList.add("smart-timeline-anchor--editing");
|
||||
});
|
||||
});
|
||||
editor.appendChild(cancel);
|
||||
|
||||
const msg = document.createElement("div");
|
||||
msg.className = "smart-timeline-anchor-msg";
|
||||
editor.appendChild(msg);
|
||||
|
||||
editor.addEventListener("submit", async () => {
|
||||
if (!opts.projectId) return;
|
||||
if (!ev.rule_code) return;
|
||||
const date = dateInput.value;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.invalid_date");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
cancel.disabled = true;
|
||||
msg.classList.remove("smart-timeline-anchor-msg--error");
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.saving");
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline/anchor`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rule_code: ev.rule_code,
|
||||
actual_date: date,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.saved");
|
||||
if (opts.onChange) await opts.onChange();
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
} catch {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
cancel.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
function renderPredecessorError(
|
||||
msg: HTMLElement,
|
||||
payload: PredecessorMissingPayload,
|
||||
_ev: TimelineEvent,
|
||||
opts: RenderOptions,
|
||||
_dateInput: HTMLInputElement,
|
||||
_submit: HTMLButtonElement,
|
||||
_cancel: HTMLButtonElement,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--predecessor");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const message = lang === "en" ? payload.message_en : payload.message_de;
|
||||
const main = document.createElement("p");
|
||||
main.textContent = message;
|
||||
msg.appendChild(main);
|
||||
|
||||
// "Stattdessen <predecessor> erfassen" — pre-fills the editor for
|
||||
// the missing parent rule, scrolls to its row if present, falls back
|
||||
// to a fresh editor in-place.
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "smart-timeline-anchor-predecessor-link";
|
||||
const predName =
|
||||
lang === "en" ? payload.missing_rule_name_en : payload.missing_rule_name_de;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Anchor „${predName}“ instead`
|
||||
: `Stattdessen „${predName}“ erfassen`;
|
||||
link.addEventListener("click", () => {
|
||||
// Find the projected row for missing_rule_code and scroll into view;
|
||||
// the row's own [Datum setzen] button takes it from there.
|
||||
const targetRow = findRowForRuleCode(payload.missing_rule_code);
|
||||
if (targetRow) {
|
||||
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const btn = targetRow.querySelector<HTMLButtonElement>(
|
||||
".smart-timeline-anchor-btn",
|
||||
);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
});
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
const chip = r.querySelector(".smart-timeline-rule-chip");
|
||||
if (chip && chip.textContent === ruleCode) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deepLinkHref(ev: TimelineEvent): string | null {
|
||||
if (ev.kind === "deadline" && ev.deadline_id) {
|
||||
return `/deadlines/${ev.deadline_id}`;
|
||||
}
|
||||
if (ev.kind === "appointment" && ev.appointment_id) {
|
||||
return `/appointments/${ev.appointment_id}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusGlyph(status: TimelineEvent["status"]): string {
|
||||
switch (status) {
|
||||
case "done": return "✓";
|
||||
case "open": return "…";
|
||||
case "overdue": return "!";
|
||||
case "court_set": return "▢";
|
||||
case "predicted": return "░";
|
||||
case "predicted_overdue": return "░!";
|
||||
case "off_script": return "⊕";
|
||||
default: return "·";
|
||||
}
|
||||
}
|
||||
|
||||
function statusKey(status: TimelineEvent["status"]) {
|
||||
return `projects.detail.smarttimeline.status.${status}` as const;
|
||||
}
|
||||
|
||||
function kindKey(kind: TimelineEvent["kind"]) {
|
||||
return `projects.detail.smarttimeline.kind.${kind}` as const;
|
||||
}
|
||||
|
||||
function dateOnlyISO(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||||
const d = new Date(raw);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function todayLocalISO(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function byDateAsc(a: TimelineEvent, b: TimelineEvent): number {
|
||||
const ai = dateOnlyISO(a.date) ?? "";
|
||||
const bi = dateOnlyISO(b.date) ?? "";
|
||||
if (ai === bi) return a.title.localeCompare(b.title);
|
||||
return ai < bi ? -1 : 1;
|
||||
}
|
||||
|
||||
function formatDateOnly(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
const parts = iso.split("-");
|
||||
if (parts.length !== 3) return iso;
|
||||
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface ScopeSpec {
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_30d" | "past_90d"
|
||||
| "past_7d" | "past_30d" | "past_90d"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
@@ -71,10 +71,13 @@ export interface FilterSpec {
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
|
||||
export interface ListConfig {
|
||||
columns?: string[];
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
row_action?: ListRowAction;
|
||||
}
|
||||
|
||||
export interface CardsConfig {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { h, Fragment } from "../jsx";
|
||||
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
|
||||
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
|
||||
const ICON_PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
|
||||
const ICON_DEADLINE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||
@@ -39,7 +38,10 @@ export function BottomNav({ currentPath }: BottomNavProps): string {
|
||||
<span className="bottom-nav-label" data-i18n="bottomnav.add">Anlegen</span>
|
||||
</button>
|
||||
|
||||
{slot("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath, "bottom-nav-agenda-badge")}
|
||||
{/* t-paliad-162 — Agenda lives inline on the dashboard now; the
|
||||
mobile slot points at Fristen so today's-deadline access stays
|
||||
one tap away from the bottom rail. */}
|
||||
{slot("/events?type=deadline", ICON_DEADLINE, "nav.fristen", "Fristen", currentPath, "bottom-nav-agenda-badge")}
|
||||
|
||||
<button type="button" className="bottom-nav-slot" id="bottom-nav-menu" aria-label="Menü">
|
||||
<span className="bottom-nav-icon" dangerouslySetInnerHTML={{ __html: ICON_MENU }} />
|
||||
|
||||
177
frontend/src/components/PaliadinWidget.tsx
Normal file
177
frontend/src/components/PaliadinWidget.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { h, Fragment } from "../jsx";
|
||||
|
||||
// PaliadinWidget — inline floating-button + slide-out drawer for the
|
||||
// Paliad assistant (t-paliad-161).
|
||||
//
|
||||
// Rendered on every authenticated page near </body>. Hidden by default
|
||||
// (display:none) and revealed by client/paliadin-widget.ts after a
|
||||
// /api/me call confirms the caller is the Paliadin owner — same fail-
|
||||
// closed pattern as the sidebar /paliadin link.
|
||||
//
|
||||
// Visibility is also gated on the current pathname: hidden on /paliadin
|
||||
// (the standalone page IS the assistant), /login, and /onboarding.
|
||||
//
|
||||
// Trigger: click the floating ✨ button or press Cmd+J / Ctrl+J. (Cmd+K
|
||||
// is reserved for the global search palette in client/search.ts.)
|
||||
|
||||
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
|
||||
const ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
const ICON_RESET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg>';
|
||||
const ICON_FULLSCREEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l-4 4"/><path d="M21 3l-4 4"/><polyline points="14 3 21 3 21 10"/><polyline points="10 21 3 21 3 14"/></svg>';
|
||||
const ICON_SEND = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||
|
||||
export function PaliadinWidget(): string {
|
||||
return (
|
||||
<Fragment>
|
||||
{/*
|
||||
Floating trigger button. Hidden by default (display:none); revealed
|
||||
by client/paliadin-widget.ts after /api/me confirms the caller is
|
||||
the Paliadin owner AND the route is one where the widget should
|
||||
show. The widget hides itself again on /paliadin, /login,
|
||||
/onboarding via the same predicate.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
id="paliadin-widget-trigger"
|
||||
className="paliadin-widget-trigger"
|
||||
style="display:none"
|
||||
aria-label="Paliadin"
|
||||
title="Paliadin (Cmd+J)"
|
||||
data-i18n-title="paliadin.widget.trigger"
|
||||
>
|
||||
<span className="paliadin-widget-trigger-glyph"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Backdrop scrim — receives clicks to close. Pointer-events guard
|
||||
on display:none (CSS) until the drawer opens.
|
||||
*/}
|
||||
<div
|
||||
id="paliadin-widget-scrim"
|
||||
className="paliadin-widget-scrim"
|
||||
aria-hidden="true"
|
||||
style="display:none"
|
||||
/>
|
||||
|
||||
{/*
|
||||
Slide-out drawer. role="dialog" + aria-modal so screen readers
|
||||
announce it as a modal panel; aria-labelledby points at the
|
||||
header h2.
|
||||
*/}
|
||||
<aside
|
||||
id="paliadin-widget-drawer"
|
||||
className="paliadin-widget-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="paliadin-widget-title"
|
||||
aria-hidden="true"
|
||||
style="display:none"
|
||||
data-open="false"
|
||||
>
|
||||
<header className="paliadin-widget-header">
|
||||
<h2 id="paliadin-widget-title" className="paliadin-widget-title">
|
||||
<span className="paliadin-widget-title-glyph"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span data-i18n="paliadin.widget.title">Paliadin</span>
|
||||
</h2>
|
||||
<div className="paliadin-widget-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="paliadin-widget-reset"
|
||||
className="paliadin-widget-action-btn"
|
||||
aria-label="Reset"
|
||||
title="Konversation zurücksetzen"
|
||||
data-i18n-title="paliadin.widget.reset"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_RESET }} />
|
||||
</button>
|
||||
<a
|
||||
href="/paliadin"
|
||||
id="paliadin-widget-fullscreen"
|
||||
className="paliadin-widget-action-btn"
|
||||
aria-label="Fullscreen"
|
||||
title="Vollbild-Modus"
|
||||
data-i18n-title="paliadin.widget.fullscreen"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_FULLSCREEN }} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
id="paliadin-widget-close"
|
||||
className="paliadin-widget-action-btn"
|
||||
aria-label="Close"
|
||||
title="Schließen"
|
||||
data-i18n-title="paliadin.widget.close"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_CLOSE }} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Context chip — shows what Paliadin knows about the current page
|
||||
(route + primary entity). Populated by paliadin-widget.ts from
|
||||
computePaliadinContext() each time the drawer opens.
|
||||
*/}
|
||||
<div
|
||||
id="paliadin-widget-context-chip"
|
||||
className="paliadin-widget-context-chip"
|
||||
style="display:none"
|
||||
>
|
||||
<span className="paliadin-widget-context-label"
|
||||
data-i18n="paliadin.widget.context.on_page">Auf dieser Seite</span>
|
||||
<span className="paliadin-widget-context-value" id="paliadin-widget-context-value" />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Message stream + empty-state starter prompts. Empty state
|
||||
renders the per-route starter list from
|
||||
frontend/src/client/paliadin-starters.ts; on first send it
|
||||
slides out and the messages take over.
|
||||
*/}
|
||||
<div
|
||||
id="paliadin-widget-messages"
|
||||
className="paliadin-widget-messages"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="paliadin-widget-empty" id="paliadin-widget-empty">
|
||||
<p className="paliadin-widget-empty-prompt"
|
||||
data-i18n="paliadin.widget.empty">Was kann ich für dich tun?</p>
|
||||
<div
|
||||
className="paliadin-widget-starters"
|
||||
id="paliadin-widget-starters"
|
||||
>
|
||||
{/* Populated by paliadin-widget.ts from the per-route registry. */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="paliadin-widget-form" id="paliadin-widget-form">
|
||||
<textarea
|
||||
className="paliadin-widget-input"
|
||||
id="paliadin-widget-input"
|
||||
rows={2}
|
||||
placeholder="Frage an Paliadin..."
|
||||
data-i18n-placeholder="paliadin.widget.input.placeholder"
|
||||
aria-label="Nachricht an Paliadin"
|
||||
data-i18n-aria-label="paliadin.widget.input.label"
|
||||
maxlength={4000}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="paliadin-widget-send-btn"
|
||||
id="paliadin-widget-send-btn"
|
||||
aria-label="Senden"
|
||||
title="Senden"
|
||||
data-i18n-title="paliadin.widget.send"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: ICON_SEND }} />
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<script src="/assets/paliadin-widget.js" defer />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -153,6 +153,20 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description" data-i18n="projects.field.description">Notizen</label>
|
||||
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projects.field.description.placeholder" />
|
||||
|
||||
@@ -7,6 +7,10 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
|
||||
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
|
||||
// Open-book icon for the /tools/fristenrechner?path=a "Verfahrensablauf"
|
||||
// nav entry (t-paliad-168). Distinct from ICON_BOOK (Glossar, closed)
|
||||
// so the two affordances read as different at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
@@ -17,7 +21,6 @@ const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
const ICON_AGENDA = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>';
|
||||
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
||||
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
@@ -25,6 +28,10 @@ const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
|
||||
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
||||
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
||||
// Newspaper icon for the /changelog "Neuigkeiten" entry. Sparkle is now
|
||||
// reserved for the Paliadin AI surface so the two affordances don't
|
||||
// share a glyph (m's 2026-05-08 21:11 dogfood).
|
||||
const ICON_NEWSPAPER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8z"/></svg>';
|
||||
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
|
||||
const ICON_BELL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
|
||||
@@ -112,33 +119,35 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
|
||||
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
|
||||
|
||||
{/* Paliadin top-level entry (t-paliad-162) \u2014 owner-only, hidden
|
||||
by default. sidebar.ts reveals it after /api/me confirms the
|
||||
caller is the Paliadin owner (t-paliad-146 PoC scope). Same
|
||||
fail-closed pattern as the admin group below. Sits directly
|
||||
under Home per m's design call so owners hit their assistant
|
||||
with one click from anywhere. */}
|
||||
<a href="/paliadin"
|
||||
className={`sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}`}
|
||||
id="sidebar-paliadin-link" style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>
|
||||
</a>
|
||||
|
||||
{group("nav.group.uebersicht", "\u00DCbersicht",
|
||||
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
|
||||
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
|
||||
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
|
||||
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
|
||||
// Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts
|
||||
// reveals it after /api/me confirms the caller is the
|
||||
// Paliadin owner (t-paliad-146 PoC scope). Same fail-closed
|
||||
// pattern as the admin group below.
|
||||
`<a href="/paliadin" class="sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}" id="sidebar-paliadin-link" style="display:none">` +
|
||||
`<span class="sidebar-icon">${ICON_SPARKLE}</span>` +
|
||||
`<span class="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>` +
|
||||
`</a>` +
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.arbeit", "Arbeit",
|
||||
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
|
||||
navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath) +
|
||||
navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath),
|
||||
)}
|
||||
|
||||
{/* t-paliad-144 Phase A2 — Meine Sichten group. Hydrated by
|
||||
client/sidebar.ts from /api/user-views on mount. The
|
||||
"+ Neue Sicht" entry is always present so first-time
|
||||
users have an obvious way in. */}
|
||||
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
|
||||
dogfood: "all views under one — not Ansichten and meine Ansichten").
|
||||
Holds the built-in Fristen + Termine, the user-defined views
|
||||
hydrated by client/sidebar.ts from /api/user-views, and the
|
||||
"+ Neue Sicht" entry. The previous "Meine Sichten" split is gone. */}
|
||||
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.user_views">Meine Sichten</div>
|
||||
<div className="sidebar-group-label" data-i18n="nav.group.ansichten">Ansichten</div>
|
||||
{navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath)}
|
||||
{navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath)}
|
||||
<div className="sidebar-views-items" id="sidebar-views-items" />
|
||||
<a href="/views/new" className="sidebar-item sidebar-views-new">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
||||
@@ -146,21 +155,20 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-162 — single Werkzeuge group consolidating the prior
|
||||
Werkzeuge / Wissen / Ressourcen splits. Order follows m's
|
||||
brief: calculators first, then reference (Checklisten /
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.wissen", "Wissen",
|
||||
navItem("/tools/fristenrechner?path=a", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath) +
|
||||
navItem("/glossary", ICON_BOOK, "nav.glossar", "Glossar", currentPath) +
|
||||
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.ressourcen", "Ressourcen",
|
||||
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath) +
|
||||
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath),
|
||||
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath) +
|
||||
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath),
|
||||
)}
|
||||
|
||||
{group("nav.group.einstellungen", "Einstellungen",
|
||||
@@ -194,7 +202,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
<div className="sidebar-bottom">
|
||||
{authenticated ? (
|
||||
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_NEWSPAPER }} />
|
||||
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
|
||||
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -120,6 +121,7 @@ export function renderCourts(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/courts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -11,6 +12,35 @@ import { PWAHead } from "./components/PWAHead";
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
// .dashboard-section[aria-expanded="true"] selector — see global.css.
|
||||
const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
|
||||
|
||||
// Render a collapsible dashboard section. The toggle button is the entire
|
||||
// header row so the heading text doubles as the affordance. State is
|
||||
// hydrated client-side from localStorage by client/dashboard.ts; SSR
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
dangerouslySetInnerHTML={{ __html: ICON_CHEVRON }} />
|
||||
</button>
|
||||
<div className="dashboard-section-body">
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderDashboard(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
@@ -58,10 +88,7 @@ export function renderDashboard(): string {
|
||||
</div>
|
||||
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<section className="dashboard-summary" aria-labelledby="dashboard-summary-heading">
|
||||
<h2 id="dashboard-summary-heading" className="dashboard-section-heading" data-i18n="dashboard.summary.heading">
|
||||
Fristen auf einen Blick
|
||||
</h2>
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
@@ -84,9 +111,11 @@ export function renderDashboard(): string {
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Matter summary card */}
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
@@ -110,38 +139,59 @@ export function renderDashboard(): string {
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* Two-column lists */}
|
||||
{/* Two-column lists — each column is its own collapsible section
|
||||
so users can hide deadlines or appointments independently.
|
||||
The .dashboard-columns wrapper is preserved so the grid
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<section className="dashboard-col">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.deadlines.heading">Kommende Fristen</h3>
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
|
||||
<section className="dashboard-col">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.appointments.heading">Kommende Termine</h3>
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
<section className="dashboard-activity">
|
||||
<h3 className="dashboard-section-heading" data-i18n="dashboard.activity.heading">Letzte Aktivität</h3>
|
||||
{/* Inline Agenda (t-paliad-162). Same item shape as the
|
||||
standalone /agenda page, rendered via the shared
|
||||
agenda-render module. The dashboard variant is read-only:
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
Keine Fälligkeiten in den nächsten 30 Tagen.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollständige Agenda öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
</p>
|
||||
</section>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -75,6 +76,7 @@ export function renderDeadlinesCalendar(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -43,6 +44,9 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
<span id="deadline-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
|
||||
Wartet auf Genehmigung
|
||||
</span>
|
||||
<a id="deadline-project-link" className="entity-ref" href="#" />
|
||||
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
|
||||
</div>
|
||||
@@ -54,6 +58,9 @@ export function renderDeadlinesDetail(): string {
|
||||
<button id="deadline-reopen-btn" type="button" className="btn-primary btn-cta-lime btn-small" style="display:none" data-i18n="deadlines.detail.reopen">
|
||||
Wieder öffnen
|
||||
</button>
|
||||
<button id="deadline-withdraw-btn" type="button" className="btn-secondary btn-small" style="display:none" data-i18n="approvals.withdraw.cta">
|
||||
Genehmigungsanfrage zurückziehen
|
||||
</button>
|
||||
<button id="deadline-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="deadlines.detail.edit" title="Bearbeiten">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
@@ -135,6 +142,7 @@ export function renderDeadlinesDetail(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -54,9 +55,50 @@ export function renderDeadlinesNew(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
@@ -100,6 +142,7 @@ export function renderDeadlinesNew(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -75,6 +76,7 @@ export function renderDownloads(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/downloads.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -284,6 +285,7 @@ export function renderEvents(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/events.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -112,27 +113,116 @@ export function renderFristenrechner(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v3 landing fork (t-paliad-133) — visible by default, hidden once
|
||||
the user picks a pathway. URL ?path= drives visibility. */}
|
||||
<div className="fristen-pathway-fork" id="fristen-pathway-fork" role="group" aria-label="Pathway selector">
|
||||
<h2 className="fristen-pathway-fork-heading" data-i18n="deadlines.pathway.fork.heading">Was möchten Sie tun?</h2>
|
||||
<div className="fristen-pathway-fork-cards">
|
||||
<button type="button" className="fristen-pathway-card" data-path="a" id="fristen-pathway-a-cta">
|
||||
<span className="fristen-pathway-card-icon" aria-hidden="true">📖</span>
|
||||
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.a.desc">
|
||||
Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.
|
||||
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
|
||||
Akte (project) that scopes the rest of the flow. Filtered
|
||||
list of visible projects + "Neue Akte anlegen" link +
|
||||
four ad-hoc explore-mode chips for users who just want to
|
||||
look up a rule without saving anywhere. */}
|
||||
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
|
||||
Schritt 1 — Welche Akte?
|
||||
</h2>
|
||||
<div className="fristen-step1-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-akte-search"
|
||||
className="fristen-akte-search" autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.step1.search.placeholder"
|
||||
placeholder="Akte suchen…" />
|
||||
</div>
|
||||
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
|
||||
</div>
|
||||
{/* return-bounce: projects-new.ts honours ?return= and
|
||||
redirects back to /tools/fristenrechner?project=<new_uuid>
|
||||
so the new Akte preselects itself in Step 1. */}
|
||||
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
|
||||
data-i18n="deadlines.step1.new.cta">
|
||||
+ Neue Akte anlegen
|
||||
</a>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
|
||||
</div>
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
Custom UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
Custom DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
Custom EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
Custom DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
|
||||
proceeding-summary collapse pattern from 097e21c. */}
|
||||
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
|
||||
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
|
||||
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">—</strong>
|
||||
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
|
||||
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
|
||||
data-i18n="deadlines.step1.reselect">
|
||||
Andere Akte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
|
||||
satisfied. Click on a card routes to the existing Pathway A
|
||||
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
|
||||
we keep the routing primitive in showPathway()/showBMode(). */}
|
||||
<div className="fristen-step2" id="fristen-step2" hidden>
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
|
||||
Schritt 2 — Was möchten Sie tun?
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">✏️</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
|
||||
Etwas einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
|
||||
Outgoing — eine Frist tritt aus eigener Handlung ein.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-pathway-card" data-path="b" id="fristen-pathway-b-cta">
|
||||
<span className="fristen-pathway-card-icon" aria-hidden="true">📅</span>
|
||||
<span className="fristen-pathway-card-title" data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
<span className="fristen-pathway-card-desc" data-i18n="deadlines.pathway.b.desc">
|
||||
Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.
|
||||
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📥</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
|
||||
Etwas ist passiert
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-168 — third card: discoverable browse-/learn-mode
|
||||
entry. Drops directly into Pathway A (Verfahrensablauf
|
||||
wizard) with no save flow — mirrors the existing ad-hoc
|
||||
explore behaviour: timeline renders, save CTA stays
|
||||
disabled because there's no save intent. */}
|
||||
<button type="button" className="fristen-step2-card" data-action="browse" id="fristen-step2-browse">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📖</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.browse.title">
|
||||
Verfahrensablauf einsehen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.browse.desc">
|
||||
Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-pathway-fork-shortcut">
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
oder direkt zu einer Frist springen:
|
||||
</div>
|
||||
@@ -170,8 +260,62 @@ export function renderFristenrechner(): string {
|
||||
fristen-b1-cascade hosts the breadcrumb / question / button row.
|
||||
fristen-b1-results hosts the narrowing concept-card list,
|
||||
populated by runB1Search() in fristenrechner.ts. The cards
|
||||
reuse renderConceptCard() (B2's card shape). */}
|
||||
reuse renderConceptCard() (B2's card shape).
|
||||
|
||||
m/paliad#15 follow-up: the inbox-channel chip lives at the
|
||||
top of THIS panel (not page-level) — m's call: "inside the
|
||||
decision tree because it helps us to determine what to do
|
||||
next". The chip narrows the cascade entry-points + B2 fine
|
||||
forum filter; Pathway A's Verlauf doesn't see it. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
{/* Slice 3c — perspective chip strip. Klägerseite vs
|
||||
Beklagtenseite hides cascade leaves whose party tag
|
||||
contradicts the user's side. "Beide" / no chip
|
||||
leaves the cascade unfiltered. */}
|
||||
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
|
||||
data-i18n-title="deadlines.perspective.claimant.title" title="Klägerseite (Proactive)">
|
||||
<span data-i18n="deadlines.perspective.claimant.short">Kläger</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
|
||||
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
|
||||
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
|
||||
data-i18n-title="deadlines.inbox.cms.title" title="UPC — über CMS">
|
||||
CMS
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
|
||||
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren — über beA">
|
||||
beA
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
|
||||
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren — Postzustellung">
|
||||
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
|
||||
<span data-i18n="deadlines.inbox.all">Alle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
@@ -215,6 +359,54 @@ export function renderFristenrechner(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3a — outgoing-intent chooser. Reached when the user
|
||||
picks "Etwas einreichen" on Step 2. Three options per
|
||||
m's 2026-05-08 18:09 spec: File (drives the Pathway A
|
||||
wizard), Draft (future drafting surface; v1
|
||||
placeholder), Enter (routes to the existing manual-
|
||||
create form). */}
|
||||
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">✏️</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.heading">Was möchten Sie einreichen?</span>
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📝</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
|
||||
Schriftsatz einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
|
||||
Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
|
||||
data-i18n-title="deadlines.step3a.soon">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">🖉</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
|
||||
Schriftsatz entwerfen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
|
||||
Vorbereitung — später mit Drafting-Surface verknüpft.
|
||||
</span>
|
||||
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">💾</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
|
||||
Frist manuell erfassen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
|
||||
Direkt eintragen — bereits bekanntes Datum / bekannter Typ.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway A container — wraps the existing wizard.
|
||||
Hidden until ?path=a. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
|
||||
@@ -238,33 +430,47 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group">
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
|
||||
a choice is made; this summary line replaces the four
|
||||
group blocks with a one-line "Selected: X [Reselect]"
|
||||
affordance. JS toggles `.proceeding-summary` visibility
|
||||
in lockstep with `.proceeding-group` blocks. */}
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
@@ -329,12 +535,12 @@ export function renderFristenrechner(): string {
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" checked />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -432,6 +638,7 @@ export function renderFristenrechner(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/fristenrechner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -259,6 +260,7 @@ export function renderGebuehrentabellen(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/gebuehrentabellen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -130,6 +131,7 @@ export function renderGlossary(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/glossary.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,7 +19,10 @@ export type I18nKey =
|
||||
| "admin.approval_policies.bulk.modal.title"
|
||||
| "admin.approval_policies.bulk.modal.writes_label"
|
||||
| "admin.approval_policies.bulk.no_descendants"
|
||||
| "admin.approval_policies.cell.clear"
|
||||
| "admin.approval_policies.cell.clear.title"
|
||||
| "admin.approval_policies.cell.error_msg"
|
||||
| "admin.approval_policies.cell.requires"
|
||||
| "admin.approval_policies.cell.saved_msg"
|
||||
| "admin.approval_policies.entity.appointment"
|
||||
| "admin.approval_policies.entity.deadline"
|
||||
@@ -44,6 +47,7 @@ export type I18nKey =
|
||||
| "admin.approval_policies.section.units"
|
||||
| "admin.approval_policies.section.units.hint"
|
||||
| "admin.approval_policies.source.ancestor"
|
||||
| "admin.approval_policies.source.no_approval"
|
||||
| "admin.approval_policies.source.project"
|
||||
| "admin.approval_policies.source.unit_default"
|
||||
| "admin.approval_policies.subtitle"
|
||||
@@ -212,9 +216,12 @@ export type I18nKey =
|
||||
| "admin.paliadin.col.classifier"
|
||||
| "admin.paliadin.col.count"
|
||||
| "admin.paliadin.col.duration"
|
||||
| "admin.paliadin.col.origin"
|
||||
| "admin.paliadin.col.prompt"
|
||||
| "admin.paliadin.col.response"
|
||||
| "admin.paliadin.col.started"
|
||||
| "admin.paliadin.col.tools"
|
||||
| "admin.paliadin.col.user"
|
||||
| "admin.paliadin.daily_heading"
|
||||
| "admin.paliadin.heading"
|
||||
| "admin.paliadin.last7"
|
||||
@@ -406,6 +413,9 @@ export type I18nKey =
|
||||
| "approvals.action.approve"
|
||||
| "approvals.action.reject"
|
||||
| "approvals.action.revoke"
|
||||
| "approvals.agent.byline"
|
||||
| "approvals.agent.label"
|
||||
| "approvals.agent.suggestion_pending"
|
||||
| "approvals.decided_by"
|
||||
| "approvals.decision_kind.admin_override"
|
||||
| "approvals.decision_kind.derived_peer"
|
||||
@@ -416,6 +426,7 @@ export type I18nKey =
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
| "approvals.entity.deadline"
|
||||
| "approvals.error.awaiting_approval"
|
||||
| "approvals.error.concurrent_pending"
|
||||
| "approvals.error.no_qualified_approver"
|
||||
| "approvals.error.not_authorized"
|
||||
@@ -427,6 +438,7 @@ export type I18nKey =
|
||||
| "approvals.lifecycle.delete"
|
||||
| "approvals.lifecycle.update"
|
||||
| "approvals.note.placeholder"
|
||||
| "approvals.pending.badge"
|
||||
| "approvals.pending_complete.label"
|
||||
| "approvals.pending_create.label"
|
||||
| "approvals.pending_delete.label"
|
||||
@@ -454,6 +466,9 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.error"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -637,6 +652,7 @@ export type I18nKey =
|
||||
| "dashboard.action.short.fristen_imported"
|
||||
| "dashboard.action.short.note_created"
|
||||
| "dashboard.action.short.notiz_created"
|
||||
| "dashboard.action.short.our_side_changed"
|
||||
| "dashboard.action.short.partei_added"
|
||||
| "dashboard.action.short.partei_removed"
|
||||
| "dashboard.action.short.project_archived"
|
||||
@@ -655,6 +671,9 @@ export type I18nKey =
|
||||
| "dashboard.activity.event"
|
||||
| "dashboard.activity.heading"
|
||||
| "dashboard.activity.system"
|
||||
| "dashboard.agenda.empty"
|
||||
| "dashboard.agenda.full_link"
|
||||
| "dashboard.agenda.heading"
|
||||
| "dashboard.appointment_summary.heading"
|
||||
| "dashboard.appointments.empty"
|
||||
| "dashboard.appointments.heading"
|
||||
@@ -666,6 +685,8 @@ export type I18nKey =
|
||||
| "dashboard.matters.heading"
|
||||
| "dashboard.matters.total"
|
||||
| "dashboard.onboarding"
|
||||
| "dashboard.section.collapse"
|
||||
| "dashboard.section.expand"
|
||||
| "dashboard.summary.completed"
|
||||
| "dashboard.summary.heading"
|
||||
| "dashboard.summary.later"
|
||||
@@ -719,6 +740,7 @@ export type I18nKey =
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
| "deadlines.complete.action"
|
||||
| "deadlines.court.indirect"
|
||||
| "deadlines.court.label"
|
||||
| "deadlines.court.set"
|
||||
| "deadlines.date.edit.hint"
|
||||
@@ -798,7 +820,11 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
@@ -830,6 +856,12 @@ export type I18nKey =
|
||||
| "deadlines.flag.rev_cci"
|
||||
| "deadlines.form.approval_hint"
|
||||
| "deadlines.heading"
|
||||
| "deadlines.inbox.all"
|
||||
| "deadlines.inbox.bea.title"
|
||||
| "deadlines.inbox.cms.title"
|
||||
| "deadlines.inbox.label"
|
||||
| "deadlines.inbox.posteingang"
|
||||
| "deadlines.inbox.posteingang.title"
|
||||
| "deadlines.kalender.empty"
|
||||
| "deadlines.kalender.heading"
|
||||
| "deadlines.kalender.list"
|
||||
@@ -849,6 +881,7 @@ export type I18nKey =
|
||||
| "deadlines.neu.submit"
|
||||
| "deadlines.neu.subtitle"
|
||||
| "deadlines.neu.title"
|
||||
| "deadlines.optional.badge"
|
||||
| "deadlines.party.both"
|
||||
| "deadlines.party.both.label"
|
||||
| "deadlines.party.claimant"
|
||||
@@ -869,13 +902,22 @@ export type I18nKey =
|
||||
| "deadlines.pathway.fork.heading"
|
||||
| "deadlines.pathway.shortcut.label"
|
||||
| "deadlines.perspective.appeal_filed_by.label"
|
||||
| "deadlines.perspective.both.short"
|
||||
| "deadlines.perspective.claimant"
|
||||
| "deadlines.perspective.claimant.short"
|
||||
| "deadlines.perspective.claimant.title"
|
||||
| "deadlines.perspective.defendant"
|
||||
| "deadlines.perspective.defendant.short"
|
||||
| "deadlines.perspective.defendant.title"
|
||||
| "deadlines.perspective.label"
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
| "deadlines.save.cta"
|
||||
| "deadlines.save.cta.adhoc.hint"
|
||||
| "deadlines.save.error"
|
||||
| "deadlines.save.modal.akte"
|
||||
| "deadlines.save.modal.akte.choose"
|
||||
@@ -914,8 +956,37 @@ export type I18nKey =
|
||||
| "deadlines.status.pending"
|
||||
| "deadlines.status.waived"
|
||||
| "deadlines.step1"
|
||||
| "deadlines.step1.adhoc.de"
|
||||
| "deadlines.step1.adhoc.dpma"
|
||||
| "deadlines.step1.adhoc.epa"
|
||||
| "deadlines.step1.adhoc.upc"
|
||||
| "deadlines.step1.divider.adhoc"
|
||||
| "deadlines.step1.divider.new"
|
||||
| "deadlines.step1.heading"
|
||||
| "deadlines.step1.new.cta"
|
||||
| "deadlines.step1.reselect"
|
||||
| "deadlines.step1.search.empty"
|
||||
| "deadlines.step1.search.placeholder"
|
||||
| "deadlines.step1.selected"
|
||||
| "deadlines.step1.summary.adhoc.suffix"
|
||||
| "deadlines.step2"
|
||||
| "deadlines.step2.browse.desc"
|
||||
| "deadlines.step2.browse.title"
|
||||
| "deadlines.step2.file.desc"
|
||||
| "deadlines.step2.file.title"
|
||||
| "deadlines.step2.happened.desc"
|
||||
| "deadlines.step2.happened.title"
|
||||
| "deadlines.step2.heading"
|
||||
| "deadlines.step3"
|
||||
| "deadlines.step3a.back"
|
||||
| "deadlines.step3a.draft.desc"
|
||||
| "deadlines.step3a.draft.title"
|
||||
| "deadlines.step3a.enter.desc"
|
||||
| "deadlines.step3a.enter.title"
|
||||
| "deadlines.step3a.file.desc"
|
||||
| "deadlines.step3a.file.title"
|
||||
| "deadlines.step3a.heading"
|
||||
| "deadlines.step3a.soon"
|
||||
| "deadlines.subtitle"
|
||||
| "deadlines.summary.completed"
|
||||
| "deadlines.summary.later"
|
||||
@@ -1045,6 +1116,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
| "event.title.project_created"
|
||||
| "event.title.project_reparented"
|
||||
@@ -1379,13 +1451,11 @@ export type I18nKey =
|
||||
| "nav.gerichte"
|
||||
| "nav.glossar"
|
||||
| "nav.group.admin"
|
||||
| "nav.group.arbeit"
|
||||
| "nav.group.ansichten"
|
||||
| "nav.group.einstellungen"
|
||||
| "nav.group.ressourcen"
|
||||
| "nav.group.uebersicht"
|
||||
| "nav.group.user_views"
|
||||
| "nav.group.werkzeuge"
|
||||
| "nav.group.wissen"
|
||||
| "nav.home"
|
||||
| "nav.inbox"
|
||||
| "nav.kostenrechner"
|
||||
@@ -1398,6 +1468,7 @@ export type I18nKey =
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -1477,6 +1548,8 @@ export type I18nKey =
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
| "paliadin.send"
|
||||
| "paliadin.starter.concept"
|
||||
@@ -1485,6 +1558,17 @@ export type I18nKey =
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
| "paliadin.widget.empty"
|
||||
| "paliadin.widget.fullscreen"
|
||||
| "paliadin.widget.input.label"
|
||||
| "paliadin.widget.input.placeholder"
|
||||
| "paliadin.widget.reset"
|
||||
| "paliadin.widget.reset.confirm"
|
||||
| "paliadin.widget.send"
|
||||
| "paliadin.widget.title"
|
||||
| "paliadin.widget.trigger"
|
||||
| "partner_unit.heading"
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
@@ -1611,6 +1695,78 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
| "projects.detail.smarttimeline.add.choice.counterclaim"
|
||||
| "projects.detail.smarttimeline.add.choice.deadline"
|
||||
| "projects.detail.smarttimeline.add.choice.disabled"
|
||||
| "projects.detail.smarttimeline.add.choice.milestone"
|
||||
| "projects.detail.smarttimeline.add.cta"
|
||||
| "projects.detail.smarttimeline.add.modal.title"
|
||||
| "projects.detail.smarttimeline.add.submit"
|
||||
| "projects.detail.smarttimeline.anchor.cancel"
|
||||
| "projects.detail.smarttimeline.anchor.error"
|
||||
| "projects.detail.smarttimeline.anchor.invalid_date"
|
||||
| "projects.detail.smarttimeline.anchor.save"
|
||||
| "projects.detail.smarttimeline.anchor.saved"
|
||||
| "projects.detail.smarttimeline.anchor.saving"
|
||||
| "projects.detail.smarttimeline.anchor.set"
|
||||
| "projects.detail.smarttimeline.audit.toggle.hide"
|
||||
| "projects.detail.smarttimeline.audit.toggle.show"
|
||||
| "projects.detail.smarttimeline.client.matter_list.empty"
|
||||
| "projects.detail.smarttimeline.client.matter_list.heading"
|
||||
| "projects.detail.smarttimeline.client.matter_list.hint"
|
||||
| "projects.detail.smarttimeline.client.toggle.lanes"
|
||||
| "projects.detail.smarttimeline.client.toggle.matter_list"
|
||||
| "projects.detail.smarttimeline.counterclaim.case_number"
|
||||
| "projects.detail.smarttimeline.counterclaim.flip_hint"
|
||||
| "projects.detail.smarttimeline.counterclaim.flip_override"
|
||||
| "projects.detail.smarttimeline.counterclaim.procedure"
|
||||
| "projects.detail.smarttimeline.counterclaim.saving"
|
||||
| "projects.detail.smarttimeline.counterclaim.submit"
|
||||
| "projects.detail.smarttimeline.counterclaim.title"
|
||||
| "projects.detail.smarttimeline.depends_on.date_open"
|
||||
| "projects.detail.smarttimeline.depends_on.hide_path"
|
||||
| "projects.detail.smarttimeline.depends_on.path_hint"
|
||||
| "projects.detail.smarttimeline.depends_on.prefix"
|
||||
| "projects.detail.smarttimeline.depends_on.show_path"
|
||||
| "projects.detail.smarttimeline.empty"
|
||||
| "projects.detail.smarttimeline.error.generic"
|
||||
| "projects.detail.smarttimeline.error.title_required"
|
||||
| "projects.detail.smarttimeline.kind.appointment"
|
||||
| "projects.detail.smarttimeline.kind.deadline"
|
||||
| "projects.detail.smarttimeline.kind.milestone"
|
||||
| "projects.detail.smarttimeline.kind.projected"
|
||||
| "projects.detail.smarttimeline.lane.empty"
|
||||
| "projects.detail.smarttimeline.lane.filter.all"
|
||||
| "projects.detail.smarttimeline.lane.filter.label"
|
||||
| "projects.detail.smarttimeline.lookahead.less"
|
||||
| "projects.detail.smarttimeline.lookahead.more"
|
||||
| "projects.detail.smarttimeline.milestone.bubble_up"
|
||||
| "projects.detail.smarttimeline.milestone.bubble_up_hint"
|
||||
| "projects.detail.smarttimeline.milestone.date"
|
||||
| "projects.detail.smarttimeline.milestone.description"
|
||||
| "projects.detail.smarttimeline.milestone.title"
|
||||
| "projects.detail.smarttimeline.section.future"
|
||||
| "projects.detail.smarttimeline.section.past"
|
||||
| "projects.detail.smarttimeline.section.undated"
|
||||
| "projects.detail.smarttimeline.status.court_set"
|
||||
| "projects.detail.smarttimeline.status.done"
|
||||
| "projects.detail.smarttimeline.status.off_script"
|
||||
| "projects.detail.smarttimeline.status.open"
|
||||
| "projects.detail.smarttimeline.status.overdue"
|
||||
| "projects.detail.smarttimeline.status.predicted"
|
||||
| "projects.detail.smarttimeline.status.predicted_overdue"
|
||||
| "projects.detail.smarttimeline.today"
|
||||
| "projects.detail.smarttimeline.track.both"
|
||||
| "projects.detail.smarttimeline.track.header.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.header.parent"
|
||||
| "projects.detail.smarttimeline.track.header.parent_context"
|
||||
| "projects.detail.smarttimeline.track.label"
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
@@ -1672,6 +1828,14 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
@@ -1799,6 +1963,8 @@ export type I18nKey =
|
||||
| "team.broadcast.error.no_recipients"
|
||||
| "team.broadcast.error.subject_required"
|
||||
| "team.broadcast.error.too_many"
|
||||
| "team.broadcast.mailto.label"
|
||||
| "team.broadcast.mailto.tooltip"
|
||||
| "team.broadcast.markdown_hint"
|
||||
| "team.broadcast.placeholders_hint"
|
||||
| "team.broadcast.recipients"
|
||||
@@ -1853,6 +2019,78 @@ export type I18nKey =
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa"
|
||||
| "views.action.edit"
|
||||
| "views.bar.action.reset"
|
||||
| "views.bar.action.save_as_view"
|
||||
| "views.bar.appointment_type.consultation"
|
||||
| "views.bar.appointment_type.deadline_hearing"
|
||||
| "views.bar.appointment_type.hearing"
|
||||
| "views.bar.appointment_type.meeting"
|
||||
| "views.bar.approval_entity.appointment"
|
||||
| "views.bar.approval_entity.deadline"
|
||||
| "views.bar.approval_role.any_visible"
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
| "views.bar.common.all"
|
||||
| "views.bar.deadline_status.completed"
|
||||
| "views.bar.deadline_status.pending"
|
||||
| "views.bar.density.comfortable"
|
||||
| "views.bar.density.compact"
|
||||
| "views.bar.label.appointment_type"
|
||||
| "views.bar.label.approval_entity"
|
||||
| "views.bar.label.approval_role"
|
||||
| "views.bar.label.approval_status"
|
||||
| "views.bar.label.deadline_status"
|
||||
| "views.bar.label.density"
|
||||
| "views.bar.label.personal"
|
||||
| "views.bar.label.project_event_kind"
|
||||
| "views.bar.label.shape"
|
||||
| "views.bar.label.sort"
|
||||
| "views.bar.label.time"
|
||||
| "views.bar.label.timeline_status"
|
||||
| "views.bar.label.timeline_track"
|
||||
| "views.bar.personal.on"
|
||||
| "views.bar.save.cancel"
|
||||
| "views.bar.save.confirm"
|
||||
| "views.bar.save.error.name_required"
|
||||
| "views.bar.save.error.network"
|
||||
| "views.bar.save.error.slug_format"
|
||||
| "views.bar.save.error.slug_taken"
|
||||
| "views.bar.save.field.name"
|
||||
| "views.bar.save.field.show_count"
|
||||
| "views.bar.save.field.slug"
|
||||
| "views.bar.save.field.slug_hint"
|
||||
| "views.bar.save.heading"
|
||||
| "views.bar.shape.calendar"
|
||||
| "views.bar.shape.cards"
|
||||
| "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"
|
||||
| "views.bar.timeline_status.macro.past"
|
||||
| "views.bar.timeline_status.off_script"
|
||||
| "views.bar.timeline_status.open"
|
||||
| "views.bar.timeline_status.overdue"
|
||||
| "views.bar.timeline_status.predicted"
|
||||
| "views.bar.timeline_status.predicted_overdue"
|
||||
| "views.bar.timeline_track.counterclaim"
|
||||
| "views.bar.timeline_track.off_script"
|
||||
| "views.bar.timeline_track.parent"
|
||||
| "views.calendar.mobile_fallback"
|
||||
| "views.col.actor"
|
||||
| "views.col.appointment_type"
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Approval inbox page (t-paliad-138). Two-tab UI:
|
||||
// - "Zur Genehmigung": requests where the caller is qualified to approve
|
||||
// - "Meine Anfragen": requests submitted by the caller
|
||||
// /inbox — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
|
||||
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
|
||||
// hydration and re-renders.
|
||||
// The page is a thin shell around two host divs: one for the
|
||||
// <FilterBar> primitive and one for the result list. The bar takes
|
||||
// care of every axis (approval_viewer_role chip cluster replaces the
|
||||
// two-tab UI; status / entity_type / time chips are new affordances).
|
||||
// Rows render via shape-list.ts with row_action="approve" — the
|
||||
// inbox-specific markup that produces the diff + approve/reject/revoke
|
||||
// buttons. Action handlers are wired in client/inbox.ts.
|
||||
//
|
||||
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
|
||||
// to ?a_role=self_requested before the bar mounts so old bookmarks
|
||||
// (sidebar bell, Genehmigungen email links) keep landing on the
|
||||
// expected sub-view.
|
||||
|
||||
export function renderInbox(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -37,18 +45,11 @@ export function renderInbox(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="agenda-controls">
|
||||
<div className="agenda-filter-group" role="group">
|
||||
<div className="agenda-chip-row" id="inbox-tab-row">
|
||||
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
|
||||
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="inbox-filter-bar" />
|
||||
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</div>
|
||||
<div className="entity-empty" id="inbox-empty" style="display:none" />
|
||||
<ul className="inbox-list" id="inbox-list" />
|
||||
<div id="inbox-results" />
|
||||
|
||||
{/* t-paliad-154 — admin-only nudge surfaced when:
|
||||
- the user is global_admin
|
||||
@@ -67,6 +68,7 @@ export function renderInbox(): string {
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
</main>
|
||||
|
||||
<script src="/assets/inbox.js" defer />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -238,6 +239,7 @@ export function renderKostenrechner(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/kostenrechner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -127,6 +128,7 @@ export function renderLinks(): string {
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/links.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -42,6 +43,7 @@ export function renderNotFound(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/notfound.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -73,7 +74,7 @@ export function renderProjectsDetail(): string {
|
||||
<nav className="entity-tabs" id="project-tabs">
|
||||
<a className="entity-tab" data-tab="history" href="#" data-i18n="projects.detail.tab.verlauf">Verlauf</a>
|
||||
<a className="entity-tab" data-tab="team" href="#" data-i18n="projects.detail.tab.team">Team</a>
|
||||
<a className="entity-tab" data-tab="children" href="#" data-i18n="projects.detail.tab.kinder">Untergeordnet</a>
|
||||
<a className="entity-tab" data-tab="children" href="#" data-i18n="projects.detail.tab.kinder">Projektbaum</a>
|
||||
<a className="entity-tab" data-tab="parties" href="#" data-i18n="projects.detail.tab.parteien">Parteien</a>
|
||||
<a className="entity-tab" data-tab="deadlines" href="#" data-i18n="projects.detail.tab.fristen">Fristen</a>
|
||||
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
|
||||
@@ -81,21 +82,132 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) */}
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
The legacy <ul.entity-events> + Mehr-laden controls are
|
||||
replaced by the vertical timeline (rendered by
|
||||
client/views/shape-timeline.ts). The bar from t-paliad-170
|
||||
keeps driving filter state via its customRunner. */}
|
||||
<section className="entity-tab-panel" id="tab-history">
|
||||
<div className="party-controls">
|
||||
<div className="smart-timeline-controls">
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
<ul className="entity-events" id="project-events-list" />
|
||||
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
</p>
|
||||
<div className="entity-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
|
||||
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projects.detail.verlauf.loadMore">
|
||||
Mehr laden
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
|
||||
Audit-Log anzeigen
|
||||
</button>
|
||||
{/* Slice 4 — Client-level Timeline-Ansicht toggle.
|
||||
Hidden by default (display:none); the client TS
|
||||
flips it visible only when project.type === 'client'. */}
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-client-toggle" aria-pressed="false" style="display:none" data-i18n="projects.detail.smarttimeline.client.toggle.lanes">
|
||||
Timeline-Ansicht
|
||||
</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
|
||||
+ Eintrag
|
||||
</button>
|
||||
</div>
|
||||
<div id="project-events-filter-bar" />
|
||||
<div id="project-smart-timeline" className="smart-timeline" />
|
||||
|
||||
{/* "Eigener Meilenstein" modal. Hidden by default; opened
|
||||
by the "+ Eintrag" CTA above. The other modal options
|
||||
route to existing flows (see client wiring). */}
|
||||
<div id="smart-timeline-add-modal" className="smart-timeline-modal" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="smart-timeline-modal-card">
|
||||
<h3 data-i18n="projects.detail.smarttimeline.add.modal.title">
|
||||
Neuer Eintrag im SmartTimeline
|
||||
</h3>
|
||||
|
||||
<div className="smart-timeline-add-choices">
|
||||
<a id="smart-timeline-add-deadline" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.deadline">
|
||||
Frist anlegen
|
||||
</a>
|
||||
<a id="smart-timeline-add-appointment" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.appointment">
|
||||
Termin anlegen
|
||||
</a>
|
||||
<button type="button" id="smart-timeline-add-counterclaim" className="smart-timeline-add-choice" data-i18n="projects.detail.smarttimeline.add.choice.counterclaim">
|
||||
Widerklage (CCR)
|
||||
</button>
|
||||
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.amend">
|
||||
Antrag auf Änderung (R.30)
|
||||
</button>
|
||||
<button type="button" id="smart-timeline-add-milestone" className="smart-timeline-add-choice smart-timeline-add-choice--primary" data-i18n="projects.detail.smarttimeline.add.choice.milestone">
|
||||
Eigener Meilenstein
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="smart-timeline-milestone-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-title" data-i18n="projects.detail.smarttimeline.milestone.title">Titel</label>
|
||||
<input type="text" id="smart-timeline-milestone-title" required maxLength={200} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-date" data-i18n="projects.detail.smarttimeline.milestone.date">Datum (optional)</label>
|
||||
<input type="date" id="smart-timeline-milestone-date" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
|
||||
<textarea id="smart-timeline-milestone-desc" rows={3} />
|
||||
</div>
|
||||
{/* Slice 4 — bubble-up override (t-paliad-175 §7.2 Q5). */}
|
||||
<div className="form-field form-field--checkbox">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="smart-timeline-milestone-bubble-up" />
|
||||
<span data-i18n="projects.detail.smarttimeline.milestone.bubble_up">In übergeordneten Akten anzeigen</span>
|
||||
</label>
|
||||
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.milestone.bubble_up_hint">
|
||||
Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.
|
||||
</small>
|
||||
</div>
|
||||
<div id="smart-timeline-milestone-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.add.submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* CCR sub-project create form (Slice 3, t-paliad-174). The
|
||||
proceeding-type select is populated by the client at
|
||||
runtime; our_side defaults to inverted with a
|
||||
"Stimmt nicht?" override toggle for the R.49.2.b
|
||||
edge case. Title is auto-suggested server-side and
|
||||
can be overridden inline. */}
|
||||
<form id="smart-timeline-counterclaim-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
|
||||
<select id="smart-timeline-counterclaim-procedure">
|
||||
{/* Options injected from client; defaults to UPC_REV */}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-title" data-i18n="projects.detail.smarttimeline.counterclaim.title">Titel (optional)</label>
|
||||
<input type="text" id="smart-timeline-counterclaim-title" maxLength={200} placeholder="Auto-Vorschlag aus Patentnummer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-case-number" data-i18n="projects.detail.smarttimeline.counterclaim.case_number">CCR-Aktenzeichen (optional)</label>
|
||||
<input type="text" id="smart-timeline-counterclaim-case-number" maxLength={200} placeholder="ACT_xxx_2026" />
|
||||
</div>
|
||||
<div className="form-field form-field--checkbox">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="smart-timeline-counterclaim-flip-toggle" />
|
||||
<span data-i18n="projects.detail.smarttimeline.counterclaim.flip_override">Unsere Seite NICHT umkehren (Stimmt nicht?)</span>
|
||||
</label>
|
||||
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.counterclaim.flip_hint">
|
||||
Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.
|
||||
</small>
|
||||
</div>
|
||||
<div id="smart-timeline-counterclaim-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-counterclaim-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.counterclaim.submit">Widerklage anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="smart-timeline-modal-close-row">
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-modal-close" data-i18n="projects.detail.smarttimeline.add.cancel">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -250,14 +362,14 @@ export function renderProjectsDetail(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Children (Untergeordnet) */}
|
||||
{/* Project Tree (Projektbaum) — full visible hierarchy with current node highlighted */}
|
||||
<section className="entity-tab-panel" id="tab-children" style="display:none">
|
||||
<div className="party-controls">
|
||||
<a id="child-add-link" className="btn-primary btn-cta-lime btn-small" href="/projects/new" data-i18n="projects.detail.kinder.add">
|
||||
Untervorhaben anlegen
|
||||
</a>
|
||||
</div>
|
||||
<ul id="children-list" className="projekt-children-list" />
|
||||
<div id="project-tree" className="projekt-tree" />
|
||||
<p className="entity-events-empty" id="children-empty" style="display:none" data-i18n="projects.detail.kinder.empty">
|
||||
Keine untergeordneten Projekte.
|
||||
</p>
|
||||
@@ -506,6 +618,7 @@ export function renderProjectsDetail(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/projects-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -52,6 +53,7 @@ export function renderProjectsNew(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/projects-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -179,6 +180,7 @@ export function renderProjects(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/projects.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -346,6 +347,7 @@ export function renderSettings(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -84,6 +85,7 @@ export function renderTeam(): string {
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/team.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -144,6 +145,7 @@ export function renderViewsEditor(): string {
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
</main>
|
||||
|
||||
<script src="/assets/views-editor.js" defer />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
@@ -96,6 +97,7 @@ export function renderViews(): string {
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
</main>
|
||||
|
||||
<script src="/assets/views.js" defer />
|
||||
|
||||
14
internal/db/migrations/063_frist_verpasst_upc.down.sql
Normal file
14
internal/db/migrations/063_frist_verpasst_upc.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Reverse t-paliad-157 / m/paliad#14 section C: removes the UPC leaf under
|
||||
-- "Frist verpasst" along with its junction row and the cross-cutting
|
||||
-- trigger_event for UPC R.320 Wiedereinsetzung.
|
||||
|
||||
DELETE FROM paliad.event_category_concepts
|
||||
WHERE event_category_id = (SELECT id FROM paliad.event_categories WHERE slug = 'frist-verpasst.upc')
|
||||
AND concept_id = (SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung');
|
||||
|
||||
DELETE FROM paliad.event_categories
|
||||
WHERE slug = 'frist-verpasst.upc';
|
||||
|
||||
DELETE FROM paliad.trigger_events
|
||||
WHERE id = 207
|
||||
AND code = 'wegfall_hindernisses_upc';
|
||||
80
internal/db/migrations/063_frist_verpasst_upc.up.sql
Normal file
80
internal/db/migrations/063_frist_verpasst_upc.up.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- t-paliad-157 cascade gap (m/paliad#14 section C):
|
||||
-- The "Frist verpasst" branch surfaced national-DE (PatG / ZPO), EPA and
|
||||
-- DPMA Wiedereinsetzung paths but had no UPC option. UPC R.320 RoP grants
|
||||
-- re-establishment of rights with the same shape as the national
|
||||
-- equivalents (2 months from removal of obstacle, 12-month outer limit
|
||||
-- from the missed deadline).
|
||||
--
|
||||
-- Three additions, all idempotent:
|
||||
-- 1. New trigger_event "Wegfall des Hindernisses (UPC R.320)" tied to
|
||||
-- the existing wiedereinsetzung concept (so the concept card picks
|
||||
-- up a UPC pill alongside DE / EPA / DPMA).
|
||||
-- 2. New event_categories leaf frist-verpasst.upc, slotted at
|
||||
-- sort_order 50 so UPC reads first under "Frist verpasst" (national
|
||||
-- & supranational siblings remain at 100/200/300/400).
|
||||
-- 3. Junction row linking the new leaf to the wiedereinsetzung concept.
|
||||
-- NULL proceeding_type_code mirrors the existing siblings — the
|
||||
-- Wiedereinsetzung pills come from cross-cutting trigger_events
|
||||
-- that bypass the forum filter by design (search service docs);
|
||||
-- per-leaf narrowing is part of the IA-reframe issue (#16), not
|
||||
-- this fix.
|
||||
--
|
||||
-- The materialised view paliad.deadline_search refreshes on the next
|
||||
-- server boot via services.RefreshSearchView (cmd/server/main.go:94),
|
||||
-- so the new trigger event becomes searchable as soon as the deploy
|
||||
-- restarts the process. No matview refresh from the migration itself.
|
||||
|
||||
INSERT INTO paliad.trigger_events
|
||||
(id, code, name, name_de, description, is_active, concept_id)
|
||||
VALUES
|
||||
(207,
|
||||
'wegfall_hindernisses_upc',
|
||||
'Removal of obstacle (UPC R.320)',
|
||||
'Wegfall des Hindernisses (UPC R.320)',
|
||||
'Der Tag, an dem das Hindernis weggefallen ist, das die Einhaltung einer UPC-Frist verhinderte (R.320 RoP). Antrag auf Wiedereinsetzung binnen zwei Monaten ab Wegfall, höchstens zwölf Monate ab Ablauf der versäumten Frist.',
|
||||
true,
|
||||
'wiedereinsetzung')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
code = EXCLUDED.code,
|
||||
name = EXCLUDED.name,
|
||||
name_de = EXCLUDED.name_de,
|
||||
description = EXCLUDED.description,
|
||||
is_active = EXCLUDED.is_active,
|
||||
concept_id = EXCLUDED.concept_id;
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, description_de, description_en,
|
||||
step_question_de, step_question_en, sort_order, is_leaf, is_active)
|
||||
VALUES
|
||||
('frist-verpasst.upc',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'frist-verpasst'),
|
||||
'UPC (R.320 RoP)',
|
||||
'UPC (R.320 RoP)',
|
||||
'Wiedereinsetzung in den vorigen Stand vor dem Einheitlichen Patentgericht (R.320 RoP). Zwei Monate ab Wegfall des Hindernisses, höchstens zwölf Monate ab Fristablauf.',
|
||||
'Re-establishment of rights before the Unified Patent Court (R.320 RoP). Two months from removal of the obstacle, capped at twelve months from the missed deadline.',
|
||||
NULL,
|
||||
NULL,
|
||||
50,
|
||||
true,
|
||||
true)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
description_de = EXCLUDED.description_de,
|
||||
description_en = EXCLUDED.description_en,
|
||||
step_question_de = EXCLUDED.step_question_de,
|
||||
step_question_en = EXCLUDED.step_question_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now();
|
||||
|
||||
INSERT INTO paliad.event_category_concepts
|
||||
(event_category_id, concept_id, proceeding_type_code, sort_order)
|
||||
VALUES
|
||||
((SELECT id FROM paliad.event_categories WHERE slug = 'frist-verpasst.upc'),
|
||||
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung'),
|
||||
NULL,
|
||||
100)
|
||||
ON CONFLICT DO NOTHING;
|
||||
5
internal/db/migrations/064_users_forum_pref.down.sql
Normal file
5
internal/db/migrations/064_users_forum_pref.down.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Reverse t-paliad-157 / m/paliad#15: drops the persisted
|
||||
-- Fristenrechner inbox-channel preference column.
|
||||
|
||||
ALTER TABLE paliad.users DROP CONSTRAINT IF EXISTS users_forum_pref_check;
|
||||
ALTER TABLE paliad.users DROP COLUMN IF EXISTS forum_pref;
|
||||
32
internal/db/migrations/064_users_forum_pref.up.sql
Normal file
32
internal/db/migrations/064_users_forum_pref.up.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-157 / m/paliad#15: persisted Fristenrechner inbox-channel
|
||||
-- preference.
|
||||
--
|
||||
-- Stores the user's typical inbox channel (cms = UPC, bea = national-DE,
|
||||
-- posteingang = national-DE — slower channel, same set of forums) so
|
||||
-- /tools/fristenrechner can pre-narrow the proceeding picker without
|
||||
-- re-asking on every visit. The chip on the page persists changes here
|
||||
-- via the existing PATCH /api/me endpoint. URL ?inbox= overrides for
|
||||
-- the current visit so a colleague can share a CMS-narrowed link
|
||||
-- without flipping anyone's saved preference.
|
||||
--
|
||||
-- The 3-value CHECK keeps the schema honest while leaving room to add
|
||||
-- epa / dpma channels later (the Fristenrechner already supports those
|
||||
-- forums via the fine-grained B2 chips; the inbox-channel chip starts
|
||||
-- with the channels m named explicitly in t-paliad-157).
|
||||
--
|
||||
-- NULL = no preference, picker shows everything. Default for existing
|
||||
-- rows.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN forum_pref text;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_forum_pref_check
|
||||
CHECK (forum_pref IS NULL OR forum_pref IN ('cms', 'bea', 'posteingang'));
|
||||
|
||||
COMMENT ON COLUMN paliad.users.forum_pref IS
|
||||
'Persisted Fristenrechner inbox-channel preference (#15). '
|
||||
'cms = UPC; bea = national-DE; posteingang = national-DE (slower '
|
||||
'channel, same forums). NULL = no preference (picker shows '
|
||||
'everything). URL ?inbox= overrides for the current visit. Set via '
|
||||
'PATCH /api/me.';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Reverse t-paliad-157 / m/paliad#15 follow-up: drop the forums column.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.event_categories_forums_gin;
|
||||
|
||||
ALTER TABLE paliad.event_categories
|
||||
DROP CONSTRAINT IF EXISTS event_categories_forums_check;
|
||||
|
||||
ALTER TABLE paliad.event_categories
|
||||
DROP COLUMN IF EXISTS forums;
|
||||
67
internal/db/migrations/065_event_categories_forums.up.sql
Normal file
67
internal/db/migrations/065_event_categories_forums.up.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- t-paliad-157 / m/paliad#15 follow-up: tag event_categories with the
|
||||
-- coarse forums they belong to so the inbox-channel chip can narrow the
|
||||
-- B1 cascade entry-points (the chip already narrows Pathway A's picker
|
||||
-- and B2's fine-bucket forum filter; B1 was the last hold-out).
|
||||
--
|
||||
-- Allowed values: upc | de | epa | dpma. NULL = "neutral" — node stays
|
||||
-- reachable from every inbox setting. Mixed-jurisdiction nodes (top-
|
||||
-- level branches, generic court actions like Ladung / Kostenfestsetzung,
|
||||
-- mündliche Verhandlung sub-states) intentionally stay NULL.
|
||||
--
|
||||
-- Two-step backfill:
|
||||
--
|
||||
-- 1. Regex on slug for nodes that carry the forum token explicitly
|
||||
-- (`upc-`, `.upc`, `-upc`, `.de-`, `-de-`, `.epa-`, `dpma`, …).
|
||||
-- Tokens are bounded by `^` / dot / dash / `$` so e.g. `.dpma`
|
||||
-- doesn't accidentally match the `de` rule.
|
||||
--
|
||||
-- 2. Explicit slug list for stragglers whose name doesn't carry the
|
||||
-- token (BGH / BPatG / Versäumnisurteil / Hinweisbeschluss are
|
||||
-- DE-only; r116-eingaben is EPA-only).
|
||||
--
|
||||
-- Anything still NULL after both passes is intentionally neutral.
|
||||
|
||||
ALTER TABLE paliad.event_categories
|
||||
ADD COLUMN forums text[];
|
||||
|
||||
ALTER TABLE paliad.event_categories
|
||||
ADD CONSTRAINT event_categories_forums_check
|
||||
CHECK (forums IS NULL OR forums <@ ARRAY['upc','de','epa','dpma']::text[]);
|
||||
|
||||
COMMENT ON COLUMN paliad.event_categories.forums IS
|
||||
'Coarse forum tags driving the #15 inbox-channel chip narrowing. '
|
||||
'Allowed: upc, de, epa, dpma. NULL = neutral (visible from every '
|
||||
'inbox setting). Empty array is treated the same as NULL by the '
|
||||
'frontend filter (CHECK constraint allows it for forward '
|
||||
'compatibility but the migration writes NULL where neutral).';
|
||||
|
||||
CREATE INDEX event_categories_forums_gin
|
||||
ON paliad.event_categories USING GIN (forums)
|
||||
WHERE forums IS NOT NULL;
|
||||
|
||||
UPDATE paliad.event_categories SET forums = ARRAY['upc']
|
||||
WHERE forums IS NULL AND slug ~ '(^|[\.-])upc([\.-]|$)';
|
||||
|
||||
UPDATE paliad.event_categories SET forums = ARRAY['de']
|
||||
WHERE forums IS NULL AND slug ~ '(^|[\.-])de([\.-]|$)';
|
||||
|
||||
UPDATE paliad.event_categories SET forums = ARRAY['epa']
|
||||
WHERE forums IS NULL AND slug ~ '(^|[\.-])epa([\.-]|$)';
|
||||
|
||||
UPDATE paliad.event_categories SET forums = ARRAY['dpma']
|
||||
WHERE forums IS NULL AND slug ~ '(^|[\.-])dpma([\.-]|$)';
|
||||
|
||||
UPDATE paliad.event_categories SET forums = ARRAY['de']
|
||||
WHERE forums IS NULL AND slug IN (
|
||||
'beschluss-entscheidung.beschluss-bpatg-beschwerde',
|
||||
'beschluss-entscheidung.versaeumnisurteil',
|
||||
'cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde',
|
||||
'cms-eingang.gericht.endentscheidung.versaeumnisurteil',
|
||||
'cms-eingang.gericht.hinweisbeschluss',
|
||||
'ich-moechte-einreichen.berufung.bgh-rb',
|
||||
'ich-moechte-einreichen.berufung.bpatg-beschwerde'
|
||||
);
|
||||
|
||||
UPDATE paliad.event_categories SET forums = ARRAY['epa']
|
||||
WHERE forums IS NULL
|
||||
AND slug = 'ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben';
|
||||
77
internal/db/migrations/066_approval_policy_split.down.sql
Normal file
77
internal/db/migrations/066_approval_policy_split.down.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Reverse t-paliad-160 M1: drop the new columns + restore the previous
|
||||
-- paliad.approval_policy_effective() shape from migration 062.
|
||||
--
|
||||
-- M1 is additive in code (dual-read), so this down migration restores the
|
||||
-- previous resolver semantics (project row wins outright, MAX(level) over
|
||||
-- ancestors+unit defaults). The required_role column was never dropped
|
||||
-- in M1 so the legacy values are still the source of truth.
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle;
|
||||
IF FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
)
|
||||
SELECT a.required_role, a.src, a.sid
|
||||
FROM (
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
) AS a
|
||||
ORDER BY a.lvl DESC, a.src ASC
|
||||
LIMIT 1;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_min_role_xor_required;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_min_role_check;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP COLUMN IF EXISTS requires_approval,
|
||||
DROP COLUMN IF EXISTS min_role;
|
||||
237
internal/db/migrations/066_approval_policy_split.up.sql
Normal file
237
internal/db/migrations/066_approval_policy_split.up.sql
Normal file
@@ -0,0 +1,237 @@
|
||||
-- t-paliad-160 (M1, slice 1): split approval_policies.required_role into
|
||||
-- two columns — requires_approval (the gate) + min_role (the seniority
|
||||
-- threshold). The legacy required_role='none' sentinel conflated two
|
||||
-- concepts: "approval applies at all" vs "who can approve". This
|
||||
-- migration introduces the split and backfills.
|
||||
--
|
||||
-- M1 = additive + dual-read. New code paths read both old and new columns
|
||||
-- so a rollback to pre-deploy code keeps working. M2 (follow-up
|
||||
-- migration) will drop required_role once everything writes the new
|
||||
-- shape exclusively.
|
||||
--
|
||||
-- Resolver semantics also change with this split: when both a project-
|
||||
-- level row and a partner-unit-default row resolve for the same
|
||||
-- (entity_type, lifecycle_event), most-strict-wins now applies on BOTH
|
||||
-- axes:
|
||||
-- - requires_approval: OR (true if either side says true).
|
||||
-- - min_role: MAX along approval_role_level().
|
||||
-- That update lives in the paliad.approval_policy_effective() rewrite
|
||||
-- in §4 below.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER paliad.approval_policies ADD COLUMN requires_approval + min_role.
|
||||
-- 2. Backfill: required_role='none' → (false, NULL); else → (true, role).
|
||||
-- 3. Constraint: (requires_approval=false) OR (min_role IS NOT NULL).
|
||||
-- 4. Replace paliad.approval_policy_effective() with most-strict-wins
|
||||
-- across the new columns. Returns (requires_approval, min_role,
|
||||
-- source, source_id) — back-compat shim required_role column kept
|
||||
-- in result type so callers reading the old column don't break
|
||||
-- until they cut over.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. New columns. Both nullable in the schema; the constraint in §3
|
||||
-- enforces the relationship instead of a NOT NULL on requires_approval
|
||||
-- (we want Postgres to keep the row out cleanly when min_role is NULL
|
||||
-- and requires_approval = false).
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD COLUMN requires_approval boolean,
|
||||
ADD COLUMN min_role text;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Backfill from required_role.
|
||||
-- 'none' → (false, NULL)
|
||||
-- else (any of partner/of_counsel/associate/senior_pa/pa) → (true, role)
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET requires_approval = false,
|
||||
min_role = NULL
|
||||
WHERE required_role = 'none';
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET requires_approval = true,
|
||||
min_role = required_role
|
||||
WHERE required_role <> 'none';
|
||||
|
||||
-- After backfill every row has a non-NULL requires_approval. Tighten.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ALTER COLUMN requires_approval SET NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. The split-grammar invariant: a row that demands approval must name
|
||||
-- a min_role; a row that does not demand approval has min_role NULL.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_min_role_xor_required CHECK (
|
||||
(requires_approval = false AND min_role IS NULL)
|
||||
OR
|
||||
(requires_approval = true AND min_role IS NOT NULL)
|
||||
);
|
||||
|
||||
-- min_role values mirror the approval ladder. NULL is allowed (the
|
||||
-- requires_approval=false branch); any other value must be on the ladder.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_min_role_check CHECK (
|
||||
min_role IS NULL OR min_role IN (
|
||||
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. paliad.approval_policy_effective — most-strict-wins resolver.
|
||||
--
|
||||
-- Returns at most one row, or zero rows when no policy applies. The result
|
||||
-- shape adds two columns (requires_approval, min_role) while keeping the
|
||||
-- legacy required_role column for back-compat dual-read. Old callers that
|
||||
-- still read required_role keep working; new callers branch on
|
||||
-- requires_approval/min_role.
|
||||
--
|
||||
-- Resolution:
|
||||
-- Step 1 — collect candidates for (project, entity, lifecycle):
|
||||
-- a) project-specific row (project_id = p_project_id).
|
||||
-- b) ancestor rows on the project's ltree path (excluding self).
|
||||
-- c) unit-default rows for partner units attached to this project.
|
||||
-- Step 2 — most-strict-wins over the union:
|
||||
-- requires_approval := bool_or(c.requires_approval) -- true if any says true
|
||||
-- min_role := role with MAX(approval_role_level) among the
|
||||
-- candidates whose requires_approval=true.
|
||||
-- NULL if no candidate demands approval.
|
||||
-- Step 3 — the project-specific row no longer wins outright. The 'none'
|
||||
-- sentinel is gone; suppression is now expressed as an explicit
|
||||
-- requires_approval=false at project level, which loses to any
|
||||
-- ancestor / unit_default with requires_approval=true under
|
||||
-- most-strict-wins. This is intentional: the user-locked semantics
|
||||
-- is "tighten only, never loosen by inheritance" and the project
|
||||
-- row that wants to relax inherited rules has to be authored at the
|
||||
-- ancestor / unit level instead. (See t-paliad-160 §A resolver lock.)
|
||||
--
|
||||
-- Returned columns:
|
||||
-- requires_approval boolean — the gate
|
||||
-- min_role text — the threshold (NULL when gate is off)
|
||||
-- required_role text — back-compat: NULL when gate is off,
|
||||
-- else equals min_role. Old callers that
|
||||
-- read required_role keep working until
|
||||
-- M2 drops the column.
|
||||
-- source text — 'project' | 'ancestor' | 'unit_default'
|
||||
-- (the source of the WINNING min_role; for
|
||||
-- a pure requires_approval=false result,
|
||||
-- the source of the highest-priority
|
||||
-- 'false' row in the order project >
|
||||
-- ancestor > unit_default).
|
||||
-- source_id uuid — project_id or partner_unit_id of source.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
requires_approval boolean,
|
||||
min_role text,
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
project_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'project'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
1 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
2 AS src_priority
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
3 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM project_rows
|
||||
UNION ALL
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
),
|
||||
-- Pick the strictest min_role: highest approval_role_level among the
|
||||
-- requires_approval=true candidates. Tie-break: project > ancestor >
|
||||
-- unit_default for stable attribution.
|
||||
strictest_role AS (
|
||||
SELECT c.min_role,
|
||||
c.src AS source,
|
||||
c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = true
|
||||
AND c.min_role IS NOT NULL
|
||||
ORDER BY paliad.approval_role_level(c.min_role) DESC,
|
||||
c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
-- If nothing demands approval, surface the project row's "no approval"
|
||||
-- if present, else any (false) row with stable tie-break, so attribution
|
||||
-- still works for the UI ("inherited from <unit>").
|
||||
no_approval_attribution AS (
|
||||
SELECT c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = false
|
||||
ORDER BY c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
summary AS (
|
||||
SELECT bool_or(c.requires_approval) AS req
|
||||
FROM candidates c
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.req, false) AS requires_approval,
|
||||
sr.min_role AS min_role,
|
||||
sr.min_role AS required_role,
|
||||
COALESCE(sr.source, na.source) AS source,
|
||||
COALESCE(sr.source_id, na.source_id) AS source_id
|
||||
FROM summary s
|
||||
LEFT JOIN strictest_role sr ON true
|
||||
LEFT JOIN no_approval_attribution na ON true
|
||||
WHERE EXISTS (SELECT 1 FROM candidates); -- zero rows when no policy applies
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
|
||||
'Effective approval policy resolver (t-paliad-160 most-strict-wins). '
|
||||
'Returns requires_approval (OR across candidates), min_role (MAX along '
|
||||
'the role ladder among requires_approval=true candidates), and the '
|
||||
'source attribution. required_role mirrors min_role for back-compat '
|
||||
'dual-read with code that hasn''t cut over yet. Zero rows when no '
|
||||
'policy candidates exist for the (project, entity_type, lifecycle).';
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Reverse t-paliad-160 M2: re-add the required_role column on
|
||||
-- paliad.approval_policies and re-introduce the dual-read function shape
|
||||
-- from migration 064.
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD COLUMN required_role text;
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET required_role = CASE
|
||||
WHEN requires_approval = false OR min_role IS NULL THEN 'none'
|
||||
ELSE min_role
|
||||
END;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ALTER COLUMN required_role SET NOT NULL;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_required_role_check CHECK (
|
||||
required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none')
|
||||
);
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
requires_approval boolean,
|
||||
min_role text,
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
project_rows AS (
|
||||
SELECT ap.requires_approval, ap.min_role,
|
||||
'project'::text AS src, ap.project_id AS sid, 1 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.requires_approval, ap.min_role,
|
||||
'ancestor'::text AS src, ap.project_id AS sid, 2 AS src_priority
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.requires_approval, ap.min_role,
|
||||
'unit_default'::text AS src, ap.partner_unit_id AS sid, 3 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM project_rows UNION ALL
|
||||
SELECT * FROM ancestor_rows UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
),
|
||||
strictest_role AS (
|
||||
SELECT c.min_role, c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = true AND c.min_role IS NOT NULL
|
||||
ORDER BY paliad.approval_role_level(c.min_role) DESC, c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
no_approval_attribution AS (
|
||||
SELECT c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = false
|
||||
ORDER BY c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
summary AS (
|
||||
SELECT bool_or(c.requires_approval) AS req FROM candidates c
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.req, false) AS requires_approval,
|
||||
sr.min_role AS min_role,
|
||||
sr.min_role AS required_role,
|
||||
COALESCE(sr.source, na.source) AS source,
|
||||
COALESCE(sr.source_id, na.source_id) AS source_id
|
||||
FROM summary s
|
||||
LEFT JOIN strictest_role sr ON true
|
||||
LEFT JOIN no_approval_attribution na ON true
|
||||
WHERE EXISTS (SELECT 1 FROM candidates);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- t-paliad-160 (M2): drop the legacy `required_role` column from
|
||||
-- paliad.approval_policies. Migration 064 (M1) introduced the
|
||||
-- requires_approval + min_role split-grammar columns and kept
|
||||
-- required_role as a dual-read mirror so a rollback to pre-deploy
|
||||
-- code would still work. M2 retires the mirror once all writers have
|
||||
-- cut over.
|
||||
--
|
||||
-- Deploy ordering — IMPORTANT:
|
||||
--
|
||||
-- 1. Migration 064 must already be applied (introduces the new
|
||||
-- columns and rewrites approval_policy_effective() to the
|
||||
-- new shape). The Go service layer in slice 1+2 reads the new
|
||||
-- columns AND writes both old + new on every Upsert*. With
|
||||
-- this migration the writes drop the legacy column path.
|
||||
-- 2. After 065, NO code path may reference
|
||||
-- paliad.approval_policies.required_role.
|
||||
-- 3. paliad.approval_requests.required_role is a different column
|
||||
-- (the in-flight snapshot of the policy at submission time) and
|
||||
-- is intentionally untouched here.
|
||||
--
|
||||
-- The function paliad.approval_policy_effective() is also updated to
|
||||
-- stop returning the redundant required_role column.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Replace approval_policy_effective() with a 4-column return that no
|
||||
-- longer mirrors required_role.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
requires_approval boolean,
|
||||
min_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
project_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'project'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
1 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
2 AS src_priority
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
3 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM project_rows
|
||||
UNION ALL
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
),
|
||||
strictest_role AS (
|
||||
SELECT c.min_role,
|
||||
c.src AS source,
|
||||
c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = true
|
||||
AND c.min_role IS NOT NULL
|
||||
ORDER BY paliad.approval_role_level(c.min_role) DESC,
|
||||
c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
no_approval_attribution AS (
|
||||
SELECT c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = false
|
||||
ORDER BY c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
summary AS (
|
||||
SELECT bool_or(c.requires_approval) AS req
|
||||
FROM candidates c
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.req, false) AS requires_approval,
|
||||
sr.min_role AS min_role,
|
||||
COALESCE(sr.source, na.source) AS source,
|
||||
COALESCE(sr.source_id, na.source_id) AS source_id
|
||||
FROM summary s
|
||||
LEFT JOIN strictest_role sr ON true
|
||||
LEFT JOIN no_approval_attribution na ON true
|
||||
WHERE EXISTS (SELECT 1 FROM candidates);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
|
||||
'Effective approval policy resolver (t-paliad-160 M2). Returns '
|
||||
'requires_approval (OR across candidates) + min_role (MAX along the '
|
||||
'role ladder among requires_approval=true candidates) + source '
|
||||
'attribution. Zero rows when no policy candidates exist.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Drop the legacy column.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP COLUMN required_role;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse t-paliad-157 / m's batch Item 2.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP COLUMN IF EXISTS is_optional;
|
||||
33
internal/db/migrations/068_deadline_rules_is_optional.up.sql
Normal file
33
internal/db/migrations/068_deadline_rules_is_optional.up.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- t-paliad-157 / m's 2026-05-08 batch Item 2: mark rules that are not
|
||||
-- always part of every proceeding instance as optional. Examples m
|
||||
-- named: Antrag auf Kostenentscheidung (RoP.151) — only fires when a
|
||||
-- party files for it; some appeal-related deadlines that depend on
|
||||
-- specific facts.
|
||||
--
|
||||
-- Distinct from is_mandatory: a rule can be is_mandatory=true (the
|
||||
-- deadline is statutorily fixed when it applies) AND is_optional=true
|
||||
-- (whether the deadline applies at all is a per-case choice). Default
|
||||
-- false on backfill so the existing always-applies semantic stays.
|
||||
--
|
||||
-- Frontend reads is_optional and pre-unchecks the row in the save-
|
||||
-- to-project modal; user toggles to opt in. Timeline view stays
|
||||
-- unchanged — the rule still renders so the user sees what could
|
||||
-- apply.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS is_optional boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.is_optional IS
|
||||
'When true, the deadline is conditional on an act the user takes '
|
||||
'(filing a cost-decision request, choosing to appeal, etc.) and '
|
||||
'should be opt-in when saving to a project. Distinct from '
|
||||
'is_mandatory, which is about statutory strictness once the rule '
|
||||
'applies. Backfill: false on every existing row except a small '
|
||||
'starter set m identified (RoP.151).';
|
||||
|
||||
-- Starter backfill: m's explicit example. More flips can land via
|
||||
-- targeted UPDATEs as m reviews the rule library.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_optional = true
|
||||
WHERE rule_code = 'RoP.151'
|
||||
AND is_active = true;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Reverse t-paliad-157 / m's batch Item 4: drop the new opponent-side
|
||||
-- cascade entries and their concept junctions.
|
||||
|
||||
DELETE FROM paliad.event_category_concepts
|
||||
WHERE event_category_id IN (
|
||||
SELECT id FROM paliad.event_categories
|
||||
WHERE slug LIKE 'cms-eingang.gegenseite.upc-app.%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.upc-pi.%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.de-bgh-inf.%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.de-bgh-null.%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.dpma-bgh.%'
|
||||
);
|
||||
|
||||
DELETE FROM paliad.event_categories
|
||||
WHERE slug LIKE 'cms-eingang.gegenseite.upc-app%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.upc-pi%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.de-bgh-inf%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.de-bgh-null%'
|
||||
OR slug LIKE 'cms-eingang.gegenseite.dpma-bgh%';
|
||||
@@ -0,0 +1,245 @@
|
||||
-- t-paliad-157 / m's 2026-05-08 batch Item 4: more opponent-side
|
||||
-- proceeding types in the B1 cascade.
|
||||
--
|
||||
-- Today `cms-eingang.gegenseite` exposes UPC INF/REV, DE INF/NULL, EPA
|
||||
-- OPP/APP, DPMA OPP — but is missing the appellate / interim-measures
|
||||
-- arms m named: UPC Berufung, UPC einstweilige Maßnahmen, DE BGH
|
||||
-- (Revision/NZB), DE BGH-Berufung Nichtigkeit, DPMA Rechtsbeschwerde.
|
||||
-- Each parent gets a few concrete leaves wired to the relevant
|
||||
-- deadline_concepts so the result-card pills land on the right rules.
|
||||
--
|
||||
-- forums tag matches the parent jurisdiction so the inbox-channel chip
|
||||
-- (m/paliad#15) correctly shows / hides each subtree.
|
||||
--
|
||||
-- proceeding_type_code on each junction row narrows the result card to
|
||||
-- the relevant proceeding (so the user picking "DE BGH Revision" sees
|
||||
-- DE_INF_BGH pills, not all proceedings sharing the underlying
|
||||
-- concept).
|
||||
--
|
||||
-- Idempotent: every INSERT uses ON CONFLICT (slug) DO UPDATE / DO
|
||||
-- NOTHING so re-running the migration after a partial apply is safe.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. New parent nodes under cms-eingang.gegenseite
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, step_question_de, step_question_en,
|
||||
sort_order, is_leaf, is_active, forums)
|
||||
VALUES
|
||||
('cms-eingang.gegenseite.upc-app',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite'),
|
||||
'UPC Berufungsverfahren',
|
||||
'UPC Appeal',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?',
|
||||
250, false, true, ARRAY['upc']),
|
||||
|
||||
('cms-eingang.gegenseite.upc-pi',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite'),
|
||||
'UPC einstweilige Maßnahmen',
|
||||
'UPC Provisional Measures',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?',
|
||||
280, false, true, ARRAY['upc']),
|
||||
|
||||
('cms-eingang.gegenseite.de-bgh-inf',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite'),
|
||||
'DE Revision / NZB BGH (Verletzung)',
|
||||
'DE Revision / NZB BGH (infringement)',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?',
|
||||
320, false, true, ARRAY['de']),
|
||||
|
||||
('cms-eingang.gegenseite.de-bgh-null',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite'),
|
||||
'DE Berufung BGH (Nichtigkeit)',
|
||||
'DE Appeal BGH (nullity)',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?',
|
||||
450, false, true, ARRAY['de']),
|
||||
|
||||
('cms-eingang.gegenseite.dpma-bgh',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite'),
|
||||
'DPMA Rechtsbeschwerde BGH',
|
||||
'DPMA Rechtsbeschwerde BGH',
|
||||
'Welcher Schriftsatz?',
|
||||
'Which submission?',
|
||||
750, false, true, ARRAY['dpma'])
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
step_question_de = EXCLUDED.step_question_de,
|
||||
step_question_en = EXCLUDED.step_question_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
forums = EXCLUDED.forums,
|
||||
updated_at = now();
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. UPC_APP children
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, sort_order, is_leaf, is_active, forums)
|
||||
VALUES
|
||||
('cms-eingang.gegenseite.upc-app.berufungsschrift',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-app'),
|
||||
'Berufungsschrift', 'Notice of Appeal', 100, true, true, ARRAY['upc']),
|
||||
('cms-eingang.gegenseite.upc-app.berufungsbegruendung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-app'),
|
||||
'Berufungsbegründung', 'Statement of Grounds', 200, true, true, ARRAY['upc']),
|
||||
('cms-eingang.gegenseite.upc-app.berufungserwiderung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-app'),
|
||||
'Berufungserwiderung', 'Response to Appeal', 300, true, true, ARRAY['upc']),
|
||||
('cms-eingang.gegenseite.upc-app.anschlussberufung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-app'),
|
||||
'Anschlussberufung (R.237)', 'Cross-Appeal (R.237)', 400, true, true, ARRAY['upc']),
|
||||
('cms-eingang.gegenseite.upc-app.reply-to-cross-appeal',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-app'),
|
||||
'Erwiderung Anschlussberufung (R.238)', 'Reply to Cross-Appeal (R.238)', 500, true, true, ARRAY['upc'])
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
forums = EXCLUDED.forums,
|
||||
updated_at = now();
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. UPC_PI children
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, sort_order, is_leaf, is_active, forums)
|
||||
VALUES
|
||||
('cms-eingang.gegenseite.upc-pi.antrag',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-pi'),
|
||||
'Antrag auf einstw. Maßnahmen', 'Application for Provisional Measures', 100, true, true, ARRAY['upc']),
|
||||
('cms-eingang.gegenseite.upc-pi.erwiderung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.upc-pi'),
|
||||
'Erwiderung', 'Statement of Defence (PI)', 200, true, true, ARRAY['upc'])
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
forums = EXCLUDED.forums,
|
||||
updated_at = now();
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. DE_INF_BGH children
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, sort_order, is_leaf, is_active, forums)
|
||||
VALUES
|
||||
('cms-eingang.gegenseite.de-bgh-inf.nzb',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-inf'),
|
||||
'Nichtzulassungsbeschwerde', 'Non-Admission Appeal (NZB)', 100, true, true, ARRAY['de']),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.nzb-begr',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-inf'),
|
||||
'NZB-Begründung', 'NZB Statement of Grounds', 200, true, true, ARRAY['de']),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.revision',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-inf'),
|
||||
'Revisionsschrift', 'Notice of Revision', 300, true, true, ARRAY['de']),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.revisionsbegr',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-inf'),
|
||||
'Revisionsbegründung', 'Revision Statement of Grounds', 400, true, true, ARRAY['de']),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.revisionserw',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-inf'),
|
||||
'Revisionserwiderung', 'Response to Revision', 500, true, true, ARRAY['de'])
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
forums = EXCLUDED.forums,
|
||||
updated_at = now();
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. DE_NULL_BGH children
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, sort_order, is_leaf, is_active, forums)
|
||||
VALUES
|
||||
('cms-eingang.gegenseite.de-bgh-null.berufung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-null'),
|
||||
'Berufungsschrift', 'Notice of Appeal', 100, true, true, ARRAY['de']),
|
||||
('cms-eingang.gegenseite.de-bgh-null.begruendung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-null'),
|
||||
'Berufungsbegründung', 'Statement of Grounds of Appeal', 200, true, true, ARRAY['de']),
|
||||
('cms-eingang.gegenseite.de-bgh-null.erwiderung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.de-bgh-null'),
|
||||
'Berufungserwiderung', 'Response to Appeal', 300, true, true, ARRAY['de'])
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
forums = EXCLUDED.forums,
|
||||
updated_at = now();
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. DPMA_BGH_RB children
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_categories
|
||||
(slug, parent_id, label_de, label_en, sort_order, is_leaf, is_active, forums)
|
||||
VALUES
|
||||
('cms-eingang.gegenseite.dpma-bgh.rechtsbeschwerde',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.dpma-bgh'),
|
||||
'Rechtsbeschwerde', 'Rechtsbeschwerde', 100, true, true, ARRAY['dpma']),
|
||||
('cms-eingang.gegenseite.dpma-bgh.begruendung',
|
||||
(SELECT id FROM paliad.event_categories WHERE slug = 'cms-eingang.gegenseite.dpma-bgh'),
|
||||
'Rechtsbeschwerde-Begründung', 'Rechtsbeschwerde Statement of Grounds', 200, true, true, ARRAY['dpma'])
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
label_de = EXCLUDED.label_de,
|
||||
label_en = EXCLUDED.label_en,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
is_leaf = EXCLUDED.is_leaf,
|
||||
is_active = EXCLUDED.is_active,
|
||||
forums = EXCLUDED.forums,
|
||||
updated_at = now();
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. Junctions: leaves → deadline_concepts (with proceeding_type_code
|
||||
-- narrowing so each result card pills only the relevant proceeding).
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.event_category_concepts (event_category_id, concept_id, proceeding_type_code, sort_order)
|
||||
SELECT ec.id, dc.id, mapping.proc, 100
|
||||
FROM (VALUES
|
||||
('cms-eingang.gegenseite.upc-app.berufungsschrift', 'notice-of-appeal', 'UPC_APP'),
|
||||
('cms-eingang.gegenseite.upc-app.berufungsbegruendung', 'statement-of-grounds-of-appeal', 'UPC_APP'),
|
||||
('cms-eingang.gegenseite.upc-app.berufungserwiderung', 'response-to-appeal', 'UPC_APP'),
|
||||
('cms-eingang.gegenseite.upc-app.anschlussberufung', 'cross-appeal', 'UPC_APP'),
|
||||
('cms-eingang.gegenseite.upc-app.reply-to-cross-appeal', 'reply-to-cross-appeal', 'UPC_APP'),
|
||||
('cms-eingang.gegenseite.upc-pi.antrag', 'application-for-provisional-measures', 'UPC_PI'),
|
||||
('cms-eingang.gegenseite.upc-pi.erwiderung', 'statement-of-defence', 'UPC_PI'),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.nzb', 'nichtzulassungsbeschwerde', 'DE_INF_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.nzb-begr', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.revision', 'revisionsfrist', 'DE_INF_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.revisionsbegr', 'revisionsbegruendung', 'DE_INF_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-inf.revisionserw', 'response-to-appeal', 'DE_INF_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-null.berufung', 'notice-of-appeal', 'DE_NULL_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-null.begruendung', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH'),
|
||||
('cms-eingang.gegenseite.de-bgh-null.erwiderung', 'response-to-appeal', 'DE_NULL_BGH'),
|
||||
('cms-eingang.gegenseite.dpma-bgh.rechtsbeschwerde', 'rechtsbeschwerde', 'DPMA_BGH_RB'),
|
||||
('cms-eingang.gegenseite.dpma-bgh.begruendung', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB')
|
||||
) AS mapping(leaf_slug, concept_slug, proc)
|
||||
JOIN paliad.event_categories ec ON ec.slug = mapping.leaf_slug
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug
|
||||
ON CONFLICT DO NOTHING;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user