feat: implement Fristenrechner (patent deadline calculator)

Go deadline engine (internal/calc/):
- 9 proceeding types: UPC (INF/REV/PI/APP), DE (INF/NULL), EPA (OPP/APP/GRANT)
- ~50 deadline rules with durations, parties, rule references
- German federal holiday computation (Easter via Anonymous Gregorian)
- Weekend/holiday adjustment with transparency (original vs adjusted dates)
- 8 unit tests covering holidays, adjustment, and full deadline chains

Frontend (Bun/TSX):
- 3-step wizard: select proceeding → enter date → view timeline
- Visual timeline with party badges, rule references, adjustment warnings
- Print-friendly layout

API: POST /api/tools/fristenrechner (protected, JSON)
     GET /api/tools/proceeding-types (protected, JSON)
Route: GET /tools/fristenrechner (protected page)

Home page: Added "Werkzeuge" section with cards linking to both tools
This commit is contained in:
m
2026-04-14 17:31:04 +02:00
parent bd621664cf
commit d94f8e7e25
11 changed files with 1322 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ import { join } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
const DIST = join(import.meta.dir, "dist");
@@ -16,6 +17,7 @@ async function build() {
entrypoints: [
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -40,6 +42,7 @@ async function build() {
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,189 @@
// Fristenrechner client-side logic
// 3-step wizard: select proceeding → enter date → view timeline
interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
isMandatory: boolean;
ruleRef: string;
notes?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
isRootEvent: boolean;
isCourtSet: boolean;
}
interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
}
const PARTY_LABELS: Record<string, [string, string]> = {
claimant: ["Kl\u00e4ger", "party-claimant"],
defendant: ["Beklagter", "party-defendant"],
court: ["Gericht", "party-court"],
both: ["Beide", "party-both"],
};
function formatDate(dateStr: string): string {
if (!dateStr) return "\u2014";
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function partyBadge(party: string): string {
const [label, cls] = PARTY_LABELS[party] || [party, "party-both"];
return `<span class="party-badge ${cls}">${label}</span>`;
}
let selectedType = "";
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
if (el) el.style.display = i <= n ? "block" : "none";
}
const resetBtn = document.getElementById("reset-btn")!;
resetBtn.style.display = n > 1 ? "block" : "none";
}
async function calculate() {
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
const triggerDate = dateInput.value;
if (!triggerDate || !selectedType) return;
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingType: selectedType,
triggerDate,
}),
});
if (!resp.ok) {
const err = await resp.json();
console.error("API error:", err);
return;
}
const data: DeadlineResponse = await resp.json();
renderTimeline(data);
showStep(3);
} catch (e) {
console.error("Fetch error:", e);
}
}
function renderTimeline(data: DeadlineResponse) {
const container = document.getElementById("timeline-container")!;
const printBtn = document.getElementById("fristen-print-btn")!;
let html = `<div class="timeline-header">
<strong>${data.proceedingName}</strong>
<span class="card-en">Trigger: ${formatDate(data.triggerDate)}</span>
</div>`;
html += '<div class="timeline">';
for (const dl of data.deadlines) {
const dateStr = dl.isCourtSet
? '<span class="timeline-court-set">vom Gericht bestimmt<br><span class="card-en">set by court</span></span>'
: `<span class="timeline-date">${formatDate(dl.dueDate)}</span>`;
const mandatoryBadge = dl.isMandatory
? ""
: '<span class="optional-badge">optional</span>';
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">\u26a0 Verschoben: ${formatDate(dl.originalDate)} \u2192 ${formatDate(dl.dueDate)} (Wochenende/Feiertag)</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
const notes = dl.notes
? `<div class="timeline-notes">${dl.notes}</div>`
: "";
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
<div class="timeline-item-header">
<span class="timeline-name">
${dl.name}
<span class="card-en">${dl.nameEN}</span>
${mandatoryBadge}
</span>
${dateStr}
</div>
<div class="timeline-meta">
${partyBadge(dl.party)}
${ruleRef}
</div>
${adjustedNote}
${notes}
</div>
</div>
`;
}
html += "</div>";
container.innerHTML = html;
printBtn.style.display = "block";
}
function reset() {
selectedType = "";
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
document.getElementById("timeline-container")!.innerHTML = "";
document.getElementById("fristen-print-btn")!.style.display = "none";
showStep(1);
}
document.addEventListener("DOMContentLoaded", () => {
// Proceeding type selection
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code!;
// Update trigger event name
const name = btn.querySelector("strong")?.textContent || "";
document.getElementById("trigger-event")!.textContent = name;
showStep(2);
});
});
// Calculate button
document.getElementById("calculate-btn")!.addEventListener("click", calculate);
// Also calculate on Enter in date field
document.getElementById("trigger-date")!.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") calculate();
});
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);
// Print button
document.getElementById("fristen-print-btn")!.addEventListener("click", () => window.print());
});

View File

@@ -0,0 +1,141 @@
import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
interface ProceedingDef {
code: string;
name: string;
nameEN: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong>{p.name}</strong>
<span className="card-en">{p.nameEN}</span>
</button>
);
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "UPC_INF", name: "Verletzungsverfahren", nameEN: "Infringement" },
{ code: "UPC_REV", name: "Nichtigkeitsklage", nameEN: "Revocation" },
{ code: "UPC_PI", name: "Einstw. Ma\u00dfnahmen", nameEN: "Provisional Measures" },
{ code: "UPC_APP", name: "Berufung", nameEN: "Appeal" },
];
const DE_TYPES: ProceedingDef[] = [
{ code: "DE_INF", name: "Verletzungsklage (LG)", nameEN: "Infringement" },
{ code: "DE_NULL", name: "Nichtigkeitsverfahren", nameEN: "Nullity" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "EPA_OPP", name: "Einspruchsverfahren", nameEN: "Opposition" },
{ code: "EPA_APP", name: "Beschwerdeverfahren", nameEN: "Appeal" },
{ code: "EP_GRANT", name: "EP-Erteilungsverfahren", nameEN: "Grant Procedure" },
];
export function renderFristenrechner(): string {
const today = new Date().toISOString().split("T")[0];
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fristenrechner patholo</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body>
<Header showLogout={true} />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1>Fristenrechner <span className="card-en">Patent Deadline Calculator</span></h1>
<p className="tool-subtitle">
Berechnung von Verfahrensfristen f&uuml;r UPC-, deutsche und EPA-Verfahren.
<br />
<span className="card-en">Calculate procedural deadlines for UPC, German, and EPA proceedings.</span>
</p>
</div>
<div className="fristen-wizard">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
Verfahrensart w&auml;hlen <span className="card-en">Select Proceeding Type</span>
</h3>
<div className="proceeding-group">
<h4>UPC <span className="card-en">Unified Patent Court</span></h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<h4>Deutsche Gerichte <span className="card-en">German Courts</span></h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<h4>EPA <span className="card-en">European Patent Office</span></h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
Ausgangsdatum eingeben <span className="card-en">Enter Trigger Date</span>
</h3>
<div className="date-input-group">
<div className="date-field-row">
<label htmlFor="trigger-event" className="date-label">Ausl&ouml;sendes Ereignis:</label>
<span id="trigger-event" className="trigger-event-name"></span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={today} />
</div>
<button type="button" id="calculate-btn" className="calculate-btn">
Fristen berechnen <span className="card-en">Calculate Deadlines</span>
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
Ergebnis <span className="card-en">Result</span>
</h3>
<div id="timeline-container">
</div>
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
Drucken <span className="card-en">Print</span>
</button>
</div>
<button type="button" id="reset-btn" className="reset-btn" style="display:none">
&larr; Neu berechnen <span className="card-en">Start Over</span>
</button>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/fristenrechner.js"></script>
</body>
</html>
);
}

View File

@@ -5,6 +5,8 @@ import { Footer } from "./components/Footer";
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>';
const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
export function renderIndex(): string {
return "<!DOCTYPE html>" + (
@@ -54,6 +56,25 @@ export function renderIndex(): string {
</div>
</section>
<section className="sections">
<div className="container">
<h3 className="section-heading">Werkzeuge <span className="card-en">Tools</span></h3>
<div className="grid grid-2">
<a href="/tools/kostenrechner" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CALC }} />
<h2>Kostenrechner <span className="card-en">Cost Calculator</span></h2>
<p>Sch&auml;tzung der Verfahrenskosten f&uuml;r DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.</p>
</a>
<a href="/tools/fristenrechner" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CLOCK }} />
<h2>Fristenrechner <span className="card-en">Deadline Calculator</span></h2>
<p>Berechnung von Verfahrensfristen f&uuml;r UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.</p>
</a>
</div>
</div>
</section>
<section className="offices">
<div className="container">
<h3>Standorte <span className="card-en">Offices</span></h3>

View File

@@ -207,6 +207,33 @@ main {
line-height: 1.6;
}
.section-heading {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
margin-bottom: 1rem;
}
.section-heading .card-en {
text-transform: none;
letter-spacing: normal;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.card-link {
text-decoration: none;
color: inherit;
display: block;
}
.card-link:hover {
border-color: var(--color-accent);
}
/* --- Offices --- */
.offices {
@@ -810,6 +837,325 @@ input[type="range"]::-moz-range-thumb {
color: var(--color-accent);
}
/* --- Fristenrechner --- */
.fristen-wizard {
max-width: 720px;
}
.wizard-step {
margin-bottom: 2rem;
}
.wizard-step-label {
font-size: 0.92rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.proceeding-group {
margin-bottom: 1.25rem;
}
.proceeding-group h4 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.proceeding-group h4 .card-en {
text-transform: none;
letter-spacing: normal;
}
.proceeding-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.proceeding-btn {
font-family: var(--font-sans);
font-size: 0.85rem;
padding: 0.6rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.proceeding-btn:hover {
border-color: var(--color-accent);
box-shadow: var(--shadow);
}
.proceeding-btn.active {
border-color: var(--color-accent);
background: rgba(101, 163, 13, 0.06);
box-shadow: 0 0 0 3px rgba(101, 163, 13, 0.15);
}
.proceeding-btn strong {
font-weight: 500;
}
/* Date input */
.date-input-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 400px;
}
.date-field-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.date-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
min-width: 160px;
}
.trigger-event-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-accent);
}
.date-input {
font-family: var(--font-sans);
font-size: 0.92rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
outline: none;
}
.date-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(101, 163, 13, 0.15);
}
.calculate-btn {
font-family: var(--font-sans);
font-size: 0.88rem;
font-weight: 600;
padding: 0.6rem 1.25rem;
border: none;
border-radius: var(--radius);
background: var(--color-accent);
color: #fff;
cursor: pointer;
transition: background 0.15s ease;
align-self: flex-start;
}
.calculate-btn:hover {
background: var(--color-accent-light);
}
.reset-btn {
font-family: var(--font-sans);
font-size: 0.82rem;
font-weight: 500;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.reset-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Timeline */
.timeline-header {
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--color-accent);
}
.timeline-header strong {
font-size: 1rem;
}
.timeline {
position: relative;
}
.timeline-item {
display: flex;
gap: 0.75rem;
min-height: 4rem;
}
.timeline-item:last-child .timeline-line {
display: none;
}
.timeline-dot-col {
display: flex;
flex-direction: column;
align-items: center;
width: 14px;
flex-shrink: 0;
}
.timeline-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-accent);
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 2px var(--color-accent);
flex-shrink: 0;
margin-top: 0.35rem;
}
.dot-root {
width: 14px;
height: 14px;
margin-top: 0.2rem;
}
.timeline-line {
width: 2px;
flex: 1;
background: var(--color-border);
margin: 0.25rem 0;
}
.timeline-content {
flex: 1;
padding-bottom: 1rem;
}
.timeline-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.timeline-name {
font-size: 0.88rem;
font-weight: 500;
}
.timeline-date {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.timeline-court-set {
font-size: 0.82rem;
color: var(--color-text-muted);
font-style: italic;
text-align: right;
}
.timeline-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.party-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.45rem;
border-radius: 99px;
}
.party-claimant {
background: #dbeafe;
color: #1e40af;
}
.party-defendant {
background: #fef3c7;
color: #92400e;
}
.party-court {
background: #f3e8ff;
color: #6b21a8;
}
.party-both {
background: #e5e7eb;
color: #374151;
}
.optional-badge {
font-size: 0.68rem;
font-weight: 500;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: #fef3c7;
color: #92400e;
}
.timeline-rule {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-text-muted);
}
.timeline-adjusted {
font-size: 0.78rem;
color: #d97706;
margin-top: 0.2rem;
}
.timeline-notes {
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
margin-top: 0.15rem;
}
/* --- Responsive --- */
@media (max-width: 768px) {
@@ -821,7 +1167,7 @@ input[type="range"]::-moz-range-thumb {
font-size: 2rem;
}
.grid {
.grid, .grid-2 {
grid-template-columns: 1fr;
gap: 1rem;
}
@@ -841,12 +1187,27 @@ input[type="range"]::-moz-range-thumb {
.nav-right {
gap: 0.75rem;
}
.proceeding-btns {
flex-direction: column;
}
.date-field-row {
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
}
.timeline-item-header {
flex-direction: column;
gap: 0.25rem;
}
}
/* --- Print --- */
@media print {
.header, .footer, .tool-input, .print-btn {
.header, .footer, .tool-input, .print-btn, .reset-btn, #step-1, #step-2, .calculate-btn {
display: none !important;
}