Release-Vorbereitung: Proxy, Kostenoptimierung, Freemium, Store-Assets

- Backend-Proxy (Cloudflare Worker in proxy/): API-Key serverseitig,
  Modell-Whitelist, max_tokens-Deckel, Tageslimits pro Gerät und IP
- Modell-Split: Analyse auf Sonnet 5, Übersetzung/Antwort auf Haiku 4.5
- Bild-Downscaling auf 2000px vor dem Upload (ScanScreen)
- Expo SDK 56 -> 57 Upgrade
- Freemium: 3 Gratis-Analysen mit lokalem Zähler und Paywall-Hinweis
- Einstellungen: API-Key-Feld nur noch im Dev-Modus (NUTZT_PROXY)
- Build-Setup: Bundle-IDs, eas.json, Splash-Screen-Plugin
- Tests: jest-expo + 17 Tests für die Ampel-/Fristlogik
- Rechtliches: Datenschutzerklärung (Entwurf), Webseite (webseite/,
  live auf behoerdenklar.pages.dev), Store-Texte (store/)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:14:00 +02:00
parent 88c49e1b54
commit 9dc1d8d87b
26 changed files with 6587 additions and 480 deletions

View File

@@ -1,6 +1,7 @@
/**
* Einstellungen: API-Schlüssel (Dev-Modus), Datenschutz-Info,
* "Alle Daten löschen".
* Einstellungen: Datenschutz-Info und "Alle Daten löschen".
* Das API-Schlüssel-Feld erscheint nur im Entwickler-Modus (ohne Proxy) —
* im Produktionsmodus läuft der KI-Zugang über den Backend-Proxy.
*/
import React, { useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
@@ -8,6 +9,7 @@ import * as Notifications from 'expo-notifications';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../types';
import { holeApiKey, speichereApiKey, loescheAlleDaten } from '../services/storage';
import { NUTZT_PROXY } from '../services/claudeClient';
import { useAppStore } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
import { farben, schrift, abstand } from '../theme';
@@ -35,7 +37,7 @@ export function EinstellungenScreen({ navigation }: Props) {
const allesLoeschen = () => {
Alert.alert(
'Wirklich ALLE Daten löschen?',
'Alle gescannten Briefe, Übersetzungen, Entwürfe, der API-Schlüssel und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
'Alle gescannten Briefe, Übersetzungen, Entwürfe und die Einwilligung werden dauerhaft gelöscht. Das kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
@@ -57,42 +59,47 @@ export function EinstellungenScreen({ navigation }: Props) {
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m }}>
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
<Text style={styles.text}>
Status:{' '}
<Text style={{ fontWeight: '700', color: keyVorhanden ? farben.ampelGruen : farben.fehler }}>
{keyVorhanden ? 'Schlüssel hinterlegt ✓' : 'Kein Schlüssel hinterlegt'}
</Text>
</Text>
<Text style={styles.hinweis}>
Der Schlüssel wird verschlüsselt auf dem Gerät gespeichert (Secure Store)
und nur für die Brief-Analyse verwendet. Einen Schlüssel erhalten Sie
unter console.anthropic.com.
</Text>
<TextInput
style={styles.eingabe}
placeholder="sk-ant-…"
placeholderTextColor={farben.textSekundaer}
value={apiKey}
onChangeText={setApiKey}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="API-Schlüssel eingeben"
/>
<GrossButton
titel={gespeichert ? 'Gespeichert ✓' : 'Schlüssel speichern'}
onPress={speichern}
deaktiviert={!apiKey.trim()}
/>
{!NUTZT_PROXY && (
<>
<Text style={styles.abschnittTitel}>KI-Zugang (API-Schlüssel)</Text>
<Text style={styles.text}>
Status:{' '}
<Text style={{ fontWeight: '700', color: keyVorhanden ? farben.ampelGruen : farben.fehler }}>
{keyVorhanden ? 'Schlüssel hinterlegt ✓' : 'Kein Schlüssel hinterlegt'}
</Text>
</Text>
<Text style={styles.hinweis}>
Der Schlüssel wird verschlüsselt auf dem Gerät gespeichert (Secure Store)
und nur für die Brief-Analyse verwendet. Einen Schlüssel erhalten Sie
unter console.anthropic.com.
</Text>
<TextInput
style={styles.eingabe}
placeholder="sk-ant-…"
placeholderTextColor={farben.textSekundaer}
value={apiKey}
onChangeText={setApiKey}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
accessibilityLabel="API-Schlüssel eingeben"
/>
<GrossButton
titel={gespeichert ? 'Gespeichert ✓' : 'Schlüssel speichern'}
onPress={speichern}
deaktiviert={!apiKey.trim()}
/>
<View style={styles.trenner} />
<View style={styles.trenner} />
</>
)}
<Text style={styles.abschnittTitel}>Datenschutz</Text>
<View style={styles.karte}>
<Text style={styles.text}>
Brieffotos werden nur zur Analyse an Anthropic gesendet und dort
nicht dauerhaft gespeichert.{'\n\n'}
Brieffotos werden nur zur Analyse an den KI-Dienst Anthropic (USA)
gesendet und dort nach spätestens 30 Tagen gelöscht. Sie werden nicht
zum Training der KI verwendet.{'\n\n'}
Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'}
Diese App ersetzt keine Rechtsberatung.
</Text>

View File

@@ -2,15 +2,18 @@
* Brief erfassen: Foto aufnehmen, aus Galerie wählen oder PDF hochladen.
* Danach läuft die KI-Analyse; bei Erfolg geht es direkt zum Ergebnis.
*/
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import { File } from 'expo-file-system';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList, BriefEintrag } from '../types';
import { analysiereBrief } from '../services/analyse';
import { ClaudeFehler } from '../services/claudeClient';
import { ClaudeFehler, NUTZT_PROXY } from '../services/claudeClient';
import { GRATIS_ANALYSEN, holeAnzahlAnalysen, zaehleAnalyse } from '../services/storage';
import { planeErinnerungen } from '../services/erinnerungen';
import { useAppStore, neueId } from '../store/useAppStore';
import { GrossButton } from '../components/GrossButton';
@@ -18,15 +21,68 @@ import { farben, schrift, abstand } from '../theme';
type Props = NativeStackScreenProps<RootStackParamList, 'Scan'>;
/**
* Maximale Kantenlänge fürs KI-Bild. Ein A4-Brief bleibt bei 2000px gut
* lesbar (~170 dpi); moderne Handyfotos (4000px+) würden unverkleinert
* bis zu 3x mehr Bild-Token kosten.
*/
const MAX_BILD_KANTE = 2000;
/**
* Verkleinert ein Foto auf max. MAX_BILD_KANTE (lange Kante, Seitenverhältnis
* bleibt erhalten), komprimiert als JPEG und liefert base64 für die Analyse.
*/
async function bildFuerAnalyse(asset: ImagePicker.ImagePickerAsset): Promise<string> {
const kontext = ImageManipulator.manipulate(asset.uri);
if (Math.max(asset.width, asset.height) > MAX_BILD_KANTE) {
kontext.resize(
asset.width >= asset.height
? { width: MAX_BILD_KANTE }
: { height: MAX_BILD_KANTE }
);
}
const bild = await kontext.renderAsync();
const ergebnis = await bild.saveAsync({
format: SaveFormat.JPEG,
compress: 0.6,
base64: true,
});
return ergebnis.base64!;
}
export function ScanScreen({ navigation }: Props) {
const addBrief = useAppStore((s) => s.addBrief);
const [laedt, setLaedt] = useState(false);
const [verbraucht, setVerbraucht] = useState(0);
// Zähler bei jedem Screen-Besuch frisch laden
useFocusEffect(
useCallback(() => {
holeAnzahlAnalysen().then(setVerbraucht);
}, [])
);
// Freemium gilt nur im Produktionsmodus (Proxy). Im Dev-Modus mit
// eigenem API-Key zahlt der Entwickler selbst — kein Limit nötig.
const uebrig = GRATIS_ANALYSEN - verbraucht;
const kontingentLeer = NUTZT_PROXY && uebrig <= 0;
/** Prüft das Gratis-Kontingent; false = Analyse nicht starten. */
const pruefeKontingent = (): boolean => {
if (!kontingentLeer) return true;
Alert.alert(
'Gratis-Analysen aufgebraucht',
`Sie haben Ihre ${GRATIS_ANALYSEN} kostenlosen Brief-Analysen genutzt. Bald können Sie hier ein Abo abschließen, um weiter Briefe zu analysieren.`
);
return false;
};
/** Gemeinsamer Abschluss: analysieren, speichern, Erinnerungen, weiter. */
const verarbeite = async (base64: string, mimeType: string) => {
setLaedt(true);
try {
const analyse = await analysiereBrief(base64, mimeType);
await zaehleAnalyse();
const brief: BriefEintrag = {
id: neueId(),
erstelltAm: new Date().toISOString(),
@@ -49,6 +105,7 @@ export function ScanScreen({ navigation }: Props) {
};
const fotoAufnehmen = async () => {
if (!pruefeKontingent()) return;
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
Alert.alert('Kamera-Zugriff nötig', 'Bitte erlauben Sie den Kamera-Zugriff in den Geräte-Einstellungen.');
@@ -56,16 +113,17 @@ export function ScanScreen({ navigation }: Props) {
}
const ergebnis = await ImagePicker.launchCameraAsync({
mediaTypes: 'images',
quality: 0.6, // Kompromiss: lesbar für die KI, aber kleine Datenmenge
base64: true,
// Volle Qualität vom Picker — verkleinert & komprimiert wird einmal
// zentral in bildFuerAnalyse (doppelte JPEG-Kompression vermeiden)
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
// base64 der ImagePicker-Assets ist laut Doku immer JPEG
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
if (!ergebnis.canceled && ergebnis.assets[0]) {
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
}
};
const ausGalerie = async () => {
if (!pruefeKontingent()) return;
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) {
Alert.alert('Foto-Zugriff nötig', 'Bitte erlauben Sie den Zugriff auf Ihre Fotos in den Geräte-Einstellungen.');
@@ -73,15 +131,15 @@ export function ScanScreen({ navigation }: Props) {
}
const ergebnis = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
quality: 0.6,
base64: true,
quality: 1,
});
if (!ergebnis.canceled && ergebnis.assets[0]?.base64) {
await verarbeite(ergebnis.assets[0].base64, 'image/jpeg');
if (!ergebnis.canceled && ergebnis.assets[0]) {
await verarbeite(await bildFuerAnalyse(ergebnis.assets[0]), 'image/jpeg');
}
};
const pdfWaehlen = async () => {
if (!pruefeKontingent()) return;
const ergebnis = await DocumentPicker.getDocumentAsync({
type: 'application/pdf',
copyToCacheDirectory: true,
@@ -118,6 +176,13 @@ export function ScanScreen({ navigation }: Props) {
<GrossButton titel="Foto aufnehmen" symbol="📷" onPress={fotoAufnehmen} />
<GrossButton titel="Aus Galerie wählen" symbol="🖼️" variante="sekundaer" onPress={ausGalerie} />
<GrossButton titel="PDF hochladen" symbol="📄" variante="sekundaer" onPress={pdfWaehlen} />
{NUTZT_PROXY && (
<Text style={styles.kontingent}>
{kontingentLeer
? 'Gratis-Analysen aufgebraucht — Abo folgt in Kürze'
: `Noch ${uebrig} von ${GRATIS_ANALYSEN} kostenlosen Analysen`}
</Text>
)}
</View>
);
}
@@ -145,6 +210,12 @@ const styles = StyleSheet.create({
padding: abstand.xl,
gap: abstand.m,
},
kontingent: {
fontSize: schrift.klein,
color: farben.textSekundaer,
textAlign: 'center',
marginTop: abstand.m,
},
ladeText: { fontSize: schrift.gross, fontWeight: '700', color: farben.text },
ladeHinweis: {
fontSize: schrift.basis,

View File

@@ -4,7 +4,7 @@
* Sprache, Fachbegriffe, Fristen, Termine, Checkliste und Antwort-Optionen.
*/
import { BriefAnalyse } from '../types';
import { claudeJsonAufruf, ContentBlock } from './claudeClient';
import { claudeJsonAufruf, ContentBlock, MODELL_ANALYSE } from './claudeClient';
/**
* JSON-Schema für die Analyse. Die Claude API erzwingt dieses Schema
@@ -159,6 +159,7 @@ export async function analysiereBrief(
};
return claudeJsonAufruf<BriefAnalyse>({
modell: MODELL_ANALYSE,
system: SYSTEM_PROMPT,
schema: ANALYSE_SCHEMA,
messages: [

View File

@@ -4,7 +4,7 @@
* Entwurf danach frei bearbeiten und als PDF exportieren.
*/
import { AntwortTyp, ANTWORT_TYP_LABEL, BriefAnalyse } from '../types';
import { claudeJsonAufruf } from './claudeClient';
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
const ANTWORT_SCHEMA = {
type: 'object',
@@ -36,6 +36,8 @@ export async function generiereAntwort(
hinweise: string
): Promise<AntwortEntwurf> {
return claudeJsonAufruf<AntwortEntwurf>({
modell: MODELL_EINFACH,
maxTokens: 4000,
system: `Du schreibst formelle, höfliche Antwortbriefe an deutsche Behörden im Namen von Privatpersonen. Regeln:
- Korrektes, formelles Deutsch (der Empfänger ist eine Behörde — hier KEINE einfache Sprache).
- Kurz und präzise, keine Floskeln über das Nötige hinaus.

View File

@@ -13,16 +13,32 @@
* deshalb hier bewusst rohes fetch gegen die Messages API.
*/
import * as Network from 'expo-network';
import { holeApiKey } from './storage';
import { holeApiKey, holeGeraeteId } from './storage';
/** Für Produktion hier die eigene Server-URL eintragen, z. B. 'https://api.meine-app.de/claude'. */
const PROXY_URL: string | null = null;
/**
* Für Produktion hier die URL des deployten Workers eintragen,
* z. B. 'https://behoerdenklar-proxy.<account>.workers.dev'.
* Setup-Anleitung: proxy/README.md
*/
const PROXY_URL: string | null =
'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
/** true = Produktionsmodus über den Backend-Proxy (kein API-Key in der App nötig). */
export const NUTZT_PROXY = PROXY_URL !== null;
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const ANTHROPIC_VERSION = '2023-06-01';
/** Standard-Modell für alle Aufrufe. */
export const MODELL = 'claude-opus-4-8';
/**
* Modell-Split zur Kostenoptimierung:
* - Brief-Analyse (Vision, Genauigkeit wichtig): Sonnet 5 (~2,5 Cent/Brief)
* - Übersetzung & Antwort-Entwürfe (einfache Textaufgaben): Haiku 4.5 (~halber Preis)
*
* Hinweis: Der Backend-Proxy erlaubt nur diese Modelle (Whitelist in
* proxy/src/index.ts) — bei Änderungen dort mitziehen.
*/
export const MODELL_ANALYSE = 'claude-sonnet-5';
export const MODELL_EINFACH = 'claude-haiku-4-5';
// ---- Typen für den Request-Body (Teilmenge der Messages API) ----
@@ -44,6 +60,8 @@ export interface DocumentBlock {
export type ContentBlock = TextBlock | ImageBlock | DocumentBlock;
export interface ClaudeRequest {
/** Welches Modell: MODELL_ANALYSE (Vision) oder MODELL_EINFACH (Text). */
modell: string;
system?: string;
messages: { role: 'user' | 'assistant'; content: string | ContentBlock[] }[];
/** JSON-Schema für Structured Output — garantiert valides JSON in der Antwort. */
@@ -75,10 +93,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
// 2. Request-Body bauen
const body: Record<string, unknown> = {
model: MODELL,
model: anfrage.modell,
max_tokens: anfrage.maxTokens ?? 16000,
// Adaptives Denken verbessert die Extraktions-Genauigkeit deutlich
thinking: { type: 'adaptive' },
// Kein explizites thinking-Feld: Sonnet 5 denkt adaptiv von selbst
// (gut für die Extraktions-Genauigkeit), Haiku 4.5 unterstützt den
// adaptiven Modus nicht und würde die Anfrage ablehnen.
messages: anfrage.messages,
};
if (anfrage.system) body.system = anfrage.system;
@@ -96,6 +115,8 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
};
if (PROXY_URL) {
url = PROXY_URL; // Der Proxy hält den API-Key serverseitig
// Anonyme ID, damit der Proxy das Tageslimit pro Gerät durchsetzen kann
headers['x-geraete-id'] = await holeGeraeteId();
} else {
const apiKey = await holeApiKey();
if (!apiKey) {
@@ -137,8 +158,11 @@ export async function claudeJsonAufruf<T>(anfrage: ClaudeRequest): Promise<T> {
technisch
);
case 429:
// Direkt-Modus: API-Rate-Limit. Proxy-Modus: Tageslimit pro Gerät.
throw new ClaudeFehler(
'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
PROXY_URL
? 'Das tägliche Kontingent ist aufgebraucht. Bitte versuchen Sie es morgen erneut.'
: 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.',
technisch
);
case 413:

View File

@@ -14,6 +14,16 @@ import { BriefEintrag } from '../types';
const KEY_API = 'anthropic_api_key';
const KEY_BRIEFE = 'behoerdenklar_briefe';
const KEY_CONSENT = 'behoerdenklar_consent';
const KEY_GERAETE_ID = 'behoerdenklar_geraete_id';
const KEY_SCAN_ANZAHL = 'behoerdenklar_scan_anzahl';
/**
* Freemium: so viele Brief-Analysen sind kostenlos. Danach braucht es das
* Abo (kommt mit dem IAP-Einbau). Der Zähler liegt lokal — das ist bewusst
* "weich" (Neuinstallation setzt ihn zurück); die harte Grenze ist das
* Tageslimit des Backend-Proxys. Echte Abo-Prüfung folgt serverseitig.
*/
export const GRATIS_ANALYSEN = 3;
// ---- API-Key (sensibel -> SecureStore) ----
@@ -29,6 +39,42 @@ export async function speichereApiKey(key: string): Promise<void> {
}
}
// ---- Geräte-ID (anonym, nur fürs Tageslimit des Backend-Proxys) ----
/**
* Liefert eine zufällige, anonyme Geräte-ID (wird beim ersten Aufruf erzeugt).
* Sie enthält keine Gerätedaten und dient dem Proxy ausschließlich dazu,
* das Anfrage-Tageslimit pro Installation durchzusetzen.
*/
export async function holeGeraeteId(): Promise<string> {
let id = await AsyncStorage.getItem(KEY_GERAETE_ID);
if (!id) {
// Math.random reicht hier: die ID ist kein Geheimnis, nur ein Zähl-Schlüssel
id =
'g_' +
Array.from({ length: 24 }, () =>
Math.floor(Math.random() * 36).toString(36)
).join('');
await AsyncStorage.setItem(KEY_GERAETE_ID, id);
}
return id;
}
// ---- Freemium-Zähler ----
/** Wie viele Brief-Analysen wurden auf diesem Gerät schon durchgeführt? */
export async function holeAnzahlAnalysen(): Promise<number> {
const roh = await AsyncStorage.getItem(KEY_SCAN_ANZAHL);
const anzahl = roh ? parseInt(roh, 10) : 0;
return Number.isFinite(anzahl) ? anzahl : 0;
}
/** Nach jeder erfolgreichen Analyse aufrufen. */
export async function zaehleAnalyse(): Promise<void> {
const bisher = await holeAnzahlAnalysen();
await AsyncStorage.setItem(KEY_SCAN_ANZAHL, String(bisher + 1));
}
// ---- Einwilligung (Datenschutz-Consent) ----
export async function holeConsent(): Promise<boolean> {
@@ -59,6 +105,14 @@ export async function speichereBriefe(briefe: BriefEintrag[]): Promise<void> {
// ---- Alles löschen (Datenschutz-Anforderung) ----
export async function loescheAlleDaten(): Promise<void> {
await AsyncStorage.multiRemove([KEY_BRIEFE, KEY_CONSENT]);
// "Alle Daten löschen" heißt wirklich alle — auch Zähler und Geräte-ID.
// Dass damit das Gratis-Kontingent zurücksetzbar ist, nehmen wir in Kauf
// (Neuinstallation könnte das ohnehin); der Proxy limitiert weiterhin.
await AsyncStorage.multiRemove([
KEY_BRIEFE,
KEY_CONSENT,
KEY_GERAETE_ID,
KEY_SCAN_ANZAHL,
]);
await SecureStore.deleteItemAsync(KEY_API);
}

View File

@@ -4,7 +4,7 @@
* Archiv gecacht (siehe store), damit kein Aufruf doppelt läuft.
*/
import { BriefAnalyse, Sprache, Uebersetzung } from '../types';
import { claudeJsonAufruf } from './claudeClient';
import { claudeJsonAufruf, MODELL_EINFACH } from './claudeClient';
const UEBERSETZUNG_SCHEMA = {
type: 'object',
@@ -36,6 +36,8 @@ export async function uebersetzeAnalyse(
sprache: Sprache
): Promise<Uebersetzung> {
return claudeJsonAufruf<Uebersetzung>({
modell: MODELL_EINFACH,
maxTokens: 6000,
system: `Du übersetzt Erklärungen deutscher Behördenbriefe in andere Sprachen. Übersetze in einfache, klare Sprache (Niveau A2/B1 der Zielsprache). Deutsche Fachbegriffe (Feld "begriff") NICHT übersetzen — sie stehen so im Original-Brief und der Nutzer muss sie wiedererkennen. Nur die Erklärungen übersetzen.`,
schema: UEBERSETZUNG_SCHEMA,
messages: [

View File

@@ -0,0 +1,161 @@
/**
* Tests für die Dringlichkeits-Ampel — die kritischste Logik der App:
* Eine falsche Ampel könnte dazu führen, dass ein Nutzer eine Behörden-Frist
* verpasst. Deshalb hier alle Grenzfälle deterministisch abgesichert.
*/
import { berechneAmpel, tageBis } from '../ampel';
import { BriefAnalyse } from '../../types';
/** Fixes "Heute" für alle Tests: 15.07.2026 (Mitte des Monats, kein Randfall). */
const HEUTE = new Date('2026-07-15T10:30:00');
/** Minimal gültige Analyse; frist/termin pro Test überschreiben. */
function analyse(teile: Partial<BriefAnalyse>): BriefAnalyse {
return {
brieftyp: 'Test',
absender: 'Testamt',
kernaussage: '',
erklaerung_einfach: '',
fachbegriffe: [],
frist: null,
termin: null,
checkliste: [],
antwort_noetig: false,
antwort_optionen: [],
...teile,
};
}
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(HEUTE);
});
afterAll(() => {
jest.useRealTimers();
});
describe('tageBis', () => {
it('liefert 0 für heute', () => {
expect(tageBis('2026-07-15')).toBe(0);
});
it('liefert positive Tage für die Zukunft', () => {
expect(tageBis('2026-07-16')).toBe(1);
expect(tageBis('2026-07-22')).toBe(7);
});
it('liefert negative Tage für die Vergangenheit', () => {
expect(tageBis('2026-07-14')).toBe(-1);
expect(tageBis('2026-07-01')).toBe(-14);
});
it('zählt ganze Tage unabhängig von der Uhrzeit "jetzt"', () => {
// 10:30 Uhr darf keine halben Tage erzeugen
expect(tageBis('2026-07-18')).toBe(3);
});
it('funktioniert über Monatsgrenzen hinweg', () => {
expect(tageBis('2026-08-01')).toBe(17);
});
});
describe('berechneAmpel — Stufen', () => {
it('grün ohne Frist und Termin', () => {
const status = berechneAmpel(analyse({}));
expect(status.stufe).toBe('gruen');
expect(status.text).toContain('Keine Frist');
});
it('rot bei abgelaufener Frist (inkl. Hinweis)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-10', aktion: 'Unterlagen einreichen' } })
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('abgelaufen');
});
it('rot bei Frist heute', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-15', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('HEUTE');
});
it('rot bei Frist in 6 Tagen (< 7)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-21', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('rot');
});
it('gelb bei Frist in genau 7 Tagen (Grenzwert)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-22', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('gelb');
});
it('gelb bei Frist in genau 21 Tagen (Grenzwert)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-08-05', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('gelb');
});
it('grün bei Frist in 22 Tagen (> 21)', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-08-06', aktion: 'Antwort' } })
);
expect(status.stufe).toBe('gruen');
});
});
describe('berechneAmpel — Frist und Termin kombiniert', () => {
it('das dringendste Datum bestimmt die Ampel (Termin näher als Frist)', () => {
const status = berechneAmpel(
analyse({
frist: { datum: '2026-08-20', aktion: 'Unterlagen' }, // 36 Tage → grün
termin: { datum: '2026-07-18', uhrzeit: '09:30', ort: 'Raum 1' }, // 3 Tage → rot
})
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('Termin');
});
it('das dringendste Datum bestimmt die Ampel (Frist näher als Termin)', () => {
const status = berechneAmpel(
analyse({
frist: { datum: '2026-07-25', aktion: 'Antwort' }, // 10 Tage → gelb
termin: { datum: '2026-09-01', uhrzeit: null, ort: null }, // weit weg
})
);
expect(status.stufe).toBe('gelb');
expect(status.text).toContain('Frist');
});
it('nur Termin ohne Frist funktioniert', () => {
const status = berechneAmpel(
analyse({ termin: { datum: '2026-07-16', uhrzeit: null, ort: null } })
);
expect(status.stufe).toBe('rot');
expect(status.text).toContain('Termin');
});
});
describe('berechneAmpel — Textdetails', () => {
it('Einzahl bei 1 Tag ("in 1 Tag", nicht "Tagen")', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-16', aktion: 'Antwort' } })
);
expect(status.text).toContain('in 1 Tag ');
});
it('Einzahl bei 1 Tag abgelaufen', () => {
const status = berechneAmpel(
analyse({ frist: { datum: '2026-07-14', aktion: 'Antwort' } })
);
expect(status.text).toContain('vor 1 Tag)');
});
});