feat(team): Datenschicht für Property On
Property On wird als Funktionsbereich "Teamübersicht" in Property Match eingebettet — sieben digitale Mitarbeiter, die ausführen, während der Mensch freigibt, anpasst oder zurückweist. Durchgehend Provider → Service → Hook, kein übersprungener Layer: - domain/: teamAgent, agentWorkItem, agentProtocol, agentConnection, agentFilters - mock-data/: 7 Personalblätter aus dem Agenten-Katalog (Aufgabenzahlen exakt: Ferdi 9, Sina 7, Lea 11, Bruno 9, Reto 10, Livia 11, Nora 8), 14 Vorgänge, 38 Protokolleinträge, 9 Verbindungen - provider/: vier Mockup-Provider mit versionierter localStorage-Persistenz - services/: Fachlogik inkl. Freigabe, Zurückweisung, Kennzahlen, Demo-Reset - hooks/: React Query, Mutationen entwerten Vorgänge, Kennzahlen und Protokoll gemeinsam — sonst zeigt die Oberfläche widersprüchliche Zahlen Namensgebung: Der Entitätstyp heisst TeamAgent, nicht Agent. Im Repo existiert bereits PowerOn als KI-Backend-Proxy, und Claude Code legt Arbeitskopien unter agent-* ab; ein nackter Typ Agent wäre in Suchen nicht auffindbar. Alle Satellitentypen sind Agent*-präfixiert, weil domain/index.ts per export * bündelt und generische Namen dort kollidieren. Eigener Protokolltyp statt ActivityEvent: den Namen gibt es im Repo dreifach und gegenseitig inkompatibel (domain/activityEvent.ts, governanceService.ts, PropertyActivityLogPanel.tsx). Ein Anschluss hätte den Konflikt zementiert. Demo-Uhr in lib/teamClock.ts: die Mockdaten sind auf den 20.05.2026 verankert. Gegen die Systemzeit gerechnet wären die Filter "heute / diese Woche / dieser Monat" an jedem anderen Kalendertag leer und die Auswertung sähe kaputt aus, obwohl sie korrekt rechnet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
36ce548169
commit
52dc7de25e
38 files changed
+9055
No files matched your search
@@ -0,0 +1,16 @@
|
||||
import type {
|
||||
AgentConnection,
|
||||
AgentConnectionTestResult,
|
||||
CreateAgentConnectionInput,
|
||||
} from '../domain/agentConnection'
|
||||
|
||||
export interface IAgentConnectionProvider {
|
||||
getAll(): Promise<AgentConnection[]>
|
||||
getById(id: string): Promise<AgentConnection | null>
|
||||
/** Simulierter Verbindungsaufbau aus dem mehrstufigen Assistenten (§13.5). */
|
||||
connect(input: CreateAgentConnectionInput): Promise<AgentConnection>
|
||||
disconnect(id: string): Promise<AgentConnection>
|
||||
/** Deterministisches Testergebnis — kein Zufall (§19.2). */
|
||||
test(id: string): Promise<AgentConnectionTestResult>
|
||||
reset(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
AgentProtocolEntry,
|
||||
AgentProtocolEventType,
|
||||
AgentProtocolStatus,
|
||||
CreateAgentProtocolEntryInput,
|
||||
} from '../domain/agentProtocol'
|
||||
import type { AgentChannelType } from '../domain/teamAgent'
|
||||
import type { AgentPeriod } from '../domain/agentFilters'
|
||||
|
||||
/** Filterdimensionen des Protokoll-Tabs (§12.3). */
|
||||
export interface AgentProtocolFilters {
|
||||
agentId?: string
|
||||
period?: AgentPeriod
|
||||
eventType?: AgentProtocolEventType
|
||||
status?: AgentProtocolStatus
|
||||
objectId?: string
|
||||
channel?: AgentChannelType
|
||||
/** Nur Einträge mit Freigabeentscheidung. */
|
||||
onlyApprovals?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface IAgentProtocolProvider {
|
||||
getAll(filters?: AgentProtocolFilters): Promise<AgentProtocolEntry[]>
|
||||
create(entry: CreateAgentProtocolEntryInput): Promise<AgentProtocolEntry>
|
||||
reset(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
AgentWorkItem,
|
||||
AgentWorkItemKind,
|
||||
AgentWorkItemPriority,
|
||||
AgentWorkItemStatus,
|
||||
AgentDomainArea,
|
||||
UpdateAgentWorkItemInput,
|
||||
} from '../domain/agentWorkItem'
|
||||
import type { AgentChannelType } from '../domain/teamAgent'
|
||||
import type { AgentPeriod, AgentWorkItemSort } from '../domain/agentFilters'
|
||||
|
||||
/** Filterdimensionen des Bearbeitungsverlaufs (§5.3). */
|
||||
export interface AgentWorkItemFilters {
|
||||
/** true → «Pendente Anfragen», false → «Erledigte Aufträge», undefined → alle. */
|
||||
requiresDecision?: boolean
|
||||
agentId?: string
|
||||
area?: AgentDomainArea
|
||||
kind?: AgentWorkItemKind
|
||||
priority?: AgentWorkItemPriority
|
||||
channel?: AgentChannelType
|
||||
status?: AgentWorkItemStatus
|
||||
period?: AgentPeriod
|
||||
search?: string
|
||||
sort?: AgentWorkItemSort
|
||||
}
|
||||
|
||||
export interface IAgentWorkItemProvider {
|
||||
getAll(filters?: AgentWorkItemFilters): Promise<AgentWorkItem[]>
|
||||
getById(id: string): Promise<AgentWorkItem | null>
|
||||
update(id: string, data: UpdateAgentWorkItemInput): Promise<AgentWorkItem>
|
||||
reset(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TeamAgent, UpdateTeamAgentInput } from '../domain/teamAgent'
|
||||
|
||||
export interface ITeamAgentProvider {
|
||||
getAll(): Promise<TeamAgent[]>
|
||||
getById(id: string): Promise<TeamAgent | null>
|
||||
update(id: string, data: UpdateTeamAgentInput): Promise<TeamAgent>
|
||||
/** Stellt den Auslieferungszustand her («Demo zurücksetzen», §16.2). */
|
||||
reset(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { IAgentConnectionProvider } from './IAgentConnectionProvider'
|
||||
import type {
|
||||
AgentConnection,
|
||||
AgentConnectionTestResult,
|
||||
CreateAgentConnectionInput,
|
||||
} from '../domain/agentConnection'
|
||||
import { AgentConnectionCategory } from '../domain/agentConnection'
|
||||
import { AgentConnectionStatus } from '../domain/teamAgent'
|
||||
import { mockAgentConnections } from '../mock-data/agentConnections'
|
||||
import {
|
||||
TEAM_STORAGE_KEYS,
|
||||
loadVersioned,
|
||||
persistVersioned,
|
||||
simulateLatency,
|
||||
} from './teamPersistence'
|
||||
import { teamNowIso } from '../lib/teamClock'
|
||||
import { AGENT_SIM_DELAY_MS, AGENT_SIM_DELAY_SLOW_MS } from '../lib/constants'
|
||||
|
||||
function seed(): AgentConnection[] {
|
||||
return loadVersioned<AgentConnection[]>(TEAM_STORAGE_KEYS.CONNECTIONS) ?? structuredClone(mockAgentConnections)
|
||||
}
|
||||
|
||||
let store: AgentConnection[] = seed()
|
||||
|
||||
function save(): void {
|
||||
persistVersioned(TEAM_STORAGE_KEYS.CONNECTIONS, store)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feste Testszenarien statt Zufall (§19.2): eine unverbundene und eine geplante
|
||||
* Verbindung schlagen reproduzierbar fehl, damit der Fehlerzustand in der Demo
|
||||
* gezeigt werden kann, ohne dass die Oberfläche unvorhersehbar wird.
|
||||
*/
|
||||
function evaluateTest(connection: AgentConnection): AgentConnectionTestResult {
|
||||
const checkedAt = teamNowIso()
|
||||
if (connection.status === AgentConnectionStatus.ROADMAP) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Diese Anbindung ist geplant und steht in der Demo noch nicht zur Verfügung.',
|
||||
checkedAt,
|
||||
}
|
||||
}
|
||||
if (connection.status === AgentConnectionStatus.DISCONNECTED) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'Es ist keine Verbindung hinterlegt. Zuerst über «Verbinden» einrichten.',
|
||||
checkedAt,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Verbindung erreichbar. Simulierter Test, es wurde kein echtes System kontaktiert.',
|
||||
checkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export const MockupAgentConnectionProvider: IAgentConnectionProvider = {
|
||||
async getAll() {
|
||||
return structuredClone(store)
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const found = store.find(c => c.id === id)
|
||||
return found ? structuredClone(found) : null
|
||||
},
|
||||
|
||||
async connect(input: CreateAgentConnectionInput) {
|
||||
await simulateLatency(AGENT_SIM_DELAY_SLOW_MS)
|
||||
const idx = store.findIndex(c => c.id === input.connectionId)
|
||||
if (idx === -1) throw new Error(`Verbindung «${input.connectionId}» nicht gefunden.`)
|
||||
|
||||
const current = store[idx]
|
||||
store[idx] = {
|
||||
...current,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
accountCount: Math.max(1, current.accountCount),
|
||||
connectionLabel: input.connectionLabel,
|
||||
usedByAgentIds: input.agentIds,
|
||||
lastSyncAt: teamNowIso(),
|
||||
...(current.category === AgentConnectionCategory.ERP && input.vendor
|
||||
? { vendor: input.vendor }
|
||||
: {}),
|
||||
permissions: current.permissions.map(p => ({
|
||||
...p,
|
||||
granted: input.permissionIds.includes(p.id),
|
||||
})),
|
||||
}
|
||||
save()
|
||||
return structuredClone(store[idx])
|
||||
},
|
||||
|
||||
async disconnect(id) {
|
||||
await simulateLatency(AGENT_SIM_DELAY_MS)
|
||||
const idx = store.findIndex(c => c.id === id)
|
||||
if (idx === -1) throw new Error(`Verbindung «${id}» nicht gefunden.`)
|
||||
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
status: AgentConnectionStatus.DISCONNECTED,
|
||||
accountCount: 0,
|
||||
lastSyncAt: undefined,
|
||||
connectionLabel: undefined,
|
||||
permissions: store[idx].permissions.map(p => ({ ...p, granted: false })),
|
||||
}
|
||||
save()
|
||||
return structuredClone(store[idx])
|
||||
},
|
||||
|
||||
async test(id) {
|
||||
await simulateLatency(AGENT_SIM_DELAY_SLOW_MS)
|
||||
const found = store.find(c => c.id === id)
|
||||
if (!found) throw new Error(`Verbindung «${id}» nicht gefunden.`)
|
||||
return evaluateTest(found)
|
||||
},
|
||||
|
||||
async reset() {
|
||||
store = structuredClone(mockAgentConnections)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { IAgentProtocolProvider, AgentProtocolFilters } from './IAgentProtocolProvider'
|
||||
import type { AgentProtocolEntry, CreateAgentProtocolEntryInput } from '../domain/agentProtocol'
|
||||
import { AgentPeriod, isWithinPeriod } from '../domain/agentFilters'
|
||||
import { mockAgentProtocol } from '../mock-data/agentProtocol'
|
||||
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
|
||||
import { teamNow } from '../lib/teamClock'
|
||||
|
||||
function seed(): AgentProtocolEntry[] {
|
||||
return loadVersioned<AgentProtocolEntry[]>(TEAM_STORAGE_KEYS.PROTOCOL) ?? structuredClone(mockAgentProtocol)
|
||||
}
|
||||
|
||||
let store: AgentProtocolEntry[] = seed()
|
||||
|
||||
function save(): void {
|
||||
persistVersioned(TEAM_STORAGE_KEYS.PROTOCOL, store)
|
||||
}
|
||||
|
||||
export const MockupAgentProtocolProvider: IAgentProtocolProvider = {
|
||||
async getAll(filters?: AgentProtocolFilters) {
|
||||
let results = store
|
||||
|
||||
if (filters?.agentId) results = results.filter(e => e.agentId === filters.agentId)
|
||||
if (filters?.eventType) results = results.filter(e => e.eventType === filters.eventType)
|
||||
if (filters?.status) results = results.filter(e => e.status === filters.status)
|
||||
if (filters?.objectId) results = results.filter(e => e.objectId === filters.objectId)
|
||||
if (filters?.channel) results = results.filter(e => e.channel === filters.channel)
|
||||
if (filters?.onlyApprovals) results = results.filter(e => e.approvalDecision !== undefined)
|
||||
if (filters?.period && filters.period !== AgentPeriod.ALL) {
|
||||
const now = teamNow()
|
||||
results = results.filter(e => isWithinPeriod(e.timestamp, filters.period!, now))
|
||||
}
|
||||
if (filters?.search) {
|
||||
const q = filters.search.trim().toLowerCase()
|
||||
results = results.filter(
|
||||
e =>
|
||||
e.title.toLowerCase().includes(q) ||
|
||||
e.description.toLowerCase().includes(q) ||
|
||||
(e.objectId?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
// Neueste zuerst — das Protokoll wird immer von oben gelesen.
|
||||
return structuredClone([...results].sort((a, b) => b.timestamp.localeCompare(a.timestamp)))
|
||||
},
|
||||
|
||||
async create(entry: CreateAgentProtocolEntryInput) {
|
||||
const next: AgentProtocolEntry = { id: crypto.randomUUID(), ...entry }
|
||||
store = [next, ...store]
|
||||
save()
|
||||
return structuredClone(next)
|
||||
},
|
||||
|
||||
async reset() {
|
||||
store = structuredClone(mockAgentProtocol)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { IAgentWorkItemProvider, AgentWorkItemFilters } from './IAgentWorkItemProvider'
|
||||
import type { AgentWorkItem, UpdateAgentWorkItemInput } from '../domain/agentWorkItem'
|
||||
import { AgentWorkItemPriority } from '../domain/agentWorkItem'
|
||||
import { AgentPeriod, AgentWorkItemSort, isWithinPeriod } from '../domain/agentFilters'
|
||||
import { mockAgentWorkItems } from '../mock-data/agentWorkItems'
|
||||
import {
|
||||
TEAM_STORAGE_KEYS,
|
||||
loadVersioned,
|
||||
persistVersioned,
|
||||
simulateLatency,
|
||||
} from './teamPersistence'
|
||||
import { teamNow } from '../lib/teamClock'
|
||||
import { AGENT_SIM_DELAY_MS } from '../lib/constants'
|
||||
|
||||
const PRIORITY_RANK: Record<string, number> = {
|
||||
[AgentWorkItemPriority.CRITICAL]: 0,
|
||||
[AgentWorkItemPriority.HIGH]: 1,
|
||||
[AgentWorkItemPriority.MEDIUM]: 2,
|
||||
[AgentWorkItemPriority.LOW]: 3,
|
||||
}
|
||||
|
||||
function seed(): AgentWorkItem[] {
|
||||
return loadVersioned<AgentWorkItem[]>(TEAM_STORAGE_KEYS.WORK_ITEMS) ?? structuredClone(mockAgentWorkItems)
|
||||
}
|
||||
|
||||
let store: AgentWorkItem[] = seed()
|
||||
|
||||
function save(): void {
|
||||
persistVersioned(TEAM_STORAGE_KEYS.WORK_ITEMS, store)
|
||||
}
|
||||
|
||||
/** Volltextsuche über die Felder, die ein Bewirtschafter tatsächlich im Kopf hat. */
|
||||
function matchesSearch(item: AgentWorkItem, query: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return (
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
item.summary.toLowerCase().includes(q) ||
|
||||
(item.objectId?.toLowerCase().includes(q) ?? false) ||
|
||||
(item.objectLabel?.toLowerCase().includes(q) ?? false) ||
|
||||
(item.escalationReason?.toLowerCase().includes(q) ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
function sortItems(items: AgentWorkItem[], sort: AgentWorkItemSort): AgentWorkItem[] {
|
||||
const sorted = [...items]
|
||||
switch (sort) {
|
||||
case AgentWorkItemSort.OLDEST:
|
||||
return sorted.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||
case AgentWorkItemSort.PRIORITY:
|
||||
return sorted.sort((a, b) => {
|
||||
const delta = (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b.priority] ?? 9)
|
||||
return delta !== 0 ? delta : b.createdAt.localeCompare(a.createdAt)
|
||||
})
|
||||
case AgentWorkItemSort.AGENT:
|
||||
return sorted.sort((a, b) => {
|
||||
const delta = a.agentId.localeCompare(b.agentId)
|
||||
return delta !== 0 ? delta : b.createdAt.localeCompare(a.createdAt)
|
||||
})
|
||||
case AgentWorkItemSort.OBJECT_ID:
|
||||
return sorted.sort((a, b) => {
|
||||
const delta = (a.objectId ?? '').localeCompare(b.objectId ?? '')
|
||||
return delta !== 0 ? delta : b.createdAt.localeCompare(a.createdAt)
|
||||
})
|
||||
case AgentWorkItemSort.NEWEST:
|
||||
default:
|
||||
return sorted.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
}
|
||||
}
|
||||
|
||||
export const MockupAgentWorkItemProvider: IAgentWorkItemProvider = {
|
||||
async getAll(filters?: AgentWorkItemFilters) {
|
||||
let results = store
|
||||
|
||||
if (filters?.requiresDecision !== undefined) {
|
||||
results = results.filter(i => i.requiresDecision === filters.requiresDecision)
|
||||
}
|
||||
if (filters?.agentId) results = results.filter(i => i.agentId === filters.agentId)
|
||||
if (filters?.area) results = results.filter(i => i.area === filters.area)
|
||||
if (filters?.kind) results = results.filter(i => i.kind === filters.kind)
|
||||
if (filters?.priority) results = results.filter(i => i.priority === filters.priority)
|
||||
if (filters?.status) results = results.filter(i => i.status === filters.status)
|
||||
if (filters?.channel) {
|
||||
results = results.filter(
|
||||
i => i.sourceChannel === filters.channel || i.targetChannel === filters.channel,
|
||||
)
|
||||
}
|
||||
if (filters?.period && filters.period !== AgentPeriod.ALL) {
|
||||
const now = teamNow()
|
||||
results = results.filter(i => isWithinPeriod(i.completedAt ?? i.createdAt, filters.period!, now))
|
||||
}
|
||||
if (filters?.search) {
|
||||
results = results.filter(i => matchesSearch(i, filters.search!))
|
||||
}
|
||||
|
||||
return structuredClone(sortItems(results, filters?.sort ?? AgentWorkItemSort.NEWEST))
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const found = store.find(i => i.id === id)
|
||||
return found ? structuredClone(found) : null
|
||||
},
|
||||
|
||||
async update(id, data: UpdateAgentWorkItemInput) {
|
||||
await simulateLatency(AGENT_SIM_DELAY_MS)
|
||||
const idx = store.findIndex(i => i.id === id)
|
||||
if (idx === -1) throw new Error(`Vorgang «${id}» nicht gefunden.`)
|
||||
|
||||
const current = store[idx]
|
||||
const history = data.historyEntry
|
||||
? [...current.history, { id: crypto.randomUUID(), ...data.historyEntry }]
|
||||
: current.history
|
||||
|
||||
store[idx] = {
|
||||
...current,
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.result ? { result: data.result } : {}),
|
||||
...(data.editableFields ? { editableFields: data.editableFields } : {}),
|
||||
...(data.rejectionReason !== undefined ? { rejectionReason: data.rejectionReason } : {}),
|
||||
...(data.completedAt !== undefined ? { completedAt: data.completedAt } : {}),
|
||||
...(data.requiresDecision !== undefined ? { requiresDecision: data.requiresDecision } : {}),
|
||||
history,
|
||||
}
|
||||
save()
|
||||
return structuredClone(store[idx])
|
||||
},
|
||||
|
||||
async reset() {
|
||||
store = structuredClone(mockAgentWorkItems)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ITeamAgentProvider } from './ITeamAgentProvider'
|
||||
import type { TeamAgent, UpdateTeamAgentInput } from '../domain/teamAgent'
|
||||
import { mockTeamAgents } from '../mock-data/teamAgents'
|
||||
import {
|
||||
TEAM_STORAGE_KEYS,
|
||||
loadVersioned,
|
||||
persistVersioned,
|
||||
simulateLatency,
|
||||
} from './teamPersistence'
|
||||
import { AGENT_SIM_DELAY_MS } from '../lib/constants'
|
||||
|
||||
/**
|
||||
* Der Bestand wird beim Laden aus dem Local Storage überlagert: geänderte
|
||||
* Einstellungen, Aufgabenschalter, Kanalschalter und pausierte Mitarbeiter
|
||||
* überleben so einen Reload. Fehlt oder passt der gespeicherte Bestand nicht
|
||||
* zur Schemaversion, greift der Seed aus `src/mock-data/`.
|
||||
*/
|
||||
function seed(): TeamAgent[] {
|
||||
return loadVersioned<TeamAgent[]>(TEAM_STORAGE_KEYS.AGENTS) ?? structuredClone(mockTeamAgents)
|
||||
}
|
||||
|
||||
let store: TeamAgent[] = seed()
|
||||
|
||||
function save(): void {
|
||||
persistVersioned(TEAM_STORAGE_KEYS.AGENTS, store)
|
||||
}
|
||||
|
||||
export const MockupTeamAgentProvider: ITeamAgentProvider = {
|
||||
async getAll() {
|
||||
return structuredClone(store)
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const found = store.find(a => a.id === id)
|
||||
return found ? structuredClone(found) : null
|
||||
},
|
||||
|
||||
async update(id, data: UpdateTeamAgentInput) {
|
||||
await simulateLatency(AGENT_SIM_DELAY_MS)
|
||||
const idx = store.findIndex(a => a.id === id)
|
||||
if (idx === -1) throw new Error(`Digitaler Mitarbeiter «${id}» nicht gefunden.`)
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.tasks ? { tasks: data.tasks } : {}),
|
||||
...(data.channels ? { channels: data.channels } : {}),
|
||||
...(data.settings ? { settings: data.settings } : {}),
|
||||
}
|
||||
save()
|
||||
return structuredClone(store[idx])
|
||||
},
|
||||
|
||||
async reset() {
|
||||
store = structuredClone(mockTeamAgents)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Property On — lokale Persistenz (§16.2).
|
||||
*
|
||||
* Ausschliesslich von den Mockup-Providern benutzt: die Providerschicht ist der
|
||||
* einzige Ort, der Datenhaltung berührt (CLAUDE.md §4.1). Services, Hooks und
|
||||
* Komponenten sehen davon nichts.
|
||||
*
|
||||
* Persistiert werden geänderte Einstellungen, Aufgabenstatus, erledigte
|
||||
* Vorgänge, Verbindungsstatus und pausierte Mitarbeiter. «Demo zurücksetzen»
|
||||
* löscht genau diese Schlüssel und stellt die ursprünglichen Mockdaten her.
|
||||
*/
|
||||
|
||||
const PREFIX = 'property-on:'
|
||||
|
||||
export const TEAM_STORAGE_KEYS = {
|
||||
AGENTS: `${PREFIX}agents`,
|
||||
WORK_ITEMS: `${PREFIX}work-items`,
|
||||
PROTOCOL: `${PREFIX}protocol`,
|
||||
CONNECTIONS: `${PREFIX}connections`,
|
||||
} as const
|
||||
|
||||
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS]
|
||||
|
||||
/**
|
||||
* Liest einen persistierten Bestand.
|
||||
* Gibt null zurück, wenn nichts gespeichert ist oder der Inhalt unlesbar wurde —
|
||||
* dann greift der Seed aus `src/mock-data/`. Ein defekter Local-Storage-Eintrag
|
||||
* darf die Anwendung nie blockieren.
|
||||
*/
|
||||
export function loadPersisted<T>(key: TeamStorageKey): T | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt einen Bestand. Fehler (z. B. volles Kontingent) werden bewusst geschluckt. */
|
||||
export function persist<T>(key: TeamStorageKey, value: T): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
// Persistenz ist ein Komfort, kein Muss — die Demo läuft auch ohne weiter.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemaversion der persistierten Bestände. Erhöhen, sobald sich eine Struktur
|
||||
* unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
|
||||
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte.
|
||||
*/
|
||||
export const TEAM_SCHEMA_VERSION = 1
|
||||
|
||||
interface PersistedEnvelope<T> {
|
||||
version: number
|
||||
data: T
|
||||
}
|
||||
|
||||
/** Wie `loadPersisted`, verwirft aber Bestände einer fremden Schemaversion. */
|
||||
export function loadVersioned<T>(key: TeamStorageKey): T | null {
|
||||
const envelope = loadPersisted<PersistedEnvelope<T>>(key)
|
||||
if (!envelope || envelope.version !== TEAM_SCHEMA_VERSION) return null
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
export function persistVersioned<T>(key: TeamStorageKey, data: T): void {
|
||||
persist<PersistedEnvelope<T>>(key, { version: TEAM_SCHEMA_VERSION, data })
|
||||
}
|
||||
|
||||
/** Löscht sämtliche Property-On-Schlüssel («Demo zurücksetzen»). */
|
||||
export function clearTeamPersistence(): void {
|
||||
try {
|
||||
for (const key of Object.values(TEAM_STORAGE_KEYS)) {
|
||||
window.localStorage.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// siehe oben
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulierte Ladezeit (§19.1). Bewusst deterministisch statt zufällig — ein
|
||||
* flackernder Zufallswert macht die Demo unvorhersehbar und Tests instabil.
|
||||
*/
|
||||
export function simulateLatency(ms: number): Promise<void> {
|
||||
return new Promise(resolve => { setTimeout(resolve, ms) })
|
||||
}
|
||||
Reference in new issue
Block a user