Private, mobile-first peanut-allergen barcode scanner. Scan a product barcode in the browser, look it up on Open Food Facts, and get a clear peanut verdict.
Sicherheitshinweis: Kein Erdnuss-Hinweis in der Datenbank gefunden bedeutet nicht garantiert erdnussfrei. Rezeptur und Verpackung können sich bei gleicher EAN ändern; maßgeblich ist deshalb immer die Packung in der Hand. Bei unklarer Datenlage wird nie „NEIN" angezeigt.
Wenn Open Food Facts die Metadaten liefert, zeigt das Ergebnis außerdem Datum und Revisionsnummer der letzten Datenbankbearbeitung. Das macht einen alten Datensatz sichtbar, beweist aber keine Rezepturänderung: Eine Revision kann ebenso durch ein neues Foto oder eine reine Textkorrektur entstehen.
| Status | Bedeutung | Anzeige |
|---|---|---|
JA |
Erdnuss in Allergenen / Zutaten(-Text) | Rot |
SPUREN |
Erdnuss nur in Spuren (traces) |
Orange |
NEIN |
Produktdaten vorhanden und kein Erdnuss-Hinweis | Grün |
KEINE_DATEN |
Nicht gefunden / keine Zutaten-/Allergendaten / Fehler | Rot (Warnung) |
Jeder unklare Fall (nicht gefunden, fehlende Daten, Netzwerk-/API-Fehler) wird
fail-safe als KEINE_DATEN rot angezeigt – Erdnuss kann dann nicht
ausgeschlossen werden. Die Route unterscheidet dabei drei Gründe (kind:
„not-found" / „no-data" / „error"), damit das Ergebnis den richtigen Rat gibt
– „Zutatenliste in der Hand prüfen" bei fehlenden Daten, „später erneut
prüfen" bei einem Netzwerk-/Server-Fehler. Rot gilt für die große
Ergebnis-Anzeige, wo die Warnung zählt; die kleinen Verlaufs-Badges zeigen
KEINE_DATEN bewusst weiter neutral-grau, weil sie neben anderen Einträgen
stehen und ein erneuter Scan das Bild jederzeit ändern kann.
Ein NEIN ist nur so gut wie der Datensatz dahinter. Deshalb prüft die API
zusätzlich, ob dem grünen Ergebnis etwas Wesentliches fehlt, und liefert
caveats. Ein Ergebnis mit Vorbehalt wird nicht mehr grün, sondern amber
dargestellt (Verdict partial) und nennt den Grund im Klartext:
| Caveat | Auslöser |
|---|---|
restricted-code |
Barcode aus dem GS1-Bereich für Handels-Eigencodes (EAN-8 mit 0/2, Präfix 020–029, 040–049, 200–299). Solche Codes sind nicht weltweit eindeutig – der Treffer kann ein anderes Produkt sein. |
checksum-mismatch |
GS1-Prüfziffer passt nicht: Fehlscan oder kein Standard-Barcode. |
traces-unknown |
Der Datensatz hat gar kein traces-Feld. Fehlende Spurenangabe ist keine geprüfte Spurenfreiheit. |
Vorbehalte können ein Ergebnis nur abwerten: an JA und SPUREN werden sie
nie angehängt, und aus KEINE_DATEN wird nie etwas Grüneres.
Ob ein Datensatz überhaupt das Produkt in der Hand beschreibt, kann die App
nicht wissen — der Mensch davor schon. Bei einem Identitäts-Vorbehalt
(restricted-code, checksum-mismatch) zeigt das Ergebnis deshalb das
Datenbank-Foto groß und fragt: „Passt das zu deiner Packung?"
- Ja → der Identitäts-Vorbehalt ist erledigt, das Ergebnis wird grün (sofern nichts anderes dagegen spricht).
- Nein → der Eintrag gehört zu einem anderen Produkt, das Ergebnis fällt auf
KEINE_DATENzurück.
Die Antwort wird pro Barcode lokal gemerkt (peanot.packmatch.v1, max. 200
Einträge), gilt also beim nächsten Scan desselben Codes sofort — auch im
Verlauf. Gefragt wird nur bei Identitäts-Vorbehalt, damit die Frage nicht zur
Reflex-Bestätigung verkommt. Auch hier gilt fail-safe: eine Antwort kann ein
JA/SPUREN niemals entkräften. Ein Ja verfällt nach 90 Tagen und wird
dann erneut gefragt — Handels-Eigencodes sind genau dafür berüchtigt, dass
derselbe Code später ein anderes Produkt bezeichnet. Ein Nein gilt fort,
bis es zurückgenommen wird (fail-safe).
Wo peaNOT sagt, dass seine Daten falsch oder unvollständig sind, verlinkt es den
Eintrag bei Open Food Facts (lib/off/link.ts) — bei gemeldeter Abweichung zum
Prüfen, bei fehlender Spurenangabe zum Ergänzen. Korrekturen landen so dort, wo
sie dem nächsten Menschen mit demselben Code helfen.
Nicht deklarierte Allergene sind einer der häufigsten Gründe für amtliche Lebensmittel-Rückrufe — genau der Fall, den keine Zutatenliste der Welt abfängt. Deshalb gleicht die API jeden Treffer zusätzlich gegen die aktuellen Meldungen von lebensmittelwarnung.de ab (amtliches Portal der Bundesländer und des BVL; REST-Schnittstelle wie bei bund.dev dokumentiert, inklusive des dort veröffentlichten statischen Zugriffsschlüssels).
Amtliche Meldungen nennen Produktnamen, keine Barcodes. Der Abgleich läuft
deshalb über den Namen und die Marke aus dem Open-Food-Facts-Datensatz
(lib/recalls/match.ts) und ist bewusst warn-only:
- Ein Namens-Treffer zeigt eine rote Karte „Rückruf könnte dieses Produkt betreffen" mit Link zur amtlichen Meldung — das Verdict (JA/NEIN/…) ändert er nie, denn der Abgleich kann irren. Prüfen muss der Mensch: Meldung öffnen, Charge und MHD vergleichen.
- Warn-only heißt aber nicht leise: Die Karte steht über dem Stempel,
nicht darunter. Ein grüner Stempel mit der Warnung unter der Falz war
genau der Fall, für den der Abgleich gebaut wurde — nicht deklarierte
Erdnuss ist der häufigste Rückrufgrund. Bei einem Treffer auf
NEINoder einem Vorbehalt wird der Stempel deshalb amber statt grün und die Überschrift sagt „Kein Treffer in den Daten — aber ein Rückruf könnte passen." Das berechnete Verdict, der Verlaufseintrag, der Teilen-Text und die Screenreader-Ansage bleiben davon unberührt; die Warnung ändert ausschließlich die Lesereihenfolge. - Kein Treffer heißt nur „kein Namens-Treffer im Abgleichsfenster (180 Tage)", nie „kein Rückruf existiert". Das Ergebnis zeigt das als dezente Statuszeile, nicht als Entwarnung.
- Ist das Portal nicht erreichbar, sagt die Statuszeile auch das — still scheitern würde ein grünes Ergebnis vertrauenswürdiger aussehen lassen, als es ist.
Ein einzelnes generisches Wort („Erdnüsse") reicht nie für einen Treffer:
verlangt wird ein Großteil des Produktnamens, mit niedrigerer Schwelle, wenn
zusätzlich die Marke in der Meldung auftaucht. Die Warnliste wird serverseitig
gecacht (LMW_REVALIDATE_S), sodass Scans das Portal nicht pro Anfrage
treffen.
Bei KEINE_DATEN — echt fehlende Daten oder ein per Packungs-Gegencheck
verworfener Treffer — bleibt nur die Zutatenliste in der Hand. Eine
aufklappbare Checkliste „Diese Begriffe bedeuten Erdnuss" zeigt dafür genau
die Wörter, nach denen peaNOT selbst sucht (textKeywords aus
lib/allergens/profile.ts, dieselbe Liste wie in der Erkennung), aufbereitet
und groß genug für den Abgleich mit der Packung. Kein OCR, kein eigener
Verdict: reine Lesehilfe für die menschliche Prüfung, die die App ohnehin
verlangt.
npm install
npm run dev # http://localhost:3000
npm test # Vitest (alle Tests)
npm run typecheck # tsc --noEmit
npm run lint # ESLint (next/core-web-vitals + next/typescript)
npm run build # Production-BuildKamerazugriff (getUserMedia) benötigt einen sicheren Kontext: localhost im
Dev oder HTTPS in Produktion. Auf dem iPhone die manuelle Eingabe nutzen, falls
die Kamera blockiert ist.
UI nach Design-Richtung A · Bold Stamp (warmes Cremepapier, Tinte, Senf-Akzent; Fraunces-Serif + Space Grotesk + JetBrains Mono). Flow: Onboarding → Scan → Ergebnis → Verlauf → Profil mit unterer Tab-Navigation. Das Ergebnis ist der ganze Screen (Stempel, Belegstelle mit markierter „Erdnüsse", Allergen-Chips).
Über das Karten-Symbol in der oberen Leiste (Scan/Verlauf/Profil — ein Tap von überall) sowie den Button „Allergie-Karte zeigen" (Scan-Screen) erreichbar: eine Karte zum Vorzeigen vor Ort mit einem vorformulierten Satz, der die gewählte Allergie erklärt und um eine sichere Auswahl ohne Verunreinigung bittet. Auswählbar nach Ort (Eisdiele, Restaurant, Bäckerei & Café, Kita & Schule, Allgemein) und Sprache (16 gängige Reise-/Weltsprachen, inkl. RTL für Arabisch). Die Startsprache richtet sich nach der Gerätesprache; „Groß anzeigen" zeigt den Satz bildschirmfüllend, hält per Wake Lock (wo unterstützt) den Bildschirm wach und schließt nur über den eigenen Schließen-Button oder Escape — nie durch versehentliches Antippen.
Texte liegen in lib/phrases.ts; lib/phrases.test.ts stellt sicher, dass jede
gelistete Sprache jeden Kern-Ort abdeckt (keine stillen Lücken). „Kita &
Schule" ist bisher nur auf Deutsch/Englisch übersetzt — jede andere Sprache
fällt dafür bewusst auf ihren eigenen „Allgemein"-Satz zurück (dokumentiert in
OPTIONAL_VENUE_LANGS, ebenfalls testabgedeckt), nie auf eine fremde Sprache
oder auf Stille. Übersetzungen sind eine Hilfe, keine Garantie.
Optional lässt sich unterhalb der Karte ein eigener Zusatz eintragen (z. B.
„Adrenalin-Pen ist im Rucksack") — reiner Freitext, lokal in
prefs.cardNote gespeichert, nie übersetzt und auf der Karte klar vom
geprüften Satz abgesetzt.
Scan-Verlauf, Notizen, Favoriten, Packungs-Antworten und Einstellungen liegen
ausschließlich im Browser des Geräts (localStorage, je ein eigener Key:
peanot.history.v1, peanot.notes.v1, peanot.favorites.v1,
peanot.packmatch.v1, peanot.prefs.v1). Zu den Einstellungen zählen
Akzentfarbe, Darstellung (Hell/Dunkel/System), Schriftgröße, geprüfte
Allergene, Vibrieren/Ton bei Treffer, Spuren-Strikt, automatischer
Kamera-Start sowie der Karten-Zusatztext und der Notfallplan (siehe unten).
Kein Account, kein Server-State – „Leeren" entfernt den Verlauf wieder.
Der Verlauf hält einen Eintrag pro Barcode: Ein erneuter Check ersetzt die alte Zeile, statt eine zweite anzulegen. Er ist damit eine Produktliste, kein Ereignis-Log — sonst hätte der vorgesehene Favoriten-Durchlauf vor dem Einkauf am 200er-Deckel genau die selten geprüften Produkte verdrängt, deren Verdict man nicht im Kopf hat. Undo nach dem Löschen und der Import laufen durch dieselbe Zusammenfassung.
Beim Lesen aus localStorage läuft der Verlauf durch sanitizeHistory
(lib/backup.ts, dieselbe Funktion wie im Import-Pfad). Eine kaputte Zeile
— unbekanntes Verdict, fehlendes Feld — fällt einzeln weg, statt beim
Rendern die ganze App umzuwerfen; die gültigen Zeilen daneben bleiben.
Fällt beim Rendern etwas um, fing das vorher niemand auf — Next.js' nackte
Meldung „Application error" blieb stehen, ohne Zurück und ohne Neuladen.
Für eine App, die man einhändig im Laden bedient, ist das der
schlechtestmögliche Endzustand. app/error.tsx bietet stattdessen „Erneut
versuchen" und, zweistufig bestätigt, „Verlauf zurücksetzen" — das entfernt
ausschließlich peanot.history.v1; Notizen, Favoriten, Einstellungen und
Notfallplan bleiben unangetastet. app/global-error.tsx deckt zusätzlich
Abstürze im Root-Layout ab.
Der Service Worker cacht die App-Shell und, FIFO-begrenzt auf 150 Einträge,
zuletzt abgerufene Produkt-Checks und -Fotos. Fällt eine Anfrage offline auf
den Cache zurück, markiert er die Antwort ehrlich als solche (cachedAt) –
das Ergebnis zeigt dann „offline · zwischengespeichert" statt einer
scheinbar frischen Prüfung, mitsamt dem Alter der letzten echten Abfrage.
Ein transienter Server-/Netzwerkfehler wird dagegen nie gecacht, damit er
später keinen bereits vorhandenen guten Treffer für denselben Barcode
verdrängt.
Die Schriften kommen über next/font/google, werden also zur Build-Zeit
geholt und von der eigenen Domain ausgeliefert. Zur Laufzeit geht damit kein
Request mehr an Google — vorher bekam es bei jedem Erststart Referrer und IP
des Geräts, was quer zum „alles bleibt auf diesem Gerät" des Rests stand.
Der Service Worker braucht dafür keine Sonderbehandlung mehr: Die Dateien
liegen unter /_next/static/ und fallen unter die vorhandene
Same-Origin-Regel. Merkposten für später: Bei der variable-Option hasht
next/font den Familiennamen nicht, weshalb die in components/ hart
codierten Namen ("'Fraunces', serif" usw.) unverändert greifen — bei
className-Nutzung wäre das anders.
Ist ein Barcode weder live noch aus dem Cache zu klären, zeigt das Ergebnis
KEINE_DATEN mit „zuletzt bekannt" – dem letzten echten Verdict aus dem
Verlauf, klar als nicht mehr verifiziert gekennzeichnet, rein informativ.
Die Scan-Kopfzeile (live/offline, mit Punkt) spiegelt navigator.onLine;
kehrt die Verbindung zurück, während ein Netzwerkfehler-Ergebnis offen ist,
prüft die App denselben Barcode automatisch erneut – niemand muss sich
merken, „Erneut prüfen" zu tippen.
Der Screen ist nach Häufigkeit sortiert, nicht nach Technik: Kamera →
Favoriten → „zuletzt geprüft" → Eingabe-Alternativen („Manuell" und
„Suchen" in einer Zeile) → ein abgesetztes Paar aus Allergie-Karte und
Notfallplan. Der Kamerakasten ist auf 38dvh gedeckelt, sonst bliebe auf
einem 390×844-Display selbst direkt darunter kein Platz für die Favoriten —
den Vor-dem-Einkauf-Check, für den sie da sind. Der Notfallplan bleibt
kräftig rot: Auffindbarkeit unter Stress schlägt visuelle Zurückhaltung.
Das Eingabe-Sheet schließt beim Absenden und zeigt den Ladezustand in sich
selbst; vorher saß der Spinner im Kamerakasten hinter dem Scrim. Die
Trefferliste der Namenssuche folgt der visualViewport-Höhe statt einem
festen Deckel und zeigt für bereits geprüfte Produkte den bekannten Verdict
als Punkt mit Glyphe — samt Prüfzeitpunkt im aria-label, damit er
nicht als frische Prüfung durchgeht.
Standardmäßig startet die Kamera erst nach Tap auf „Kamera starten". Wer in Profil → Scanner „Kamera beim Öffnen automatisch starten" einschaltet, bekommt die Kamera sofort beim Öffnen des Scan-Screens — das iPhone fragt trotzdem bei jedem Start kurz nach Zugriff, das lässt sich app-seitig nicht abstellen (iOS merkt sich Kamera-Berechtigungen für installierte Home-Screen-Apps nicht). Damit ein Frühscan (Kamera zeigt noch Hosentasche oder Tisch) nicht sofort ein Ergebnis auslöst, ignoriert der Scanner Treffer für die ersten ~800ms, nachdem das Bild zu laufen beginnt — und zwar bei beiden Startwegen, manuellem Tap wie Auto-Start gleichermaßen.
Unterstützt die Kamera optischen/digitalen Zoom (getCapabilities().zoom),
erscheint neben dem Blitzlicht-Knopf ein kleiner 1×/2×-Umschalter
(track.applyConstraints); ohne diese Fähigkeit bleibt er unsichtbar, ein
Fehlschlag beim Umschalten wird still ignoriert.
Im Ergebnis lässt sich pro Barcode eine kurze eigene Notiz hinterlegen (z. B.
„Sorte Schoko okay, Crunchy nicht" oder „Reaktion 2024") — lokal gespeichert
(peanot.notes.v1, max. 200 Einträge, gleiche Machart wie
lib/packmatch.ts) und im Verlauf als Vorschauzeile sichtbar. Rein
informativ: Eine Notiz wird an keiner Stelle gelesen, die ein Verdict
berechnet, und kann ein Ergebnis nie beeinflussen.
Bei KEINE_DATEN endet der Weg immer gleich: Packung in die Hand nehmen und
selbst lesen. Ein einmal abfotografiertes Zutatenfeld, lokal zum Barcode
gespeichert, kostet diese Arbeit genau einmal. Speicher ist IndexedDB
(Fotos sprengen den localStorage-Rahmen), Bilder werden vor dem Speichern
verkleinert, Deckel 50 Einträge FIFO.
Das Foto ist ein Gedächtnis, kein Beleg für den aktuellen Stand: Das Aufnahmedatum steht immer dabei, und nach 180 Tagen sagt die Karte deutlich, dass es alt ist — viel kürzer als die 24 Monate, ab denen ein Open-Food-Facts-Datensatz als alt gilt, weil ein selbst geschossenes Foto eher zum Draufverlassen verführt. Kein OCR, kein eigenes Verdict: Das Foto wird an keiner Stelle gelesen, die ein Verdict berechnet.
Der Alltag einer Allergiker-Familie besteht meist aus denselben 10–20
Produkten. Ein Stern (Ergebnis-Kopfzeile, Verlaufszeile) merkt sich ein
Produkt als Stammprodukt — lokal (peanot.favorites.v1, max. 50 Einträge,
gleiche Machart wie lib/notes.ts/lib/packmatch.ts) mit Name, Marke,
letztem Verdict und Prüfzeitpunkt. Die Favoriten erscheinen als eigene
Zeile oberhalb von „Zuletzt geprüft" auf dem Scan-Screen; ein Tipp darauf
löst den ganz normalen Prüf-Vorgang erneut aus (keine Cache-Anzeige), sodass
sich vor dem Einkauf mit einem Tipp bestätigen lässt, dass ein Stammprodukt noch
grün ist — kombiniert mit der Änderungs-Warnung oben genau der Fall, den das
README selbst benennt (Rezeptur kann sich bei gleicher EAN ändern). Rein
informativ: Der gespeicherte Verdict wird nirgends gelesen, das ein Ergebnis
berechnet, nur nach jedem echten Check aktualisiert.
Der „Teilen"-Button (Ergebnis-Kopfzeile) öffnet das native Teilen-Sheet
(navigator.share, z. B. AirDrop/Nachrichten) mit Produktname, Marke, EAN,
dem Verdict-Label inklusive Vorbehalts-Formulierung (nie ein blankes
„sicher") und dem Link zum Open-Food-Facts-Eintrag. Ohne Web-Share-API (oder
bei fehlenden Zielen) landet derselbe Text stattdessen in der Zwischenablage,
mit kurzer Bestätigung auf dem Screen — derselbe Zwei-Stufen-Fallback wie
beim Export (F1).
„Liste teilen" (Verlauf-Kopfzeile) schickt die aktuell gefilterte Auswahl
als Klartext — Suchfeld und Filter-Chips haben sie sichtbar
zusammengestellt. Jede Zeile trägt ein absolutes Datum, nicht
formatRelative: „Heute" stimmt nur für den, der gerade auf den Schirm sieht,
und ist in einer Nachricht, die am nächsten Morgen gelesen wird, schlicht
falsch. Ein Fusssatz nennt die Momentaufnahme beim Namen, die Labels kommen
unverändert aus VERDICT, und eine Kürzung ab 30 Einträgen wird benannt statt
still vorgenommen.
Da alle Daten nur lokal liegen, ersetzt ein manueller Export den fehlenden
Familien-Sync: „Exportieren" (Profil → Daten) baut eine JSON-Datei aus
Verlauf, Notizen, Packungs-Antworten und Einstellungen
({format:"peanot-export", v:1, …}) und übergibt sie per Web-Share-Sheet
(z. B. AirDrop aufs zweite Familien-Handy) oder, falls nicht verfügbar, als
Direkt-Download. Favoriten reisen mit; bei einem Konflikt um
denselben Barcode gewinnt für Verdict und Name die zuletzt geprüfte Seite,
während addedAt lokal bleibt, damit ein Import die Reihenfolge der
Favoriten-Leiste nicht durchschüttelt.
„Importieren" liest eine solche Datei und merged statt zu überschreiben:
- Verlauf: dedupliziert nach ID (Fallback Barcode+Zeitstempel), bei Konflikt gewinnt der neuere Eintrag.
- Notizen: rein additiv, bei Konflikt gewinnt die neuer bearbeitete Notiz.
- Packungs-Antworten: additiv, aber fail-safe — da eine Antwort ein Verdict verändern kann (siehe Packungs-Gegencheck oben), gewinnt bei einem Konflikt immer „Nein" gegen „Ja", unabhängig vom Zeitstempel. Ein Import kann einen bereits verworfenen Datensatz also nie wieder grün machen.
- Einstellungen werden nie automatisch übernommen — erst nach expliziter
Bestätigung, da sie u. a.
tracesStrictund die geprüften Allergene enthalten.
Die reinen Merge-Funktionen sind in lib/backup.ts gekapselt und ohne
localStorage/DOM testbar (lib/backup.test.ts).
Über „Notfallplan" (Scan-Screen, neben „Allergie-Karte zeigen", sowie Profil → „Für den Notfall") erreichbar: ein Anrufknopf für 112, eine editierbare Schrittliste für den familieneigenen Adrenalin-Autoinjektor-Notfallplan und ein Freitextfeld für Medikament, Dosis und Notfallset-Ort. Gedacht auch für Oma, Babysitter oder die Lehrkraft, denen man im Ernstfall das Handy in die Hand drückt.
Die Schrittliste startet mit einer allgemeinen, unverbindlichen
Beispiel-Vorlage — bewusst keine medizinische Anweisung der App. Die Familie
muss sie einmal bestätigen (unverändert übernehmen) oder bearbeiten
und speichern, bevor sie als „ihr eigener Plan" gilt (confirmed). Bis dahin
öffnet der Screen in einer Leseansicht mit dem Disclaimer und den zwei
Aktionen „Unverändert übernehmen" und „Bearbeiten" — vorher landete man
sofort im Editor, dessen Textfelder die mehrzeiligen Vorlage-Schritte mitten
im Satz abschnitten, ausgerechnet in dem Moment, in dem man sie vollständig
lesen und bewusst bestätigen soll. Alle Textfelder wachsen jetzt mit ihrem
Inhalt, auch bei „Sehr groß". Gespeichert wird lokal in
prefs.emergencyPlan (peanot.prefs.v1), wie prefs.cardNote rein
informativ und an keiner Stelle mit Verdict-Logik verbunden.
Dazu zwei Listen, beide optional und leer gültig:
- Autoinjektoren mit Ort und Ablaufdatum (
YYYY-MM-DD). Ein abgelaufener Pen ist ein realer, verbreiteter Notfall-Fehler — das Datum steht klein auf einem Gerät, das man hoffentlich nie benutzt. Abgelaufen und „läuft in unter 60 Tagen ab" erscheinen auch oben auf dem Scan-Screen. Bewusst kein „in Ordnung"-Zustand: Die App ist kein Medizinprodukt, ein Datum ist eine Erinnerung, keine Freigabe.getPenStatusvergleicht auf Kalendertag-Ebene, nicht auf Millisekunden — sonst kippte der Status je nach Zeitzone um einen Tag. - Notfallkontakte (max. 4) als
tel:-Links direkt unter der 112, die groß und primär bleibt. Für Oma oder den Babysitter ist das der Unterschied zwischen „Notfallplan" und „Notfallhilfe".
Die Allergen-Auswahl gehört zu einer Person, nicht zum Gerät. Es gibt immer genau eine aktive Person; ein Ergebnis gilt immer nur für sie und nennt sie ab der zweiten Person beim Namen. Bewusst keine Vereinigungsmenge über mehrere Personen — die hätte genau das Ausgangsproblem zurückgebracht: ein „sicher", das in Wahrheit nur für eine der beiden stimmt.
prefs.selectedAllergens bleibt als abgeleitetes Feld erhalten und
spiegelt immer die Allergene der aktiven Person (ein halbes Dutzend Stellen
liest es, u. a. die API-Route). Nie direkt setzen.
migratePersonsState läuft bei jedem Laden und ist damit zugleich
Alt-zu-neu-Migration und dauerhafte Validierung. Erste Regel: Gibt es keine
gültige persons-Liste, wird selectedAllergens exakt übernommen — eine
Migration darf nie still ändern, was ein Scan als Treffer meldet. Eine neue
Person erbt die Allergene der bisher aktiven, statt leer zu starten: Sie ist
ab dem Anlegen sofort aktiv, und eine leere Liste hieße, dass bis zur ersten
Auswahl niemand entschieden hat, worauf geprüft wird.
Ein-Personen-Haushalt ist der Normalfall und bleibt unverändert schlicht: kein Umschalter auf dem Scan-Screen, keine Namen im Verlauf, keine Änderung am Ergebnis.
Sobald es zwei Personen gibt, ist ein Verlaufseintrag „Sicher" ohne Personenangabe mehrdeutig und damit gefährlich: Wer für Ben einkauft und eine für Anna geprüfte Zeile sieht, liest eine Entwarnung, die nie für ihn galt. Deshalb:
HistoryEntryträgtpersonIdundpersonName— der Name, damit ein alter Eintrag nach dem Umbenennen oder Löschen einer Person weiter sagen kann, für wen er galt. Die Dedup-Regel greift entsprechend nach Barcode + Person, sonst löschte Bens Prüfung Annas Zeile.- Einträge ohne Personenangabe stammen aus der Zeit, als es genau eine Person gab; sie werden beim Lesen der ersten Person zugeordnet, nicht durch Umschreiben des gespeicherten Verlaufs.
- „Liste teilen" sendet ab zwei Personen nur die Einträge der aktiven Person
und sagt das —
buildShareListTexthat kein Personenfeld, eine gemischte Liste läse sich sonst als eine einzige Entwarnung. - Der Stern bleibt haushaltsweit: Ein Favorit ist ein Produkt, das die
Familie kauft, und ein eigener Stern pro Person hieße doppelte Einträge für
dieselben Stammprodukte. Gefährlich ist nicht der geteilte Stern, sondern
ein unbeschrifteter Verdict daran — deshalb merkt sich
FavoriteEntry, wer zuletzt geprüft hat, und die Karte zeigt es ab zwei Personen an.
Der Abgleich aus dem Ergebnis-Screen läuft nur beim Scannen. Was schon im Schrank steht, erführe einen Rückruf nie — dabei ist genau das der Fall, für den er existiert. Der Wächter prüft deshalb Favoriten und jüngeren Verlauf gegen dieselbe amtliche Warnliste, höchstens alle 6 Stunden (die Liste ist serverseitig ohnehin so lange gecacht) und nie offline.
Warn-only wie im Ergebnis: Der Streifen auf dem Scan-Screen ändert kein
Verdict und keinen Verlaufseintrag. Und er behauptet nie das Gegenteil — bei
keinem Treffer oder unerreichbarem Portal steht dort nichts, nirgends ein
„keine Rückrufe". Ist das Portal nicht erreichbar, antwortet die Route
unavailable statt einer leeren Trefferliste: Die sähe für den Client exakt
aus wie „geprüft, nichts gefunden".
Die Route spiegelt sich in ?screen= (scan, verlauf, profil, karte,
notfall). Tab-Wechsel schreiben per replaceState, damit der Zurück-Stack
nicht volläuft; karte und notfall hängen an useHistoryOverlay, das
seinen History-Eintrag ohnehin schon pusht.
Bewusst nicht abgebildet: result (hängt an einem transienten Lookup —
ein Deep-Link darauf zeigte eine Sicherheitsanzeige ohne Datengrundlage) und
onboarding (hängt an prefs.onboarded, das immer gegen die URL gewinnt).
Praktischer Nutzen auf dem iPhone: /?screen=notfall in Safari öffnen und
als zweites Icon zum Home-Bildschirm hinzufügen — ein Tap vom Sperrbild
zum Notfallplan. Die shortcuts im Manifest sind für Android; iOS wertet
Manifest-Shortcuts nicht aus.
lib/allergens/– reine, getestete Erdnuss-Erkennung (überAllergenProfileauf weitere Allergene erweiterbar) inkl.labels(Allergen-Tags → Labels),evidence(Erdnuss-Fundstelle im Zutatentext) undchecklist(Lese-Hilfe- Begriffe je Allergen, aus denselbentextKeywords).lib/off/– serverseitiger Open-Food-Facts-Client (setzt User-Agent) + defensive Normalisierung.lib/recalls/– Client für lebensmittelwarnung.de, namensbasierter Rückruf-Abgleich (warn-only) undcheckRecalls-Fassade für die Route.lib/theme.ts,lib/verdict.ts,lib/time.ts– Palette, Status→Verdict-Mapping, relative Zeiten.lib/packmatch.ts,lib/notes.ts,lib/favorites.ts– lokale Stores (Gegencheck-Antwort, Notiz bzw. Favorit), gleiche Machart (localStorage, Cap, defensives Parsen).lib/emergency.ts– Default-Vorlage und Typ für den familieneigenen Notfallplan (F4); gespeichert inprefs.emergencyPlan, nicht in einem eigenen Store.lib/share.ts– reiner Text-Baustein für „Teilen" (F6), aus den bereits auf dem Ergebnis-Screen angezeigten Strings zusammengesetzt.lib/backup.ts– reine Parse-/Merge-Logik für Export/Import (F1); die eigentlichen localStorage-Zugriffe bleiben bei den Stores, die sie schon besitzen (components/useHistory.ts,lib/packmatch.ts,lib/notes.ts).public/sw.js– Service Worker: App-Shell- und Produkt-Cache, ehrliche Cache-Kennzeichnung (cachedAt), nie ein transienter Fehler im Cache.components/useOnlineStatus.tsliefert dazunavigator.onLineals Hook.app/api/product/[barcode]/route.ts– API-Route, komponiert Client + Erkennung.app/page.tsx– Client-Router über die Screens (inkl.notfall-Route fürEmergencyScreen), spiegelt die Route in?screen=.app/error.tsx,app/global-error.tsx– Auffangnetz für Render-Fehler, mit „Erneut versuchen" und einem eng begrenzten „Verlauf zurücksetzen".components/–BarcodeScanner(@zxing/browser),ManualEntry, geteilte UI-Atome (ui.tsx),useHistory/usePrefs/useNote/useFavorites(localStorage),useBackup(Export/Import-Orchestrierung) undscreens/(inkl.EmergencyScreen, dem Notfallplan-Screen aus F4).
Repository in Vercel importieren – keine Umgebungsvariablen, keine Datenbank nötig. Vercel liefert HTTPS (für die Kamera erforderlich). Die API-Route läuft im Node-Runtime.
next.config.mjs setzt auf jeder Route Permissions-Policy: camera=(self), microphone=(), geolocation=() (nur die Kamera wird gebraucht),
X-Frame-Options: DENY (nie in ein fremdes Iframe einbettbar) und
X-Content-Type-Options: nosniff.