feat(t-paliad-122): GET /api/tools/courts + Fristenrechner court picker

GET /api/tools/courts[?courtType=UPC-LD] returns the deadline-
computation slice of paliad.courts (id, code, names, country, regime,
court_type) — distinct from the rich Gerichtsverzeichnis at
/api/courts. Optional courtType filter narrows to a single tier.

POST /api/tools/fristenrechner and POST /api/tools/fristenrechner/
calculate-rule both accept an optional courtId field. When set, the
calculator resolves the court's (country, regime) and uses that
calendar; when omitted, the proceeding's existing jurisdiction column
seeds a sensible default — preserves today's behaviour for callers
that don't yet send a court.

Frontend: court-picker-row added to step 2 of the Fristenrechner
wizard. Visible only for proceeding types with multiple compatible
courts (today: every UPC-flavoured proceeding — UPC LDs span 12
countries, plus UPC CD seats and the CoA). DE-only proceedings (BPatG
nullity, BGH appeals, DPMA, EPA, EP grant) keep the form unchanged.
Picker re-runs the calc on selection so the user sees the same
deadlines shift to a different calendar without a manual click. i18n
key deadlines.court.label added for both DE and EN.

Default courts wired sensibly: UPC_INF / UPC_REV / UPC_PI etc. → UPC
LD München (HLC's home venue); UPC_APP / UPC_APP_ORDERS /
UPC_COST_APPEAL → UPC CoA Luxembourg; UPC_REV → UPC CD Paris.
This commit is contained in:
m
2026-05-06 12:50:59 +02:00
parent d72990ad1b
commit 733917aae2
6 changed files with 147 additions and 0 deletions

View File

@@ -226,6 +226,15 @@ async function calculate() {
const overrides: Record<string, string> = {};
for (const [code, date] of anchorOverrides) overrides[code] = date;
// Court picker — only meaningful when the picker row is visible
// (multi-court proceeding types). When hidden, server resolves the
// default for the proceeding's jurisdiction.
const courtPickerRow = document.getElementById("court-picker-row");
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
? courtPicker.value
: "";
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
@@ -236,6 +245,7 @@ async function calculate() {
priorityDate: priorityDate || undefined,
flags: flags.length > 0 ? flags : undefined,
anchorOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
courtId: courtId || undefined,
}),
});
@@ -721,11 +731,104 @@ function selectProceeding(btn: HTMLButtonElement) {
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
syncInfAmendEnabled();
populateCourtPicker(selectedType);
showStep(2);
scheduleProcCalc(0);
}
// Court picker — t-paliad-122. Visible only for proceeding types that can
// land in multiple courts with different holiday calendars (today: every
// UPC-flavoured proceeding type, since UPC LDs span DE/FR/IT/NL/BE/FI/PT/
// AT/SI/DK + Stockholm RD + 3 CD seats). For DE-only proceedings (DE_NULL,
// DE_NULL_BGH, DE_INF_BGH, DPMA_*, EPA_*, EP_GRANT) the court is fixed by
// the proceeding type — no picker, server resolves the default.
//
// The picker calls /api/tools/courts?courtType=UPC-LD on first need and
// caches the response per-type. Defaulting to upc-ld-muenchen matches HLC's
// most common venue and keeps current behaviour for users who don't choose.
interface CourtRow {
id: string;
code: string;
nameDE: string;
nameEN: string;
country: string;
regime?: string;
courtType: string;
}
const courtCache = new Map<string, CourtRow[]>();
function courtTypesFor(proceedingType: string): string[] {
// Map proceeding code to compatible court types. UPC proceedings → UPC-LD
// (most common); appeals → UPC-CoA; central-division revocations → UPC-CD.
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return ["UPC-CoA"];
}
if (proceedingType === "UPC_REV") {
return ["UPC-CD", "UPC-LD"]; // CD is the default revocation forum, LD when joined with infringement
}
if (proceedingType.startsWith("UPC_")) {
return ["UPC-LD"];
}
return [];
}
function defaultCourtFor(proceedingType: string): string {
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
return "upc-coa-luxembourg";
}
if (proceedingType === "UPC_REV") {
return "upc-cd-paris";
}
return "upc-ld-muenchen";
}
async function fetchCourts(courtType: string): Promise<CourtRow[]> {
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
try {
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
if (!resp.ok) return [];
const rows = (await resp.json()) as CourtRow[];
courtCache.set(courtType, rows);
return rows;
} catch {
return [];
}
}
async function populateCourtPicker(proceedingType: string): Promise<void> {
const row = document.getElementById("court-picker-row");
const select = document.getElementById("court-picker") as HTMLSelectElement | null;
if (!row || !select) return;
const types = courtTypesFor(proceedingType);
if (types.length === 0) {
row.style.display = "none";
select.innerHTML = "";
return;
}
// Load all compatible court types and concatenate (CD before LD for REV).
const lists = await Promise.all(types.map(t => fetchCourts(t)));
const courts = lists.flat();
if (courts.length <= 1) {
// Single compatible court — no point asking the user. Server's
// jurisdiction default lands the same place.
row.style.display = "none";
select.innerHTML = "";
return;
}
const lang = getLang();
const defaultID = defaultCourtFor(proceedingType);
select.innerHTML = courts.map(c => {
const name = lang === "en" ? c.nameEN : c.nameDE;
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
}).join("");
row.style.display = "";
}
// inf-amend-flag is only meaningful when ccr-flag is on (R.30 application
// is filed within the Defence to CCR). When ccr-flag flips off, also
// untick inf-amend-flag so the calc payload stays coherent.
@@ -807,6 +910,8 @@ document.addEventListener("DOMContentLoaded", () => {
if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0));
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0));
// Click-to-edit on timeline / column dates: open an inline date input
// and persist the user's choice as an anchor override so downstream

View File

@@ -211,6 +211,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.trigger.date": "Datum:",
"deadlines.trigger.label": "Ausgangsdatum",
"deadlines.priority.date": "Priorit\u00e4tstag (optional):",
"deadlines.court.label": "Gericht:",
"deadlines.flag.ccr": "Mit Widerklage auf Nichtigkeit",
"deadlines.flag.inf_amend": "Mit Antrag auf Patentänderung (R.30)",
"deadlines.flag.rev_amend": "Mit Antrag auf Patentänderung (R.49.2.a)",
@@ -1806,6 +1807,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.trigger.date": "Date:",
"deadlines.trigger.label": "Trigger date",
"deadlines.priority.date": "Priority date (optional):",
"deadlines.court.label": "Court:",
"deadlines.flag.ccr": "Counterclaim for revocation filed",
"deadlines.flag.inf_amend": "Application to amend the patent filed (R.30)",
"deadlines.flag.rev_amend": "Application to amend the patent filed (R.49.2.a)",

View File

@@ -282,6 +282,10 @@ export function renderFristenrechner(): string {
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={today} />
</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="priority-date-row" style="display:none">
<label htmlFor="priority-date" className="date-label" data-i18n="deadlines.priority.date">Priorit&auml;tstag (optional):</label>
<input type="date" id="priority-date" className="date-input" />

View File

@@ -569,6 +569,7 @@ export type I18nKey =
| "deadlines.col.status"
| "deadlines.col.title"
| "deadlines.complete.action"
| "deadlines.court.label"
| "deadlines.court.set"
| "deadlines.date.edit.hint"
| "deadlines.de"

View File

@@ -31,6 +31,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
PriorityDate string `json:"priorityDate,omitempty"`
Flags []string `json:"flags,omitempty"`
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
CourtID string `json:"courtId,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -45,6 +46,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {
@@ -81,6 +83,7 @@ func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
RuleLocalCode string `json:"ruleLocalCode"`
TriggerDate string `json:"triggerDate"`
Flags []string `json:"flags,omitempty"`
CourtID string `json:"courtId,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -103,6 +106,7 @@ func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
RuleLocalCode: req.RuleLocalCode,
TriggerDate: req.TriggerDate,
Flags: req.Flags,
CourtID: req.CourtID,
})
if err != nil {
switch {
@@ -187,3 +191,33 @@ func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, resp)
}
// GET /api/tools/courts — list active courts for the Fristenrechner court
// picker. Optional ?courtType=UPC-LD filter narrows to a single tier so the
// UI can render only the courts compatible with the selected proceeding.
// Returns the deadline-computation slice (id, code, names, country, regime,
// court_type, sort_order) — NOT the full Gerichtsverzeichnis catalog. The
// rich addresses / phone / languages payload still lives at /api/courts.
func handleCourtsList(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.courts == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Gerichte vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
courtType := r.URL.Query().Get("courtType")
var (
courts []services.Court
err error
)
if courtType != "" {
courts, err = dbSvc.courts.ByCourtType(courtType)
} else {
courts, err = dbSvc.courts.All()
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Gerichte nicht laden"})
return
}
writeJSON(w, http.StatusOK, courts)
}

View File

@@ -140,6 +140,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)
protected.HandleFunc("GET /api/tools/courts", handleCourtsList)
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
protected.HandleFunc("GET /downloads", handleDownloadsPage)