Compare commits

...

12 Commits

Author SHA1 Message Date
mAi
d834b36313 test(submissions): live-DB round-trip for filename_keyword composer_meta merge/clear (t-paliad-354)
Some checks are pending
Paliad CI gate / build (push) Waiting to run
Paliad CI gate / test-go (push) Waiting to run
Paliad CI gate / deploy (push) Blocked by required conditions
2026-06-01 10:40:55 +02:00
mAi
4092c889c4 feat(submissions): generated-doc filename <date> <keyword> (<case>) + user-replaceable keyword
Generated documents now download as "YYYY-MM-DD keyword (case number).docx"
(date first/sortable, case number bracketed) instead of the old
"rule-case-date.docx" shape.

- submissionFileName: date-led frame; keyword = user override > lang-aware
  rule name > "submission"; case number always bracketed, placeholder
  "Az. folgt" (named const) when the project has no Aktenzeichen.
- SanitiseSubmissionFileName hardened to fold the full Windows-reserved
  set (colon star question angle pipe) on top of slash/backslash, while
  preserving spaces + parentheses so the assembled frame stays
  human-facing yet filesystem-safe.
- User-replaceable keyword stored in the draft's composer_meta jsonb
  (filename_keyword, no migration). Editor gains a "Stichwort (Dateiname)"
  input that placeholders the auto rule name and persists via the draft
  PATCH path. One-click /generate has no draft row -> keeps auto keyword.

Tests: submissionFileName (full / no-AZ / override / EN / slash case-no /
blank override / empty rule), submissionFilenameKeyword, extended
sanitiser cases.

t-paliad-354
2026-06-01 10:35:23 +02:00
mAi
db1040968f Merge: t-paliad-352 submission draft auto-naming (m/paliad#155)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-31 15:29:32 +02:00
mAi
f292338919 feat(submissions): auto-name new drafts <date> <client>./.<forum>./.<opponent> (m/paliad#155)
New project-bound submission drafts now default to a sortable, legal-
convention title instead of the bare "Entwurf N" counter:

    <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>

- Date leads (ISO, Europe/Berlin) so drafts list chronologically; " ./. "
  is the German legal "gegen" separator.
- Client = root 'client' ancestor of the project tree.
- Forum = proceeding-type jurisdiction (UPC/EPA/DPMA); German proceedings
  resolve to the deciding court (LG/OLG/BGH/BPatG) from the code tail.
- Opponent = primary opposing party, picked by our_side posture
  (active → defendant bucket, reactive → claimant bucket).
- Any segment that resolves empty is omitted with its leading separator;
  a project-less draft keeps the legacy "Entwurf N" scheme entirely.
- Create-time only: existing drafts are never renamed, and a lawyer's
  later manual rename via Update is untouched. Same-slot collisions
  de-duplicate with a " (N)" suffix.

Customization scope (per-user / firm / template, issue #155 Q4) is v1.1 —
the template is hardcoded in submission_autoname.go for now; the override
string is documented as the single extension point on AutoSubmissionTitle.

Example output:
  full:        2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma
  no opponent: 2026-05-31 Bayer AG ./. BPatG
  no forum:    2026-05-31 Bayer AG ./. Novartis Pharma
  date only:   2026-05-31

AutoSubmissionTitle + segment resolvers are pure and table-tested
(submission_autoname_test.go); the Create flow is covered end-to-end
against real Postgres in submission_draft_autoname_live_test.go (gated
on TEST_DATABASE_URL).
2026-05-31 15:28:54 +02:00
mAi
2b240e7dd0 Merge: docs PRD schema corrections (planck feedback)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-31 15:16:55 +02:00
mAi
c945cbd330 docs(prd): fix 3 schema inaccuracies in litigation-planner PRD
planck flagged via mai report feedback (id 12301) after the B5+B6
verification round caught them:

- §5.4 'INSERT into paliad.project_parties' → real table is paliad.parties
- §5.4 'status=open' → real CHECK constraint allows pending/completed/cancelled/waived
- §7.4 listed verfahrensablauf-detail-mode.ts as dead code, but builder
  imports filterByDetailMode from it; struck through with KEEP note.

Code shipped (B5+B6) used the correct values throughout; this aligns
the historical PRD with reality so a future reader doesn't repeat the
verification time planck spent.
2026-05-31 15:16:55 +02:00
mAi
639ff4f672 Merge: t-paliad-350 B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 20:52:06 +02:00
mAi
264cc39a6b chore(builder): B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (t-paliad-350)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Litigation Builder slice B6 (m/paliad#153 PRD §7.1 + §7.4 + §10) — last
slice of the train; the Builder is now complete.

Mobile basic-read (<640px, PRD §10):
- builder.ts wireMobileGuard — a capture-phase document click listener that,
  only when matchMedia("(max-width:640px)") matches, intercepts taps on
  mutating affordances (rename/share/promote/new-scenario/add-proceeding +
  every in-triplet button/input/select), preventDefault+stopPropagation,
  and surfaces a "Auf größerem Bildschirm öffnen" toast. Desktop code paths
  are untouched (guard early-returns off-mobile). Column-triplets already
  collapse to a single column via the reused .fr-columns-view @640px rule;
  reading (open/switch scenarios, search, mode tabs) stays fully functional.
- global.css — .builder-mobile-toast + full-bleed modal on phones.

Dead U0-U4 catalog cleanup (PRD §7.4) — deleted, no remaining references
(grep "from.*fristenrechner-|from.*verfahrensablauf" shows only the kept
verfahrensablauf-core + verfahrensablauf-detail-mode the builder reuses):
- client/fristenrechner-mode-a.ts, fristenrechner-result.ts(+test),
  fristenrechner-wizard.ts(+test)
- client/verfahrensablauf.ts, client/views/event-card-choices.ts,
  client/views/verfahrensablauf-state.ts(+test)
- components/VerfahrensablaufBody.tsx
(/tools/fristenrechner + /tools/verfahrensablauf stay as 301 redirects to
/tools/procedures — handlers already redirectToProcedures.)

i18n finalised (DE+EN): removed 4 duplicate deadlines.party.* keys per
block (behaviour-preserving — the later, winning copy is kept) and added
the missing DE "cal.today". Codegen clean, no dupes, full DE/EN parity.

go build/vet clean; bun build + 227 frontend tests pass. Playwright-
verified: at 375px the triplet collapses to one column + the scenario
list reads, while "+ Verfahren hinzufügen" and "Teilen" are blocked
(toast shown, no action); at 1280px the same actions work normally.
2026-05-29 20:44:40 +02:00
mAi
28d860a07d Merge: t-paliad-350 B5 — share + promote-to-project wizard (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 20:37:37 +02:00
mAi
d913f4fc30 feat(builder): B5 — share + promote-to-project wizard (t-paliad-350)
Litigation Builder slice B5 (m/paliad#153 PRD §2.4 + §2.5 + §5.4 + §10).

Backend (internal/services/scenario_builder_service.go):
- ListSharedWithMe — scenarios shared read-only with the caller (the
  "Geteilt mit mir" bucket).
- PromoteScenario — transactional promote-to-project (PRD §10, no partial
  promotions). One Postgres tx: INSERT paliad.projects ('case',
  origin_scenario_id, proceeding_type_id + scenario_flags from the primary
  triplet) → creator team lead + wizard-selected colleagues → parties →
  deadlines (filed→completed, planned→pending with computed/actual date,
  skipped→none) → flip scenario to 'promoted' + promoted_project_id. The
  primary top-level proceeding + its spawned descendants form the one case
  file; additional standalone proceedings are reported via
  ProceedingsSkipped and stay in the scenario. Planned dates come from the
  injected FristenrechnerService.Calculate; court-set/undated planned
  events are skipped + counted.
- NewScenarioBuilderService gains a *FristenrechnerService dep (wired in
  cmd/server/main.go; nil in tests that don't promote).

Handlers/routes:
- GET /api/builder/scenarios/shared, POST /api/builder/scenarios/{id}/promote.

Frontend:
- builder-shares.ts — share modal (HLC user picker + current-shares list +
  revoke).
- builder-promote.ts — 3-step wizard (Bestätigen → Parteien ergänzen →
  Akte-Metadaten) → POST /promote → navigate to /projects/{id}.
- builder.ts — bucketed side panel (Aktiv / Geteilt mit mir / Als Projekt
  angelegt / Archiviert), read-only chrome (watermark + locked affordances)
  for shared/promoted scenarios, wired share + promote buttons, deep-link
  auto-load now covers shared scenarios.
- procedures.tsx — enabled buttons, bucket containers, readonly watermark slot.
- global.css — modal scaffold, share UI, promote wizard, buckets, readonly
  state. i18n.ts + i18n-keys.ts — DE+EN keys.

Tests: TestScenarioBuilderPromote (live-DB) pins the transactional cascade
+ readonly-after-promote + re-promote rejection. go build/vet/test + bun
build clean. Verified end-to-end via Playwright: Journey E (share → 2nd
user read-only watermark + locked canvas, incl. deep-link) and Journey D
(promote wizard 3 steps → project created with party → navigate → scenario
flipped to promoted).
2026-05-29 20:37:05 +02:00
mAi
e091716f48 Merge: t-paliad-349 docforge slice 8 — neutral model + Markdown importer + Exporter iface (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 18:11:39 +02:00
mAi
8763ab013c feat(docforge): slice 8 — neutral model + Markdown importer + Exporter iface (t-paliad-349)
The final slice: land the format-neutral document model with REAL consumers
and unify the Markdown parser — no duplication, byte-identical output.

Neutral model (pkg/docforge/model.go): Document / Block / InlineSpan.
BlockKind values are the stylemap keys. A hyperlink is a span with Link set
+ Children (the label's spans), preserving link boundaries so adjacent
same-URL links stay distinct — byte-exact with the pre-model walker.

Markdown importer (pkg/docforge/markdown): Import(md) → Document. The SINGLE
Markdown parser for docforge — block split, marker detection, inline
bold/italic/link tokenisation, {{placeholder}} pass-through (the b78a984
fix). Relocated out of the docx walker.

docx renderer (pkg/docforge/docx/markdown.go): now RENDERS a Document →
OOXML (RenderDocumentToOOXML); RenderMarkdownToOOXML[WithStyles] = render(
markdown.Import(md)). The shipped submission walker routes through the model,
so there is one parser, not two. The comprehensive byte-exact render tests
(RenderMarkdownToOOXML_*) all PASS unchanged = output identical.

Exporter interface (pkg/docforge/exporter.go, PRD §4 B4): Exporter{Format,
MIMEType, RenderBody(Document)} with the .docx impl (pkg/docforge/docx/
exporter.go). The seam a future PDF/HTML exporter slots into.

Tests: parser tests relocated to the markdown pkg (parseSpans/detectBlockMarker)
+ new importer Document tests + exporter conformance test.

Verification: go build/vet clean; gofmt clean; full NO-DB test suite GREEN
(authoritative — proves no regression); docforge byte-exact render oracle
PASS; composer live test renders through the rewired walker (PASS); bun build
+ bun test 274/274. The shared-DB live run fails ~85 tests across unrelated
services from a harness pq-42P08 $1-type seeding quirk + a stale
deadline_rules test — systemic/environmental (the no-DB run is clean), not
this change.

docforge train complete: 8 slices, the engine extracted + cleaned + a working
author→generate→export loop on uploaded templates, plus the neutral model +
importer + exporter seam for future formats/consumers.

m/paliad#157
2026-05-29 18:10:16 +02:00
45 changed files with 3880 additions and 5088 deletions

View File

@@ -256,7 +256,7 @@ func main() {
// Akte-mode dual-write: project-backed scenarios write through
// to paliad.projects.scenario_flags + paliad.deadlines via the
// injected project + scenarioFlags services.
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc)),
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

View File

@@ -509,14 +509,14 @@ Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `pal
Transaction:
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
SET origin_scenario_id = <scenario.id>
2. INSERT into paliad.project_parties from step-2 payload
2. INSERT into paliad.parties from step-2 payload
3. For each scenario_proceeding (depth-first, parent before child):
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
children become sub-projects via parent_project_id)
b. For each filed scenario_event: INSERT paliad.deadlines row with
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
c. For each planned scenario_event: INSERT paliad.deadlines row with
status='open', due_date=computed (or actual_date override)
status='pending', due_date=computed (or actual_date override)
d. Skipped events: not inserted (no deadline row)
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
5. Navigate to /projects/<new>
@@ -636,7 +636,7 @@ Dead code to delete (verify with grep before deletion):
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
**Kept**:

View File

@@ -0,0 +1,370 @@
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
// + §5.4, B5).
//
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
// names) → Akte-Metadaten (title, reference, case number, our_side,
// litigation parent, team). Commit POSTs the merged payload to
// /api/builder/scenarios/{id}/promote — a single server-side transaction
// (no partial promotions) that creates the paliad.projects 'case' row,
// cascades deadlines, and flips the scenario to 'promoted'. On success
// the wizard navigates to /projects/{new-id}.
import { t } from "./i18n";
interface ProjectOption {
id: string;
title: string;
type: string;
reference?: string;
}
interface UserOption {
id: string;
email: string;
display_name?: string;
office?: string;
}
interface PartyRow {
name: string;
role: string;
representative: string;
}
export interface PromoteContext {
scenarioId: string;
ownerId?: string;
proceedingLabel: string;
filedCount: number;
plannedCount: number;
flagCount: number;
extraTopLevel: number;
defaultOurSide: "claimant" | "defendant" | null;
defaultTitle: string;
onSuccess: (projectId: string) => void;
}
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
// Parallel fetch: litigation parents + HLC users (both optional pickers).
const [parents, users] = await Promise.all([
fetchProjects("litigation"),
fetchUsers(),
]);
let step = 1;
const parties: PartyRow[] = [];
const meta = {
title: ctx.defaultTitle || "",
reference: "",
caseNumber: "",
clientNumber: "",
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
parentId: "",
teamIds: new Set<string>(),
};
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
const modal = document.createElement("div");
modal.className = "builder-modal builder-promote-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-label", t("builder.promote.title"));
backdrop.appendChild(modal);
const close = () => {
document.removeEventListener("keydown", onEsc, true);
backdrop.remove();
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
document.addEventListener("keydown", onEsc, true);
function stepHeader(): string {
const steps = [
t("builder.promote.step1"),
t("builder.promote.step2"),
t("builder.promote.step3"),
];
const dots = steps.map((label, i) => {
const n = i + 1;
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
}).join("");
return `<ol class="builder-promote-steps">${dots}</ol>`;
}
function renderStep1(): string {
const rows = [
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
].join("");
const extra = ctx.extraTopLevel > 0
? `<p class="builder-promote-note">${escHtml(
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
)}</p>`
: "";
return (
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
);
}
function renderStep2(): string {
const list = parties.length === 0
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
: parties.map((p, i) => (
`<div class="builder-promote-party" data-idx="${i}">` +
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
`</div>`
)).join("");
return (
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
`<div class="builder-promote-parties">${list}</div>` +
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
);
}
function renderStep3(): string {
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
.concat(parents.map((p) => {
const sel = p.id === meta.parentId ? " selected" : "";
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
})).join("");
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
const team = users
.filter((u) => u.id !== ctx.ownerId)
.slice(0, 40)
.map((u) => {
const checked = meta.teamIds.has(u.id) ? " checked" : "";
const label = (u.display_name || "").trim()
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
: u.email;
return (
`<label class="builder-promote-team-item">` +
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
`<span>${escHtml(label)}</span></label>`
);
}).join("");
return (
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
`</div>` +
`<div class="builder-promote-field-row">` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
`<select class="builder-promote-ourside">` +
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
`</select></label>` +
`</div>` +
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
`<div class="builder-promote-team">${team}</div></div>` +
`<p class="builder-promote-error" hidden></p>`
);
}
function render(): void {
let body = "";
if (step === 1) body = renderStep1();
else if (step === 2) body = renderStep2();
else body = renderStep3();
const backLabel = t("builder.promote.back");
const cancelLabel = t("builder.promote.cancel");
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
modal.innerHTML = `
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
</header>
${stepHeader()}
<div class="builder-promote-body">${body}</div>
<footer class="builder-promote-footer">
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
<span class="builder-promote-footer-spacer"></span>
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
</footer>`;
wire();
}
function captureStep2(): void {
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
const idx = Number(row.getAttribute("data-idx"));
if (Number.isNaN(idx) || !parties[idx]) return;
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
});
}
function captureStep3(): void {
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
meta.title = get(".builder-promote-title");
meta.reference = get(".builder-promote-reference");
meta.caseNumber = get(".builder-promote-casenumber");
meta.clientNumber = get(".builder-promote-clientnumber");
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
meta.teamIds = new Set(
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
.map((cb) => cb.getAttribute("data-user-id") || "")
.filter(Boolean),
);
}
function wire(): void {
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
if (step === 2) captureStep2();
if (step === 3) captureStep3();
step = Math.max(1, step - 1);
render();
});
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
if (step === 2) captureStep2();
if (step < 3) {
step += 1;
render();
return;
}
captureStep3();
void commit();
});
if (step === 2) {
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
captureStep2();
parties.push({ name: "", role: "", representative: "" });
render();
});
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
btn.addEventListener("click", () => {
captureStep2();
const row = btn.closest(".builder-promote-party") as HTMLElement;
const idx = Number(row?.getAttribute("data-idx"));
if (!Number.isNaN(idx)) parties.splice(idx, 1);
render();
});
});
}
}
async function commit(): Promise<void> {
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
const showErr = (msg: string) => {
if (errEl) {
errEl.textContent = msg;
errEl.hidden = false;
}
};
if (!meta.title.trim()) {
showErr(t("builder.promote.error.title_required"));
return;
}
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
if (nextBtn) nextBtn.disabled = true;
const payload: Record<string, unknown> = {
title: meta.title.trim(),
reference: meta.reference.trim() || undefined,
case_number: meta.caseNumber.trim() || undefined,
client_number: meta.clientNumber.trim() || undefined,
our_side: meta.ourSide || undefined,
parent_id: meta.parentId || undefined,
parties: parties
.filter((p) => p.name.trim())
.map((p) => ({
name: p.name.trim(),
role: p.role.trim() || undefined,
representative: p.representative.trim() || undefined,
})),
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
};
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!resp.ok) {
if (nextBtn) nextBtn.disabled = false;
showErr(t("builder.promote.error.generic"));
return;
}
const out = (await resp.json()) as { project_id: string };
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
ctx.onSuccess(out.project_id);
} catch {
if (nextBtn) nextBtn.disabled = false;
showErr(t("builder.promote.error.generic"));
}
}
render();
document.body.appendChild(backdrop);
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
}
async function fetchProjects(type: string): Promise<ProjectOption[]> {
try {
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
if (!resp.ok) return [];
const data = (await resp.json()) as ProjectOption[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async function fetchUsers(): Promise<UserOption[]> {
try {
const resp = await fetch("/api/users");
if (!resp.ok) return [];
const data = (await resp.json()) as UserOption[];
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,229 @@
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
//
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
// stays sole editor. Existing shares are listed with a revoke affordance.
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
// only) — that side is handled by builder.ts.
import { t } from "./i18n";
export interface ShareUser {
id: string;
email: string;
display_name?: string;
office?: string;
}
export interface BuilderShareRow {
id: string;
scenario_id: string;
shared_with_user_id: string;
created_by: string;
created_at: string;
}
interface ShareModalOpts {
scenarioId: string;
ownerId?: string;
currentShares: BuilderShareRow[];
// Called after a successful add/revoke with the fresh share list so the
// caller can update state.active.shares + re-render side panel buckets.
onChanged: (shares: BuilderShareRow[]) => void;
}
let allUsers: ShareUser[] | null = null;
async function fetchUsers(): Promise<ShareUser[]> {
if (allUsers) return allUsers;
try {
const resp = await fetch("/api/users");
if (!resp.ok) return [];
const data = (await resp.json()) as ShareUser[];
allUsers = Array.isArray(data) ? data : [];
return allUsers;
} catch {
return [];
}
}
function userLabel(u: ShareUser): string {
const name = (u.display_name || "").trim();
if (name) return u.office ? `${name} · ${u.office}` : name;
return u.email;
}
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
const users = await fetchUsers();
let shares = [...opts.currentShares];
const backdrop = document.createElement("div");
backdrop.className = "builder-modal-backdrop";
backdrop.innerHTML = `
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
aria-label="${escAttr(t("builder.share.title"))}">
<header class="builder-modal-header">
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
</header>
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
<div class="builder-share-pickerbox">
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
</div>
<div class="builder-share-current">
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
<ul class="builder-share-current-list"></ul>
</div>
</div>`;
const close = () => {
document.removeEventListener("keydown", onEsc, true);
backdrop.remove();
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
document.addEventListener("keydown", onEsc, true);
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
function renderCurrent(): void {
if (shares.length === 0) {
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
return;
}
currentEl.innerHTML = shares.map((sh) => {
const u = users.find((x) => x.id === sh.shared_with_user_id);
const label = u ? userLabel(u) : sh.shared_with_user_id;
return (
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
`</li>`
);
}).join("");
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
const id = li.getAttribute("data-share-id");
if (!id) return;
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
void revoke(id);
});
});
}
function renderResults(): void {
const q = searchEl.value.trim().toLowerCase();
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
const matches = users
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
.filter((u) => {
if (!q) return true;
return (
(u.display_name || "").toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.office || "").toLowerCase().includes(q)
);
})
.slice(0, 12);
if (matches.length === 0) {
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
return;
}
resultsEl.innerHTML = matches.map((u) => (
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
`</li>`
)).join("");
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
const uid = li.getAttribute("data-user-id");
if (!uid) return;
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
void add(uid);
});
});
}
async function add(userId: string): Promise<void> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shared_with_user_id: userId }),
},
);
if (!resp.ok) {
flashError();
return;
}
const row = (await resp.json()) as BuilderShareRow;
shares = [...shares.filter((s) => s.id !== row.id), row];
searchEl.value = "";
renderResults();
renderCurrent();
opts.onChanged(shares);
} catch {
flashError();
}
}
async function revoke(shareId: string): Promise<void> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
"/shares/" + encodeURIComponent(shareId),
{ method: "DELETE" },
);
if (!resp.ok && resp.status !== 204) {
flashError();
return;
}
shares = shares.filter((s) => s.id !== shareId);
renderResults();
renderCurrent();
opts.onChanged(shares);
} catch {
flashError();
}
}
function flashError(): void {
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
if (!err) {
err = document.createElement("p");
err.className = "builder-share-error";
box.appendChild(err);
}
err.textContent = t("builder.share.error");
}
searchEl.addEventListener("input", renderResults);
renderResults();
renderCurrent();
document.body.appendChild(backdrop);
searchEl.focus();
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -44,6 +44,8 @@ import {
SCENARIO_FLAG_CHANGED_EVENT,
type ScenarioFlagChangedDetail,
} from "./scenario-flags";
import { openShareModal, type BuilderShareRow } from "./builder-shares";
import { openPromoteWizard } from "./builder-promote";
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
// parent proceeding, the builder auto-creates a child proceeding row
@@ -125,6 +127,14 @@ type EntryMode = "cold" | "event" | "akte";
interface State {
active: BuilderScenarioDeep | null;
list: BuilderScenario[];
// B5 — scenarios shared read-only with me (the "Geteilt mit mir"
// bucket). Disjoint from `list` (which is owner-scoped). readonly is
// true when the active scenario is one of these OR is promoted —
// either way every mutating affordance is disabled + a watermark shows.
shared: BuilderScenario[];
readonly: boolean;
// owner display-name cache for the read-only watermark.
ownerNameById: Map<string, string>;
procTypes: ProceedingTypeMeta[];
procTypesById: Map<number, ProceedingTypeMeta>;
procTypesByCode: Map<string, ProceedingTypeMeta>;
@@ -151,6 +161,9 @@ interface State {
const state: State = {
active: null,
list: [],
shared: [],
readonly: false,
ownerNameById: new Map(),
procTypes: [],
procTypesById: new Map(),
procTypesByCode: new Map(),
@@ -184,10 +197,29 @@ async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T |
}
async function fetchScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
// B5 — pull every status so the side panel can bucket into Aktiv /
// Promoted / Archiviert. The picker + recent list filter to active.
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
return Array.isArray(out) ? out : [];
}
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
return Array.isArray(out) ? out : [];
}
// fetchOwnerNames lazily loads the user directory once so the read-only
// watermark can render "Geteilt von <Name>". Failures degrade to showing
// the owner uuid; the watermark is informational, not load-bearing.
async function ensureOwnerNames(): Promise<void> {
if (state.ownerNameById.size > 0) return;
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
if (!Array.isArray(users)) return;
for (const u of users) {
state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
}
}
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
}
@@ -396,20 +428,32 @@ async function flushAutoSave(): Promise<void> {
// ────────────────────────────────────────────────────────────────────────────
async function refreshScenarioList(): Promise<void> {
state.list = await fetchScenarios();
// Owned (all statuses) + shared-with-me run in parallel.
const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
state.list = owned;
state.shared = shared;
renderScenarioList();
renderScenarioPicker();
}
function renderScenarioList(): void {
const ul = document.getElementById("builder-scenario-list-active");
// renderBucket paints one side-panel bucket UL + toggles its wrapper's
// hidden attribute when empty. The Aktiv bucket always renders (shows the
// empty hint); the others hide when they have no rows.
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
const ul = document.getElementById(listId);
if (!ul) return;
if (state.list.length === 0) {
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
if (wrapId) {
const wrap = document.getElementById(wrapId);
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
}
if (scenarios.length === 0) {
ul.innerHTML = alwaysShow
? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
: "";
return;
}
const activeId = state.active?.id;
ul.innerHTML = state.list.map((sc) => {
ul.innerHTML = scenarios.map((sc) => {
const isActive = sc.id === activeId;
return (
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
@@ -428,12 +472,32 @@ function renderScenarioList(): void {
});
}
function renderScenarioList(): void {
renderBucket("builder-scenario-list-active", null,
state.list.filter((s) => s.status === "active"), true);
renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
state.list.filter((s) => s.status === "promoted"), false);
renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
state.list.filter((s) => s.status === "archived"), false);
}
function renderScenarioPicker(): void {
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
if (!sel) return;
const placeholderText = t("builder.picker.placeholder");
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
for (const sc of state.list) {
// Picker shows openable scenarios: active owned + shared-with-me.
const pickable = [
...state.list.filter((s) => s.status === "active"),
...state.shared,
];
// Ensure the currently-active scenario is selectable even if promoted/
// archived (so the dropdown reflects reality when one is open).
if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
pickable.unshift(state.active);
}
for (const sc of pickable) {
const selected = sc.id === state.active?.id ? " selected" : "";
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
}
@@ -977,13 +1041,16 @@ async function loadScenario(id: string): Promise<void> {
if (!Array.isArray(deep.shares)) deep.shares = [];
state.active = deep;
state.pending = {};
// B5 — read-only when the scenario is shared with me (I'm not the
// owner) or already promoted (server blocks mutations either way).
const isShared = state.shared.some((s) => s.id === id);
state.readonly = isShared || deep.status === "promoted";
writeScenarioToUrl(id);
setSaveState("saved");
// Sync header inputs to scenario state.
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = false;
await applyScenarioChrome(deep, isShared);
// B4 — reflect the scenario's Akte link on the page-header picker
// + banner. Project-backed scenarios reveal the source project so
// the user knows the builder writes feed into that Akte; non-Akte
@@ -1040,6 +1107,114 @@ function openAddProceedingPicker(anchor: HTMLElement): void {
});
}
// applyScenarioChrome sets the page-header action buttons + read-only
// watermark + body class for the freshly-loaded scenario. Editable
// scenarios get rename / share / promote enabled; read-only ones (shared
// with me, or promoted) lock all three and show the watermark. The body
// class drives the CSS that neutralises in-canvas mutating affordances.
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
document.body.classList.toggle("builder-readonly", state.readonly);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = state.readonly;
if (share) share.disabled = state.readonly;
if (promote) promote.disabled = state.readonly;
const wm = document.getElementById("builder-readonly-watermark");
if (!wm) return;
if (!state.readonly) {
wm.hidden = true;
wm.textContent = "";
return;
}
if (isShared) {
await ensureOwnerNames();
const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
} else {
// Promoted (owned) scenario — read-only reference.
wm.textContent = t("builder.bucket.promoted");
}
wm.hidden = false;
}
// resetScenarioChrome clears the page-header action state + watermark
// when no scenario is active (cold-open / picker cleared).
function resetScenarioChrome(): void {
document.body.classList.remove("builder-readonly");
for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
const b = document.getElementById(id) as HTMLButtonElement | null;
if (b) b.disabled = true;
}
const wm = document.getElementById("builder-readonly-watermark");
if (wm) {
wm.hidden = true;
wm.textContent = "";
}
}
// onShareClick opens the share modal for the active (owned, editable)
// scenario. PRD §2.5.
function onShareClick(): void {
if (!state.active || state.readonly) return;
void openShareModal({
scenarioId: state.active.id,
ownerId: state.active.owner_id,
currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
onChanged: (shares) => {
if (state.active) state.active.shares = shares;
},
});
}
// onPromoteClick gathers the summary numbers for wizard step 1 and opens
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
// ordinal top-level) + its spawned descendants are what the server
// promotes into one case file; additional standalone proceedings are
// reported in the summary as staying behind.
function onPromoteClick(): void {
if (!state.active || state.readonly) return;
const sc = state.active;
const topLevel = sc.proceedings
.filter((p) => !p.parent_scenario_proceeding_id)
.sort((a, b) => a.ordinal - b.ordinal);
const primary = topLevel[0];
if (!primary) {
setSaveState("error");
return;
}
// Collect primary + descendants to scope the event counts.
const subtree = new Set<string>([primary.id]);
for (let changed = true; changed; ) {
changed = false;
for (const p of sc.proceedings) {
if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
subtree.add(p.id);
changed = true;
}
}
}
const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
const meta = state.procTypesById.get(primary.proceeding_type_id);
const label = meta ? meta.name || meta.code : "?";
const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
void openPromoteWizard({
scenarioId: sc.id,
ownerId: sc.owner_id,
proceedingLabel: label,
filedCount: evs.filter((e) => e.state === "filed").length,
plannedCount: evs.filter((e) => e.state === "planned").length,
flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
extraTopLevel: topLevel.length - 1,
defaultOurSide: defaultParty,
defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
onSuccess: (projectId) => {
window.location.href = "/projects/" + encodeURIComponent(projectId);
},
});
}
async function onRenameClick(): Promise<void> {
if (!state.active) return;
const current = state.active.name;
@@ -1194,6 +1369,12 @@ function wirePageHeader(): void {
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
void onRenameClick();
});
document.getElementById("builder-share-btn")?.addEventListener("click", () => {
onShareClick();
});
document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
onPromoteClick();
});
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
void onNewScenarioClick();
});
@@ -1206,7 +1387,9 @@ function wirePageHeader(): void {
if (id) void loadScenario(id);
else {
state.active = null;
state.readonly = false;
writeScenarioToUrl(null);
resetScenarioChrome();
renderCanvas();
}
});
@@ -1247,11 +1430,80 @@ function wireScenarioFlagsListener(): void {
});
}
// ────────────────────────────────────────────────────────────────────────────
// B6 — mobile basic-read guard (PRD §10 + §7.1)
// ────────────────────────────────────────────────────────────────────────────
// Mutating affordances that get gated on narrow viewports. Reading
// (open a scenario from the side panel, switch via the picker, search,
// switch entry mode) stays fully functional — only scenario-mutating
// taps are intercepted.
const MOBILE_MUTATING_SELECTOR = [
"#builder-rename-btn",
"#builder-share-btn",
"#builder-promote-btn",
"#builder-new-scenario-btn",
"#builder-cta-new",
"#builder-add-proceeding-btn",
".builder-triplet-host button",
".builder-triplet-host input",
".builder-triplet-host select",
].join(",");
function isNarrowViewport(): boolean {
return typeof window.matchMedia === "function" &&
window.matchMedia("(max-width: 640px)").matches;
}
let mobileToastTimer: number | null = null;
function showMobileBlockedToast(): void {
let toast = document.getElementById("builder-mobile-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "builder-mobile-toast";
toast.className = "builder-mobile-toast";
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
document.body.appendChild(toast);
}
toast.textContent = t("builder.mobile.blocked");
toast.classList.add("is-visible");
if (mobileToastTimer !== null) window.clearTimeout(mobileToastTimer);
mobileToastTimer = window.setTimeout(() => {
document.getElementById("builder-mobile-toast")?.classList.remove("is-visible");
}, 2600);
}
// wireMobileGuard intercepts taps on mutating affordances when the
// viewport is narrow (<640px), surfacing the "Auf größerem Bildschirm
// öffnen" toast instead of running the action. Capture phase so it
// pre-empts the control's own (bubble-phase) handler; calling
// preventDefault on a checkbox click also blocks its toggle + change
// event. Desktop is untouched — the guard early-returns unless the media
// query matches, so the desktop interaction code paths stay identical
// (PRD §10).
function wireMobileGuard(): void {
document.addEventListener(
"click",
(e) => {
if (!isNarrowViewport()) return;
const target = e.target as HTMLElement | null;
if (!target || !target.closest(MOBILE_MUTATING_SELECTOR)) return;
e.preventDefault();
e.stopPropagation();
showMobileBlockedToast();
},
true,
);
}
export async function mountBuilder(): Promise<void> {
wirePageHeader();
wireModeBar();
wireSearch();
wireScenarioFlagsListener();
wireMobileGuard();
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
// for the add-proceeding picker + scenario_flag_catalog for the
// per-triplet flag strip. PRD §0.4 — UPC v1.
@@ -1277,8 +1529,15 @@ export async function mountBuilder(): Promise<void> {
});
const requested = readScenarioFromUrl();
if (requested && state.list.some((s) => s.id === requested)) {
await loadScenario(requested);
// Deep-link auto-load covers both owned scenarios and ones shared with
// me (so a "Geteilt mit mir" link opens straight into the read-only
// view, not the cold-open canvas). loadScenario derives read-only from
// state.shared, so the share watermark + locked affordances apply.
const isKnown =
requested != null &&
(state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
if (isKnown) {
await loadScenario(requested as string);
} else {
renderCanvas();
}

View File

@@ -1,507 +0,0 @@
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
//
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
// Partei) over a free-text search box over a result list of
// procedural_events. Clicking a row locks the event as the trigger and
// transitions to the shared result view (S2). Inbox channel chip lives
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
// / Postal auto-sets the Forum chip.
//
// Section-split visual hierarchy per m §11.Q3: filter strip on top
// ("Filter (eingrenzen)") with the four chip groups, search box and
// result list below — clicking a result row IS the qualifier action.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
// Mirrors services.EventSearchResponse server-side.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
description?: string;
primary_party?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
follow_up_count: number;
concept_id?: string;
score: number;
}
interface EventSearchResponse {
query: string;
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
// Module-local state — single Mode A surface at a time.
interface ModeAState {
jurisdiction: string; // "" = Alle
proc: string; // proceeding_types.code, "" = Alle
eventKind: string; // "" = Alle
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
q: string; // free-text query
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
inboxOpen: boolean;
}
const state: ModeAState = {
jurisdiction: "",
proc: "",
eventKind: "",
party: "",
q: "",
inbox: "",
inboxOpen: false,
};
// Debounce token for search input — avoid hammering the server on
// every keystroke.
let searchSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Chip data — static. Forum and event-kind are closed-set per design;
// party is closed-set with "Beide" option (Mode A is filter mode,
// §11.Q8). Inbox secondary chip set per §3.3.
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
const PARTIES = ["claimant", "defendant", "both"] as const;
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
// Postal → no narrowing (postal arrives at every jurisdiction).
const INBOX_TO_FORUM: Record<string, string> = {
cms: "UPC",
bea: "DE",
postal: "",
};
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
// The mode shell (fristenrechner-result.mountModeShell) creates this
// element under the overhaul root and hands it to Mode A; Mode A
// otherwise has no opinion about its placement on the page.
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
export function isModeASurfaceMounted(): boolean {
return !!document.getElementById("fristen-mode-a-root");
}
// mountModeA renders the Mode A surface into the overhaul root. Reads
// initial state from URL params so deep links restore the previous
// filter / search state.
export async function mountModeA(): Promise<void> {
const root = document.getElementById(MODE_A_HOST_ID);
if (!root) return;
// Hydrate state from URL.
const params = new URLSearchParams(window.location.search);
state.jurisdiction = (params.get("forum") || "").toUpperCase();
state.proc = params.get("pt") || "";
state.eventKind = params.get("kind") || "";
state.party = params.get("party") || "";
state.q = params.get("q") || "";
renderShell();
await loadProceedingChips();
void runSearch();
}
// renderShell builds the Mode A markup. Idempotent re-call from the
// boot path; row-level rewrites use renderResults / renderFilterStrip
// for finer-grained updates.
function renderShell(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.innerHTML = `
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
<header class="fristen-mode-a-filters-header">
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
</header>
<div class="fristen-mode-a-chip-row" data-axis="forum">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="proc">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="kind">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="party">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
</div>
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
<div class="fristen-mode-a-chip-row" data-axis="inbox">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
</div>
</details>
</section>
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
<div class="fristen-mode-a-search-input-wrap">
<svg class="fristen-mode-a-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-mode-a-search-input"
class="fristen-mode-a-search-input"
autocomplete="off" spellcheck="false"
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
value="${escAttr(state.q)}" />
</div>
</section>
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
<header class="fristen-mode-a-results-header">
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
</header>
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
</section>
</div>
`;
renderForumChips();
renderKindChips();
renderPartyChips();
renderInboxChips();
// Proceeding chips render later, after fetch.
// Wire search input.
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
if (input) {
input.addEventListener("input", () => {
state.q = input.value;
scheduleSearch(180);
});
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
scheduleSearch(0);
}
});
}
}
// Filter-strip chip renderers ----------------------------------------
function renderForumChips(): void {
const host = document.getElementById("fristen-mode-a-chips-forum");
if (!host) return;
const chips = [
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.jurisdiction = v;
// Forum change invalidates the proc pick if it falls outside.
state.proc = "";
syncUrl();
renderForumChips();
void loadProceedingChips();
scheduleSearch(0);
});
});
}
function renderKindChips(): void {
const host = document.getElementById("fristen-mode-a-chips-kind");
if (!host) return;
const chips = [
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.eventKind = btn.dataset.value || "";
syncUrl();
renderKindChips();
scheduleSearch(0);
});
});
}
function renderPartyChips(): void {
const host = document.getElementById("fristen-mode-a-chips-party");
if (!host) return;
const chips = [
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.party = btn.dataset.value || "";
syncUrl();
renderPartyChips();
scheduleSearch(0);
});
});
}
function renderInboxChips(): void {
const host = document.getElementById("fristen-mode-a-chips-inbox");
if (!host) return;
const opts = [
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
{ v: "cms", label: "CMS" },
{ v: "bea", label: "beA" },
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
];
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.inbox = v;
// Auto-nudge forum from inbox per design §3.3.
const nudge = INBOX_TO_FORUM[v];
if (nudge !== undefined && nudge !== "") {
state.jurisdiction = nudge;
state.proc = "";
renderForumChips();
void loadProceedingChips();
}
renderInboxChips();
scheduleSearch(0);
});
});
}
// Proceeding chips — dynamic fetch.
let lastProcFetchKey = "";
async function loadProceedingChips(): Promise<void> {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const key = `j=${state.jurisdiction}`;
if (lastProcFetchKey === key) return; // cached for current jurisdiction
lastProcFetchKey = key;
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
let chips: ProceedingChip[] = [];
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (resp.ok) {
const data = (await resp.json()) as ProceedingChip[] | null;
chips = data || [];
}
} catch {
// Soft-fail: chip strip just hides; search still runs without
// proceeding narrowing.
}
renderProceedingChips(chips);
}
function renderProceedingChips(chips: ProceedingChip[]): void {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const lang = getLang();
if (chips.length === 0) {
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
return;
}
const rendered = [
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
...chips.map((c) => {
const label = lang === "en" ? c.nameEN || c.name : c.name;
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
}),
];
host.innerHTML = rendered.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.proc = btn.dataset.value || "";
syncUrl();
renderProceedingChips(chips);
scheduleSearch(0);
});
});
}
// Search ------------------------------------------------------------
function scheduleSearch(delayMs: number): void {
if (searchTimer !== null) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTimer = null;
void runSearch();
}, delayMs);
}
async function runSearch(): Promise<void> {
searchSeq++;
const mySeq = searchSeq;
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
count.textContent = "";
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
if (state.q) url.searchParams.set("q", state.q);
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
if (state.proc) url.searchParams.set("proc", state.proc);
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
if (state.party) url.searchParams.set("party", state.party);
let data: EventSearchResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
data = (await resp.json()) as EventSearchResponse;
} catch {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
if (mySeq !== searchSeq) return; // stale response
renderResults(data);
}
function renderResults(data: EventSearchResponse): void {
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
if (data.events.length === 0) {
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
return;
}
const lang = getLang();
list.innerHTML = data.events.map((e) => {
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
const pt = e.proceeding_type;
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
const icon = eventKindIconForChip(e.event_kind);
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
const juris = pt.jurisdiction || "";
return `
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
<div class="fristen-mode-a-result-body">
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
<div class="fristen-mode-a-result-meta">
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
</div>
</div>
<span class="fristen-mode-a-result-cta" aria-hidden="true">&rarr;</span>
</li>
`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
li.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") {
e.preventDefault();
commitEvent(li.dataset.eventCode || "");
}
});
});
}
// Commit — user picked a result; lock the event as trigger and
// transition to the §4 result view (S2).
function commitEvent(code: string): void {
if (!code) return;
// Reflect in URL before re-mounting so the result view's deep link
// is consistent.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", code);
// Preserve project / forum / kind filters so a back-navigation
// brings Mode A back with the same filters.
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({
eventRef: code,
party: state.party || undefined,
});
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const t = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIconForChip(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
default: return "&#128269;";
}
}
// syncUrl writes the active filter set into the URL so the deep link
// restores Mode A in the same state.
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
setOrClear(url, "forum", state.jurisdiction);
setOrClear(url, "pt", state.proc);
setOrClear(url, "kind", state.eventKind);
setOrClear(url, "party", state.party);
setOrClear(url, "q", state.q);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -1,72 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
defaultChecked,
groupFollowUps,
type FollowUpRule,
} from "./fristenrechner-result";
// Pure helpers exercised here; the DOM-driven render path is covered
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
// entry-mode UIs in later slices).
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
return {
rule_id: "r" + Math.random().toString(36).slice(2, 8),
event_code: "evt",
title_de: "Frist",
title_en: "Deadline",
priority: "mandatory",
is_court_set: false,
is_spawn: false,
is_bilateral: false,
has_condition: false,
...partial,
};
}
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
test("groups by priority; conditional takes precedence over priority", () => {
const rows = [
mk({ priority: "mandatory" }),
mk({ priority: "recommended" }),
mk({ priority: "optional" }),
mk({ priority: "mandatory", has_condition: true }), // → conditional
mk({ priority: "optional", has_condition: true }), // → conditional
];
const g = groupFollowUps(rows);
expect(g.mandatory.length).toBe(1);
expect(g.recommended.length).toBe(1);
expect(g.optional.length).toBe(1);
expect(g.conditional.length).toBe(2);
});
test("unknown priority falls through to optional", () => {
const g = groupFollowUps([mk({ priority: "informational" })]);
expect(g.optional.length).toBe(1);
expect(g.mandatory.length).toBe(0);
});
});
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
test("mandatory rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
});
test("recommended rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
});
test("optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
});
test("conditional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
});
test("court-set rules unchecked even when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
});
test("spawned rules pre-checked when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
});
test("spawned optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
});
});

View File

@@ -1,693 +0,0 @@
// Fristenrechner overhaul — shared result view (design §4).
//
// Given a locked trigger event + a trigger date, this module renders
// the result surface: a sticky trigger card on top, then four priority
// groups (mandatory / recommended / optional / conditional) of follow-up
// rules with computed dates, then a write-back footer that calls the
// existing POST /api/projects/{id}/deadlines/bulk.
//
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
// wizard in S4) both land here once they've identified a trigger
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
// services.FollowUpsResponse server-side.
export interface FollowUpRule {
rule_id: string;
event_code: string;
title_de: string;
title_en: string;
priority: string;
primary_party?: string;
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
// primary_party is the side opposite the perspective. Drives the
// Gegenseitig badge + muted style + unchecked default.
is_cross_party: boolean;
duration_value?: number;
duration_unit?: string;
timing?: string;
due_date?: string;
original_due_date?: string;
was_adjusted?: boolean;
is_court_set: boolean;
is_spawn: boolean;
is_bilateral: boolean;
has_condition: boolean;
rule_code?: string;
legal_source?: string;
legal_source_display?: string;
legal_source_url?: string;
notes_de?: string;
notes_en?: string;
spawn_label?: string;
spawn_proceeding_code?: string;
concept_id?: string;
}
export interface FollowUpsResponse {
trigger: {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
};
trigger_date: string;
party?: string;
follow_ups: FollowUpRule[];
}
// Per-rule UI state — checkbox, optional date override.
interface RuleSelection {
checked: boolean;
override?: string;
}
// Module-local state. Single result view at a time; the surface
// re-renders in place when the user changes the trigger date or
// re-locks a different event.
let currentResponse: FollowUpsResponse | null = null;
const selections = new Map<string, RuleSelection>();
let currentProjectId: string | null = null;
// Public API ----------------------------------------------------------
// isOverhaulMode reports whether the page is in overhaul mode.
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
// a two-week deprecation window. The `?overhaul=1` deep links from
// S2-S4 still work — they're now redundant with the default but kept
// alive so bookmarks don't 302 / lose state.
export function isOverhaulMode(): boolean {
return new URLSearchParams(window.location.search).get("legacy") !== "1";
}
// resolveProjectId reads the active Akte from the URL query string.
// Returns null when in kontextfrei mode (no project picked).
function resolveProjectId(): string | null {
const p = new URLSearchParams(window.location.search).get("project");
return p && p.length > 0 ? p : null;
}
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
// link path bypasses these (jumps straight to the result view via
// ?event=); the tabs appear when no event is locked yet.
export type ModeTab = "search" | "wizard";
// mountModeShell renders the mode-tab pair under the page header and
// hosts whichever mode panel is currently active. Called from the boot
// path when no `?event=` is present. S3 wires Mode A; S4 will add
// Mode B and the actual tab switching.
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
// Defer to the per-mode module to render into the root. The tab
// strip itself is a small header above the mode panel — for S3 we
// render the shell + Mode A in one shot.
// S4 will replace this with a real tab switcher.
const tabs = `
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "search"}" data-tab="search">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#9889;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
</button>
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#129517;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
</button>
</nav>
<div id="fristen-overhaul-mode-host"></div>
`;
root.innerHTML = tabs;
// Wire tab switching. S3 only has Mode A wired; Mode B is a
// placeholder until S4.
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab || "search") as ModeTab;
void mountModeShell(tab);
});
});
// Mount the active mode panel into the host. S3 only routes "search";
// "wizard" renders a placeholder until S4 lands.
const host = document.getElementById("fristen-overhaul-mode-host");
if (!host) return;
if (activeTab === "search") {
// Lazy import to keep the bundle layered and avoid a circular ref
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
const mod = await import("./fristenrechner-mode-a");
await mod.mountModeA();
} else {
const mod = await import("./fristenrechner-wizard");
await mod.mountWizard();
}
}
// MountOptions configures the surface entry. Both entry-mode paths
// (Mode A in S3, Mode B in S4) call mount() with the event reference
// that the user committed.
export interface MountOptions {
// eventRef is the procedural_event code OR its uuid OR the anchor
// sequencing_rule id. Resolved server-side; the wire returns the
// canonical code so the URL bookmark is stable.
eventRef: string;
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
triggerDate?: string;
// party is "claimant" | "defendant"; mode A may pass "both" or
// "court". When omitted, follow-ups are returned without party
// narrowing.
party?: string;
// courtId selects the holiday calendar for the per-rule date
// adjustment. Optional.
courtId?: string;
}
// mountResultView fetches /follow-ups and renders the result surface
// into the host container. Re-callable: replaces previous state.
export async function mountResultView(opts: MountOptions): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
const triggerDate = opts.triggerDate || todayIso();
currentProjectId = resolveProjectId();
// Show a quick "loading…" placeholder so the user sees something
// immediately, even on a cold fetch.
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", opts.eventRef);
url.searchParams.set("trigger_date", triggerDate);
if (opts.party) url.searchParams.set("party", opts.party);
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
let data: FollowUpsResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
return;
}
data = (await resp.json()) as FollowUpsResponse;
} catch (err) {
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
return;
}
currentResponse = data;
selections.clear();
for (const r of data.follow_ups) {
selections.set(r.rule_id, { checked: defaultChecked(r) });
}
renderSurface();
// Reflect the canonical event code + trigger date in the URL so the
// deep-link survives a reload.
syncUrlState(data.trigger.code, data.trigger_date);
}
// Render --------------------------------------------------------------
function renderSurface(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root || !currentResponse) return;
const lang = getLang();
const trig = currentResponse.trigger;
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
const juris = trig.proceeding_type.jurisdiction || "";
const kindIcon = eventKindIcon(trig.event_kind);
const triggerCard = `
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
<header class="fristen-overhaul-trigger-header">
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
</header>
<div class="fristen-overhaul-trigger-meta">
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
</div>
<div class="fristen-overhaul-trigger-date">
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
${escHtml(t("deadlines.overhaul.trigger.date"))}
</label>
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
value="${escAttr(currentResponse.trigger_date)}" />
</div>
</section>
`;
const groups = groupFollowUps(currentResponse.follow_ups);
const groupHtml = renderGroups(groups, lang);
const nudge = currentProjectId
? ""
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
const footer = currentProjectId
? renderFooter()
: "";
root.innerHTML = `
${triggerCard}
${nudge}
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
${groupHtml}
</section>
${footer}
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
`;
wireSurfaceEvents();
}
export interface GroupedFollowUps {
mandatory: FollowUpRule[];
recommended: FollowUpRule[];
optional: FollowUpRule[];
conditional: FollowUpRule[];
}
// groupFollowUps splits the wire list into the four visible groups per
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
// precedence over the priority bucket so a "nur wenn CCR" mandatory
// rule renders under Conditional with the gating language visible.
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
for (const r of rows) {
if (r.has_condition) {
out.conditional.push(r);
continue;
}
switch (r.priority) {
case "mandatory":
out.mandatory.push(r);
break;
case "recommended":
out.recommended.push(r);
break;
case "optional":
out.optional.push(r);
break;
default:
// unknown / informational — fold into optional so the row is at
// least visible. Future Phase 2 'informational' tier gets a
// dedicated bucket once seeded.
out.optional.push(r);
}
}
return out;
}
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
const blocks: string[] = [];
if (groups.mandatory.length > 0) {
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
}
if (groups.recommended.length > 0) {
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
}
if (groups.optional.length > 0) {
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
}
if (groups.conditional.length > 0) {
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
}
if (blocks.length === 0) {
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
}
return blocks.join("");
}
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
const items = rows.map((r) => renderRule(r, lang)).join("");
return `
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
<ul class="fristen-overhaul-rule-list">
${items}
</ul>
</div>
`;
}
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
const sel = selections.get(r.rule_id);
const checked = sel ? sel.checked : defaultChecked(r);
const dateOverride = sel?.override;
const computedDate = r.due_date || "";
const effectiveDate = dateOverride || computedDate;
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
const durationPhrase = formatDurationPhrase(r, lang);
const dateCell = r.is_court_set
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
: effectiveDate
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">&mdash;</span>`;
const partyBadge = r.primary_party
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
: "";
const sourceBadge = r.legal_source_display
? r.legal_source_url
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
: r.rule_code
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
: "";
const spawnBadge = r.is_spawn
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
: "";
const condBadge = r.has_condition
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
: "";
const crossPartyBadge = r.is_cross_party
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.badge"))}</span>`
: "";
const notesHtml = notes
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
: "";
const editBtn = r.is_court_set || r.is_spawn || !computedDate
? ""
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
return `
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}${r.is_cross_party ? " is-cross-party" : ""}" data-rule-id="${escAttr(r.rule_id)}">
<label class="fristen-overhaul-rule-check">
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
</label>
<div class="fristen-overhaul-rule-body">
<div class="fristen-overhaul-rule-title-row">
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
${spawnBadge}
${condBadge}
${crossPartyBadge}
</div>
<div class="fristen-overhaul-rule-meta-row">
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
${partyBadge}
${sourceBadge}
</div>
${notesHtml}
</div>
<div class="fristen-overhaul-rule-date-cell">
${dateCell}
${editBtn}
</div>
</li>
`;
}
function renderFooter(): string {
const selectedCount = countSelected();
return `
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
</span>
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
id="fristen-overhaul-write-back"
${selectedCount === 0 ? "disabled" : ""}>
${escHtml(t("deadlines.overhaul.footer.cta"))}
</button>
</footer>
`;
}
// Event wiring --------------------------------------------------------
function wireSurfaceEvents(): void {
// Trigger-date change → re-fetch with new date.
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
if (dateInput && currentResponse) {
dateInput.addEventListener("change", () => {
if (!currentResponse) return;
const newDate = dateInput.value;
if (!newDate) return;
void mountResultView({
eventRef: currentResponse.trigger.code,
triggerDate: newDate,
party: currentResponse.party,
});
});
}
// Checkbox toggles → update selections + footer count.
const root = document.getElementById("fristen-overhaul-root");
if (root) {
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.ruleId || "";
const sel = selections.get(id) ?? { checked: cb.checked };
sel.checked = cb.checked;
selections.set(id, sel);
refreshFooterCount();
});
});
// Per-rule date override.
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
btn.addEventListener("click", () => editRuleDate(btn));
});
}
// Write-back CTA.
const cta = document.getElementById("fristen-overhaul-write-back");
if (cta) cta.addEventListener("click", () => void submitWriteBack());
}
function editRuleDate(btn: HTMLButtonElement): void {
const ruleId = btn.dataset.ruleId || "";
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
if (!rule) return;
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
const current = sel.override || rule.due_date || todayIso();
const dateCell = btn.parentElement;
if (!dateCell) return;
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
if (!dateSpan) return;
const input = document.createElement("input");
input.type = "date";
input.value = current;
input.className = "fristen-overhaul-rule-date-input";
dateSpan.replaceWith(input);
btn.disabled = true;
input.focus();
const commit = () => {
const newDate = input.value;
if (newDate && newDate !== current) {
sel.override = newDate;
selections.set(ruleId, sel);
}
renderSurface();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
input.blur();
} else if ((e as KeyboardEvent).key === "Escape") {
renderSurface();
}
});
}
function refreshFooterCount(): void {
const countEl = document.getElementById("fristen-overhaul-footer-count");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const n = countSelected();
if (countEl) {
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
}
if (cta) cta.disabled = n === 0;
}
function countSelected(): number {
let n = 0;
if (!currentResponse) return 0;
for (const r of currentResponse.follow_ups) {
if (r.is_court_set) continue;
// Cross-party rows are unconditionally excluded from write-back
// (design §2.4). Even if the user manually checks the box, they
// describe what the opponent files — not Akte work for our side.
if (r.is_cross_party) continue;
const sel = selections.get(r.rule_id);
if (sel?.checked) n++;
}
return n;
}
// Write-back ----------------------------------------------------------
async function submitWriteBack(): Promise<void> {
if (!currentResponse) return;
if (!currentProjectId) return;
const msg = document.getElementById("fristen-overhaul-msg");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const lang = getLang();
const deadlines: Array<Record<string, unknown>> = [];
for (const r of currentResponse.follow_ups) {
const sel = selections.get(r.rule_id);
if (!sel?.checked) continue;
if (r.is_court_set) continue;
// Skip cross-party rows even if checked — they describe opposing-
// side filings and don't belong in our side's Akte deadline set
// (design §2.4, write-back exclusion).
if (r.is_cross_party) continue;
const dueDate = sel.override || r.due_date;
if (!dueDate) continue;
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
deadlines.push({
title,
rule_code: r.rule_code || undefined,
due_date: dueDate,
original_due_date: r.original_due_date || r.due_date || undefined,
source: "fristenrechner",
rule_id: r.rule_id,
notes: notes || undefined,
audit_reason: auditReason(),
});
}
if (deadlines.length === 0 || !msg || !cta) return;
cta.disabled = true;
msg.textContent = "";
msg.className = "fristen-overhaul-msg";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = body.error || t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "fristen-overhaul-msg form-msg-ok";
setTimeout(() => {
if (cta) cta.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
}
}
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
function auditReason(): string {
if (!currentResponse) return "";
const name = currentResponse.trigger.name_de;
const date = currentResponse.trigger_date;
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
}
// Helpers -------------------------------------------------------------
export function defaultChecked(r: FollowUpRule): boolean {
// Cross-party rows are unchecked by default — they describe what the
// OTHER side files. They render to honestly show the workflow, but
// the Akte write-back excludes them unconditionally (design §2.4).
if (r.is_cross_party) return false;
if (r.is_court_set) return false;
if (r.is_spawn) return r.priority === "mandatory";
if (r.has_condition) return false;
return r.priority === "mandatory" || r.priority === "recommended";
}
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
if (!r.duration_value || !r.duration_unit) return "";
const unitDE: Record<string, string> = {
days: "Tage",
months: "Monate",
weeks: "Wochen",
years: "Jahre",
};
const unitEN: Record<string, string> = {
days: "days",
months: "months",
weeks: "weeks",
years: "years",
};
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
return `${r.duration_value} ${u}`;
}
function formatDateForLang(iso: string, lang: "de" | "en"): string {
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
if (!iso || iso.length < 10) return iso;
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
if (lang === "en") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const idx = parseInt(m, 10) - 1;
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
return `${parseInt(d, 10)} ${mn} ${y}`;
}
return `${d}.${m}.${y}`;
}
function eventKindIcon(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;"; // inbox/letter
case "hearing": return "&#127963;&#65039;"; // courthouse
case "decision": return "&#9878;&#65039;"; // scales
case "order": return "&#128220;"; // page
default: return "&#128197;"; // calendar
}
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function syncUrlState(eventCode: string, triggerDate: string): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", eventCode);
url.searchParams.set("trigger_date", triggerDate);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}

View File

@@ -1,47 +0,0 @@
import { describe, expect, test } from "bun:test";
import { followUpsDifferByParty } from "./fristenrechner-wizard";
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
test("true when both claimant and defendant rules present", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false when all claimant", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "claimant" },
])).toBe(false);
});
test("false when all defendant", () => {
expect(followUpsDifferByParty([
{ primary_party: "defendant" },
])).toBe(false);
});
test("false when only 'both' rules", () => {
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
// Erwiderung); they don't gate R5 because either party can be
// looking at them.
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "both" },
])).toBe(false);
});
test("false when only court rules", () => {
expect(followUpsDifferByParty([
{ primary_party: "court" },
])).toBe(false);
});
test("true when mixed with both / court alongside the asymmetric pair", () => {
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "claimant" },
{ primary_party: "court" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false on empty list", () => {
expect(followUpsDifferByParty([])).toBe(false);
});
});

View File

@@ -1,711 +0,0 @@
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
//
// 3-5 question row stack that lands the user on one procedural_event
// (the trigger), then transitions to the shared §4 result view.
//
// R1 Was ist passiert? (event_kind) always asked
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
// R4 Welches Schriftstück? (procedural_event — land) always asked
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
//
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
// perspective is a qualifier).
// Pre-fill + collapse rows from project (project.proceeding_type →
// R3 + R2 derived; project.our_side → R5). Preserve compatible
// downstream picks on back-navigation (§11.Q10).
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
// need; kept local so the wizard doesn't depend on Mode A.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
follow_up_count: number;
}
interface EventSearchResponse {
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
interface ProjectSummary {
id: string;
proceeding_type_id?: number | null;
our_side?: string | null;
}
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
type WizardParty = "claimant" | "defendant";
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
// fristenrechner-result.mountModeShell which creates the host element
// under the overhaul root.
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
// so re-grouping happens in one place.
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
// Single wizard state. Module-local; one wizard at a time.
interface WizardState {
// Picks. "" = not answered. R5 only set when the question is asked.
r1: EventKindRow | "";
r2: Forum | "";
r3: string; // proceeding_types.code
r4: string; // procedural_events.code
r5: WizardParty | "";
// Pre-fill provenance — when a pick came from the project context,
// the row renders with an "aus Akte" tag so the user notices.
r2FromProject: boolean;
r3FromProject: boolean;
r5FromProject: boolean;
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
// if downstream R3 lookup returns a single forum we can mark R2 as
// implicit).
r2Implicit: boolean;
r3Implicit: boolean;
}
const state: WizardState = {
r1: "", r2: "", r3: "", r4: "", r5: "",
r2FromProject: false, r3FromProject: false, r5FromProject: false,
r2Implicit: false, r3Implicit: false,
};
// Loaded from the project (if any).
let projectSummary: ProjectSummary | null = null;
// Proceeding chip cache key: jurisdiction × event_kind.
let lastProcCacheKey = "";
let cachedProcChips: ProceedingChip[] = [];
// Event chip cache: keyed on R3 code + R1 event_kind.
let lastEventCacheKey = "";
let cachedEventChips: EventSearchHit[] = [];
// Public API ---------------------------------------------------------
export async function mountWizard(): Promise<void> {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
const params = new URLSearchParams(window.location.search);
state.r1 = (params.get("kind") as EventKindRow) || "";
state.r2 = (params.get("forum") as Forum) || "";
state.r3 = params.get("pt") || "";
state.r4 = params.get("event") || "";
state.r5 = (params.get("party") as WizardParty) || "";
// Project prefills.
const projectId = params.get("project");
if (projectId) {
projectSummary = await fetchProject(projectId);
await applyProjectPrefills();
} else {
projectSummary = null;
}
renderShell();
void renderRows();
}
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
// haven't been set explicitly. Project picks take precedence over
// unspecified state, but a user-supplied URL pick wins over the
// project default.
async function applyProjectPrefills(): Promise<void> {
if (!projectSummary) return;
// Map our_side → R5.
if (!state.r5) {
const side = projectSummary.our_side;
if (side === "claimant" || side === "applicant" || side === "appellant") {
state.r5 = "claimant";
state.r5FromProject = true;
} else if (side === "defendant" || side === "respondent") {
state.r5 = "defendant";
state.r5FromProject = true;
}
}
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
if (projectSummary.proceeding_type_id && !state.r3) {
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
if (pt) {
state.r3 = pt.code;
state.r3FromProject = true;
if (pt.group && !state.r2) {
state.r2 = pt.group as Forum;
state.r2FromProject = true;
}
}
}
}
// Render -------------------------------------------------------------
function renderShell(): void {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
host.innerHTML = `
<div class="fristen-wizard-root">
<header class="fristen-wizard-header">
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
</header>
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
</div>
`;
}
async function renderRows(): Promise<void> {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
// Resolve dynamic row prerequisites BEFORE building markup so chip
// sets are populated.
if (state.r1 && state.r2) {
await ensureProceedingChips(state.r2, state.r1);
// Auto-skip R3 when the narrowed pool has exactly one option.
if (!state.r3 && cachedProcChips.length === 1) {
state.r3 = cachedProcChips[0].code;
state.r3Implicit = true;
}
}
if (state.r1 && state.r3) {
await ensureEventChips(state.r3, state.r1);
}
const rows: string[] = [];
rows.push(rowR1());
if (shouldShowR2()) rows.push(rowR2());
if (shouldShowR3()) rows.push(rowR3());
if (shouldShowR4()) rows.push(rowR4());
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
host.innerHTML = rows.join("");
wireRowEvents();
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
// to see whether they actually differ by party. If yes, show R5. If
// no, or R5 already set, transition straight to result view.
if (state.r4) {
void maybeAdvanceFromR4();
}
}
// Should-show predicates --------------------------------------------
function shouldShowR2(): boolean {
// Skip R2 only when R1 narrows to a single forum — which today
// never happens for the closed event_kind set (every kind exists in
// multiple jurisdictions). Always show R2 until we have empirical
// evidence otherwise.
return state.r1 !== "" && state.r1 !== "missed";
}
function shouldShowR3(): boolean {
if (state.r1 === "" || state.r2 === "") return false;
if (state.r3 && state.r3Implicit) return true; // visible compact
return true;
}
function shouldShowR4(): boolean {
return state.r3 !== "" && state.r1 !== "";
}
// shouldShowR5Sync renders the placeholder row immediately; the actual
// asked-or-not decision happens after the async follow-ups probe in
// maybeAdvanceFromR4.
function shouldShowR5Sync(): boolean {
return state.r4 !== "";
}
// Row builders ------------------------------------------------------
function rowR1(): string {
const chips = EVENT_KINDS.map((k) => {
const label = t(`deadlines.overhaul.kind.${k}` as never);
const icon = eventKindIcon(k);
return chipHtml("r1", k, label, state.r1 === k, icon);
}).join("");
return rowShell({
n: 1,
badge: "filter",
label: t("deadlines.overhaul.wizard.r1.label"),
active: !state.r1,
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR2(): string {
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
return rowShell({
n: 2,
badge: "filter",
label: t("deadlines.overhaul.wizard.r2.label"),
active: !state.r2,
fromProject: state.r2FromProject,
answeredText: state.r2 || "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR3(): string {
if (cachedProcChips.length === 0) {
return rowShell({
n: 3, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedProcChips.map((p) => {
const label = lang === "en" ? p.nameEN || p.name : p.name;
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
}).join("");
let answered = "";
if (state.r3) {
const hit = cachedProcChips.find((p) => p.code === state.r3);
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
}
return rowShell({
n: 3,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: !state.r3,
fromProject: state.r3FromProject,
implicit: state.r3Implicit,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR4(): string {
if (cachedEventChips.length === 0) {
return rowShell({
n: 4, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedEventChips.map((e) => {
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
}).join("");
let answered = "";
if (state.r4) {
const hit = cachedEventChips.find((e) => e.code === state.r4);
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
}
return rowShell({
n: 4,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: !state.r4,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR5Loading(): string {
// Placeholder while we probe whether R5 is needed. The async
// follow-ups probe replaces this with rowR5 chips or skips
// straight to the result view.
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
});
}
function rowR5Chips(): string {
const chips = (["claimant", "defendant"] as const).map((p) =>
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
interface RowShellOpts {
n: number;
badge: "filter" | "qualifier";
label: string;
active: boolean;
body: string;
answeredText?: string;
fromProject?: boolean;
implicit?: boolean;
}
function rowShell(o: RowShellOpts): string {
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
(o.active ? " is-active" : " is-answered") +
(o.fromProject ? " is-from-project" : "") +
(o.implicit ? " is-implicit" : "");
const badgeText = o.badge === "filter"
? t("deadlines.overhaul.wizard.badge.filter")
: t("deadlines.overhaul.wizard.badge.qualifier");
const annotations: string[] = [];
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
const answered = o.answeredText
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
: "";
const edit = !o.active
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
: "";
return `
<section class="${cls}" data-row="${o.n}">
<header class="fristen-wizard-row-header">
<span class="fristen-wizard-row-n">${o.n}</span>
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
${annotations.join("")}
${answered}
${edit}
</header>
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
</section>
`;
}
// Event wiring ------------------------------------------------------
function wireRowEvents(): void {
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const axis = btn.dataset.axis || "";
const value = btn.dataset.value || "";
handleChip(axis, value);
});
});
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
btn.addEventListener("click", () => {
const n = parseInt(btn.dataset.row || "0", 10);
handleEdit(n);
});
});
}
function handleChip(axis: string, value: string): void {
switch (axis) {
case "r1": {
if (state.r1 === value) return;
state.r1 = value as EventKindRow;
// R1 change resets R3/R4 (event-kind narrows the pools).
state.r3 = "";
state.r3Implicit = false;
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
cachedEventChips = [];
lastEventCacheKey = "";
cachedProcChips = [];
lastProcCacheKey = "";
break;
}
case "r2": {
if (state.r2 === value) return;
state.r2 = value as Forum;
state.r2FromProject = false;
state.r2Implicit = false;
// R2 change may invalidate R3 → reset.
state.r3 = "";
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedProcChips = [];
lastProcCacheKey = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r3": {
if (state.r3 === value) return;
state.r3 = value;
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r4": {
if (state.r4 === value) return;
state.r4 = value;
break;
}
case "r5": {
if (state.r5 === value) return;
state.r5 = value as WizardParty;
state.r5FromProject = false;
break;
}
}
syncUrl();
void renderRows();
}
function handleEdit(n: number): void {
switch (n) {
case 1:
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 2:
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 3:
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 4:
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
break;
case 5:
state.r5 = ""; state.r5FromProject = false;
break;
}
syncUrl();
void renderRows();
}
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
// decide whether R5 is needed. If R5 is already set OR the
// follow-ups don't differ by party, transition straight to the
// result view. Else swap the R5 loading row for the chip picker.
async function maybeAdvanceFromR4(): Promise<void> {
if (!state.r4) return;
if (state.r5) {
// R5 already answered (project prefill or explicit pick) → go.
void launchResult();
return;
}
// Probe follow-ups.
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", state.r4);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
// Soft-fail → swap to R5 chips so the user can decide manually.
swapR5(rowR5Chips());
return;
}
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
const differs = followUpsDifferByParty(data.follow_ups);
if (!differs) {
void launchResult();
return;
}
swapR5(rowR5Chips());
} catch {
swapR5(rowR5Chips());
}
}
function swapR5(html: string): void {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
if (!r5) {
host.insertAdjacentHTML("beforeend", html);
} else {
r5.outerHTML = html;
}
wireRowEvents();
}
function launchResult(): void {
// Hand off to the §4 result view. The URL already carries the
// picks via syncUrl(); add event= so the boot path treats this
// as a deep-link.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", state.r4);
if (state.r5) url.searchParams.set("party", state.r5);
else url.searchParams.delete("party");
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
}
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
let hasClaimant = false, hasDefendant = false;
for (const r of rows) {
if (r.primary_party === "claimant") hasClaimant = true;
else if (r.primary_party === "defendant") hasDefendant = true;
if (hasClaimant && hasDefendant) return true;
}
return false;
}
// Fetches -----------------------------------------------------------
async function fetchProject(id: string): Promise<ProjectSummary | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
return (await resp.json()) as ProjectSummary;
} catch {
return null;
}
}
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
// The proceeding-types endpoint returns codes, names, jurisdictions
// but doesn't carry the id (the wire shape FristenrechnerType is
// code-keyed). Walk the unfiltered list and pick by sort-order
// proximity / sort-fallback: we need the row whose id matches; since
// the wire doesn't expose id, fetch the projects detail to get the
// code directly. Cheap workaround: rely on /api/projects/{id}'s
// proceeding_type_id being matched against the proceeding-types list
// by jurisdiction round-trip is not possible without id. Instead
// expose the proceeding-types-by-id mapping via a follow-up endpoint
// later. For now hit the unfiltered list and assume the project's
// pick is in the active set.
//
// Pragmatic fallback: query the full list and return the only entry
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
// until the wire shape includes id; for the project-prefill case the
// user can always re-pick R3 / R2 if the prefill misfires.
try {
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
const list = (await resp.json()) as ProceedingChip[] | null;
if (!list || list.length === 0) return null;
// Without id in the wire we cannot match by id. Skip the prefill
// silently — R3 stays unanswered and the user picks manually.
// (S5/follow-up can extend the wire shape to include id.)
void id;
return null;
} catch {
return null;
}
}
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
const key = `${forum}\x00${kind}`;
if (lastProcCacheKey === key) return;
lastProcCacheKey = key;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
url.searchParams.set("jurisdiction", forum);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedProcChips = [];
return;
}
const data = (await resp.json()) as ProceedingChip[] | null;
cachedProcChips = data || [];
} catch {
cachedProcChips = [];
}
}
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
const key = `${procCode}\x00${kind}`;
if (lastEventCacheKey === key) return;
lastEventCacheKey = key;
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
url.searchParams.set("proc", procCode);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
url.searchParams.set("limit", "100");
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedEventChips = [];
return;
}
const data = (await resp.json()) as EventSearchResponse;
cachedEventChips = data.events || [];
} catch {
cachedEventChips = [];
}
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const tt = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIcon(kind?: EventKindRow): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
case "missed": return "&#9202;";
default: return "&#128197;";
}
}
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("mode", "wizard");
setOrClear(url, "kind", state.r1);
setOrClear(url, "forum", state.r2);
setOrClear(url, "pt", state.r3);
// event=… is set only on launchResult; the wizard URL carries the
// R4 candidate via r4= so back/forward navigates within the wizard.
setOrClear(url, "r4", state.r4);
setOrClear(url, "party", state.r5);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -294,6 +294,62 @@ const translations: Record<Lang, Record<string, string>> = {
"builder.search.summary.projects.other": "{n} Akten",
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
// B5 \u2014 side-panel buckets, sharing, promote-to-project wizard.
"builder.bucket.shared": "Geteilt mit mir",
"builder.bucket.promoted": "Als Projekt angelegt",
"builder.bucket.archived": "Archiviert",
"builder.bucket.empty": "\u2014",
"builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt",
"builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.",
"builder.share.title": "Szenario teilen",
"builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.",
"builder.share.search.placeholder": "Name oder E-Mail suchen \u2026",
"builder.share.button": "Schreibgesch\u00fctzt teilen",
"builder.share.current.title": "Bereits geteilt mit:",
"builder.share.current.empty": "Noch mit niemandem geteilt.",
"builder.share.revoke": "Entfernen",
"builder.share.close": "Schlie\u00dfen",
"builder.share.no_results": "Keine Nutzer:innen gefunden.",
"builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.",
"builder.promote.title": "Als Projekt anlegen",
"builder.promote.step1": "Best\u00e4tigen",
"builder.promote.step2": "Parteien erg\u00e4nzen",
"builder.promote.step3": "Akte-Metadaten",
"builder.promote.next": "Weiter",
"builder.promote.back": "Zur\u00fcck",
"builder.promote.commit": "Anlegen",
"builder.promote.cancel": "Abbrechen",
"builder.promote.summary.heading": "Das wird angelegt:",
"builder.promote.summary.proceeding": "Hauptverfahren",
"builder.promote.summary.events_filed": "eingereichte Ereignisse",
"builder.promote.summary.events_planned": "geplante Ereignisse",
"builder.promote.summary.flags": "aktive Optionen",
"builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.",
"builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.",
"builder.promote.parties.add": "+ Partei hinzuf\u00fcgen",
"builder.promote.parties.name": "Name",
"builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)",
"builder.promote.parties.representative": "Vertreter:in",
"builder.promote.parties.remove": "Entfernen",
"builder.promote.parties.empty": "Noch keine Parteien.",
"builder.promote.meta.title": "Aktentitel / Mandat",
"builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung",
"builder.promote.meta.reference": "Referenz (optional)",
"builder.promote.meta.case_number": "Aktenzeichen (optional)",
"builder.promote.meta.client_number": "Mandantennummer (optional)",
"builder.promote.meta.our_side": "Unsere Seite",
"builder.promote.meta.our_side.claimant": "Kl\u00e4ger",
"builder.promote.meta.our_side.defendant": "Beklagter",
"builder.promote.meta.our_side.none": "\u2014 offen \u2014",
"builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)",
"builder.promote.meta.parent.none": "\u2014 keines \u2014",
"builder.promote.meta.team": "Team (optional)",
"builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.",
"builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.",
"builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.",
"builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026",
"builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
@@ -343,10 +399,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.epa.opp.opd": "Einspruchsverfahren",
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
"deadlines.party.claimant": "Kl\u00e4ger",
"deadlines.party.defendant": "Beklagter",
"deadlines.party.court": "Gericht",
"deadlines.party.both": "Beide",
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.court.indirect": "unbestimmt",
@@ -1072,6 +1124,7 @@ const translations: Record<Lang, Record<string, string>> = {
"cal.view.month": "Monat",
"cal.view.week": "Woche",
"cal.view.day": "Tag",
"cal.today": "Heute",
"cal.month.prev": "Vorheriger Monat",
"cal.month.next": "Nächster Monat",
"cal.week.prev": "Vorherige Woche",
@@ -1692,6 +1745,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
"submissions.draft.base.label": "Vorlagenbasis",
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
@@ -3591,6 +3648,62 @@ const translations: Record<Lang, Record<string, string>> = {
"builder.search.summary.projects.other": "{n} matters",
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
// B5 — side-panel buckets, sharing, promote-to-project wizard.
"builder.bucket.shared": "Shared with me",
"builder.bucket.promoted": "Promoted to project",
"builder.bucket.archived": "Archived",
"builder.bucket.empty": "—",
"builder.readonly.watermark": "Shared by {owner} · read-only",
"builder.readonly.blocked": "Read-only — only the owner can edit.",
"builder.share.title": "Share scenario",
"builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.",
"builder.share.search.placeholder": "Search name or email …",
"builder.share.button": "Share read-only",
"builder.share.current.title": "Already shared with:",
"builder.share.current.empty": "Not shared with anyone yet.",
"builder.share.revoke": "Remove",
"builder.share.close": "Close",
"builder.share.no_results": "No users found.",
"builder.share.error": "Sharing failed. Please try again.",
"builder.promote.title": "Create as project",
"builder.promote.step1": "Confirm",
"builder.promote.step2": "Add parties",
"builder.promote.step3": "Case metadata",
"builder.promote.next": "Next",
"builder.promote.back": "Back",
"builder.promote.commit": "Create",
"builder.promote.cancel": "Cancel",
"builder.promote.summary.heading": "What will be created:",
"builder.promote.summary.proceeding": "Primary proceeding",
"builder.promote.summary.events_filed": "filed events",
"builder.promote.summary.events_planned": "planned events",
"builder.promote.summary.flags": "active options",
"builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.",
"builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.",
"builder.promote.parties.add": "+ Add party",
"builder.promote.parties.name": "Name",
"builder.promote.parties.role": "Role (e.g. claimant)",
"builder.promote.parties.representative": "Representative",
"builder.promote.parties.remove": "Remove",
"builder.promote.parties.empty": "No parties yet.",
"builder.promote.meta.title": "Case title / matter",
"builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement",
"builder.promote.meta.reference": "Reference (optional)",
"builder.promote.meta.case_number": "Case number (optional)",
"builder.promote.meta.client_number": "Client number (optional)",
"builder.promote.meta.our_side": "Our side",
"builder.promote.meta.our_side.claimant": "Claimant",
"builder.promote.meta.our_side.defendant": "Defendant",
"builder.promote.meta.our_side.none": "— open —",
"builder.promote.meta.parent": "Parent litigation (optional)",
"builder.promote.meta.parent.none": "— none —",
"builder.promote.meta.team": "Team (optional)",
"builder.promote.meta.team.hint": "You are added as lead automatically.",
"builder.promote.error.title_required": "Please enter a case title.",
"builder.promote.error.generic": "Creation failed. Please try again.",
"builder.promote.success": "Case created — redirecting …",
"builder.mobile.blocked": "Open on a larger screen to edit.",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",
@@ -3640,10 +3753,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.epa.opp.opd": "Opposition",
"deadlines.epa.opp.boa": "Appeal",
"deadlines.epa.grant.exa": "Grant Procedure",
"deadlines.party.claimant": "Claimant",
"deadlines.party.defendant": "Defendant",
"deadlines.party.court": "Court",
"deadlines.party.both": "Both",
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.court.indirect": "tbd",
@@ -4965,6 +5074,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
// t-paliad-354 — filename keyword (leads the exported document name).
"submissions.draft.keyword.label": "Keyword (filename)",
"submissions.draft.keyword.placeholder": "Auto-derived from the submission type",
"submissions.draft.keyword.hint": "Leads the filename: <date> <keyword> (<case number>).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",

View File

@@ -503,7 +503,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -558,6 +558,7 @@ function paint(): void {
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintKeywordRow();
paintVariables();
paintSectionList();
paintPreview();
@@ -1034,6 +1035,53 @@ function paintLanguageFallback(): void {
el.style.display = fallback ? "" : "none";
}
// autoKeyword returns the lang-aware rule name that leads the exported
// filename when the user sets no override — shown as the keyword input's
// placeholder so the lawyer sees the default without it being forced.
// t-paliad-354.
function autoKeyword(): string {
const view = state.view;
if (!view?.rule) return "";
const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
return (name || "").trim();
}
// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
// draft's stored override (composer_meta.filename_keyword) and shows the
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
// blur (change), persisting under composer_meta.filename_keyword.
// t-paliad-354.
function paintKeywordRow(): void {
const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
if (!input || !state.view) return;
const stored = state.view.draft.composer_meta?.["filename_keyword"];
input.value = typeof stored === "string" ? stored : "";
const auto = autoKeyword();
if (auto) input.placeholder = auto;
input.onchange = () => { void onKeywordChange(input.value.trim()); };
}
async function onKeywordChange(keyword: string): Promise<void> {
if (!state.view) return;
const stored = state.view.draft.composer_meta?.["filename_keyword"];
const current = typeof stored === "string" ? stored.trim() : "";
if (keyword === current) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ filename_keyword: keyword });
state.view = view;
paintKeywordRow();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft keyword save:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert to the persisted value so the field doesn't lie.
paintKeywordRow();
}
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;

File diff suppressed because it is too large Load Diff

View File

@@ -1,320 +0,0 @@
// Per-event-card choice popover + chip indicator (t-paliad-265 /
// m/paliad#96).
//
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
// button on cards that carry a non-empty `choices_offered` declaration
// and an inert chip span next to the title. This module:
//
// 1. Wires a delegated click handler on the result container so the
// caret opens a popover with the offered choice-kinds.
// 2. Commits the user's pick — either by POSTing to the project-
// bound endpoint or by mutating the in-memory state for the
// unbound (no-project) case.
// 3. Rehydrates the chip on every render + after every commit so the
// glanceable indicator matches the active state.
//
// Two consumer pages — /tools/verfahrensablauf (unbound) and
// /tools/fristenrechner (project-bound) — both wire this module
// once at boot via attachEventCardChoices().
import { escAttr, escHtml } from "./verfahrensablauf-core";
import { t } from "../i18n";
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
export interface EventChoice {
submission_code: string;
choice_kind: ChoiceKind;
choice_value: string;
}
// State surface — the page passes in callbacks that own persistence.
// commit / remove must trigger a recalc on the page side (the popover
// only owns its own visual state).
export interface EventCardChoicesOpts {
container: HTMLElement;
// Initial state: a list of choices. The page seeds this from the
// server response (project-bound) or from URL params (unbound).
initial: EventChoice[];
// commit gets called for an UPSERT. The page POSTs to the API (or
// mutates URL state) AND triggers a recalc.
commit: (choice: EventChoice) => Promise<void> | void;
// remove gets called when the user resets a choice.
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
}
// One mutable bag per attach() call. The current implementation is a
// single-page singleton — paginated views (admin tables) are not in
// scope. Last-write-wins on the in-memory state.
interface AttachedState {
opts: EventCardChoicesOpts;
// active: submission_code → kind → value. Rebuilt from `initial`
// on every reseed() call.
active: Map<string, Map<ChoiceKind, string>>;
popover: HTMLDivElement | null;
}
const states = new WeakMap<HTMLElement, AttachedState>();
// attachEventCardChoices wires the delegated click + popover lifecycle
// to the given container. Call once per page after mount; safe to call
// again with a fresh container.
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
const state: AttachedState = {
opts,
active: new Map(),
popover: null,
};
for (const c of opts.initial) {
if (!state.active.has(c.submission_code)) {
state.active.set(c.submission_code, new Map());
}
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
}
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const targetEl = e.target as HTMLElement | null;
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
if (caret) {
e.stopPropagation();
openPopover(state, caret);
return;
}
// Outside-click closes the popover.
if (state.popover && !state.popover.contains(e.target as Node)) {
closePopover(state);
}
});
// ESC also closes.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.popover) {
closePopover(state);
}
});
// Repaint chips on every renderResults() call. The page is
// responsible for calling reseedChips() after re-render so the chip
// dom node (re-created by the renderer) picks the active state up.
reseedChips(opts.container);
}
// reseedChips walks every chip span in the container and re-renders
// its content from the active state map. Idempotent.
export function reseedChips(container: HTMLElement): void {
const state = states.get(container);
if (!state) return;
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const kinds = state.active.get(code);
if (!kinds || kinds.size === 0) {
chip.innerHTML = "";
chip.dataset.empty = "true";
return;
}
chip.dataset.empty = "false";
chip.innerHTML = renderChip(kinds);
});
// Skipped rows fade out via a class on the card-item ancestor.
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const skipped = state.active.get(code)?.get("skip") === "true";
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
});
}
function renderChip(kinds: Map<ChoiceKind, string>): string {
const parts: string[] = [];
if (kinds.get("skip") === "true") {
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
}
const ap = kinds.get("appellant");
if (ap && ap !== "" ) {
let label = "";
switch (ap) {
case "claimant": label = t("choices.appellant.claimant"); break;
case "defendant": label = t("choices.appellant.defendant"); break;
case "both": label = t("choices.appellant.both"); break;
case "none": label = t("choices.appellant.none"); break;
}
if (label) {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
}
}
if (kinds.get("include_ccr") === "true") {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
}
return parts.join(" ");
}
function openPopover(state: AttachedState, caret: HTMLElement): void {
closePopover(state);
const code = caret.dataset.submissionCode || "";
if (!code) return;
let offered: Record<string, unknown> = {};
try {
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
} catch {
return;
}
const isHidden = caret.dataset.isHidden === "1";
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
// t-paliad-293: hidden-card prominence. When the user opens the
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
// most likely intent — surface it as a single high-contrast action
// at the top of the popover (rather than burying it under the skip
// toggle's reset link). Clicking it clears the `skip` choice, which
// is the same wire effect as the legacy inline chip from t-paliad-290.
if (isHidden) {
blocks.push(renderUnhideBlock());
}
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
if (Array.isArray(offered.include_ccr)) {
blocks.push(renderToggleBlock(state, code, "include_ccr"));
}
if (Array.isArray(offered.skip)) {
blocks.push(renderToggleBlock(state, code, "skip"));
}
pop.innerHTML = blocks.join("");
document.body.appendChild(pop);
state.popover = pop;
positionPopover(pop, caret);
pop.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
if (!btn) return;
e.stopPropagation();
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
const value = btn.dataset.choiceValue || "";
const action = btn.dataset.choiceAction;
if (!kind) return;
try {
if (action === "set") {
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
if (!state.active.has(code)) state.active.set(code, new Map());
state.active.get(code)!.set(kind, value);
} else if (action === "clear") {
await state.opts.remove(code, kind);
state.active.get(code)?.delete(kind);
}
reseedChips(state.opts.container);
closePopover(state);
} catch (err) {
console.error("event card choice commit failed", err);
// Surface a soft inline error inside the popover; do NOT close.
const errEl = document.createElement("div");
errEl.className = "event-card-choices-error";
errEl.textContent = t("choices.commit.error");
pop.appendChild(errEl);
}
});
}
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
const current = state.active.get(code)?.get("appellant") || "";
const buttons = values
.filter((v): v is string => typeof v === "string")
.map((v) => {
const labelKey = `choices.appellant.${v}` as const;
const isActive = v === current;
return `<button type="button"
data-choice-action="set"
data-choice-kind="appellant"
data-choice-value="${escAttr(v)}"
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
})
.join("");
const reset = current
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
<div class="event-card-choices-options">${buttons}</div>
${reset}
</div>`;
}
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
const current = state.active.get(code)?.get(kind) || "false";
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
data-choice-action="set"
data-choice-kind="${kind}"
data-choice-value="${v}"
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
const reset = state.active.get(code)?.has(kind)
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
<div class="event-card-choices-options">
${opt("true", trueKey)}
${opt("false", falseKey)}
</div>
${reset}
</div>`;
}
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
// action — surfaced only when the caret is opened on a re-surfaced
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
// the same `clear` action as the skip-block reset link below, but
// labelled in the user's terms ("restore this card" rather than
// "reset skip choice"). Drops out of the popover automatically on
// non-hidden cards so the popover stays minimal. (t-paliad-293)
function renderUnhideBlock(): string {
const label = t("choices.unhide.chip");
return `<div class="event-card-choices-block event-card-choices-block--unhide">
<button type="button"
data-choice-action="clear"
data-choice-kind="skip"
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
</div>`;
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();
state.popover = null;
}
}
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
const rect = caret.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
pop.style.position = "absolute";
pop.style.top = `${rect.bottom + scrollY + 4}px`;
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
pop.style.zIndex = "1000";
}
// Returns the current in-memory choice list for the given container —
// used by the unbound /tools/verfahrensablauf page to keep the URL
// param in sync.
export function currentChoices(container: HTMLElement): EventChoice[] {
const state = states.get(container);
if (!state) return [];
const out: EventChoice[] = [];
state.active.forEach((kinds, code) => {
kinds.forEach((value, kind) => {
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
});
});
return out;
}

View File

@@ -1,309 +0,0 @@
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
//
// The contract:
// 1. URL params (proceeding, side, target, trigger_date) define which
// timeline kind the user is looking at — paste-able, shareable,
// refresh-resistant.
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
// per-user scenario tweaks (event_choices, court_id, flags,
// show_hidden) — these never leak into a shared link.
// 3. On hydrate, URL wins. localStorage fills the rest.
import { describe, expect, test } from "bun:test";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
SCENARIO_PREFIX,
URL_KEYS,
applyFiltersToSearch,
hydrate,
makeMemoryStorage,
parseAppealTargetFromSearch,
parseProceedingFromSearch,
parseSideFromSearch,
parseTriggerDateFromSearch,
readBoolFlag,
readCourtId,
readEventChoices,
readScenario,
writeBoolFlag,
writeCourtId,
writeEventChoices,
} from "./verfahrensablauf-state";
describe("URL parsers — filter chips", () => {
test("parseProceedingFromSearch returns empty string when absent", () => {
expect(parseProceedingFromSearch("")).toBe("");
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
});
test("parseProceedingFromSearch echoes the raw value", () => {
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
});
test("parseSideFromSearch validates the enum", () => {
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
expect(parseSideFromSearch("?side=neither")).toBe(null);
expect(parseSideFromSearch("")).toBe(null);
});
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
for (const t of APPEAL_TARGETS) {
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
}
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
expect(parseAppealTargetFromSearch("")).toBe("");
});
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
});
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
expect(parseTriggerDateFromSearch("")).toBe("");
});
});
describe("URL encoder — applyFiltersToSearch", () => {
test("empty filters preserve the existing query string", () => {
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
});
test("setting a filter writes the canonical key", () => {
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
});
test("setting null / empty / undefined deletes the key", () => {
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
});
test("invalid trigger_date is deleted (never written as-is)", () => {
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
});
test("setting all four filters together emits all four keys", () => {
const out = applyFiltersToSearch("", {
proceeding: "upc.apl.unified",
side: "defendant",
target: "endentscheidung",
triggerDate: "2026-05-26",
});
expect(out).toContain("proceeding=upc.apl.unified");
expect(out).toContain("side=defendant");
expect(out).toContain("target=endentscheidung");
expect(out).toContain("trigger_date=2026-05-26");
});
test("other params (project, view) are preserved", () => {
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
expect(out).toContain("project=abc");
expect(out).toContain("view=timeline");
expect(out).toContain("side=claimant");
});
test("absent keys in the filter object don't touch existing URL values", () => {
// Only updating side — proceeding should be untouched.
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
expect(out).toContain("proceeding=upc.inf.cfi");
expect(out).toContain("side=claimant");
});
});
describe("URL round-trip — encode then parse yields the same value", () => {
test("proceeding", () => {
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
});
test("side", () => {
const enc = applyFiltersToSearch("", { side: "defendant" });
expect(parseSideFromSearch(enc)).toBe("defendant");
});
test("target", () => {
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
});
test("trigger_date", () => {
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
});
});
describe("Scenario localStorage helpers", () => {
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
for (const key of Object.values(SCENARIO_KEYS)) {
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
}
});
test("readEventChoices returns [] on empty storage", () => {
const s = makeMemoryStorage();
expect(readEventChoices(s)).toEqual([]);
});
test("writeEventChoices + readEventChoices round-trip", () => {
const s = makeMemoryStorage();
const choices = [
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
];
writeEventChoices(s, choices);
expect(readEventChoices(s)).toEqual(choices);
});
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
const s = makeMemoryStorage();
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
writeEventChoices(s, []);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
});
test("readEventChoices ignores unknown choice_kind values", () => {
const s = makeMemoryStorage();
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
expect(readEventChoices(s)).toEqual([
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
]);
});
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
const s = makeMemoryStorage();
expect(readCourtId(s)).toBe("");
writeCourtId(s, "UPC-LD-MUC");
expect(readCourtId(s)).toBe("UPC-LD-MUC");
});
test("writeCourtId('') removes the key", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
writeCourtId(s, "");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
});
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
const s = makeMemoryStorage();
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
});
test("readScenario returns all fields defaulted on empty storage", () => {
const s = makeMemoryStorage();
expect(readScenario(s)).toEqual({
eventChoices: [],
courtId: "",
ccr: false,
infAmend: false,
revAmend: false,
revCci: false,
showHidden: false,
});
});
});
describe("Hydration order — URL wins, localStorage fills the rest", () => {
test("URL fills filter chips, localStorage fills scenario state", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
const out = hydrate(
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
s,
);
// URL-sourced
expect(out.proceeding).toBe("upc.inf.cfi");
expect(out.side).toBe("defendant");
expect(out.target).toBe("endentscheidung");
expect(out.triggerDate).toBe("2026-05-26");
// localStorage-sourced
expect(out.courtId).toBe("UPC-LD-MUC");
expect(out.showHidden).toBe(true);
expect(out.ccr).toBe(true);
});
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
const out = hydrate("", s);
expect(out.proceeding).toBe("");
expect(out.side).toBe(null);
expect(out.target).toBe("");
expect(out.triggerDate).toBe("");
expect(out.courtId).toBe("UPC-LD-MUC");
});
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
const s = makeMemoryStorage();
const out = hydrate(
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
s,
);
expect(out.proceeding).toBe("upc.apl.unified");
expect(out.side).toBe("claimant");
expect(out.target).toBe("anordnung");
expect(out.triggerDate).toBe("2026-07-01");
expect(out.courtId).toBe("");
expect(out.eventChoices).toEqual([]);
expect(out.showHidden).toBe(false);
});
test("a shared link doesn't leak the recipient's scenario state in", () => {
// Two storages: m's (loaded with court + flags) and a recipient's
// (empty). The same URL should reproduce filter chips identically
// but leave each user's scenario state untouched.
const mStorage = makeMemoryStorage();
writeCourtId(mStorage, "UPC-LD-MUC");
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
const recipientStorage = makeMemoryStorage();
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
const mView = hydrate(sharedURL, mStorage);
const recipientView = hydrate(sharedURL, recipientStorage);
// Filter chips identical
expect(mView.proceeding).toBe(recipientView.proceeding);
expect(mView.side).toBe(recipientView.side);
expect(mView.triggerDate).toBe(recipientView.triggerDate);
// Scenario state diverges — recipient sees defaults
expect(mView.courtId).toBe("UPC-LD-MUC");
expect(recipientView.courtId).toBe("");
expect(mView.ccr).toBe(true);
expect(recipientView.ccr).toBe(false);
});
});
describe("URL key constants match the documented contract", () => {
test("URL_KEYS uses the spec'd snake_case names", () => {
expect(URL_KEYS.proceeding).toBe("proceeding");
expect(URL_KEYS.side).toBe("side");
expect(URL_KEYS.target).toBe("target");
expect(URL_KEYS.triggerDate).toBe("trigger_date");
});
});

View File

@@ -1,263 +0,0 @@
// /tools/verfahrensablauf URL + scenario-localStorage state contract
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
// two namespaces:
//
// URL params (filter chips — the timeline kind the user is looking
// at; paste-able, shareable, refresh-resistant):
// proceeding, side, target, trigger_date
//
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
// scenario inputs — the noisy parts that don't belong in a URL):
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
// show_hidden
//
// Hydration order: URL wins. On page load, URL fills the filter chips;
// localStorage fills the rest. Filter-chip changes write to URL only.
// Scenario changes write to localStorage only. A shared link from a
// colleague reproduces the timeline kind (proceeding + side + target +
// trigger_date) but never leaks the recipient's court / flag /
// event_choices state in.
//
// All helpers in this module are pure: they take a search string (or a
// StorageLike) and return values, no DOM. The wiring in
// ../verfahrensablauf.ts mounts them onto window.location +
// window.localStorage at runtime.
import type { EventChoice, ChoiceKind } from "./event-card-choices";
// ----- URL params (filter chips) ----------------------------------
export type Side = "claimant" | "defendant" | null;
export const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
export const URL_KEYS = {
proceeding: "proceeding",
side: "side",
target: "target",
triggerDate: "trigger_date",
} as const;
// parseProceedingFromSearch extracts the proceeding code. Returns ""
// if absent. No validation against the proceeding registry — that's
// the caller's job (an unknown code from a stale link should leave
// the first-tile auto-select fallback running).
export function parseProceedingFromSearch(search: string): string {
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
return v ?? "";
}
export function parseSideFromSearch(search: string): Side {
const raw = new URLSearchParams(search).get(URL_KEYS.side);
return raw === "claimant" || raw === "defendant" ? raw : null;
}
export function parseAppealTargetFromSearch(search: string): AppealTarget {
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
// parseTriggerDateFromSearch validates the ISO-date shape so a
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
// only. Round-tripped against Date to reject 2026-02-30 etc.
export function parseTriggerDateFromSearch(search: string): string {
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
const d = new Date(raw + "T00:00:00Z");
if (Number.isNaN(d.getTime())) return "";
if (d.toISOString().slice(0, 10) !== raw) return "";
return raw;
}
// applyFiltersToSearch produces the canonical query string for the
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
// preserved verbatim. Empty values are deleted, never written as
// empty string, so the URL stays clean on the default.
export function applyFiltersToSearch(
search: string,
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
): string {
const params = new URLSearchParams(search);
if ("proceeding" in filters) {
if (filters.proceeding && filters.proceeding !== "") {
params.set(URL_KEYS.proceeding, filters.proceeding);
} else {
params.delete(URL_KEYS.proceeding);
}
}
if ("side" in filters) {
if (filters.side === "claimant" || filters.side === "defendant") {
params.set(URL_KEYS.side, filters.side);
} else {
params.delete(URL_KEYS.side);
}
}
if ("target" in filters) {
if (filters.target && filters.target !== "") {
params.set(URL_KEYS.target, filters.target);
} else {
params.delete(URL_KEYS.target);
}
}
if ("triggerDate" in filters) {
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
params.set(URL_KEYS.triggerDate, filters.triggerDate);
} else {
params.delete(URL_KEYS.triggerDate);
}
}
const s = params.toString();
return s ? `?${s}` : "";
}
// ----- localStorage (scenario state) ------------------------------
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
export const SCENARIO_KEYS = {
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
courtId: `${SCENARIO_PREFIX}.court_id`,
ccr: `${SCENARIO_PREFIX}.ccr`,
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
revCci: `${SCENARIO_PREFIX}.rev_cci`,
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
} as const;
// StorageLike is the tiny subset of the Web Storage API the scenario
// helpers actually use. Lets the tests pass a Map-backed fake without
// pulling in a full localStorage polyfill.
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
// readEventChoices is forgiving: malformed tuples or unknown
// choice_kinds are dropped silently. Same shape as the legacy URL
// codec (comma-separated `submission_code:kind=value`).
export function readEventChoices(storage: StorageLike): EventChoice[] {
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
if (choices.length === 0) {
storage.removeItem(SCENARIO_KEYS.eventChoices);
return;
}
const enc = choices
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
.join(",");
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
}
// readCourtId / writeCourtId — empty string == no court picked. The
// "" value is stored as a removed key, not an empty string entry, so
// reading it back yields null rather than "".
export function readCourtId(storage: StorageLike): string {
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
}
export function writeCourtId(storage: StorageLike, courtId: string): void {
if (courtId === "") {
storage.removeItem(SCENARIO_KEYS.courtId);
return;
}
storage.setItem(SCENARIO_KEYS.courtId, courtId);
}
// Boolean flags — "1" / "0" string encoding, removeItem on default
// (false for flags, also false for show_hidden) so the storage stays
// uncluttered on a fresh page.
export function readBoolFlag(storage: StorageLike, key: string): boolean {
return storage.getItem(key) === "1";
}
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
if (on) storage.setItem(key, "1");
else storage.removeItem(key);
}
// Read all scenario state in one call — convenience for the page's
// load-time hydration. Caller decides whether to apply each field
// (e.g. court_id is proceeding-specific; the page may discard the
// stored value if the active proceeding doesn't expose a court row).
export interface ScenarioState {
eventChoices: EventChoice[];
courtId: string;
ccr: boolean;
infAmend: boolean;
revAmend: boolean;
revCci: boolean;
showHidden: boolean;
}
export function readScenario(storage: StorageLike): ScenarioState {
return {
eventChoices: readEventChoices(storage),
courtId: readCourtId(storage),
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
};
}
// ----- URL → localStorage hydration order -------------------------
// The page's load-time contract: read URL filters, then read
// scenario state from localStorage. URL wins on conflict — but the
// only field that can conflict is none of them today (URL owns
// proceeding/side/target/trigger_date; localStorage owns the rest).
// The order matters for one edge case: if a future field migrates
// from URL → localStorage with overlap, the URL value MUST be honored.
export interface HydratedState extends ScenarioState {
proceeding: string;
side: Side;
target: AppealTarget;
triggerDate: string;
}
export function hydrate(search: string, storage: StorageLike): HydratedState {
const scenario = readScenario(storage);
return {
proceeding: parseProceedingFromSearch(search),
side: parseSideFromSearch(search),
target: parseAppealTargetFromSearch(search),
triggerDate: parseTriggerDateFromSearch(search),
...scenario,
};
}
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
// Not used by the runtime page (which mounts real localStorage), but
// kept here so test files have one well-known import.
export function makeMemoryStorage(): StorageLike {
const store = new Map<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => { store.set(k, v); },
removeItem: (k) => { store.delete(k); },
};
}

View File

@@ -1,293 +0,0 @@
import { h } from "../jsx";
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
// unified "Berufung" tile (upc.apl). After picking it, the user
// selects which decision the appeal is directed AT via the
// .appeal-target-row chip group below — the engine then filters
// rules whose applies_to_target contains the picked slug.
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
];
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
// Shared Verfahrensablauf wizard body. Renders the proceeding picker,
// perspective + date inputs, scenario flag rows, detail-mode toggle,
// view toggle, and the timeline-container that client/verfahrensablauf.ts
// (via initVerfahrensablauf()) wires against. Used by both
// /tools/verfahrensablauf (legacy) and /tools/procedures (unified).
export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string {
return (
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<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" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<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" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<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">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
</h3>
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="endentscheidung" checked />
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="kostenentscheidung" />
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="anordnung" />
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="schadensbemessung" />
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="bucheinsicht" />
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={todayIso} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
role="radiogroup" aria-label="Detail">
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="mandatory_only" />
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="selected" checked />
<span data-i18n="deadlines.detail.selected">Gewählt</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="all_options" />
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
</label>
</div>
<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="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -735,6 +735,10 @@ export type I18nKey =
| "builder.akte.banner.prefix"
| "builder.akte.none"
| "builder.bucket.active"
| "builder.bucket.archived"
| "builder.bucket.empty"
| "builder.bucket.promoted"
| "builder.bucket.shared"
| "builder.canvas.add_proceeding"
| "builder.empty.cta"
| "builder.empty.headline"
@@ -754,6 +758,7 @@ export type I18nKey =
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mobile.blocked"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
@@ -768,6 +773,45 @@ export type I18nKey =
| "builder.picker.future_jurisdiction"
| "builder.picker.placeholder"
| "builder.picker.title"
| "builder.promote.back"
| "builder.promote.cancel"
| "builder.promote.commit"
| "builder.promote.error.generic"
| "builder.promote.error.title_required"
| "builder.promote.meta.case_number"
| "builder.promote.meta.client_number"
| "builder.promote.meta.our_side"
| "builder.promote.meta.our_side.claimant"
| "builder.promote.meta.our_side.defendant"
| "builder.promote.meta.our_side.none"
| "builder.promote.meta.parent"
| "builder.promote.meta.parent.none"
| "builder.promote.meta.reference"
| "builder.promote.meta.team"
| "builder.promote.meta.team.hint"
| "builder.promote.meta.title"
| "builder.promote.meta.title.placeholder"
| "builder.promote.next"
| "builder.promote.parties.add"
| "builder.promote.parties.empty"
| "builder.promote.parties.hint"
| "builder.promote.parties.name"
| "builder.promote.parties.remove"
| "builder.promote.parties.representative"
| "builder.promote.parties.role"
| "builder.promote.step1"
| "builder.promote.step2"
| "builder.promote.step3"
| "builder.promote.success"
| "builder.promote.summary.events_filed"
| "builder.promote.summary.events_planned"
| "builder.promote.summary.flags"
| "builder.promote.summary.heading"
| "builder.promote.summary.note_extra"
| "builder.promote.summary.proceeding"
| "builder.promote.title"
| "builder.readonly.blocked"
| "builder.readonly.watermark"
| "builder.save.error"
| "builder.save.idle"
| "builder.save.saved"
@@ -789,6 +833,16 @@ export type I18nKey =
| "builder.search.summary.projects.other"
| "builder.search.summary.scenarios.one"
| "builder.search.summary.scenarios.other"
| "builder.share.button"
| "builder.share.close"
| "builder.share.current.empty"
| "builder.share.current.title"
| "builder.share.error"
| "builder.share.no_results"
| "builder.share.revoke"
| "builder.share.search.placeholder"
| "builder.share.subtitle"
| "builder.share.title"
| "builder.subtitle"
| "builder.triplet.collapse"
| "builder.triplet.detailgrad.all_options"
@@ -2788,6 +2842,9 @@ export type I18nKey =
| "submissions.draft.base.hint"
| "submissions.draft.base.label"
| "submissions.draft.import.button"
| "submissions.draft.keyword.hint"
| "submissions.draft.keyword.label"
| "submissions.draft.keyword.placeholder"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"

View File

@@ -68,12 +68,10 @@ export function renderProcedures(): string {
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="builder-pageheader-row">
@@ -141,10 +139,28 @@ export function renderProcedures(): string {
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
</div>
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
Each bucket hides itself when empty (builder.ts toggles
the hidden attribute). */}
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
</div>
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
</div>
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
</div>
</aside>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
{/* B5 — read-only watermark for shared / promoted scenarios.
builder.ts fills + unhides it when the active scenario
is not editable by the current user. */}
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}

View File

@@ -20648,3 +20648,414 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* ===================================================================
B5 — side-panel buckets, read-only watermark, share modal,
promote-to-project wizard (m/paliad#153 PRD §2.4 + §2.5).
=================================================================== */
.builder-sidepanel-bucket + .builder-sidepanel-bucket {
margin-top: 0.85rem;
padding-top: 0.65rem;
border-top: 1px solid var(--color-border);
}
.builder-bucket-label {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
/* Read-only watermark banner above the canvas. */
.builder-readonly-watermark {
display: block;
margin-bottom: 0.6rem;
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
background: repeating-linear-gradient(
45deg,
var(--color-surface-muted),
var(--color-surface-muted) 10px,
var(--color-surface-2) 10px,
var(--color-surface-2) 20px
);
border: 1px dashed var(--color-border);
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 500;
}
/* Read-only mode: neutralise every mutating affordance in the canvas
while keeping text selectable + read interactions working. PRD §2.5 /
§10 — pointer-events:none on the controls, not the cards. */
body.builder-readonly .builder-triplet-host button,
body.builder-readonly .builder-triplet-host input,
body.builder-readonly .builder-triplet-host select,
body.builder-readonly .builder-add-proceeding-btn {
pointer-events: none;
opacity: 0.5;
}
/* Generic modal scaffold (shared by share modal + promote wizard). */
.builder-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.4);
}
.builder-modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 540px;
max-height: calc(100vh - 2rem);
overflow: auto;
padding: 1.1rem 1.25rem 1.25rem;
}
.builder-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.builder-modal-title {
margin: 0;
font-size: 1.1rem;
}
.builder-modal-close {
font: inherit;
font-size: 1.4rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-muted);
padding: 0 0.3rem;
}
.builder-modal-subtitle {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
/* Share modal. */
.builder-share-pickerbox {
position: relative;
}
.builder-share-search {
width: 100%;
font: inherit;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 0.35rem;
background: var(--color-surface-2);
color: var(--color-text);
}
.builder-share-results {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
max-height: 220px;
overflow: auto;
}
.builder-share-result,
.builder-share-current-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.4rem;
border-radius: 0.3rem;
}
.builder-share-result:hover {
background: var(--color-surface-muted);
}
.builder-share-result-empty,
.builder-share-current-empty {
padding: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-muted);
list-style: none;
}
.builder-share-add,
.builder-share-revoke {
font: inherit;
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text);
white-space: nowrap;
}
.builder-share-add {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-share-current {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.builder-share-current-title {
margin: 0 0 0.4rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
}
.builder-share-current-list {
list-style: none;
margin: 0;
padding: 0;
}
.builder-share-error {
margin: 0.5rem 0 0;
font-size: 0.82rem;
color: #c0392b;
}
/* Promote wizard. */
.builder-promote-modal {
max-width: 600px;
}
.builder-promote-steps {
display: flex;
list-style: none;
margin: 0 0 1rem;
padding: 0;
gap: 0.5rem;
}
.builder-promote-step {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--color-text-muted);
flex: 1;
}
.builder-promote-step-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
border: 1px solid var(--color-border);
font-size: 0.78rem;
flex: 0 0 auto;
}
.builder-promote-step.is-active {
color: var(--color-text);
font-weight: 600;
}
.builder-promote-step.is-active .builder-promote-step-n {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.builder-promote-step.is-done .builder-promote-step-n {
background: var(--color-surface-muted);
}
.builder-promote-body {
min-height: 160px;
}
.builder-promote-section-title {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.builder-promote-summary {
list-style: none;
margin: 0;
padding: 0;
}
.builder-promote-summary li {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.builder-promote-summary li span {
color: var(--color-text-muted);
}
.builder-promote-note {
margin: 0.75rem 0 0;
padding: 0.5rem 0.65rem;
border-radius: 0.35rem;
background: var(--color-surface-muted);
font-size: 0.82rem;
color: var(--color-text-muted);
}
.builder-promote-hint,
.builder-promote-empty,
.builder-promote-team-hint {
font-size: 0.82rem;
color: var(--color-text-muted);
margin: 0 0 0.6rem;
}
.builder-promote-parties {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.builder-promote-party {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr auto;
gap: 0.35rem;
align-items: center;
}
.builder-promote-party input,
.builder-promote-field input,
.builder-promote-field select {
font: inherit;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.3rem;
background: var(--color-surface-2);
color: var(--color-text);
width: 100%;
}
.builder-promote-party-remove {
font: inherit;
font-size: 1.1rem;
line-height: 1;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-muted);
}
.builder-promote-party-add {
font: inherit;
font-size: 0.85rem;
padding: 0.3rem 0.7rem;
border-radius: 0.3rem;
cursor: pointer;
border: 1px dashed var(--color-border);
background: transparent;
color: var(--color-text);
}
.builder-promote-field {
display: block;
margin-bottom: 0.7rem;
}
.builder-promote-field > span {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.builder-promote-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.builder-promote-team {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 160px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 0.35rem;
padding: 0.4rem;
}
.builder-promote-team-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.builder-promote-error {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: #c0392b;
}
.builder-promote-success {
text-align: center;
padding: 2rem 0;
font-size: 1rem;
color: var(--color-text);
}
.builder-promote-footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px solid var(--color-border);
}
.builder-promote-footer-spacer {
flex: 1;
}
.builder-promote-cancel,
.builder-promote-backbtn,
.builder-promote-nextbtn {
font: inherit;
padding: 0.4rem 0.95rem;
border-radius: 0.35rem;
cursor: pointer;
border: 1px solid var(--color-border);
background: var(--color-surface-2);
color: var(--color-text);
}
.builder-promote-nextbtn {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-promote-nextbtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 640px) {
.builder-promote-party,
.builder-promote-field-row {
grid-template-columns: 1fr;
}
/* B6 — modals go full-bleed on phones so the wizard/share UI is at
least legible if reached; entry is gated by the mobile guard, but
keep it readable for the read-only case. */
.builder-modal {
max-width: 100%;
border-radius: 0.4rem;
}
}
/* B6 — mobile basic-read guard toast (PRD §7.1 + §10). Shown when a
mutating affordance is tapped on a narrow viewport. */
.builder-mobile-toast {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%) translateY(1rem);
z-index: 1100;
max-width: calc(100vw - 2rem);
padding: 0.6rem 1rem;
border-radius: 0.5rem;
background: var(--color-accent-dark, #0b1f33);
color: #fff;
font-size: 0.88rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.builder-mobile-toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}

View File

@@ -171,6 +171,33 @@ export function renderSubmissionDraft(): string {
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
{/* t-paliad-354 — keyword that leads the exported
document name "<date> <keyword> (<case>)". Empty
falls back to the auto-derived rule name; the
placeholder shows that default. Persisted to
composer_meta.filename_keyword via the draft-save
path on change. */}
<div className="submission-draft-keyword-row">
<label
htmlFor="submission-draft-keyword"
data-i18n="submissions.draft.keyword.label">
Stichwort (Dateiname)
</label>
<input
type="text"
id="submission-draft-keyword"
className="entity-form-input"
data-i18n-placeholder="submissions.draft.keyword.placeholder"
placeholder="Automatisch aus dem Schriftsatztyp"
/>
<p
className="submission-draft-keyword-hint"
id="submission-draft-keyword-hint"
data-i18n="submissions.draft.keyword.hint">
Führt den Dateinamen an: &lt;Datum&gt; &lt;Stichwort&gt; (&lt;Aktenzeichen&gt;).
</p>
</div>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
{/* t-paliad-277: "Aus Projekt importieren" + last-

View File

@@ -542,6 +542,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// scenario from a paliad.projects row; subsequent edits dual-write
// through to paliad.deadlines + paliad.projects.scenario_flags.
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
// m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins
// over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}.
protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared)
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
@@ -552,6 +555,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
// m/paliad#153 B5 — transactional promote-to-project wizard commit.
protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote)
// m/paliad#153 B2 — read-only passthrough so the builder can render
// per-triplet flag toggles without a per-project round-trip.
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)

View File

@@ -433,6 +433,62 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shared-with-me + Promote (B5, m/paliad#153)
// ---------------------------------------------------------------------------
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
//
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
//
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
// partial promotions) and returns PromoteResult with the new project id
// the wizard navigates to (/projects/{project_id}).
func handleBuilderScenarioPromote(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var input services.PromoteScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PromoteScenario(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// ---------------------------------------------------------------------------
// Scenario flag catalog passthrough (m/paliad#153 B2)
// ---------------------------------------------------------------------------

View File

@@ -180,6 +180,11 @@ type submissionDraftPatchInput struct {
// base_id: absent = no change, uuid = pin, null = clear.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
TemplateVersionIDSet bool `json:"-"`
// FilenameKeyword overrides the leading keyword of the exported
// document name (t-paliad-354). Absent = no change; "" = clear back
// to the auto-derived rule name; "x" = set. Persisted in
// composer_meta.filename_keyword.
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
@@ -446,6 +451,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
Variables: input.Variables,
SelectedParties: input.SelectedParties,
Language: input.Language,
FilenameKeyword: input.FilenameKeyword,
}
if input.BaseIDSet {
patch.BaseID = &input.BaseID
@@ -592,7 +598,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
// Audit + provenance updates are best-effort on a background
// context so the download still succeeds if the DB races.
@@ -939,6 +945,10 @@ type globalDraftPatchInput struct {
// (t-paliad-349 slice 7), same present/absent contract as base_id.
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
templateVersionIDProvided bool
// FilenameKeyword overrides the leading keyword of the exported
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
// set. Persisted in composer_meta.filename_keyword.
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
@@ -950,6 +960,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
FilenameKeyword *string `json:"filename_keyword,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -962,6 +973,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.SelectedParties = a.SelectedParties
g.BaseID = a.BaseID
g.TemplateVersionID = a.TemplateVersionID
g.FilenameKeyword = a.FilenameKeyword
// Detect whether "project_id" / "base_id" / "template_version_id" were
// present in the JSON object.
var raw map[string]json.RawMessage
@@ -1006,6 +1018,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
Variables: in.Variables,
SelectedParties: in.SelectedParties,
Language: in.Language,
FilenameKeyword: in.FilenameKeyword,
}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
@@ -1141,7 +1154,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()

View File

@@ -0,0 +1,144 @@
package handlers
// Regression tests for the generated-document download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
// The date segment is environment-dependent (Europe/Berlin "today"),
// so the assertions pin the keyword + bracketed case-number frame and
// the .docx suffix rather than the literal date.
import (
"strings"
"testing"
"time"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
func strptr(s string) *string { return &s }
func todayBerlin() string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
return day.Format("2006-01-02")
}
func TestSubmissionFileName(t *testing.T) {
t.Parallel()
rule := &models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of defence"}
date := todayBerlin()
cases := []struct {
name string
rule *models.DeadlineRule
project *models.Project
lang string
keyword string
want string
}{
{
name: "full data — rule name + case number",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "missing case number falls back to placeholder",
rule: rule,
project: &models.Project{CaseNumber: nil},
lang: "de",
want: date + " Klageerwiderung (Az. folgt).docx",
},
{
name: "user override keyword wins over rule name",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
keyword: "Replik Hauptantrag",
want: date + " Replik Hauptantrag (UPC_CFI_123_2026).docx",
},
{
name: "EN lang uses NameEN when no override",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "en",
want: date + " Statement of defence (UPC_CFI_123_2026).docx",
},
{
name: "case number containing slash is sanitised inside brackets",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123/2026")},
lang: "de",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "blank override falls back to rule name",
rule: rule,
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
lang: "de",
keyword: " ",
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
},
{
name: "empty rule name + no override falls back to submission",
rule: &models.DeadlineRule{Name: "", NameEN: ""},
project: &models.Project{CaseNumber: nil},
lang: "de",
want: date + " submission (Az. folgt).docx",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := submissionFileName(tc.rule, tc.project, tc.lang, tc.keyword)
if got != tc.want {
t.Errorf("submissionFileName() = %q, want %q", got, tc.want)
}
if !strings.HasSuffix(got, ".docx") {
t.Errorf("filename %q missing .docx suffix", got)
}
})
}
}
func TestSubmissionFilenameKeyword(t *testing.T) {
t.Parallel()
cases := []struct {
name string
draft *services.SubmissionDraft
want string
}{
{"nil draft", nil, ""},
{"nil meta", &services.SubmissionDraft{}, ""},
{
"key absent",
&services.SubmissionDraft{ComposerMeta: map[string]any{"other": "x"}},
"",
},
{
"key set",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
"Replik",
},
{
"key set with surrounding whitespace is trimmed",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": " Replik "}},
"Replik",
},
{
"non-string value ignored",
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": 42}},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := submissionFilenameKeyword(tc.draft); got != tc.want {
t.Errorf("submissionFilenameKeyword() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -336,7 +336,9 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
// One-click /generate has no saved draft row → no override store, so
// the keyword stays the auto-derived rule name (t-paliad-354).
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, "")
// Audit write is best-effort with a background context so the
// download still succeeds if the DB races. Audit failure here only
@@ -355,34 +357,66 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
}
}
// submissionFileName produces the user-facing download name per
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
// Empty case_number drops the segment entirely (no fallback hash —
// the lawyer can rename if the project lacks an Aktenzeichen).
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
// so the file lands cleanly on legacy SMB shares.
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot
// when the project has no Aktenzeichen yet. Kept as a named const so the
// wording is one-line changeable (m left the exact text open, t-paliad-354).
const submissionNoCaseNumberPlaceholder = "Az. folgt"
// submissionFileName produces the user-facing download name
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
//
// - Date first (Europe/Berlin) so the files sort chronologically.
// - keyword is the user override when set, else the lang-aware rule
// name, else "submission".
// - The case number is always rendered in parentheses; when the project
// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder.
//
// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for
// legacy SMB shares, strips the Windows-reserved set so a case number like
// "UPC_CFI_123/2026" stays safe) while the assembled "<date> <kw> (<case>)"
// frame keeps its spaces and brackets — the sanitiser preserves both.
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
day := time.Now()
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
day = day.In(loc)
}
ruleName := strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
ruleName = strings.TrimSpace(rule.NameEN)
kw := strings.TrimSpace(keyword)
if kw == "" {
kw = strings.TrimSpace(rule.Name)
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
kw = strings.TrimSpace(rule.NameEN)
}
}
if ruleName == "" {
ruleName = "submission"
if kw == "" {
kw = "submission"
}
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
caseNo := ""
if project != nil && project.CaseNumber != nil {
caseNo = strings.TrimSpace(*project.CaseNumber)
}
if caseNo != "" {
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
if caseNo == "" {
caseNo = submissionNoCaseNumberPlaceholder
}
parts = append(parts, day.Format("2006-01-02"))
return strings.Join(parts, "-") + ".docx"
return fmt.Sprintf("%s %s (%s).docx",
day.Format("2006-01-02"),
services.SanitiseSubmissionFileName(kw),
services.SanitiseSubmissionFileName(caseNo),
)
}
// submissionFilenameKeyword pulls the user's filename keyword override
// from a saved draft's composer_meta jsonb (t-paliad-354). Empty when the
// key is absent or blank — callers then fall back to the auto-derived rule
// name inside submissionFileName. The one-click /generate path has no draft
// row and always passes "".
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
if d == nil || d.ComposerMeta == nil {
return ""
}
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// writeSubmissionAuditRow files one row in paliad.system_audit_log per

View File

@@ -37,16 +37,24 @@ type ScenarioBuilderService struct {
db *sqlx.DB
projects *ProjectService
flags *ScenarioFlagsService
// fristenrechner computes planned-deadline due dates during the B5
// promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in
// test setups that don't exercise promotion; the promote path then
// skips planned events that have no actual_date (it can't assert a
// date it didn't compute) and reports them via DeadlinesSkipped.
fristenrechner *FristenrechnerService
}
// NewScenarioBuilderService wires the service to the shared pool plus
// the project + scenario-flags services it leans on for the Akte-mode
// dual-write. projects + flags are optional in test setups (nil → the
// dual-write hooks short-circuit), but a production wiring should
// always pass them so Akte-backed scenarios stay in sync with project
// surfaces.
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db, projects: projects, flags: flags}
// dual-write, and the Fristenrechner calc service the B5 promote path
// uses to compute planned-deadline dates. projects / flags / frist are
// optional in test setups (nil → the dual-write + promote-compute hooks
// short-circuit), but a production wiring should always pass them so
// Akte-backed scenarios stay in sync with project surfaces and
// promotion cascades real dates.
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist}
}
// ErrScenarioBuilderNotVisible is returned when the caller is neither
@@ -928,6 +936,438 @@ func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenar
return nil
}
// -----------------------------------------------------------------------------
// Shared-with-me listing (B5)
// -----------------------------------------------------------------------------
// ListSharedWithMe returns scenarios shared read-only with the caller
// (a paliad.scenario_shares row exists for shared_with_user_id = caller).
// The caller's own scenarios are excluded — they live in ListMyScenarios.
// Sorted by the share's created_at desc so the most-recently-shared sits
// on top. Promoted scenarios stay visible (read-only reference) just like
// in the owner's own list.
func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) {
out := []BuilderScenario{}
if err := s.db.SelectContext(ctx, &out,
`SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id,
sc.promoted_project_id, sc.stichtag, sc.notes,
sc.project_id, sc.description, sc.created_by,
sc.created_at, sc.updated_at
FROM paliad.scenarios sc
JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id
WHERE sh.shared_with_user_id = $1
AND (sc.owner_id IS NULL OR sc.owner_id <> $1)
ORDER BY sh.created_at DESC`, userID); err != nil {
return nil, fmt.Errorf("list shared scenarios: %w", err)
}
return out, nil
}
// -----------------------------------------------------------------------------
// Promote-to-project (B5, PRD §2.4 + §5.4 + §10)
// -----------------------------------------------------------------------------
// PromotePartyInput is one party row the wizard's "Parteien ergänzen"
// step contributes. Mirrors CreatePartyInput minus contact_info (the
// wizard collects names + roles; full contact data is filled in the Akte
// later).
type PromotePartyInput struct {
Name string `json:"name"`
Role *string `json:"role,omitempty"`
Representative *string `json:"representative,omitempty"`
}
// PromoteTeamMemberInput grants a colleague access to the new project at
// promote time. Responsibility defaults to 'member' when blank.
type PromoteTeamMemberInput struct {
UserID uuid.UUID `json:"user_id"`
Responsibility string `json:"responsibility,omitempty"`
}
// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote
// body — the merged payload from wizard steps 2 (Parteien) + 3
// (Akte-Metadaten). The procedural shape (proceeding type, flags,
// perspective) + event states come from the scenario itself; the wizard
// only supplies the client-bound metadata the scenario can't know.
type PromoteScenarioInput struct {
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
Parties []PromotePartyInput `json:"parties,omitempty"`
TeamMembers []PromoteTeamMemberInput `json:"team_members,omitempty"`
}
// PromoteResult is the outcome the wizard navigates on.
type PromoteResult struct {
ProjectID uuid.UUID `json:"project_id"`
DeadlinesCreated int `json:"deadlines_created"`
DeadlinesSkipped int `json:"deadlines_skipped"`
PartiesCreated int `json:"parties_created"`
ProceedingsSkipped int `json:"proceedings_skipped"`
}
// PromoteScenario turns a scenario into a real paliad.projects 'case' row
// in a single transaction (PRD §10 — no partial promotions). It promotes
// the scenario's primary proceeding (the lowest-ordinal top-level
// triplet) plus its spawned descendants (the CCR child etc., whose rules
// fold into the primary's timeline under the active flags). Additional
// unrelated top-level proceedings are left in the scenario and reported
// via ProceedingsSkipped — v1 promotes one case file per call, matching
// the singular acceptance criterion (one project, navigate to one id);
// the scenario stays visible as 'promoted' for historical reference and
// can seed a second promotion later.
//
// The cascade, all inside the tx:
// 1. INSERT paliad.projects (type='case', client metadata from the
// wizard, proceeding_type_id + scenario_flags from the primary
// triplet, origin_scenario_id = scenario.id).
// 2. INSERT the creator as team lead + any wizard-selected colleagues.
// 3. INSERT parties from the wizard's step-2 payload.
// 4. For each event under the promoted proceedings: filed → a completed
// deadline (due_date + completed_at = actual_date); planned → an open
// ('pending') deadline with the computed due_date; skipped → no row.
// Planned events with no computable date (court-set / conditional /
// no actual_date) are skipped and counted.
// 5. UPDATE the scenario: status='promoted', promoted_project_id = new.
//
// Any error rolls the whole transaction back.
func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, error) {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if sc.Status == "promoted" {
return nil, fmt.Errorf("%w: scenario is already promoted", ErrInvalidInput)
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
}
for i := range input.Parties {
if strings.TrimSpace(input.Parties[i].Name) == "" {
return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1)
}
}
for _, tm := range input.TeamMembers {
if tm.UserID == uuid.Nil {
return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput)
}
if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) {
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility)
}
}
// Parent visibility (mirrors ProjectService.Create): a litigation
// parent the caller can't see would leak the new sub-tree.
if input.ParentID != nil && s.projects != nil {
if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil {
return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden)
}
}
// Load the proceeding + event tree.
proceedings := []BuilderProceeding{}
if err := s.db.SelectContext(ctx, &proceedings, `
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed, created_at, updated_at
FROM paliad.scenario_proceedings
WHERE scenario_id = $1
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load proceedings: %w", err)
}
if len(proceedings) == 0 {
return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput)
}
// Primary = first top-level proceeding (lowest ordinal). Collect it +
// its spawned descendants; those form the one case file we promote.
var primary *BuilderProceeding
for i := range proceedings {
if proceedings[i].ParentScenarioProceedingID == nil {
primary = &proceedings[i]
break
}
}
if primary == nil {
return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput)
}
promoteSet := collectProceedingSubtree(proceedings, primary.ID)
topLevelCount := 0
for i := range proceedings {
if proceedings[i].ParentScenarioProceedingID == nil {
topLevelCount++
}
}
// Resolve the primary proceeding's catalog code (the calc engine keys
// off code, not id).
var primaryCode string
if err := s.db.GetContext(ctx, &primaryCode,
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil {
return nil, fmt.Errorf("resolve proceeding code: %w", err)
}
// Resolve our_side: explicit wizard value wins; otherwise fold the
// primary triplet's perspective down to the project axis.
ourSide := input.OurSide
if ourSide == nil {
ourSide = primary.PrimaryParty
}
// Compute the primary proceeding's timeline so planned events get real
// dates. The CCR child's rules fold into this timeline under the
// primary's flags (sub-track routing), so one calc covers the whole
// promoted subtree. Keyed by lowercased rule id → display name/code/date.
type computed struct {
name string
code string
dueDate string
}
timelineByRule := map[string]computed{}
if s.fristenrechner != nil {
stichtag := promoteStichtag(primary, sc)
opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)}
tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts)
if cerr != nil {
// A calc failure is not fatal — filed events still carry their
// own actual_date. Planned events then fall to DeadlinesSkipped.
tl = nil
}
if tl != nil {
for _, e := range tl.Deadlines {
if e.RuleID == "" {
continue
}
timelineByRule[strings.ToLower(e.RuleID)] = computed{
name: e.Name, code: e.Code, dueDate: e.DueDate,
}
}
}
}
// Load events for the promoted proceedings only.
events := []BuilderEvent{}
if err := s.db.SelectContext(ctx, &events, `
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
e.skip_reason, e.notes, e.horizon_optional, e.created_at, e.updated_at
FROM paliad.scenario_events e
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
WHERE sp.scenario_id = $1
ORDER BY e.created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load events: %w", err)
}
result := &PromoteResult{
ProceedingsSkipped: topLevelCount - 1,
}
newProjectID := uuid.New()
err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error {
now := time.Now().UTC()
// 1. Project row. path is filled by the BEFORE INSERT trigger
// (projects_sync_path); '' satisfies the NOT NULL constraint.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, reference, status, created_by,
case_number, client_number, proceeding_type_id, our_side,
scenario_flags, origin_scenario_id, metadata, created_at, updated_at)
VALUES ($1, 'case', $2, '', $3, $4, 'active', $5,
$6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`,
newProjectID, input.ParentID, title, input.Reference, userID,
nullableTrimmed(stringPtrOrNil(input.CaseNumber)),
nullableTrimmed(input.ClientNumber),
primary.ProceedingTypeID, nullableOurSide(ourSide),
[]byte(primary.ScenarioFlags), scenarioID, now); err != nil {
return fmt.Errorf("insert project: %w", err)
}
// 2a. Creator as team lead (RLS visibility, matches Create).
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil {
return fmt.Errorf("insert creator team row: %w", err)
}
// 2b. Wizard-selected colleagues.
for _, tm := range input.TeamMembers {
if tm.UserID == userID {
continue // creator already added as lead
}
resp := tm.Responsibility
if resp == "" {
resp = ResponsibilityMember
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, $3, $4, false, $5)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`,
newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil {
return fmt.Errorf("insert team member: %w", err)
}
}
// 3. Parties.
for _, p := range input.Parties {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.parties (project_id, name, role, representative, contact_info)
VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil {
return fmt.Errorf("insert party: %w", err)
}
result.PartiesCreated++
}
// 4. Deadlines from the promoted proceedings' events.
for _, ev := range events {
if !promoteSet[ev.ScenarioProceedingID] {
continue
}
if ev.State == "skipped" {
continue
}
if ev.SequencingRuleID == nil {
// Free-form / procedural-event-only cards have no rule to
// anchor a deadline on in v1 — skip (counts as skipped only
// when it was a dated plan; here just leave it out).
continue
}
ruleKey := strings.ToLower(ev.SequencingRuleID.String())
comp := timelineByRule[ruleKey]
title := comp.name
if strings.TrimSpace(title) == "" {
title = "Litigation-Builder Frist"
}
ruleCode := comp.code
if ev.State == "filed" && ev.ActualDate != nil {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, rule_code, due_date, sequencing_rule_id,
status, completed_at, source, approval_status)
VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`,
newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate,
*ev.SequencingRuleID, *ev.ActualDate); err != nil {
return fmt.Errorf("insert filed deadline: %w", err)
}
result.DeadlinesCreated++
continue
}
// planned — need a date. Prefer an explicit actual_date
// (court-set override the user pinned), else the computed date.
var dueDate *time.Time
if ev.ActualDate != nil {
dueDate = ev.ActualDate
} else if comp.dueDate != "" {
if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil {
dueDate = &d
}
}
if dueDate == nil {
result.DeadlinesSkipped++
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, rule_code, due_date, sequencing_rule_id,
status, source, approval_status)
VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`,
newProjectID, title, nullableTrimmed(&ruleCode), *dueDate,
*ev.SequencingRuleID); err != nil {
return fmt.Errorf("insert planned deadline: %w", err)
}
result.DeadlinesCreated++
}
// 5. Flip the scenario to promoted.
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.scenarios
SET status = 'promoted', promoted_project_id = $1, updated_at = now()
WHERE id = $2`, newProjectID, scenarioID); err != nil {
return fmt.Errorf("mark scenario promoted: %w", err)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("promote scenario: %w", err)
}
result.ProjectID = newProjectID
return result, nil
}
// collectProceedingSubtree returns the set of proceeding ids rooted at
// rootID (inclusive), walking parent_scenario_proceeding_id downwards.
func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool {
set := map[uuid.UUID]bool{rootID: true}
// Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice.
for changed := true; changed; {
changed = false
for i := range all {
p := &all[i]
if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] {
set[p.ID] = true
changed = true
}
}
}
return set
}
// promoteStichtag picks the calc anchor for the promote timeline: the
// primary proceeding's own stichtag, else the scenario default, else today.
func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string {
if primary.Stichtag != nil {
return primary.Stichtag.Format("2006-01-02")
}
if sc.Stichtag != nil {
return sc.Stichtag.Format("2006-01-02")
}
return time.Now().UTC().Format("2006-01-02")
}
// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the
// builder's scenario_flags jsonb — the array shape the calc engine's
// CalcOptions.Flags consumes.
func scenarioFlagsTruthyKeys(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
out := []string{}
for k, v := range m {
if b, ok := v.(bool); ok && b {
out = append(out, k)
}
}
return out
}
// stringPtrOrNil normalises a *string so an all-whitespace value becomes
// nil before nullableTrimmed sees it (case_number empty → NULL column).
func stringPtrOrNil(p *string) *string {
if p == nil {
return nil
}
if strings.TrimSpace(*p) == "" {
return nil
}
return p
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------

View File

@@ -83,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) {
t.Fatalf("look up upc.inf id: %v", err)
}
svc := NewScenarioBuilderService(pool, nil, nil)
svc := NewScenarioBuilderService(pool, nil, nil, nil)
// 1. Create a scenario for the owner. Empty name should default.
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
@@ -318,7 +318,7 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) {
userSvc := NewUserService(pool)
projSvc := NewProjectService(pool, userSvc)
flagsSvc := NewScenarioFlagsService(pool, projSvc)
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc)
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
// ──────────────────────────────────────────────────────────────────
// Phase A — Akte-backed scenario writes through to project tables.
@@ -459,6 +459,190 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) {
}
}
// TestScenarioBuilderPromote pins B5's load-bearing contract
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
// creates a paliad.projects 'case' row transactionally, cascades parties
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
// makes the original scenario read-only afterwards.
func TestScenarioBuilderPromote(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
owner := uuid.New()
var createdProjectID uuid.UUID
cleanup := func() {
if createdProjectID != uuid.Nil {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
}
pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang)
VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
var ptID int
if err := pool.GetContext(ctx, &ptID,
`SELECT id FROM paliad.proceeding_types WHERE code = $1 AND is_active = true LIMIT 1`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up upc.inf id: %v", err)
}
// Two distinct rule ids: one filed, one planned (with an explicit
// actual_date so the planned deadline lands even without a calc engine).
var ruleIDs []uuid.UUID
if err := pool.SelectContext(ctx, &ruleIDs,
`SELECT id FROM paliad.sequencing_rules
WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
t.Fatalf("look up sequencing_rules: %v", err)
}
if len(ruleIDs) < 2 {
t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
}
userSvc := NewUserService(pool)
projSvc := NewProjectService(pool, userSvc)
flagsSvc := NewScenarioFlagsService(pool, projSvc)
// fristenrechner nil — planned events carry an explicit actual_date in
// this test, so the cascade doesn't need computed dates.
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
if err != nil {
t.Fatalf("CreateScenario: %v", err)
}
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("defendant"),
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
})
if err != nil {
t.Fatalf("AddProceeding: %v", err)
}
filedDate := mustDate(t, "2026-01-15")
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
}); err != nil {
t.Fatalf("AddEvent filed: %v", err)
}
plannedDate := mustDate(t, "2026-04-01")
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
}); err != nil {
t.Fatalf("AddEvent planned: %v", err)
}
res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
Title: "Becker ./. X — UPC",
CaseNumber: ptrString("UPC_CFI_123/2026"),
OurSide: ptrString("defendant"),
Parties: []PromotePartyInput{
{Name: "Becker GmbH", Role: ptrString("defendant")},
{Name: "X Corp", Role: ptrString("claimant")},
},
})
if err != nil {
t.Fatalf("PromoteScenario: %v", err)
}
createdProjectID = res.ProjectID
if res.ProjectID == uuid.Nil {
t.Fatal("PromoteScenario returned nil project id")
}
if res.PartiesCreated != 2 {
t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
}
if res.DeadlinesCreated != 2 {
t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
}
// Project row exists, is a 'case', carries origin_scenario_id + flags.
var proj struct {
Type string `db:"type"`
OriginScenario *uuid.UUID `db:"origin_scenario_id"`
ProceedingType *int `db:"proceeding_type_id"`
OurSide *string `db:"our_side"`
ScenarioFlags json.RawMessage `db:"scenario_flags"`
CaseNumber *string `db:"case_number"`
}
if err := pool.GetContext(ctx, &proj,
`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
t.Fatalf("load promoted project: %v", err)
}
if proj.Type != "case" {
t.Errorf("project type = %q, want case", proj.Type)
}
if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
}
if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
}
if proj.OurSide == nil || *proj.OurSide != "defendant" {
t.Errorf("our_side = %v, want defendant", proj.OurSide)
}
// Scenario flipped to promoted with the back-ref.
var after BuilderScenario
if err := pool.GetContext(ctx, &after,
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes, project_id, description, created_by, created_at, updated_at
FROM paliad.scenarios WHERE id = $1`, sc.ID); err != nil {
t.Fatalf("reload scenario: %v", err)
}
if after.Status != "promoted" {
t.Errorf("scenario status = %q, want promoted", after.Status)
}
if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
}
// Deadlines + parties physically present.
var deadlineCount, partyCount int
pool.GetContext(ctx, &deadlineCount,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
pool.GetContext(ctx, &partyCount,
`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
if deadlineCount != 2 {
t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
}
if partyCount != 2 {
t.Errorf("parties in DB = %d, want 2", partyCount)
}
// Promoted scenario is now read-only: further PATCH is rejected.
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Name: ptrString("rename-after-promote"),
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
}
// Re-promoting is rejected.
if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("re-promote = %v, want ErrInvalidInput", err)
}
}
// mustDate parses an ISO date or fails the test. Helper for the
// dual-write test above.
func mustDate(t *testing.T, s string) time.Time {

View File

@@ -0,0 +1,178 @@
package services
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
// m/paliad#155). A new project-bound draft gets a sortable, legal-
// convention default title instead of the bare "Entwurf N" counter:
//
// <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
//
// The date leads so drafts sort chronologically; " ./. " is the German
// legal shorthand for "gegen". The three identity segments are the
// client we act for, the forum the proceeding runs in, and the opposing
// party — exactly the trio m named ("CLIENTNAME / UPC / OPPONENTNAME").
//
// Missing-segment rule: any segment that resolves empty is dropped
// together with its leading separator, so a project without an opponent
// yet renders "2026-05-31 Bayer AG ./. UPC" (no trailing separator) and
// a project-less draft never reaches this path at all (it keeps the
// "Entwurf N" counter — see SubmissionDraftService.Create).
//
// v1.1 customization hook: the template is hardcoded here in v1. When m
// promotes naming to a per-user / per-firm / per-base setting (issue
// #155 Q4), the override string lands as an extra parameter on
// AutoSubmissionTitle (or a small template struct) and the segment
// resolvers below stay as the value source. Nothing else needs to move.
import (
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
// submissionTitleSep is the separator between identity segments —
// " ./. " is the German legal convention for "gegen" / "versus".
const submissionTitleSep = " ./. "
// AutoSubmissionTitle assembles the auto-generated draft title from the
// resolved identity pieces. Pure and table-testable — every DB hop
// happens in the caller (SubmissionDraftService.autoNameForProject).
//
// clientName is passed separately because the client we act for is the
// root ancestor of the project tree, not a field on the draft's own
// project node; the caller walks the path to resolve it. ourSide and
// the proceeding type both come off the draft's project node, the
// parties hang directly off it.
//
// The date is always present (formatted in Europe/Berlin to match the
// today.* render vars); the three identity segments are appended only
// when non-empty.
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
loc, _ := time.LoadLocation("Europe/Berlin")
if loc != nil {
now = now.In(loc)
}
date := now.Format("2006-01-02")
segments := make([]string, 0, 3)
if c := strings.TrimSpace(clientName); c != "" {
segments = append(segments, c)
}
if f := submissionForumShort(pt); f != "" {
segments = append(segments, f)
}
ourSide := ""
if project != nil {
ourSide = derefString(project.OurSide)
}
if o := submissionOpponentName(parties, ourSide); o != "" {
segments = append(segments, o)
}
if len(segments) == 0 {
return date
}
return date + " " + strings.Join(segments, submissionTitleSep)
}
// submissionForumShort maps a proceeding type to the short forum label
// used in the auto-name. The jurisdiction is the forum for the
// supranational / office tracks (UPC, EPA, DPMA); German court
// proceedings disambiguate by the court that hears them (LG / OLG /
// BGH / BPatG), which is the tail segment of the proceeding code
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
func submissionForumShort(pt *models.ProceedingType) string {
if pt == nil {
return ""
}
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
case "":
return ""
case "DE":
return germanCourtShort(pt.Code)
default:
// UPC / EPA / DPMA and any future jurisdiction are their own
// forum label.
return j
}
}
// germanCourtShort returns the court abbreviation from the tail segment
// of a German proceeding code (the part after the last "."). Known
// courts get their canonical casing; anything else falls back to the
// uppercased tail so a new German proceeding still yields a label.
func germanCourtShort(code string) string {
parts := strings.Split(code, ".")
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
switch tail {
case "":
return ""
case "lg":
return "LG"
case "olg":
return "OLG"
case "bgh":
return "BGH"
case "bpatg":
return "BPatG"
default:
return strings.ToUpper(tail)
}
}
// submissionOpponentName picks the name of the primary opposing party
// given the side we act for. We act actively (claimant / applicant /
// appellant) → the opponent is on the defendant bucket; we act
// reactively (defendant / respondent) → the opponent is the claimant.
// An unknown / unset side (third_party, other, NULL) can't fix a
// posture, so no opponent is derived (the segment is omitted). The
// first party of the opposing bucket wins — PartyService.ListForProject
// orders by name, so the pick is deterministic for a given project.
func submissionOpponentName(parties []models.Party, ourSide string) string {
var want string
switch sidePosture(ourSide) {
case "active":
want = "defendant"
case "reactive":
want = "claimant"
default:
return ""
}
for i := range parties {
if partyRoleBucket(parties[i].Role) == want {
if n := strings.TrimSpace(parties[i].Name); n != "" {
return n
}
}
}
return ""
}
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
// down to the active / reactive axis. Returns "" for sides that have no
// clear posture (third_party, other) or an unset value.
func sidePosture(ourSide string) string {
switch strings.ToLower(strings.TrimSpace(ourSide)) {
case "claimant", "applicant", "appellant":
return "active"
case "defendant", "respondent":
return "reactive"
default:
return ""
}
}
// partyRoleBucket folds a party's free-text role into the
// claimant / defendant / other buckets. German and English spellings
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
// "other". Shared with addPartyVars so the two paths can't drift.
func partyRoleBucket(role *string) string {
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
return "claimant"
case "defendant", "beklagter", "beklagte":
return "defendant"
default:
return "other"
}
}

View File

@@ -0,0 +1,224 @@
package services
import (
"testing"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
func party(name, role string) models.Party {
return models.Party{Name: name, Role: strPtr(role)}
}
func proceeding(jurisdiction, code string) *models.ProceedingType {
return &models.ProceedingType{Jurisdiction: strPtr(jurisdiction), Code: code}
}
func projectSide(side string) *models.Project {
if side == "" {
return &models.Project{}
}
return &models.Project{OurSide: strPtr(side)}
}
// noon UTC on 2026-05-31 → 14:00 Europe/Berlin (CEST), same calendar day.
var fixedNow = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
func TestAutoSubmissionTitle(t *testing.T) {
cases := []struct {
name string
clientName string
project *models.Project
parties []models.Party
pt *models.ProceedingType
want string
}{
{
name: "full data — UPC, we are claimant",
clientName: "Bayer AG",
project: projectSide("claimant"),
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
pt: proceeding("UPC", "upc.inf.cfi"),
want: "2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma",
},
{
name: "full data — German court, we are respondent",
clientName: "Bayer AG",
project: projectSide("respondent"),
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: proceeding("DE", "de.null.bpatg"),
want: "2026-05-31 Bayer AG ./. BPatG ./. Acme Generics",
},
{
name: "no opponent — opposing bucket empty",
clientName: "Bayer AG",
project: projectSide("claimant"),
parties: []models.Party{party("Bayer AG", "Klägerin")}, // only our own side
pt: proceeding("UPC", "upc.inf.cfi"),
want: "2026-05-31 Bayer AG ./. UPC",
},
{
name: "no forum — proceeding type missing",
clientName: "Bayer AG",
project: projectSide("respondent"),
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: nil,
want: "2026-05-31 Bayer AG ./. Acme Generics",
},
{
name: "no client — client segment omitted",
clientName: "",
project: projectSide("claimant"),
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
pt: proceeding("UPC", "upc.inf.cfi"),
want: "2026-05-31 UPC ./. Novartis Pharma",
},
{
name: "all identity segments missing — date only",
clientName: "",
project: projectSide(""), // no our_side → no opponent posture
parties: nil,
pt: nil,
want: "2026-05-31",
},
{
name: "unknown side — opponent omitted even with parties",
clientName: "Bayer AG",
project: projectSide("third_party"),
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: proceeding("EPA", "epa.opp.opd"),
want: "2026-05-31 Bayer AG ./. EPA",
},
{
name: "nil project — opponent omitted, client + forum stand",
clientName: "Bayer AG",
project: nil,
parties: []models.Party{party("Acme Generics", "Klägerin")},
pt: proceeding("DPMA", "dpma.opp.dpma"),
want: "2026-05-31 Bayer AG ./. DPMA",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := AutoSubmissionTitle(fixedNow, c.clientName, c.project, c.parties, c.pt)
if got != c.want {
t.Errorf("AutoSubmissionTitle = %q, want %q", got, c.want)
}
})
}
}
// TestAutoSubmissionTitleBerlinDate locks the Europe/Berlin localisation:
// 22:30 UTC on 2026-05-31 is already 00:30 on 2026-06-01 in CEST, so the
// date segment must roll over.
func TestAutoSubmissionTitleBerlinDate(t *testing.T) {
lateUTC := time.Date(2026, 5, 31, 22, 30, 0, 0, time.UTC)
got := AutoSubmissionTitle(lateUTC, "Bayer AG", projectSide("claimant"),
[]models.Party{party("Novartis", "Beklagte")}, proceeding("UPC", "upc.inf.cfi"))
want := "2026-06-01 Bayer AG ./. UPC ./. Novartis"
if got != want {
t.Errorf("AutoSubmissionTitle (late UTC) = %q, want %q", got, want)
}
}
func TestSubmissionForumShort(t *testing.T) {
cases := []struct {
pt *models.ProceedingType
want string
}{
{nil, ""},
{proceeding("UPC", "upc.inf.cfi"), "UPC"},
{proceeding("EPA", "epa.opp.opd"), "EPA"},
{proceeding("DPMA", "dpma.opp.dpma"), "DPMA"},
{proceeding("DE", "de.inf.lg"), "LG"},
{proceeding("DE", "de.inf.olg"), "OLG"},
{proceeding("DE", "de.inf.bgh"), "BGH"},
{proceeding("DE", "de.null.bpatg"), "BPatG"},
{proceeding("DE", "de.null.bgh"), "BGH"},
{proceeding("DE", "de.foo.amtsgericht"), "AMTSGERICHT"}, // unknown court → uppercased tail
{proceeding("de", "de.inf.lg"), "LG"}, // lowercase jurisdiction folds
{proceeding("", ""), ""}, // no jurisdiction
}
for _, c := range cases {
if got := submissionForumShort(c.pt); got != c.want {
t.Errorf("submissionForumShort(%+v) = %q, want %q", c.pt, got, c.want)
}
}
}
func TestSubmissionOpponentName(t *testing.T) {
claimantA := party("Acme", "Klägerin")
defendantB := party("Novartis", "Beklagte")
other := party("Streithelfer X", "Streithelfer")
cases := []struct {
name string
parties []models.Party
ourSide string
want string
}{
{"active → first defendant", []models.Party{claimantA, defendantB}, "claimant", "Novartis"},
{"reactive → first claimant", []models.Party{claimantA, defendantB}, "respondent", "Acme"},
{"applicant (active) → defendant", []models.Party{defendantB}, "applicant", "Novartis"},
{"appellant (active) → defendant", []models.Party{defendantB}, "appellant", "Novartis"},
{"defendant (reactive) → claimant", []models.Party{claimantA}, "defendant", "Acme"},
{"unknown side → none", []models.Party{claimantA, defendantB}, "third_party", ""},
{"empty side → none", []models.Party{claimantA, defendantB}, "", ""},
{"no opposing party → none", []models.Party{claimantA, other}, "claimant", ""},
{"opposing bucket only 'other' → none", []models.Party{other}, "respondent", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := submissionOpponentName(c.parties, c.ourSide); got != c.want {
t.Errorf("submissionOpponentName = %q, want %q", got, c.want)
}
})
}
}
func TestUniqueDraftName(t *testing.T) {
cases := []struct {
name string
base string
existing []string
want string
}{
{"free", "2026-05-31 Bayer AG ./. UPC", nil, "2026-05-31 Bayer AG ./. UPC"},
{"first clash → (2)", "2026-05-31 Bayer AG ./. UPC",
[]string{"2026-05-31 Bayer AG ./. UPC"}, "2026-05-31 Bayer AG ./. UPC (2)"},
{"two clash → (3)", "2026-05-31 Bayer AG ./. UPC",
[]string{"2026-05-31 Bayer AG ./. UPC", "2026-05-31 Bayer AG ./. UPC (2)"},
"2026-05-31 Bayer AG ./. UPC (3)"},
{"gap reused → (2)", "X",
[]string{"X", "X (3)"}, "X (2)"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := uniqueDraftName(c.base, c.existing); got != c.want {
t.Errorf("uniqueDraftName = %q, want %q", got, c.want)
}
})
}
}
func TestNextDraftName(t *testing.T) {
cases := []struct {
name string
existing []string
lang string
want string
}{
{"empty de", nil, "de", "Entwurf 1"},
{"empty en", nil, "en", "Draft 1"},
{"highest+1", []string{"Entwurf 1", "Entwurf 3"}, "de", "Entwurf 4"},
{"ignores foreign names", []string{"2026-05-31 Bayer AG"}, "de", "Entwurf 1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := nextDraftName(c.existing, c.lang); got != c.want {
t.Errorf("nextDraftName = %q, want %q", got, c.want)
}
})
}
}

View File

@@ -0,0 +1,129 @@
package services
// Live-DB test for the submission-draft auto-naming scheme
// (t-paliad-352 / m/paliad#155). Skipped without TEST_DATABASE_URL.
//
// Verifies the shipped Create flow end-to-end against real Postgres:
// a project-bound draft is auto-named "<date> <client> ./. <forum> ./.
// <opponent>" rather than "Entwurf N", the segments resolve from the
// real project tree (client = root ancestor, forum = proceeding-type
// jurisdiction, opponent = opposing party by our_side), and a second
// draft on the same slot de-duplicates with a " (2)" suffix.
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSubmissionDraft_AutoName_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
email := "autoname-" + userID.String()[:8] + "@hlc.com"
var clientID, caseID uuid.UUID
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.parties WHERE project_id = $1`, caseID)
// Children first (FK), then root.
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, clientID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
defer cleanup()
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Auto Name', 'munich', 'standard', 'de')`, userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
// Client root → case child. The case carries the proceeding type
// (UPC) and our_side (claimant), the party is the opponent.
client, err := projects.Create(ctx, userID, CreateProjectInput{
Type: "client", Title: "Bayer AG",
})
if err != nil {
t.Fatalf("create client project: %v", err)
}
clientID = client.ID
ptID := 8 // upc.inf.cfi → jurisdiction UPC
side := "claimant"
caseProj, err := projects.Create(ctx, userID, CreateProjectInput{
Type: "case", Title: "Streitsache", ParentID: &client.ID,
ProceedingTypeID: &ptID, OurSide: &side,
})
if err != nil {
t.Fatalf("create case project: %v", err)
}
caseID = caseProj.ID
beklagte := "Beklagte"
if _, err := parties.Create(ctx, userID, caseProj.ID, CreatePartyInput{
Name: "Novartis Pharma", Role: &beklagte,
}); err != nil {
t.Fatalf("create party: %v", err)
}
loc, _ := time.LoadLocation("Europe/Berlin")
today := time.Now().In(loc).Format("2006-01-02")
wantBase := today + " Bayer AG ./. UPC ./. Novartis Pharma"
d1, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft 1: %v", err)
}
if d1.Name != wantBase {
t.Fatalf("draft 1 name = %q, want %q", d1.Name, wantBase)
}
// Second draft on the same (project, code) slot must de-duplicate.
d2, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft 2: %v", err)
}
want2 := wantBase + " (2)"
if d2.Name != want2 {
t.Fatalf("draft 2 name = %q, want %q", d2.Name, want2)
}
// A project-less draft keeps the legacy Entwurf-N counter.
dless, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create project-less draft: %v", err)
}
if dless.Name != "Entwurf 1" {
t.Fatalf("project-less draft name = %q, want %q", dless.Name, "Entwurf 1")
}
}

View File

@@ -0,0 +1,111 @@
package services
// Live-DB test for the user-replaceable filename keyword
// (t-paliad-354). Skipped without TEST_DATABASE_URL.
//
// Exercises the real Update → Get code path against Postgres: setting the
// override merges into composer_meta.filename_keyword without clobbering
// other composer keys, clearing it removes only that key, and the value
// reads back through the same jsonb decode the export handler relies on.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSubmissionDraft_FilenameKeyword_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
email := "kw-" + userID.String()[:8] + "@hlc.com"
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
defer cleanup()
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Keyword Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
// A project-less draft is the simplest fixture — no project tree
// needed to exercise composer_meta persistence.
d, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
if err != nil {
t.Fatalf("create draft: %v", err)
}
// Pre-seed an unrelated composer_meta key to prove the merge/delete
// only touches filename_keyword.
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.submission_drafts SET composer_meta = '{"other":"keep-me"}'::jsonb WHERE id = $1`,
d.ID); err != nil {
t.Fatalf("seed composer_meta: %v", err)
}
// Set the override.
kw := "Replik Hauptantrag"
got, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &kw})
if err != nil {
t.Fatalf("update set keyword: %v", err)
}
if v, _ := got.ComposerMeta["filename_keyword"].(string); v != kw {
t.Fatalf("after set: filename_keyword = %q, want %q", v, kw)
}
if v, _ := got.ComposerMeta["other"].(string); v != "keep-me" {
t.Fatalf("after set: unrelated key 'other' = %q, want %q (merge clobbered it)", v, "keep-me")
}
// Read back through Get (the path the export handler uses).
reload, err := drafts.Get(ctx, userID, d.ID)
if err != nil {
t.Fatalf("get after set: %v", err)
}
if v, _ := reload.ComposerMeta["filename_keyword"].(string); v != kw {
t.Fatalf("reload: filename_keyword = %q, want %q", v, kw)
}
// Clear the override (empty string) — only filename_keyword should go.
empty := ""
cleared, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &empty})
if err != nil {
t.Fatalf("update clear keyword: %v", err)
}
if _, present := cleared.ComposerMeta["filename_keyword"]; present {
t.Fatalf("after clear: filename_keyword still present: %v", cleared.ComposerMeta)
}
if v, _ := cleared.ComposerMeta["other"].(string); v != "keep-me" {
t.Fatalf("after clear: unrelated key 'other' = %q, want %q (delete removed too much)", v, "keep-me")
}
}

View File

@@ -183,6 +183,14 @@ type DraftPatch struct {
// **p → pin the version (validated via TemplateStore.GetVersion)
// t-paliad-349 slice 7.
TemplateVersionID **uuid.UUID
// FilenameKeyword sets (or clears) the user override that leads the
// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
// Stored under composer_meta.filename_keyword — no dedicated column:
// nil → no change
// *p == "" → clear the key (back to the auto-derived rule name)
// *p == "x" → set the override
FilenameKeyword *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -356,12 +364,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
// creates with base_id=NULL — Composer is additive, the v1 fallback
// path remains valid.
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
var project *models.Project
if projectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
p, err := s.projects.GetByID(ctx, userID, *projectID)
if err != nil {
return nil, err
}
project = p
}
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
if err != nil {
return nil, err
}
@@ -431,20 +442,94 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
return &d, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard.
// newDraftName picks the title for a freshly-created draft. Project-
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
// the user's existing drafts for the same (project, submission_code).
// Project-less drafts (and any project-bound draft whose auto-name
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
// counter.
//
// A nil projectID scopes the search to the user's project-less drafts
// for this submission_code — matches the row-uniqueness contract on
// the DB side (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
prefix := "Entwurf"
if strings.EqualFold(lang, "en") {
prefix = "Draft"
// Only Create calls this — existing drafts are never renamed (the
// scheme is create-time only, per #155). A lawyer's later manual rename
// flows through Update and is left untouched.
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
if err != nil {
return "", err
}
if project != nil {
auto, err := s.autoNameForProject(ctx, time.Now(), project)
if err != nil {
return "", err
}
if strings.TrimSpace(auto) != "" {
return uniqueDraftName(auto, existing), nil
}
}
return nextDraftName(existing, lang), nil
}
// autoNameForProject resolves the three identity segments for a
// project-bound draft and hands them to the pure AutoSubmissionTitle
// assembler. The client is the root ancestor of the project tree (the
// 'client' node), the proceeding type and our_side come off the draft's
// own project node, and the parties hang directly off it.
//
// A failure to resolve the client / proceeding type is not fatal —
// AutoSubmissionTitle just omits the empty segment — so the only errors
// returned here are genuine DB faults.
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project) (string, error) {
clientName, err := s.clientNameForProject(ctx, project.ID)
if err != nil {
return "", err
}
pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
if err != nil {
return "", err
}
var parties []models.Party
if err := s.db.SelectContext(ctx, &parties,
`SELECT id, project_id, name, role, representative, contact_info,
created_at, updated_at
FROM paliad.parties
WHERE project_id = $1
ORDER BY name`, project.ID); err != nil {
return "", fmt.Errorf("auto-name: load parties: %w", err)
}
return AutoSubmissionTitle(now, clientName, project, parties, pt), nil
}
// clientNameForProject returns the title of the 'client' ancestor in
// the project's path (the firm's mandant). Empty string when the tree
// has no client node — the auto-name then omits the client segment.
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
var title string
err := s.db.GetContext(ctx, &title,
`SELECT p.title
FROM paliad.projects target
JOIN paliad.projects p
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
WHERE target.id = $1 AND p.type = 'client'
LIMIT 1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("auto-name: resolve client name: %w", err)
}
return title, nil
}
// existingDraftNames returns the names already in use for the
// (project, submission_code, user) slot. A nil projectID scopes to the
// user's project-less drafts for this submission_code — matching the
// DB unique contract (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
var names []string
var err error
if projectID == nil {
@@ -459,16 +544,48 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
*projectID, submissionCode, userID)
}
if err != nil {
return "", fmt.Errorf("scan existing draft names: %w", err)
return nil, fmt.Errorf("scan existing draft names: %w", err)
}
return names, nil
}
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard. Pure over the supplied name list.
func nextDraftName(existing []string, lang string) string {
prefix := "Entwurf"
if strings.EqualFold(lang, "en") {
prefix = "Draft"
}
highest := 0
for _, n := range names {
for _, n := range existing {
var idx int
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
highest = idx
}
}
return fmt.Sprintf("%s %d", prefix, highest+1), nil
return fmt.Sprintf("%s %d", prefix, highest+1)
}
// uniqueDraftName returns base unchanged when it's free, otherwise
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
// "race → unique constraint is the final guard" contract of
// nextDraftName; pure over the supplied name list.
func uniqueDraftName(base string, existing []string) string {
taken := make(map[string]struct{}, len(existing))
for _, n := range existing {
taken[n] = struct{}{}
}
if _, clash := taken[base]; !clash {
return base
}
for i := 2; ; i++ {
cand := fmt.Sprintf("%s (%d)", base, i)
if _, clash := taken[cand]; !clash {
return cand
}
}
}
// Update patches the draft. Variables is replace-semantics — pass the
@@ -589,6 +706,21 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.FilenameKeyword != nil {
// Targeted jsonb merge so other composer_meta keys survive. An
// empty override removes the key entirely, restoring the
// auto-derived rule name as the filename keyword (t-paliad-354).
kw := strings.TrimSpace(*patch.FilenameKeyword)
if kw == "" {
setParts = append(setParts, "composer_meta = composer_meta - 'filename_keyword'")
} else {
setParts = append(setParts,
fmt.Sprintf("composer_meta = composer_meta || jsonb_build_object('filename_keyword', $%d::text)", idx))
args = append(args, kw)
idx++
}
}
if len(setParts) == 0 {
return existing, nil
}

View File

@@ -412,11 +412,10 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimants, defendants, others []models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
switch partyRoleBucket(parties[i].Role) {
case "claimant":
claimants = append(claimants, parties[i])
case "defendant", "beklagter", "beklagte":
case "defendant":
defendants = append(defendants, parties[i])
default:
others = append(others, parties[i])

View File

@@ -240,7 +240,7 @@ var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
// the body — from the start of the opening anchor's <w:p> element
// through the end of the closing anchor's </w:p>.
type anchorPair struct {
key string
key string
openStart int // start of <w:p> for the opening anchor
closeEnd int // index just past </w:p> for the closing anchor
}
@@ -251,10 +251,10 @@ type anchorPair struct {
// span is non-overlapping.
func findAllAnchorPairs(body string) []anchorPair {
type marker struct {
key string
key string
paraStart int
paraEnd int
isOpen bool
isOpen bool
}
var markers []marker

View File

@@ -185,7 +185,12 @@ func SanitiseSubmissionFileName(s string) string {
s = umlautFolder.Replace(s)
s = strings.Map(func(r rune) rune {
switch r {
case '/', '\\':
// Path separators and the rest of the Windows-reserved set —
// fold to underscore so a case number like "UPC_CFI_123/2026"
// stays one filesystem-safe segment. Spaces and parentheses are
// intentionally preserved: the human-facing download name
// "<date> <keyword> (<case>)" relies on them (t-paliad-354).
case '/', '\\', ':', '*', '?', '<', '>', '|':
return '_'
case '"', '\'':
return -1

View File

@@ -241,9 +241,12 @@ func TestSanitiseSubmissionFileName(t *testing.T) {
"Klageerwiderung": "Klageerwiderung",
"Berufungsbegründung": "Berufungsbegruendung",
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
`Statement of "Defence"`: "Statement of Defence",
` Klage `: "Klage",
"Größe": "Groesse",
"UPC_CFI_123/2026": "UPC_CFI_123_2026",
"a:b*c?d<e>f|g": "a_b_c_d_e_f_g",
"Klageerwiderung (Frist)": "Klageerwiderung (Frist)",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {

View File

@@ -0,0 +1,39 @@
package docx
import "mgit.msbls.de/m/paliad/pkg/docforge"
// Exporter is the .docx implementation of docforge.Exporter — it renders a
// neutral Document to OOXML body markup (t-paliad-349 slice 8). The
// stylemap (block kind → Word paragraph style) and the optional hyperlink
// allocator are baked in at construction, so RenderBody matches the
// interface's format-neutral signature.
//
// This is the seam a future PDF/HTML exporter slots into: implement
// docforge.Exporter, no engine change. The submission composer can render
// section content through this exporter instead of calling
// RenderDocumentToOOXML directly once a second format exists.
type Exporter struct {
Stylemap map[string]string
Links HyperlinkAllocator
}
// compile-time conformance.
var _ docforge.Exporter = Exporter{}
// NewExporter builds a .docx exporter with the given stylemap + allocator.
func NewExporter(stylemap map[string]string, links HyperlinkAllocator) Exporter {
return Exporter{Stylemap: stylemap, Links: links}
}
// Format returns the format id.
func (Exporter) Format() string { return "docx" }
// MIMEType returns the .docx container MIME type.
func (Exporter) MIMEType() string {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
// RenderBody renders the Document to OOXML paragraph markup.
func (e Exporter) RenderBody(doc docforge.Document) ([]byte, error) {
return []byte(RenderDocumentToOOXML(doc, e.Stylemap, e.Links)), nil
}

View File

@@ -0,0 +1,34 @@
package docx
import (
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/markdown"
)
func TestExporter_RenderBodyMatchesWalker(t *testing.T) {
exp := NewExporter(map[string]string{"paragraph": "Body"}, nil)
if exp.Format() != "docx" {
t.Errorf("Format = %q; want docx", exp.Format())
}
if !strings.Contains(exp.MIMEType(), "wordprocessingml.document") {
t.Errorf("MIMEType = %q", exp.MIMEType())
}
md := "Hello **world**\n\n- item"
// The Exporter must produce exactly what the walker entry point does
// for the same input (both go markdown.Import → RenderDocumentToOOXML).
body, err := exp.RenderBody(markdown.Import(md))
if err != nil {
t.Fatalf("RenderBody: %v", err)
}
want := RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": "Body"}, nil)
if string(body) != want {
t.Errorf("RenderBody mismatch:\n got %q\nwant %q", body, want)
}
}
// satisfies the interface (compile-time check mirrored at runtime).
var _ docforge.Exporter = Exporter{}

View File

@@ -1,249 +1,78 @@
package docx
// Markdown → OOXML walker for Composer section content (t-paliad-313
// Slice B, design doc §9.2).
// Markdown → OOXML rendering for Composer section content (t-paliad-313
// Slice B/D; restructured in t-paliad-349 slice 8).
//
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
// pass. This walker is intentionally minimal — every Markdown construct
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
// prose round-trips losslessly even when they hit Markdown the walker
// doesn't yet understand.
// Parsing now lives in pkg/docforge/markdown, which produces the neutral
// docforge.Document. This file renders that Document into OOXML paragraph
// elements (<w:p>…</w:p>) ready to splice into a .docx body. There is one
// Markdown parser for docforge; this is the .docx exporter for its model.
//
// The output uses the base's stylemap.paragraph entry for the
// <w:pStyle> on each paragraph so the styling matches the base's
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
// base, etc.).
//
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
// pass through the walker untouched and get substituted by the v1
// SubmissionRenderer's placeholder pass after the composer assembly.
//
// Grammar supported:
//
// - Blank line → paragraph break
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
// - Otherwise → plain text run
// Output uses the base's stylemap entry for each block kind on the
// <w:pStyle>, so styling matches the base's typography (HLpat-Body-B0 on
// the HLC base, Normal on the neutral base, etc.). Placeholders ({{key}})
// ride through as literal run text and are substituted by the placeholder
// pass after assembly.
import (
"fmt"
"strconv"
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/markdown"
)
// HyperlinkAllocator hands the walker a `rId` for each external URL
// it encounters in `[label](url)` inline links. The composer's
// post-pass uses these allocations to mutate
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
// r:id="…">` elements resolve correctly. Pass nil to drop links to
// plain text (the label survives, the URL doesn't render).
//
// t-paliad-316 Slice D.
// HyperlinkAllocator hands the renderer a `rId` for each external URL it
// encounters in `[label](url)` inline links. The composer's post-pass uses
// these allocations to mutate `word/_rels/document.xml.rels` so the emitted
// `<w:hyperlink r:id="…">` elements resolve. Pass nil to drop links to
// plain text (the label survives, the URL doesn't render). t-paliad-316.
type HyperlinkAllocator func(url string) string
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
// when paragraphStyle is non-empty.
//
// Slice B shipped paragraphs + bold/italic. Slice D extends to
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
// hyperlinks via the optional HyperlinkAllocator.
//
// stylemap supplies the paragraph-style names for each kind:
// stylemap["paragraph"] — default body
// stylemap["heading_1/2/3"] — heading levels
// stylemap["list_bullet"] — bullet list paragraph style
// stylemap["list_numbered"] — numbered list paragraph style
// stylemap["blockquote"] — blockquote
// Missing entries fall back to the "paragraph" style.
//
// Empty input renders one empty paragraph so the splice site is
// well-formed even when the lawyer hasn't typed anything in this
// section.
// RenderMarkdownToOOXML renders Markdown into OOXML paragraphs with a
// single paragraph style. Slice B back-compat wrapper.
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
}
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
// RenderMarkdownToOOXMLWithStyles parses Markdown into a docforge.Document
// and renders it to OOXML. stylemap maps each block kind (paragraph,
// heading_1/2/3, list_bullet, list_numbered, blockquote) to a Word
// paragraph style; missing entries fall back to the "paragraph" style.
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
return RenderDocumentToOOXML(markdown.Import(md), stylemap, links)
}
// RenderDocumentToOOXML renders a neutral Document to OOXML paragraphs —
// the .docx side of the docforge importer→model→exporter pipeline. Any
// Document (Markdown today, a foreign-doc importer later) renders the same
// way.
func RenderDocumentToOOXML(doc docforge.Document, stylemap map[string]string, links HyperlinkAllocator) string {
defaultStyle := stylemap["paragraph"]
if md == "" {
return emptyParagraph(defaultStyle)
}
blocks := splitMarkdownBlocks(md)
if len(blocks) == 0 {
return emptyParagraph(defaultStyle)
}
// Numbered-list counter resets on every non-numbered block so
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
// determined the ordinal, the walker just renders).
numberedCounter := 0
// "1. A\n2. B\n\n1. C" renders 1./2./1. the input determined the
// ordinal, the renderer just emits it.
numbered := 0
var b strings.Builder
for _, blk := range blocks {
style := stylemap[blk.styleKey]
for _, blk := range doc.Blocks {
style := stylemap[string(blk.Kind)]
if style == "" {
style = defaultStyle
}
if blk.styleKey == "list_numbered" {
numberedCounter++
if blk.Kind == docforge.KindListNumbered {
numbered++
} else {
numberedCounter = 0
numbered = 0
}
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
b.WriteString(renderBlock(blk, style, links, numbered))
}
return b.String()
}
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
// / list_bullet / list_numbered / blockquote) and the inline content
// text. List markers, heading hashes, blockquote `> ` etc. are
// stripped from the text before storage.
type mdBlock struct {
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
text string
}
// splitMarkdownBlocks parses the source into a sequence of blocks,
// detecting heading / list / blockquote prefixes line-by-line. Blank
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
// but each line is also tagged with its block kind.
//
// Lines that look like block markers don't merge with their neighbours
// even across blank lines — every list / heading / blockquote line is
// its own block in the output. A run of unmarked lines collapses into
// one "paragraph" block (so soft line breaks inside a paragraph still
// concatenate).
//
// CRLF normalised to LF before parsing.
func splitMarkdownBlocks(md string) []mdBlock {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var blocks []mdBlock
var pendingPara []string
blankRun := 0
flushPara := func() {
if len(pendingPara) > 0 {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
pendingPara = nil
}
}
for _, raw := range lines {
line := raw
if strings.TrimSpace(line) == "" {
if len(pendingPara) > 0 {
flushPara()
blankRun = 1
continue
}
blankRun++
continue
}
// Detect heading / list / blockquote markers BEFORE we accumulate
// into the paragraph buffer.
kind, payload, ok := detectBlockMarker(line)
if ok {
flushPara()
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
blankRun = 0
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
continue
}
// Plain paragraph line.
if len(pendingPara) == 0 {
// Starting a new paragraph after a blank run — emit
// (blankRun-1) extra empty paragraphs for vertical spacing.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
}
blankRun = 0
pendingPara = append(pendingPara, line)
}
flushPara()
return blocks
}
// detectBlockMarker classifies a single line. Returns (styleKey,
// payload-with-marker-stripped, true) for recognised markers; false
// for plain paragraph lines.
//
// Recognised markers (Slice D):
// # Heading → heading_1
// ## Heading → heading_2
// ### Heading → heading_3
// - item / * item → list_bullet
// 1. item / 2. item ... → list_numbered (any positive integer)
// > quote → blockquote
//
// Leading whitespace inside the line is tolerated up to 3 spaces (per
// CommonMark) so the lawyer's contentEditable indentation doesn't
// hide the marker.
func detectBlockMarker(line string) (string, string, bool) {
trimmed := strings.TrimLeft(line, " ")
// Cap to 3 spaces of leading indent — beyond that, treat as a
// regular paragraph line (matches CommonMark).
if len(line)-len(trimmed) > 3 {
return "", "", false
}
if strings.HasPrefix(trimmed, "### ") {
return "heading_3", strings.TrimSpace(trimmed[4:]), true
}
if strings.HasPrefix(trimmed, "## ") {
return "heading_2", strings.TrimSpace(trimmed[3:]), true
}
if strings.HasPrefix(trimmed, "# ") {
return "heading_1", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "> ") {
return "blockquote", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
}
// Numbered: "N. " where N is one or more digits.
if i := indexOfNumberedMarker(trimmed); i > 0 {
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
}
return "", "", false
}
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
// trimmed line; returns the byte index just past the marker, or -1 if
// no marker present.
func indexOfNumberedMarker(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 {
return -1
}
if i >= len(s) {
return -1
}
if s[i] != '.' && s[i] != ')' {
return -1
}
if i+1 >= len(s) || s[i+1] != ' ' {
return -1
}
return i + 2
}
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
// keep the same paragraph style as a default paragraph (the Slice D
// design's contract — list styles come from the base's stylemap and
// Word's numbering.xml is honoured by adding a leading bullet/number
// prefix in the rendered text). This keeps the composer free of
// numbering.xml mutations.
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
// renderBlock emits one <w:p> for a block. List blocks get a visible
// "• " / "N. " prefix run (the base stylemap handles indentation if it
// defines a list style; the prefix at least surfaces the structure).
func renderBlock(blk docforge.Block, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
@@ -251,110 +80,61 @@ func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAll
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
if blk.text == "" {
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
b.WriteString(`</w:p>`)
// An empty block is an intentional empty paragraph: one empty run.
if len(blk.Spans) == 0 {
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
return b.String()
}
text := blk.text
// List blocks emit a visible "• " / "N. " prefix run. The
// stylemap entry handles paragraph indentation if the base
// defines a list paragraph style; otherwise the prefix at least
// surfaces the structure in plain Word. Lawyers who want Word's
// auto-numbering reapply a list style post-export.
switch blk.styleKey {
case "list_bullet":
switch blk.Kind {
case docforge.KindListBullet:
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
case "list_numbered":
case docforge.KindListNumbered:
ordinal := numberedOrdinal
if ordinal <= 0 {
ordinal = 1
}
b.WriteString(`<w:r><w:t xml:space="preserve">`)
b.WriteString(fmt.Sprintf("%d. ", ordinal))
b.WriteString(`</w:t></w:r>`)
b.WriteString(strconv.Itoa(ordinal))
b.WriteString(`. </w:t></w:r>`)
}
for _, run := range parseInlineRuns(text, links) {
b.WriteString(run)
for _, span := range blk.Spans {
b.WriteString(renderInlineSpan(span, links))
}
b.WriteString(`</w:p>`)
return b.String()
}
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
// where RID comes from the HyperlinkAllocator.
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
// Phase 1: find all hyperlink spans `[label](url)` and split the
// text around them.
type segment struct {
text string
isLink bool
url string
}
var segs []segment
rest := text
for {
idx := strings.Index(rest, "[")
if idx < 0 {
if rest != "" {
segs = append(segs, segment{text: rest})
}
break
}
// Find matching closing bracket, then a "(" right after.
closeBracket := strings.Index(rest[idx:], "](")
if closeBracket < 0 {
segs = append(segs, segment{text: rest})
break
}
closeParen := strings.Index(rest[idx+closeBracket:], ")")
if closeParen < 0 {
segs = append(segs, segment{text: rest})
break
}
// idx = start of "["
// idx+closeBracket = position of "]"
// idx+closeBracket+1 = position of "("
// idx+closeBracket+closeParen = position of ")"
label := rest[idx+1 : idx+closeBracket]
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
if idx > 0 {
segs = append(segs, segment{text: rest[:idx]})
}
segs = append(segs, segment{text: label, isLink: true, url: url})
rest = rest[idx+closeBracket+closeParen+1:]
}
var runs []string
for _, seg := range segs {
if seg.isLink && links != nil {
rid := links(seg.url)
if rid != "" {
// renderInlineSpan emits one span. A hyperlink span (Link != "") becomes a
// <w:hyperlink r:id="…"> wrapping its children when an allocator yields a
// rId; otherwise the label children render as plain runs (URL dropped).
func renderInlineSpan(span docforge.InlineSpan, links HyperlinkAllocator) string {
if span.Link != "" {
if links != nil {
if rid := links(span.Link); rid != "" {
var hb strings.Builder
hb.WriteString(`<w:hyperlink r:id="`)
hb.WriteString(xmlAttrEscape(rid))
hb.WriteString(`">`)
for _, span := range parseInlineSpans(seg.text) {
hb.WriteString(renderRunWithLinkStyle(span))
for _, child := range span.Children {
hb.WriteString(renderRunWithLinkStyle(child))
}
hb.WriteString(`</w:hyperlink>`)
runs = append(runs, hb.String())
continue
return hb.String()
}
}
for _, span := range parseInlineSpans(seg.text) {
runs = append(runs, renderRun(span))
// No allocator / no rId — render the label as plain runs.
var fb strings.Builder
for _, child := range span.Children {
fb.WriteString(renderRun(child))
}
return fb.String()
}
return runs
return renderRun(span)
}
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
// as renderRun, but additionally tags the run with the "Hyperlink"
// character style (Word's built-in) so the link renders in the
// document's hyperlink colour + underline.
func renderRunWithLinkStyle(span inlineSpan) string {
// renderRunWithLinkStyle emits a hyperlink child run with Word's built-in
// "Hyperlink" character style (colour + underline), plus B/I.
func renderRunWithLinkStyle(span docforge.InlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
if span.Bold {
@@ -369,85 +149,8 @@ func renderRunWithLinkStyle(span inlineSpan) string {
return b.String()
}
// inlineSpan is one piece of inline content: a text payload plus
// formatting flags. Bold and italic are independent — `***both***`
// produces one span with both flags set.
type inlineSpan struct {
Text string
Bold bool
Italic bool
}
// parseInlineSpans tokenises Markdown inline formatting into runs of
// (text, bold, italic). The grammar is intentionally narrow:
//
// - `**…**` → bold
// - `__…__` → bold (Markdown alternate)
// - `*…*` → italic
// - `_…_` → italic (Markdown alternate)
// - Anything else flows through as plain text.
//
// Unbalanced delimiters fall through as literal characters — the
// walker never errors on malformed Markdown. Nested formatting (e.g.
// `**bold *bold-italic* bold**`) toggles flags as it walks.
func parseInlineSpans(text string) []inlineSpan {
var out []inlineSpan
var cur strings.Builder
bold := false
italic := false
flush := func() {
if cur.Len() == 0 {
return
}
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
cur.Reset()
}
i := 0
n := len(text)
for i < n {
// Preserve {{...}} placeholders verbatim. Underscores and
// other Markdown-significant chars inside a placeholder key
// (e.g. {{project.case_number}}) must not be interpreted as
// bold/italic delimiters — otherwise the key gets stripped of
// its underscores and the v1 placeholder pass looks up the
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
// preview.
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
rel := strings.Index(text[i+2:], "}}")
if rel >= 0 {
end := i + 2 + rel + 2
cur.WriteString(text[i:end])
i = end
continue
}
// Unmatched {{ — fall through to plain character handling.
}
// Bold delimiters first (longer match wins over italic).
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
flush()
bold = !bold
i += 2
continue
}
if text[i] == '*' || text[i] == '_' {
flush()
italic = !italic
i++
continue
}
cur.WriteByte(text[i])
i++
}
flush()
if len(out) == 0 {
out = append(out, inlineSpan{Text: ""})
}
return out
}
// renderRun emits one `<w:r>` element for an inline span. Empty text
// spans render as empty runs (Word accepts them; they're harmless).
func renderRun(span inlineSpan) string {
// renderRun emits one <w:r> for a plain (text/bold/italic) span.
func renderRun(span docforge.InlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r>`)
if span.Bold || span.Italic {
@@ -466,34 +169,16 @@ func renderRun(span inlineSpan) string {
return b.String()
}
// emptyParagraph returns one empty `<w:p>` with the given style. Used
// when a section's content_md is empty so the splice site stays
// well-formed.
func emptyParagraph(paragraphStyle string) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
return b.String()
}
// xmlTextEscape escapes the five XML-significant characters for safe
// insertion into <w:t> content. & first to avoid double-encoding.
// xmlTextEscape escapes the XML-significant characters for <w:t> content.
// Quotes/apostrophes are legal in element text — not escaped.
func xmlTextEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
// Quotes and apostrophes are legal inside element text content;
// no need to escape them here.
return s
}
// xmlAttrEscape escapes for safe insertion into an attribute value
// (e.g. `<w:pStyle w:val="…"/>`).
// xmlAttrEscape escapes for an attribute value (e.g. <w:pStyle w:val="…"/>).
func xmlAttrEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")

View File

@@ -112,46 +112,6 @@ func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
}
}
func TestParseInlineSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
// Direct guard on the inline scanner. {{project.case_number}} must
// emit as a single non-italic span containing the full placeholder.
spans := parseInlineSpans("{{project.case_number}}")
if len(spans) != 1 {
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
}
if spans[0].Italic || spans[0].Bold {
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
}
if spans[0].Text != "{{project.case_number}}" {
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
}
}
func TestParseInlineSpans_ItalicAroundPlaceholder(t *testing.T) {
// Italic delimiters outside a placeholder still work; the placeholder
// itself stays literal even when it sits between italics.
spans := parseInlineSpans("_before_ {{x.y_z}} _after_")
var saw struct {
italicBefore bool
placeholder bool
italicAfter bool
}
for _, s := range spans {
if s.Italic && s.Text == "before" {
saw.italicBefore = true
}
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
saw.placeholder = true
}
if s.Italic && s.Text == "after" {
saw.italicAfter = true
}
}
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
}
}
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
// source. Tiny helper, only used by the regression test above.
func extractPlaceholders(s string) []string {
@@ -196,39 +156,6 @@ func TestRenderMarkdownToOOXML_CRLFNormalisation(t *testing.T) {
}
}
func TestParseInlineSpans_Plain(t *testing.T) {
spans := parseInlineSpans("hello world")
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
t.Errorf("expected single plain span; got %+v", spans)
}
}
func TestParseInlineSpans_UnderscoreItalic(t *testing.T) {
spans := parseInlineSpans("_emph_")
var italicHits int
for _, s := range spans {
if s.Italic && s.Text == "emph" {
italicHits++
}
}
if italicHits != 1 {
t.Errorf("expected one italic 'emph' span; got %+v", spans)
}
}
func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
spans := parseInlineSpans("__strong__")
var boldHits int
for _, s := range spans {
if s.Bold && s.Text == "strong" {
boldHits++
}
}
if boldHits != 1 {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — rich-prose constructs
// ─────────────────────────────────────────────────────────────────────
@@ -349,35 +276,3 @@ func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.
t.Errorf("hyperlink emitted without allocator: %q", out)
}
}
func TestDetectBlockMarker(t *testing.T) {
cases := []struct {
in string
kind string
want string
ok bool
}{
{"# A", "heading_1", "A", true},
{"## B", "heading_2", "B", true},
{"### C", "heading_3", "C", true},
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
{" # too-deep", "", "", false}, // 4 spaces → not a heading
{"- bullet", "list_bullet", "bullet", true},
{"* star", "list_bullet", "star", true},
{"1. one", "list_numbered", "one", true},
{"42. forty-two", "list_numbered", "forty-two", true},
{"1) paren", "list_numbered", "paren", true},
{"1.no-space", "", "", false}, // ordinal needs trailing space
{"> quote", "blockquote", "quote", true},
{"plain", "", "", false},
{"#nospace", "", "", false}, // heading needs space after hash
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
kind, payload, ok := detectBlockMarker(tc.in)
if ok != tc.ok || kind != tc.kind || payload != tc.want {
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
}
})
}
}

22
pkg/docforge/exporter.go Normal file
View File

@@ -0,0 +1,22 @@
package docforge
// Exporter renders a neutral Document into a target format's body markup.
// docforge owns the interface; each format adapter implements it (the
// .docx adapter in pkg/docforge/docx today; .pdf/.html/.md are future
// siblings — PRD §4 B4: interface now, docx-only impl). Format-specific
// configuration (a stylemap, a hyperlink allocator for .docx) is baked into
// the concrete exporter at construction, so the interface stays
// format-neutral.
//
// "Body markup" is the renderable content fragment, not a complete file —
// for .docx it is the OOXML <w:p> run the composer splices into a carrier.
// Container concerns (MIME type, packaging) are described by Format /
// MIMEType and handled by the assembling layer.
type Exporter interface {
// Format is the short format id, e.g. "docx".
Format() string
// MIMEType is the container MIME type the assembled document carries.
MIMEType() string
// RenderBody renders the document to the format's body markup.
RenderBody(doc Document) ([]byte, error)
}

View File

@@ -0,0 +1,230 @@
// Package markdown imports Markdown source into the neutral
// docforge.Document model (PRD §3.2 / §4 P4 — Markdown is the primary
// input format). It is the single Markdown parser for docforge: the .docx
// renderer consumes the Document this produces, so block-splitting and
// inline tokenisation live here, not in the format adapter.
//
// Grammar (intentionally narrow — unrecognised syntax flows through as a
// plain paragraph, so lawyer prose never errors):
//
// blank line → paragraph break
// # / ## / ### Heading → heading_1 / 2 / 3
// - item / * item → bullet list item
// N. item / N) item → numbered list item
// > quote → blockquote
// **x** / __x__ → bold
// *x* / _x_ → italic
// [label](url) → hyperlink
// {{key}} → preserved verbatim (substituted downstream)
package markdown
import (
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// Import parses Markdown into a Document. Empty (or all-blank) input yields
// a single empty paragraph so a splice site stays well-formed.
func Import(md string) docforge.Document {
blocks := splitBlocks(md)
if len(blocks) == 0 {
return docforge.Document{Blocks: []docforge.Block{{Kind: docforge.KindParagraph}}}
}
out := make([]docforge.Block, 0, len(blocks))
for _, blk := range blocks {
b := docforge.Block{Kind: docforge.BlockKind(blk.kind)}
// An empty-text block is an intentional empty paragraph: leave
// Spans nil so the exporter emits a single empty run.
if blk.text != "" {
b.Spans = parseInline(blk.text)
}
out = append(out, b)
}
return docforge.Document{Blocks: out}
}
// rawBlock is the intermediate (kind, stripped-text) form before inline
// parsing. kind values match docforge.BlockKind string values.
type rawBlock struct {
kind string
text string
}
// splitBlocks parses the source into a sequence of (kind, text) blocks,
// detecting heading / list / blockquote prefixes line-by-line. A run of
// unmarked lines collapses into one paragraph block (soft line breaks
// inside a paragraph concatenate); each marked line is its own block.
// Blank-run spacing emits extra empty paragraph blocks. CRLF normalised.
func splitBlocks(md string) []rawBlock {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var blocks []rawBlock
var pendingPara []string
blankRun := 0
flushPara := func() {
if len(pendingPara) > 0 {
blocks = append(blocks, rawBlock{kind: "paragraph", text: strings.Join(pendingPara, "\n")})
pendingPara = nil
}
}
for _, line := range lines {
if strings.TrimSpace(line) == "" {
if len(pendingPara) > 0 {
flushPara()
blankRun = 1
continue
}
blankRun++
continue
}
if kind, payload, ok := detectBlockMarker(line); ok {
flushPara()
for i := 1; i < blankRun; i++ {
blocks = append(blocks, rawBlock{kind: "paragraph", text: ""})
}
blankRun = 0
blocks = append(blocks, rawBlock{kind: kind, text: payload})
continue
}
if len(pendingPara) == 0 {
for i := 1; i < blankRun; i++ {
blocks = append(blocks, rawBlock{kind: "paragraph", text: ""})
}
}
blankRun = 0
pendingPara = append(pendingPara, line)
}
flushPara()
return blocks
}
// detectBlockMarker classifies a single line. Tolerates up to 3 leading
// spaces (CommonMark) before treating the line as a plain paragraph.
func detectBlockMarker(line string) (kind, payload string, ok bool) {
trimmed := strings.TrimLeft(line, " ")
if len(line)-len(trimmed) > 3 {
return "", "", false
}
switch {
case strings.HasPrefix(trimmed, "### "):
return "heading_3", strings.TrimSpace(trimmed[4:]), true
case strings.HasPrefix(trimmed, "## "):
return "heading_2", strings.TrimSpace(trimmed[3:]), true
case strings.HasPrefix(trimmed, "# "):
return "heading_1", strings.TrimSpace(trimmed[2:]), true
case strings.HasPrefix(trimmed, "> "):
return "blockquote", strings.TrimSpace(trimmed[2:]), true
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "):
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
}
if i := indexOfNumberedMarker(trimmed); i > 0 {
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
}
return "", "", false
}
// indexOfNumberedMarker returns the byte index just past an "N. " / "N) "
// marker at the start of s, or -1 when absent.
func indexOfNumberedMarker(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i >= len(s) {
return -1
}
if s[i] != '.' && s[i] != ')' {
return -1
}
if i+1 >= len(s) || s[i+1] != ' ' {
return -1
}
return i + 2
}
// parseInline splits text around [label](url) hyperlinks and tokenises the
// rest into bold/italic spans. Hyperlinks become a span with Link set and
// the label's spans as Children, preserving link boundaries.
func parseInline(text string) []docforge.InlineSpan {
var out []docforge.InlineSpan
rest := text
for {
idx := strings.Index(rest, "[")
if idx < 0 {
if rest != "" {
out = append(out, parseSpans(rest)...)
}
break
}
closeBracket := strings.Index(rest[idx:], "](")
if closeBracket < 0 {
out = append(out, parseSpans(rest)...)
break
}
closeParen := strings.Index(rest[idx+closeBracket:], ")")
if closeParen < 0 {
out = append(out, parseSpans(rest)...)
break
}
label := rest[idx+1 : idx+closeBracket]
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
if idx > 0 {
out = append(out, parseSpans(rest[:idx])...)
}
out = append(out, docforge.InlineSpan{Link: url, Children: parseSpans(label)})
rest = rest[idx+closeBracket+closeParen+1:]
}
return out
}
// parseSpans tokenises Markdown inline bold/italic into spans, preserving
// {{...}} placeholders verbatim (the b78a984 fix — underscores in a
// placeholder key must not be read as italic delimiters). Empty input
// yields one empty span.
func parseSpans(text string) []docforge.InlineSpan {
var out []docforge.InlineSpan
var cur strings.Builder
bold := false
italic := false
flush := func() {
if cur.Len() == 0 {
return
}
out = append(out, docforge.InlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
cur.Reset()
}
i := 0
n := len(text)
for i < n {
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
if rel := strings.Index(text[i+2:], "}}"); rel >= 0 {
end := i + 2 + rel + 2
cur.WriteString(text[i:end])
i = end
continue
}
}
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
flush()
bold = !bold
i += 2
continue
}
if text[i] == '*' || text[i] == '_' {
flush()
italic = !italic
i++
continue
}
cur.WriteByte(text[i])
i++
}
flush()
if len(out) == 0 {
out = append(out, docforge.InlineSpan{Text: ""})
}
return out
}

View File

@@ -0,0 +1,145 @@
package markdown
import (
"strings"
"testing"
)
// Inline-span + block-marker tests, relocated from the docx walker when
// parsing moved here (t-paliad-349 slice 8). parseSpans is the inline
// tokeniser; detectBlockMarker classifies a line.
func TestParseSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
// {{project.case_number}} must emit as a single non-italic span
// containing the full placeholder (the b78a984 fix).
spans := parseSpans("{{project.case_number}}")
if len(spans) != 1 {
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
}
if spans[0].Italic || spans[0].Bold {
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
}
if spans[0].Text != "{{project.case_number}}" {
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
}
}
func TestParseSpans_ItalicAroundPlaceholder(t *testing.T) {
spans := parseSpans("_before_ {{x.y_z}} _after_")
var saw struct {
italicBefore bool
placeholder bool
italicAfter bool
}
for _, s := range spans {
if s.Italic && s.Text == "before" {
saw.italicBefore = true
}
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
saw.placeholder = true
}
if s.Italic && s.Text == "after" {
saw.italicAfter = true
}
}
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
}
}
func TestParseSpans_Plain(t *testing.T) {
spans := parseSpans("hello world")
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
t.Errorf("expected single plain span; got %+v", spans)
}
}
func TestParseSpans_UnderscoreItalic(t *testing.T) {
spans := parseSpans("_emph_")
var italicHits int
for _, s := range spans {
if s.Italic && s.Text == "emph" {
italicHits++
}
}
if italicHits != 1 {
t.Errorf("expected one italic 'emph' span; got %+v", spans)
}
}
func TestParseSpans_UnderscoreBold(t *testing.T) {
spans := parseSpans("__strong__")
var boldHits int
for _, s := range spans {
if s.Bold && s.Text == "strong" {
boldHits++
}
}
if boldHits != 1 {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}
func TestDetectBlockMarker(t *testing.T) {
cases := []struct {
in string
kind string
want string
ok bool
}{
{"# A", "heading_1", "A", true},
{"## B", "heading_2", "B", true},
{"### C", "heading_3", "C", true},
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
{" # too-deep", "", "", false}, // 4 spaces → not a heading
{"- bullet", "list_bullet", "bullet", true},
{"* star", "list_bullet", "star", true},
{"1. one", "list_numbered", "one", true},
{"42. forty-two", "list_numbered", "forty-two", true},
{"1) paren", "list_numbered", "paren", true},
{"1.no-space", "", "", false}, // ordinal needs trailing space
{"> quote", "blockquote", "quote", true},
{"plain", "", "", false},
{"#nospace", "", "", false}, // heading needs space after hash
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
kind, payload, ok := detectBlockMarker(tc.in)
if ok != tc.ok || kind != tc.kind || payload != tc.want {
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
}
})
}
}
// TestImport_Document spot-checks the neutral Document the importer
// produces — block kinds, the link-span shape, and placeholder pass-through.
func TestImport_Document(t *testing.T) {
doc := Import("# Title\n\nBody **bold** and [label](http://x).\n\n- item")
if len(doc.Blocks) != 3 {
t.Fatalf("blocks = %d; want 3 (%+v)", len(doc.Blocks), doc.Blocks)
}
if doc.Blocks[0].Kind != "heading_1" {
t.Errorf("block0 kind = %q; want heading_1", doc.Blocks[0].Kind)
}
if doc.Blocks[2].Kind != "list_bullet" {
t.Errorf("block2 kind = %q; want list_bullet", doc.Blocks[2].Kind)
}
// The body paragraph carries a link span with Link set + children.
var sawLink bool
for _, s := range doc.Blocks[1].Spans {
if s.Link == "http://x" && len(s.Children) > 0 {
sawLink = true
}
}
if !sawLink {
t.Errorf("body block missing link span; got %+v", doc.Blocks[1].Spans)
}
}
func TestImport_EmptyYieldsOneEmptyParagraph(t *testing.T) {
doc := Import("")
if len(doc.Blocks) != 1 || doc.Blocks[0].Kind != "paragraph" || len(doc.Blocks[0].Spans) != 0 {
t.Errorf("empty import = %+v; want one empty paragraph block", doc.Blocks)
}
}

58
pkg/docforge/model.go Normal file
View File

@@ -0,0 +1,58 @@
package docforge
// The neutral document model — the format-independent representation an
// importer produces and an exporter consumes (PRD §3.2). A Markdown
// importer parses source into a Document; the .docx exporter renders a
// Document into OOXML; a future PDF/HTML exporter renders the same
// Document differently. The model carries editable content only —
// placeholders ({{key}}) ride through as literal span text and are
// substituted later by the format exporter's merge pass, exactly as in
// the pre-model pipeline.
//
// Slice 8 (t-paliad-349) lands this model with two real consumers: the
// Markdown importer (pkg/docforge/markdown) and the .docx renderer
// (pkg/docforge/docx), which the shipped submission walker now routes
// through — so there is one parser, not two.
// BlockKind is the logical kind of a block. Its string values are the
// stylemap keys a format exporter looks up (paragraph, heading_1, …), so
// the docx exporter maps Kind → Word paragraph style directly.
type BlockKind string
const (
KindParagraph BlockKind = "paragraph"
KindHeading1 BlockKind = "heading_1"
KindHeading2 BlockKind = "heading_2"
KindHeading3 BlockKind = "heading_3"
KindListBullet BlockKind = "list_bullet"
KindListNumbered BlockKind = "list_numbered"
KindBlockquote BlockKind = "blockquote"
)
// Document is a sequence of blocks — the whole editable content.
type Document struct {
Blocks []Block
}
// Block is one paragraph-level unit. Spans is its inline content; an empty
// Spans slice is an intentional empty paragraph (vertical spacing).
type Block struct {
Kind BlockKind
Spans []InlineSpan
}
// InlineSpan is one run of inline content. A span is either:
// - literal text with optional bold/italic (Link == "", Children nil), or
// - a hyperlink (Link != "") whose label is the Children spans.
//
// Modelling a link as a span with Children (rather than a per-span Link
// flag) preserves link boundaries: two adjacent links to the same URL stay
// two distinct hyperlink spans, so the exporter emits them byte-identically
// to the pre-model walker.
type InlineSpan struct {
Text string
Bold bool
Italic bool
Link string // non-empty → this span is a hyperlink to Link
Children []InlineSpan // hyperlink label content (only when Link != "")
}