Merge branch 'worktree-agent-a7ad50f83f2d4236b' into wartung/helfer-nutzung-dienste

This commit is contained in:
Hitonabi
2026-09-25 13:31:28 +02:00
16 changed files with 1552 additions and 101 deletions
@@ -0,0 +1,58 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { Saeule } from "@/lib/diagramm"
import { Saeulen } from "./Saeulen"
const REIHEN = [{ name: "A", farbe: "var(--reihe-1)" }, { name: "B", farbe: "var(--reihe-2)" }, { name: "C", farbe: "var(--reihe-3)" }]
const stunden = Array.from({ length: 24 }, (_, i): Saeule => {
const h = (15 + i) % 24
return {
schluessel: String(i), achse: `${String(h).padStart(2, "0")}:00`, raster: h, lang: `${h} Uhr`,
werte: h === 3 ? { A: 139 } : { A: 5, B: i % 2 },
}
})
/** Der Behälter meldet eine feste Breite, wie ein Handy (jsdom kennt keinen ResizeObserver). */
function breiteVortaeuschen(px: number) {
vi.stubGlobal("ResizeObserver", class {
constructor(private melden: (e: { contentRect: { width: number } }[]) => void) {}
observe() { this.melden([{ contentRect: { width: px } }]) }
disconnect() {}
})
}
afterEach(() => vi.unstubAllGlobals())
describe("Saeulen", () => {
it("dünnt die Stundenbeschriftung aus und nennt nur die höchste Summe", () => {
const { container } = render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" summen="hoechste" />)
const texte = [...container.querySelectorAll("svg text")].map((t) => t.textContent)
expect(texte).toEqual(expect.arrayContaining(["15:00", "18:00", "00:00", "03:00", "139"]))
expect(texte).not.toContain("16:00")
expect(texte.filter((t) => t === "6")).toHaveLength(0) // die übrigen Summen stehen nur in Anzeige und Tabelle
})
it("zeigt in der Legende nur Reihen mit Werten", () => {
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
expect(within(screen.getByRole("list", { name: "Legende" })).getAllByRole("listitem").map((l) => l.textContent))
.toEqual(["A", "B"])
})
it("stellt die Anzeige auf dem Handy unter das Diagramm statt darüber", () => {
breiteVortaeuschen(300)
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
const bild = screen.getByRole("img", { name: "Je Stunde" })
expect(bild).toHaveAttribute("width", "300")
fireEvent.focus(bild)
const anzeige = screen.getByRole("status")
expect(anzeige).not.toHaveClass("absolute")
expect(anzeige).toHaveTextContent("14 Uhr")
expect(bild.compareDocumentPosition(anzeige) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it("sagt es, wenn es nichts zu zeigen gibt", () => {
render(<Saeulen saeulen={[{ schluessel: "x", achse: "Sa", lang: "Samstag", werte: {} }]} reihen={REIHEN} titel="Leer" />)
expect(screen.getByText("Keine Anfragen in diesem Zeitraum")).toBeInTheDocument()
expect(screen.queryByRole("list", { name: "Legende" })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,225 @@
import { type KeyboardEvent, type PointerEvent, useState } from "react"
import { cn } from "cn"
import { useBreite } from "@/lib/breite"
import { type Saeule, saeulenPfad, stapeln, zaehlAchse } from "@/lib/diagramm"
export interface SaeulenReihe {
name: string
/** CSS-Farbe der Säulenstücke, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
farbe: string
}
const zahl = new Intl.NumberFormat("de-DE")
/** Breite der Anzeige in px (w-60) — fest, damit sie sich ohne Messen im Diagramm halten lässt. */
const TIPP_BREITE = 240
/** Säulen bleiben schmal; der Rest des Platzes ist Luft. */
const SAEULE_MAX = 24
const SCHRITT: Record<string, (i: number, n: number) => number> = {
ArrowLeft: (i) => Math.max(0, i - 1),
ArrowRight: (i, n) => Math.min(n - 1, i + 1),
Home: () => 0,
End: (_, n) => n - 1,
}
/** Die Werte einer Säule: Zahl vorn, Name dahinter, die Farbe nur als kurzer Strich. */
function Anzeige({ saeule, reihen, summe, className, links }: {
saeule: Saeule
reihen: SaeulenReihe[]
summe: number
className?: string
links?: number
}) {
return (
<div role="status" className={cn("flex flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2", className)}
style={links === undefined ? undefined : { left: links }}>
<span className="ziffern text-xs text-text-3">{saeule.lang}</span>
{reihen.map((r) => (
<span key={r.name} className="flex items-baseline gap-2 text-sm">
<span aria-hidden className="h-0.5 w-3 shrink-0 self-center rounded-full" style={{ background: r.farbe }} />
<span className="ziffern font-semibold text-foreground">{zahl.format(saeule.werte[r.name] ?? 0)}</span>
<span className="min-w-0 text-text-3">{r.name}</span>
</span>
))}
{reihen.length > 1 && (
<span className="flex items-baseline gap-2 border-t border-linie pt-1 text-sm">
<span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span>
<span className="text-text-3">zusammen</span>
</span>
)}
</div>
)
}
/**
* Gestapelte Säulen für Zählwerte (Anfragen je Tag, je Stunde): eine Säule je Zeitraum, darin ein Stück je Reihe in
* fester Farbe, 2 px Luft zwischen den Stücken. Zeiger, Finger oder Pfeiltasten wählen eine Säule; die Anzeige nennt
* dann alle Reihen und die Summe. Darunter stehen die Werte zum Aufklappen als Tabelle — die Anzeige ist nie der
* einzige Weg zu einer Zahl.
*/
export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200, spalte = "Zeitraum" }: {
saeulen: Saeule[]
/** In Stapelfolge, unten zuerst. */
reihen: SaeulenReihe[]
titel: string
/** Summe über jeder Säule (bei wenigen Säulen) oder nur über der höchsten. */
summen?: "alle" | "hoechste"
hoehe?: number
/** Kopf der ersten Tabellenspalte, z. B. „Tag“ oder „Stunde“. */
spalte?: string
}) {
const [ref, breite] = useBreite()
const [index, setIndex] = useState<number | null>(null)
const n = saeulen.length
// Nur Reihen, die im Zeitraum vorkommen — sonst stünden Farben ohne Säulen in der Legende.
const sichtbar = reihen.filter((r) => saeulen.some((s) => (s.werte[r.name] ?? 0) > 0))
const summenListe = saeulen.map((s) => sichtbar.reduce((a, r) => a + (s.werte[r.name] ?? 0), 0))
const max = Math.max(0, ...summenListe)
const ticks = zaehlAchse(max)
const oben = ticks[ticks.length - 1]
const rand = {
links: Math.max(28, zahl.format(oben).length * 7 + 12),
rechts: 4,
oben: 20,
unten: saeulen.some((s) => s.achse2) ? 34 : 22,
}
const innenB = Math.max(1, breite - rand.links - rand.rechts)
const innenH = hoehe - rand.oben - rand.unten
const band = innenB / Math.max(1, n)
const dicke = Math.min(SAEULE_MAX, Math.max(3, band * 0.72))
const y = (v: number) => rand.oben + innenH - (v / oben) * innenH
const grund = rand.oben + innenH
const mitte = (i: number) => rand.links + (i + 0.5) * band
// Beschriftung ausdünnen, bis sie sich nicht berührt; bei Stunden landet sie so auf 0, 3, 6 … bzw. 0, 6, 12, 18 Uhr.
const noetig = Math.max(1, ...saeulen.map((s) => s.achse.length)) * 6.6 + 8
const jede = [1, 3, 6, 12].find((k) => k * band >= noetig) ?? 24
const hoechste = summenListe.indexOf(max)
const eng = band < 34
function zeigen(e: PointerEvent<SVGRectElement>) {
if (n === 0) return
const r = e.currentTarget.getBoundingClientRect()
setIndex(Math.min(n - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * n))))
}
function taste(e: KeyboardEvent<SVGSVGElement>) {
const schritt = SCHRITT[e.key]
if (n === 0 || !schritt) return
e.preventDefault()
setIndex((i) => schritt(i ?? n - 1, n))
}
const aktiv = index !== null ? saeulen[index] : undefined
// Ab Tablet-Breite schwebt die Anzeige neben der Säule, auf der Seite mit mehr Platz und immer ganz im Diagramm.
// Auf dem Handy bedeckte sie das halbe Diagramm — dort steht sie nach dem Antippen darunter.
const schwebend = breite >= 360
const tippLinks = index === null ? 0 : Math.max(0, Math.min(breite - TIPP_BREITE,
mitte(index) + 12 + TIPP_BREITE > breite ? mitte(index) - 12 - TIPP_BREITE : mitte(index) + 12))
return (
<div className="flex flex-col gap-2">
{sichtbar.length > 1 && (
<ul className="m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm text-text-2" aria-label="Legende">
{sichtbar.map((r) => (
<li key={r.name} className="flex items-center gap-2">
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: r.farbe }} />
{r.name}
</li>
))}
</ul>
)}
<div ref={ref} className="relative w-full">
<svg
width={breite}
height={hoehe}
viewBox={`0 0 ${breite} ${hoehe}`}
role="img"
aria-label={titel}
tabIndex={0}
onKeyDown={taste}
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
onBlur={() => setIndex(null)}
className="block h-auto max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{aktiv && index !== null && (
<rect x={rand.links + index * band} y={rand.oben - 4} width={band} height={innenH + 4} rx={4} fill="var(--erhaben)" />
)}
{ticks.map((v) => (
<g key={v}>
<line x1={rand.links} x2={breite - rand.rechts} y1={y(v)} y2={y(v)} strokeWidth={1}
stroke={v === 0 ? "var(--linie-stark)" : "var(--linie)"} />
<text x={rand.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
{zahl.format(v)}
</text>
</g>
))}
{saeulen.map((s, i) => {
const x = mitte(i) - dicke / 2
return (
<g key={s.schluessel}>
{stapeln(sichtbar.map((r) => s.werte[r.name] ?? 0), y).filter((t) => t.hoehe > 0).map((t) =>
t.oben ? (
<path key={t.index} d={saeulenPfad(x, t.y, dicke, t.hoehe)} fill={sichtbar[t.index].farbe} />
) : (
<rect key={t.index} x={x} y={t.y} width={dicke} height={t.hoehe} fill={sichtbar[t.index].farbe} />
))}
{summenListe[i] > 0 && (summen === "alle" || i === hoechste) && (
<text x={mitte(i)} y={y(summenListe[i]) - 6} textAnchor="middle"
className={cn("fill-text-2 font-mono", eng ? "text-[10px]" : "text-[11px]")}>
{zahl.format(summenListe[i])}
</text>
)}
{(s.raster ?? i) % jede === 0 && (
<text x={mitte(i)} y={grund + 15} textAnchor="middle" className="fill-text-3 font-mono text-[11px]">
{s.achse}
{s.achse2 && <tspan x={mitte(i)} dy={13} className="text-[10px]">{s.achse2}</tspan>}
</text>
)}
</g>
)
})}
{max === 0 && (
<text x={rand.links + innenB / 2} y={rand.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
Keine Anfragen in diesem Zeitraum
</text>
)}
{/* Trefferfläche: das ganze Band einer Säule samt Beschriftung — niemand muss ein schmales Stück treffen. */}
<rect x={rand.links} y={0} width={innenB} height={hoehe} fill="transparent"
onPointerMove={zeigen} onPointerDown={zeigen}
onPointerLeave={(e) => { if (e.pointerType !== "touch") setIndex(null) }} />
</svg>
{aktiv && index !== null && schwebend && (
<Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} links={tippLinks}
className="pointer-events-none absolute top-1 z-10 w-60 shadow-lg" />
)}
</div>
{aktiv && index !== null && !schwebend && <Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} />}
<details className="text-sm">
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-text-3">
<th scope="col" className="py-1 pr-3 text-left font-normal">{spalte}</th>
{sichtbar.map((r) => (
<th key={r.name} scope="col" className="py-1 pr-3 text-right font-normal [overflow-wrap:anywhere]">{r.name}</th>
))}
{sichtbar.length > 1 && <th scope="col" className="py-1 text-right font-normal">Zusammen</th>}
</tr>
</thead>
<tbody>
{saeulen.map((s, i) => (
<tr key={s.schluessel} className="border-t border-linie">
<th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th>
{sichtbar.map((r) => (
<td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td>
))}
{sichtbar.length > 1 && <td className="ziffern py-1 text-right text-foreground">{zahl.format(summenListe[i])}</td>}
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
)
}
+2 -15
View File
@@ -1,4 +1,5 @@
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
import { type KeyboardEvent, type PointerEvent, useState } from "react"
import { useBreite } from "@/lib/breite"
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
@@ -12,20 +13,6 @@ export interface Reihe {
const RAND = { rechts: 12, oben: 10, unten: 24 }
/** Breite des Behälters; ohne ResizeObserver (Tests) bleibt es beim Standard. */
function useBreite(standard = 640) {
const ref = useRef<HTMLDivElement>(null)
const [breite, setBreite] = useState(standard)
useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
return [ref, breite] as const
}
/**
* Zeitverlauf einer oder zweier Messreihen: dünne Linien, bei einer Reihe eine zarte Fläche, haarfeines Raster.
* Ein Fadenkreuz folgt Zeiger oder Pfeiltasten und zeigt alle Reihen am selben Zeitpunkt. Darunter liegen die