docs: Bedienungsanleitung und Roadmap

Bis jetzt gab es Code und Betriebsdokumentation, aber nichts, was einem
beim Arbeiten hilft.

Die Anleitung ist kein Handbuch zum Durchlesen, sondern die zwoelf
Handgriffe, die im Alltag vorkommen - vom Behoerdenschreiben aufnehmen
bis zum Vertrag kuendigen. Dazu der Jahresrhythmus, die Berichte, was am
Handy anders ist, und ein Abschnitt "wenn etwas schiefgeht" mit den
Faellen, die wirklich passieren: Handy verloren, Code wird nicht
angenommen, Frist stimmt nicht, Bericht zeigt eine Differenz.

Ein eigener Abschnitt sagt, was die Anwendung NICHT tut. Wer sich auf
etwas verlaesst, das sie nicht leistet, faellt sonst genau dann darauf
herein, wenn es darauf ankommt.

Sie ist unter /anleitung auch in der Anwendung lesbar - dieselbe Datei,
kein zweiter Text, der auseinanderlaeuft. Wer im Heim steht und nicht
weiterweiss, hat sie auf dem Telefon dabei, und der Druckknopf legt sie
zu den Betreuungsunterlagen.

Dafuer ein schlichter Markdown-Baustein statt einer Bibliothek: die
Anwendung zeigt genau zwei solche Dokumente, beide schreibe ich selbst.
Rohes HTML wird nicht durchgereicht. Die Datenschutzseite benutzt ihn
jetzt ebenfalls, statt eine zweite Umsetzung zu pflegen.

Beide Dateien werden ueber outputFileTracingIncludes in den
Standalone-Build gezogen - der Pfad entsteht erst zur Laufzeit, die
Dateiverfolgung von Next.js kann ihn nicht erkennen. Ohne den Eintrag
waeren beide Seiten im Behaelter leer.

Die Roadmap sortiert die 44 Schema-Wuensche der Etappen nach einer
einzigen Frage zuerst: Kann daraus ein falscher Wert in einem Bericht
ans Gericht werden? Was das kann, steht in 1.1. Dazu ein Abschnitt
"bewusst nicht geplant" mit Begruendung - damit klar ist, worauf
niemand wartet.

Etappe: 8
Phase: Haertung
This commit is contained in:
Florian Wilde
2026-08-22 10:51:28 +02:00
parent b2ac13e128
commit 0e7da42f27
8 changed files with 1131 additions and 81 deletions
+16 -1
View File
@@ -3,7 +3,16 @@
import * as React from 'react'
import Link from 'next/link'
import { signOut } from 'next-auth/react'
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react'
import {
BookOpen,
LogOut,
Monitor,
Moon,
Settings,
ShieldCheck,
Sun,
User as UserIcon,
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -96,6 +105,12 @@ export function BenutzerMenue({
Einstellungen
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/anleitung">
<BookOpen className="size-4" />
Anleitung
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { Printer } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* Druckt die aktuelle Seite.
*
* Muss eine Client-Komponente sein - window.print() gibt es auf dem
* Server nicht. Der Knopf selbst traegt "no-print", damit er auf dem
* Ausdruck nicht auftaucht.
*/
export function DruckKnopf({ beschriftung = 'Drucken' }: { beschriftung?: string }) {
return (
<Button variant="outline" className="no-print" onClick={() => window.print()}>
<Printer aria-hidden="true" />
{beschriftung}
</Button>
)
}
+324
View File
@@ -0,0 +1,324 @@
import * as React from 'react'
/**
* Anzeige einer Markdown-Datei aus dem docs-Verzeichnis.
*
* Bewusst ohne Markdown-Bibliothek: Die Anwendung zeigt genau zwei solche
* Dokumente an - die Datenschutzhinweise und die Anleitung. Beide schreibe
* ich selbst, ich weiss also, welche Auszeichnungen darin vorkommen. Eine
* Bibliothek waere hier vor allem zusaetzliche Abhaengigkeit und
* zusaetzliche Angriffsflaeche.
*
* Unterstuetzt: Ueberschriften, Absaetze, Aufzaehlungen (auch nummeriert),
* Tabellen, Zitatbloecke, Codebloecke, Trennlinien sowie **fett**,
* `Code` und [Links](ziel) im Fliesstext.
*
* Nicht unterstuetzt und auch nicht noetig: eingebettetes HTML, Bilder,
* Fussnoten. Rohes HTML wird NICHT durchgereicht - der Text wird immer als
* Text ausgegeben, nie als Markup.
*/
type Block =
| { art: 'ueberschrift'; ebene: 2 | 3 | 4; text: string }
| { art: 'absatz'; text: string }
| { art: 'liste'; nummeriert: boolean; punkte: string[] }
| { art: 'tabelle'; kopf: string[]; zeilen: string[][] }
| { art: 'zitat'; zeilen: string[] }
| { art: 'code'; zeilen: string[] }
| { art: 'trenner' }
/** Zerlegt den Fliesstext in fett, Code und Links. */
function inline(text: string, schluessel: string): React.ReactNode[] {
const teile: React.ReactNode[] = []
// Reihenfolge zaehlt: Code zuerst, damit Sternchen darin nicht greifen.
const muster = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g
let letzte = 0
let treffer: RegExpExecArray | null
let nummer = 0
while ((treffer = muster.exec(text)) !== null) {
if (treffer.index > letzte) {
teile.push(text.slice(letzte, treffer.index))
}
const stueck = treffer[0]
const id = `${schluessel}-${nummer++}`
if (stueck.startsWith('`')) {
teile.push(
<code key={id} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.9em]">
{stueck.slice(1, -1)}
</code>,
)
} else if (stueck.startsWith('**')) {
teile.push(
<strong key={id} className="font-semibold">
{stueck.slice(2, -2)}
</strong>,
)
} else {
const beschriftung = stueck.slice(1, stueck.indexOf(']'))
const ziel = stueck.slice(stueck.indexOf('](') + 2, -1)
const extern = /^https?:\/\//.test(ziel)
teile.push(
<a
key={id}
href={ziel}
className="text-primary underline underline-offset-2 hover:no-underline"
{...(extern ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
>
{beschriftung}
</a>,
)
}
letzte = treffer.index + stueck.length
}
if (letzte < text.length) teile.push(text.slice(letzte))
return teile
}
function zerlegen(markdown: string, ohneTitel: boolean): Block[] {
const zeilen = markdown.split(/\r?\n/)
const bloecke: Block[] = []
let absatz: string[] = []
const absatzSchliessen = () => {
if (absatz.length === 0) return
bloecke.push({ art: 'absatz', text: absatz.join(' ') })
absatz = []
}
for (let i = 0; i < zeilen.length; i++) {
const roh = zeilen[i] ?? ''
const zeile = roh.trimEnd()
const gekuerzt = zeile.trim()
// --- Codeblock ---------------------------------------------------------
if (gekuerzt.startsWith('```')) {
absatzSchliessen()
const inhalt: string[] = []
i += 1
while (i < zeilen.length && !(zeilen[i] ?? '').trim().startsWith('```')) {
inhalt.push(zeilen[i] ?? '')
i += 1
}
bloecke.push({ art: 'code', zeilen: inhalt })
continue
}
if (gekuerzt === '') {
absatzSchliessen()
continue
}
if (/^-{3,}$/.test(gekuerzt) || /^\*{3,}$/.test(gekuerzt)) {
absatzSchliessen()
bloecke.push({ art: 'trenner' })
continue
}
// --- Ueberschriften ----------------------------------------------------
const ueberschrift = gekuerzt.match(/^(#{1,4})\s+(.*)$/)
if (ueberschrift) {
absatzSchliessen()
const ebene = ueberschrift[1]!.length
// Die H1 ist der Dokumenttitel - den setzt die Seite selbst.
if (ebene === 1 && ohneTitel) continue
bloecke.push({
art: 'ueberschrift',
ebene: (Math.min(Math.max(ebene, 2), 4) as 2 | 3 | 4),
text: ueberschrift[2]!,
})
continue
}
// --- Zitatblock --------------------------------------------------------
if (gekuerzt.startsWith('>')) {
absatzSchliessen()
const inhalt: string[] = []
while (i < zeilen.length && (zeilen[i] ?? '').trim().startsWith('>')) {
inhalt.push((zeilen[i] ?? '').trim().replace(/^>\s?/, ''))
i += 1
}
i -= 1
bloecke.push({ art: 'zitat', zeilen: [inhalt.join(' ')] })
continue
}
// --- Tabelle -----------------------------------------------------------
if (gekuerzt.startsWith('|')) {
absatzSchliessen()
const spalten = (z: string) =>
z
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((s) => s.trim())
const kopf = spalten(gekuerzt)
i += 1 // Trennzeile ueberspringen
const zeilenInhalt: string[][] = []
while (i + 1 < zeilen.length && (zeilen[i + 1] ?? '').trim().startsWith('|')) {
i += 1
zeilenInhalt.push(spalten(zeilen[i] ?? ''))
}
bloecke.push({ art: 'tabelle', kopf, zeilen: zeilenInhalt })
continue
}
// --- Aufzaehlung -------------------------------------------------------
const punkt = gekuerzt.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
if (punkt) {
absatzSchliessen()
const nummeriert = /^\d+\./.test(gekuerzt)
const punkte: string[] = [punkt[1]!]
while (i + 1 < zeilen.length) {
const naechste = (zeilen[i + 1] ?? '').trim()
const weiterer = naechste.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
if (weiterer && /^\d+\./.test(naechste) === nummeriert) {
punkte.push(weiterer[1]!)
i += 1
continue
}
// Eingerueckte Fortsetzung gehoert zum letzten Punkt.
if (naechste !== '' && /^\s{2,}\S/.test(zeilen[i + 1] ?? '')) {
punkte[punkte.length - 1] += ` ${naechste}`
i += 1
continue
}
break
}
bloecke.push({ art: 'liste', nummeriert, punkte })
continue
}
absatz.push(gekuerzt)
}
absatzSchliessen()
return bloecke
}
export function MarkdownSeite({
inhalt,
ohneTitel = true,
className,
}: {
inhalt: string
/** Die erste H1 weglassen - die Seite setzt ihre Ueberschrift selbst. */
ohneTitel?: boolean
className?: string
}) {
const bloecke = zerlegen(inhalt, ohneTitel)
return (
<article className={className}>
{bloecke.map((block, i) => {
switch (block.art) {
case 'ueberschrift': {
const Tag = `h${block.ebene}` as 'h2' | 'h3' | 'h4'
const groesse =
block.ebene === 2
? 'mt-10 text-xl font-semibold'
: block.ebene === 3
? 'mt-7 text-lg font-semibold'
: 'mt-5 text-base font-semibold'
return (
<Tag key={i} className={`${groesse} scroll-mt-20 first:mt-0`}>
{block.text}
</Tag>
)
}
case 'absatz':
return (
<p key={i} className="mt-3 leading-relaxed">
{inline(block.text, String(i))}
</p>
)
case 'liste': {
const Tag = block.nummeriert ? 'ol' : 'ul'
return (
<Tag
key={i}
className={`mt-3 space-y-1.5 pl-6 ${
block.nummeriert ? 'list-decimal' : 'list-disc'
}`}
>
{block.punkte.map((punkt, j) => (
<li key={j} className="leading-relaxed">
{inline(punkt, `${i}-${j}`)}
</li>
))}
</Tag>
)
}
case 'tabelle':
return (
// Tabellen scrollen im eigenen Rahmen - am Handy darf nie die
// ganze Seite seitlich wandern.
<div key={i} className="mt-4 w-full overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{block.kopf.map((spalte, j) => (
<th key={j} className="px-3 py-2 text-left font-semibold">
{inline(spalte, `${i}-k-${j}`)}
</th>
))}
</tr>
</thead>
<tbody>
{block.zeilen.map((zeile, j) => (
<tr key={j} className="border-b last:border-0">
{zeile.map((zelle, k) => (
<td key={k} className="px-3 py-2 align-top">
{inline(zelle, `${i}-${j}-${k}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
case 'zitat':
return (
<blockquote
key={i}
className="mt-4 rounded-r-md border-l-4 border-primary/40 bg-muted/40 py-3 pl-4 pr-3"
>
{block.zeilen.map((zeile, j) => (
<p key={j} className="leading-relaxed">
{inline(zeile, `${i}-${j}`)}
</p>
))}
</blockquote>
)
case 'code':
return (
<pre
key={i}
className="mt-4 overflow-x-auto rounded-lg border bg-muted/60 p-3 text-sm"
>
<code className="font-mono">{block.zeilen.join('\n')}</code>
</pre>
)
case 'trenner':
return <hr key={i} className="my-8" />
}
})}
</article>
)
}