feat(startseite): digitale Belegschaft mit Präsenz, eigener Stimme und Nachweis

Der nachgebaute Meetingraum entfällt. Eine gezeichnete Kulisse bleibt neben
fotografierten Porträts erkennbar gezeichnet — und ein Raum allein macht
niemanden menschlich. Menschlich wirkt im echten Büro dasselbe wie hier: dass
jemand da ist, etwas zu sagen hat und nachweisbar gearbeitet hat.

Die Startseite zeigt deshalb sechs Personenkarten vor einer abgedunkelten
Büroaufnahme. Jede Karte trägt, was ein Personalblatt trägt:

- Porträt mit Dienstpunkt — ist da oder pausiert
- Name, Funktion, Abteilung, Personalnummer
- Autonomiestufe: «Autonom», «Freigabe erforderlich», «Nur Vorschlag».
  Sie sagt, wie viel Verantwortung jemand trägt, und trennt Personal von
  Automatisierung.
- Ein Satz in der ersten Person zur aktuellen Lage, statt einer Kennzahl:
  «Bei 12 Fristen ist der Termin durch. Fürs Eskalieren brauche ich deine
  Freigabe.»
- Die zuletzt protokollierte Handlung mit Zeitangabe. Diese Zeile ist der
  Unterschied zwischen Behauptung und Beleg — eine Kennzahl kann jede
  Software anzeigen.

Darüber fasst ein Anriss zusammen, wie viele Mitarbeitende heute eine
Entscheidung brauchen, samt Dienststand der Belegschaft.

Schichten nach CLAUDE.md §4.2: `agentPresenceService` entscheidet, was als
letzte Handlung gilt und wie eine Zeitangabe in Alltagssprache lautet;
`agentWorkloadService` hält Zeitfenster, Schwellen und Formulierungen. Beide
nehmen den Stichzeitpunkt als Parameter — dieselbe Datenlage ergibt immer
dasselbe Ergebnis und ist ohne Renderer prüfbar (22 Tests).

Giorgio hat kein Personalblatt; Abteilung, Personalnummer und Stufe kommen
aus der zentralen Rollendefinition. Wer kein Dossier hat, gilt als im Dienst —
sonst stünde die halbe Belegschaft still, weil Stammdaten fehlen.

Bild: Unsplash, freie kommerzielle Lizenz. Herkunft und Austauschhinweis in
`src/assets/meeting-room/HERKUNFT.md`.

Geprüft: 435 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: sechs Karten ohne Überlappung, Titel und
«Filter & Übersicht» beim Einstieg sichtbar, alle sechs Klicks auf der
richtigen Subpage, keine Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-08-06 14:50:16 +02:00
parent d8f8f728cc
commit 435f2aa2f1
17 changed files with 698 additions and 487 deletions
+103
View File
@@ -0,0 +1,103 @@
import type { AgentPresence } from '../domain/agentPresence'
import type { AgentWorkload } from '../domain/agentWorkload'
import type { AgentProtocolEntry } from '../domain/agentProtocol'
import type { TeamAgent } from '../domain/teamAgent'
import { AgentStatus } from '../domain/teamAgent'
import { AGENT_SUPERVISOR, AGENT_WORKSPACES } from '../lib/agentWorkspaces'
import { AGENT_AUTONOMY_LABELS } from '../lib/constants'
/**
* Setzt die Belegschaftsansicht der Startseite zusammen (Runde 5).
*
* Reine Geschäftslogik ausserhalb von React (CLAUDE.md §4.2): welcher Eintrag
* als «letzte Handlung» gilt und wie eine Zeitangabe in Alltagssprache lautet,
* sind fachliche Festlegungen. Der Stichzeitpunkt ist ein Parameter — dieselbe
* Datenlage ergibt damit immer dasselbe Ergebnis und lässt sich prüfen.
*
* Giorgio steht bewusst am Ende: er ist Vorgesetzter, kein sechster
* Sachbearbeiter, und sein Klick führt nicht auf eine Arbeitsseite, sondern in
* die Personalverwaltung.
*/
const MINUTE = 60 * 1000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/**
* Zeitangabe in Alltagssprache.
*
* Bewusst grob: «vor 12 Min.» ist für einen Menschen brauchbarer als
* «14:32:07». Ab gestern steht die Uhrzeit dabei, weil «vor 19 Stunden»
* niemand in einen Arbeitstag einordnen kann.
*/
export function formatRelativeTime(iso: string, now: number): string {
const t = new Date(iso).getTime()
if (!Number.isFinite(t)) return ''
const diff = now - t
if (diff < 0) return 'gerade eben'
if (diff < MINUTE) return 'gerade eben'
if (diff < HOUR) return `vor ${Math.floor(diff / MINUTE)} Min.`
if (diff < DAY) {
const hours = Math.floor(diff / HOUR)
return `vor ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
}
const date = new Date(t)
const time = date.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
if (diff < 2 * DAY) return `gestern ${time}`
return date.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ` ${time}`
}
export interface AgentPresenceInput {
agents: TeamAgent[]
protocol: AgentProtocolEntry[]
workload: Record<string, AgentWorkload>
}
/**
* @param now Stichzeitpunkt in Millisekunden — als Parameter, nicht aus der
* Systemuhr, damit das Ergebnis reproduzierbar bleibt.
*/
export function buildAgentPresence(
{ agents, protocol, workload }: AgentPresenceInput,
now: number = Date.now(),
): AgentPresence[] {
// Jüngster Protokolleintrag je Mitarbeitendem. Ein Durchgang über die Liste
// statt einer Suche je Agent — das Protokoll wächst, die Belegschaft nicht.
const latest = new Map<string, AgentProtocolEntry>()
for (const entry of protocol) {
if (!entry.agentId) continue
const known = latest.get(entry.agentId)
if (!known || entry.timestamp > known.timestamp) latest.set(entry.agentId, entry)
}
const byId = new Map(agents.map(a => [a.id, a]))
const roster = [...AGENT_WORKSPACES, AGENT_SUPERVISOR]
return roster.map(member => {
const dossier = byId.get(member.id)
const action = latest.get(member.id)
return {
agentId: member.id,
name: member.name,
role: member.role,
// Personalblatt zuerst; für Giorgio, der keines hat, die Angaben aus
// der zentralen Rollendefinition.
department: dossier?.department ?? member.department ?? '',
personnelNumber: dossier?.personnelNumber ?? member.personnelNumber ?? '',
path: member.path,
// Ohne Personalblatt gilt jemand als im Dienst: die Belegschaftsansicht
// soll niemanden fälschlich als pausiert zeigen, nur weil ein Dossier fehlt.
onDuty: dossier ? dossier.status === AgentStatus.ACTIVE : true,
// Giorgio führt die Belegschaft und entscheidet nichts allein über
// Objekte — seine Stufe ergibt sich aus der Rolle, nicht aus einem Dossier.
autonomy: dossier ? (AGENT_AUTONOMY_LABELS[dossier.autonomyLevel] ?? '') : 'Vorgesetzter',
workload: workload[member.id],
lastAction: action
? { title: action.title, when: formatRelativeTime(action.timestamp, now) }
: undefined,
}
})
}