Web: Frist in den Kalender + Anruf-Hilfe — die Unterschiede zu ChatGPT ins Web geholt

Der Nutzer konnte in einer Praesentation nicht kontern, und die Ursache war
real: Alles, was BehoerdenKlar von ChatGPT unterscheidet (Erinnerungen,
Kalender, Antwort-Entwurf), steckte in der App — und die ist noch nicht
veroeffentlicht. Das Web-Produkt, das Kunden heute kaufen, war im Kern nur
'Brief erklaeren'.

Kalender (.ics, rein im Browser, nichts wird hochgeladen):
- Frist und Termin mit Erinnerung 3 Tage und 1 Tag vorher
- Zeilen nach BYTES gefaltet (RFC 5545), damit Arabisch/Tigrinya nicht reissen
- Termin 23:30 endet korrekt am naechsten Tag
- Wanduhrzeit ohne Zeitzone = Ortszeit des Geraets
- getestet: Frist, Termin mit Uhrzeit, Mitternacht, langer arabischer Text,
  Brief ohne Datum (-> keine Datei)

Anruf-Hilfe (Worker-Schema um kontakt + anruf erweitert):
- Telefonnummer als tel:-Knopf, Aktenzeichen hervorgehoben
- Spickzettel: Satz auf Deutsch + Bedeutung in der Nutzersprache
- Prompt-Regel: Kontaktdaten nur, wenn woertlich im Brief. Getestet mit Brief
  ohne Nummer -> telefon: null, nichts erfunden.

Nebenbei zwei bestehende Fehler behoben:
- Optionale Ergebniskarten wurden nie ausgeblendet: Beim zweiten Brief stand
  die Checkliste des ERSTEN Briefs noch da. Jetzt Reset zu Beginn.
- dir=auto auf den Ergebnistexten: Arabisch/Farsi/Urdu wurden im Web
  linksbuendig dargestellt (in der App war das schon behoben).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JUSNandClaude Opus 5.5 committed 2026-09-27 15:26:23 +02:00
1 parent e01813f6ec
commit fe6899da3d
3 files changed
+249 -10

No files matched your search

+30 -5
View File
@@ -60,13 +60,38 @@ bis 30.09.: Unterlagen einreichen"*.
behalten. Ein Übersetzer sagt dir, was dasteht. BehördenKlar meldet sich von
selbst, bevor es zu spät ist.
### 3. Termin in den Kalender — auf einen Tastendruck
`AnalyseScreen` → `terminZumKalender`, inklusive Erinnerung einen Tag vorher.
### 3. Frist und Termin in den Kalender — auch im Web
**App:** `AnalyseScreen` → `terminZumKalender`, auf Tastendruck.
**Bewusst nicht vollautomatisch:** Ungefragt in den Kalender eines Menschen zu
schreiben, wäre übergriffig — und iOS verlangt ohnehin eine Freigabe. Die
*Erinnerungen* laufen automatisch, der *Kalendereintrag* auf Wunsch.
**Web (seit 27.09.):** Nach der Analyse erscheint direkt unter der Frist
„📅 In meinen Kalender eintragen". Der Browser erzeugt eine `.ics`-Datei mit
Frist **und** Termin, jeweils mit Erinnerung **3 Tage und 1 Tag vorher**. Die
Datei entsteht auf dem Gerät und wird **nirgends hochgeladen** — die Erinnerungen
stellt danach der Kalender des Handys selbst. Funktioniert ohne App und ohne Konto.
Details, die schiefgehen können und abgesichert sind:
- Zeilen nach **Bytes** gefaltet (RFC 5545: max. 75), nicht nach Zeichen —
Arabisch/Tigrinya brauchen 2–4 Byte pro Zeichen
- Termin um 23:30 endet korrekt am **nächsten** Tag
- Uhrzeit ohne Zeitzone („Wanduhrzeit") = Ortszeit des Geräts
- Titel und Erinnerungstext ohne deutsche Wörter: Symbol + Datum versteht jeder,
die `aktion` kommt schon in der Sprache des Nutzers
**Bewusst nicht vollautomatisch:** Ungefragt in einen fremden Kalender zu
schreiben, wäre übergriffig.
### 3b. Anruf-Hilfe (Web, seit 27.09.)
Die KI liest `kontakt` aus dem Brief — Telefon, Ansprechpartner, Aktenzeichen,
Sprechzeiten — und schreibt einen **Spickzettel** mit 3–5 Sätzen für das Telefonat.
- Telefonnummer als **Anruf-Knopf** (`tel:`)
- Aktenzeichen **hervorgehoben** — danach wird am Telefon zuerst gefragt
- Jeder Satz **auf Deutsch** (so wird er gesagt) und darunter in der Sprache
des Nutzers (damit er versteht, was er sagt)
**Regel im Prompt:** Kontaktdaten nur, wenn sie **wörtlich** im Brief stehen.
Getestet mit einem Brief ohne Telefonnummer → `telefon: null`, nichts erfunden.
Eine falsche Nummer wäre schlimmer als keine.
### 4. Ampel statt Datum
Rot ≤ 7 Tage, gelb ≤ 21, sonst grün. Die Startseite sortiert nach
**Dringlichkeit**, nicht nach Eingang — man sieht sofort, was brennt.
+26 -2
View File
@@ -104,6 +104,8 @@ Deine Aufgabe:
4. Erstelle eine konkrete Checkliste, was der Nutzer tun muss.
5. Sei sachlich und beruhigend, nicht alarmierend.
6. Telefonnummern, Namen und Aktenzeichen übernimmst du NUR, wenn sie wörtlich im Brief stehen. Eine erfundene Telefonnummer ist schlimmer als keine — dann null.
Wichtig: Wenn das Bild kein Behördenbrief ist oder unlesbar ist, schreibe das klar in kernaussage und erklaerung_einfach und lasse frist/termin null.`;
/** Schlanke Demo-Variante des Analyse-Schemas (ohne Antwort-Optionen —
@@ -111,7 +113,7 @@ Wichtig: Wenn das Bild kein Behördenbrief ist oder unlesbar ist, schreibe das k
const DEMO_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['brieftyp', 'absender', 'kernaussage', 'erklaerung_einfach', 'fachbegriffe', 'frist', 'termin', 'checkliste'],
required: ['brieftyp', 'absender', 'kernaussage', 'erklaerung_einfach', 'fachbegriffe', 'frist', 'termin', 'checkliste', 'kontakt', 'anruf'],
properties: {
brieftyp: { type: 'string', description: 'Kurze Kategorie des Briefs, z. B. "Einladung Jobcenter".' },
absender: { type: 'string', description: 'Die Behörde, die den Brief geschickt hat.' },
@@ -150,6 +152,28 @@ const DEMO_SCHEMA = {
],
},
checkliste: { type: 'array', description: 'To-do-Liste. Leer wenn nichts zu tun ist.', items: { type: 'string' } },
kontakt: {
description: 'Kontaktdaten aus dem Brief. Jedes Feld NUR, wenn es wörtlich im Brief steht — sonst null. Niemals erfinden.',
type: 'object', additionalProperties: false,
required: ['telefon', 'ansprechpartner', 'aktenzeichen', 'sprechzeiten'],
properties: {
telefon: { description: 'Telefonnummer genau wie im Brief abgedruckt, sonst null.', anyOf: [{ type: 'string' }, { type: 'null' }] },
ansprechpartner: { description: 'Name der zuständigen Person, sonst null.', anyOf: [{ type: 'string' }, { type: 'null' }] },
aktenzeichen: { description: 'Aktenzeichen, Kunden-, Steuer- oder BG-Nummer — das, wonach am Telefon gefragt wird. Sonst null.', anyOf: [{ type: 'string' }, { type: 'null' }] },
sprechzeiten: { description: 'Telefonische Erreichbarkeit laut Brief, sonst null.', anyOf: [{ type: 'string' }, { type: 'null' }] },
},
},
anruf: {
description: 'Spickzettel für einen Anruf bei der Behörde: 3 bis 5 kurze, höfliche Sätze, die man am Telefon sagen kann — Begrüßung, Aktenzeichen nennen, Anliegen, Nachfrage. Leeres Array, wenn ein Anruf nicht sinnvoll ist.',
type: 'array',
items: {
type: 'object', additionalProperties: false, required: ['deutsch', 'bedeutung'],
properties: {
deutsch: { type: 'string', description: 'Der Satz in einfachem Deutsch — genau so wird er am Telefon gesagt.' },
bedeutung: { type: 'string', description: 'Derselbe Satz in der Sprache des Nutzers, damit er versteht, was er sagt.' },
},
},
},
},
};
@@ -314,7 +338,7 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
max_tokens: MAX_TOKENS_OBERGRENZE,
system: sprachCode === 'de'
? DEMO_SYSTEM_PROMPT
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format.`,
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`,
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
messages: [
{
+193 -3
View File
@@ -159,6 +159,12 @@
/* Ergebnis */
#ergebnis { display: none; }
.anruf-satz { border-inline-start: 3px solid var(--amtsblau); background: #fff; padding: 6px 12px; margin: 10px 0; }
.anruf-de { font-weight: 700; margin: 0; }
.anruf-bed { color: var(--grau); font-size: 15px; margin: 4px 0 0; }
.kontakt-zeile { margin: 6px 0; font-size: 16px; }
.az-wert { font-family: 'Courier New', ui-monospace, monospace; font-weight: 700; background: var(--marker); padding: 1px 6px; }
#ergAnrufKontakt .cta { display: inline-block; width: auto; margin: 4px 0 8px; }
.brief-karte {
background: #fff; border: 1px solid rgba(33, 33, 29, 0.28);
padding: 22px 24px; position: relative; margin-top: 22px;
@@ -374,13 +380,23 @@
<div class="brief-karte">
<span class="karten-label neutral" id="ergBrieftyp">Brief</span>
<p style="margin-top: 6px;"><strong id="ergKern"></strong></p>
<p style="margin-top: 6px;"><strong id="ergKern" dir="auto"></strong></p>
<p class="ampelzeile" id="ergAmpel" style="display: none;"></p>
</div>
<div class="brief-karte" id="ergKalenderKarte" style="display: none; border: 2px solid var(--amtsblau);">
<span class="karten-label">Nicht vergessen</span>
<div id="ergKalenderText" style="margin-top: 6px;"></div>
<button class="cta" id="kalenderKnopf" type="button" style="margin-top: 12px;">📅 In meinen Kalender eintragen</button>
<p class="klein">
Ihr Handy erinnert Sie <strong>3 Tage</strong> und <strong>1 Tag</strong> vorher.
Der Eintrag landet nur in Ihrem eigenen Kalender — wir speichern nichts.
</p>
</div>
<div class="brief-karte">
<span class="karten-label">Einfach erklärt</span>
<p id="ergErklaerung" style="white-space: pre-line;"></p>
<p id="ergErklaerung" dir="auto" style="white-space: pre-line;"></p>
</div>
<div class="brief-karte" id="ergChecklisteKarte" style="display: none;">
@@ -388,6 +404,12 @@
<div id="ergCheckliste"></div>
</div>
<div class="brief-karte" id="ergAnrufKarte" style="display: none;">
<span class="karten-label neutral">Anrufen</span>
<div id="ergAnrufKontakt" style="margin-top: 6px;"></div>
<div id="ergAnrufSaetze"></div>
</div>
<div class="brief-karte" id="ergBegriffeKarte" style="display: none;">
<span class="karten-label neutral">Schwere Wörter erklärt</span>
<div id="ergBegriffe"></div>
@@ -688,6 +710,10 @@
function zeigeErgebnis(analyse, versuch, guthabenRest) {
status.style.display = 'none';
// Optionale Karten zuruecksetzen. Sonst bleibt beim zweiten Brief z. B.
// die Checkliste des ERSTEN Briefs stehen — zwei Briefe vermischt.
['ergAmpel', 'ergKalenderKarte', 'ergChecklisteKarte', 'ergAnrufKarte', 'ergBegriffeKarte']
.forEach(function (id) { document.getElementById(id).style.display = 'none'; });
document.getElementById('ergBrieftyp').textContent = analyse.brieftyp || 'Ihr Brief';
document.getElementById('ergKern').textContent = analyse.kernaussage || '';
document.getElementById('ergErklaerung').textContent = analyse.erklaerung_einfach || '';
@@ -705,7 +731,7 @@
if (analyse.checkliste && analyse.checkliste.length) {
document.getElementById('ergChecklisteKarte').style.display = 'block';
document.getElementById('ergCheckliste').innerHTML = analyse.checkliste
.map(function (t) { var d = document.createElement('div'); d.className = 'todo'; d.textContent = t; return d.outerHTML; })
.map(function (t) { var d = document.createElement('div'); d.className = 'todo'; d.setAttribute('dir', 'auto'); d.textContent = t; return d.outerHTML; })
.join('');
}
if (analyse.fachbegriffe && analyse.fachbegriffe.length) {
@@ -723,6 +749,9 @@
.join('');
}
zeigeKalender(analyse);
zeigeAnruf(analyse);
document.getElementById('ergebnis').style.display = 'block';
// Bezahlter Modus: Guthaben-Stand zeigen, Eingabe für den nächsten Brief öffnen
@@ -759,6 +788,167 @@
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
}
// ---- Kalender (.ics) --------------------------------------------------
// Die Datei entsteht im Browser und wird nirgends hochgeladen. Die
// Erinnerungen stellt danach der Kalender des Handys selbst.
var letzteAnalyse = null;
function icsText(t) {
return String(t || '').replace(/\\/g, '\\\\').replace(/;/g, '\;')
.replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
// RFC 5545: max. 75 BYTE pro Zeile. Nach Bytes falten, nicht nach Zeichen —
// arabische oder tigrinische Texte brauchen 2–4 Byte pro Zeichen.
function icsFalte(zeile) {
var enc = new TextEncoder(), teile = [], akt = '', bytes = 0;
for (var zeichen of zeile) {
var b = enc.encode(zeichen).length;
if (bytes + b > 75) { teile.push(akt); akt = ' ' + zeichen; bytes = 1 + b; }
else { akt += zeichen; bytes += b; }
}
teile.push(akt);
return teile.join('\r\n');
}
function isoGueltig(d) { return typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d); }
function deDatum(iso) { return iso.split('-').reverse().join('.'); }
function zweistellig(x) { return ('0' + x).slice(-2); }
// "Wanduhrzeit" ohne Zeitzone: der Kalender nimmt die Ortszeit des Geraets.
// Richtig fuer einen Termin in Deutschland, egal wo der Server steht.
function wanduhr(dt) {
return dt.getUTCFullYear() + zweistellig(dt.getUTCMonth() + 1) + zweistellig(dt.getUTCDate()) +
'T' + zweistellig(dt.getUTCHours()) + zweistellig(dt.getUTCMinutes()) + '00';
}
function nurDatum(dt) { return wanduhr(dt).slice(0, 8); }
function ausIso(iso, h, m) { var t = iso.split('-'); return new Date(Date.UTC(+t[0], +t[1] - 1, +t[2], h || 0, m || 0)); }
function eindeutig() {
return (window.crypto && crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(16).slice(2)) + '@behoerdenklar.de';
}
function alarm(ausloeser, text) {
return ['BEGIN:VALARM', 'ACTION:DISPLAY', 'TRIGGER:' + ausloeser, 'DESCRIPTION:' + icsText(text), 'END:VALARM'];
}
function baueIcs(a) {
var z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Webklar GbR//BehoerdenKlar//DE',
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH'];
var titel = a.brieftyp || 'Brief', stempel = wanduhr(new Date()) + 'Z', n = 0;
if (a.frist && isoGueltig(a.frist.datum)) {
var fTag = ausIso(a.frist.datum);
var fDanach = new Date(fTag.getTime() + 86400000);
// Titel und Hinweis ohne deutsche Worte: Datum und Symbol versteht jeder,
// "aktion" kommt bereits in der Sprache des Nutzers von der KI.
z.push('BEGIN:VEVENT', 'UID:' + eindeutig(), 'DTSTAMP:' + stempel,
'DTSTART;VALUE=DATE:' + nurDatum(fTag), 'DTEND;VALUE=DATE:' + nurDatum(fDanach),
'SUMMARY:' + icsText('⚠️ ' + titel),
'DESCRIPTION:' + icsText((a.frist.aktion || '') + '\n\nbehoerdenklar.de'));
// Ganztaegig beginnt um 00:00 — so fallen beide Erinnerungen auf 9 Uhr morgens.
z.push.apply(z, alarm('-P2DT15H', '⏰ ' + titel + ' — ' + deDatum(a.frist.datum)));
z.push.apply(z, alarm('-PT15H', '⏰ ' + titel + ' — ' + deDatum(a.frist.datum)));
z.push('END:VEVENT'); n++;
}
if (a.termin && isoGueltig(a.termin.datum)) {
var zeit = a.termin.uhrzeit && String(a.termin.uhrzeit).match(/(\d{1,2})[:.](\d{2})/);
var h = zeit ? +zeit[1] : -1, m = zeit ? +zeit[2] : -1;
z.push('BEGIN:VEVENT', 'UID:' + eindeutig(), 'DTSTAMP:' + stempel);
if (zeit && h <= 23 && m <= 59) {
var beginn = ausIso(a.termin.datum, h, m);
var ende = new Date(beginn.getTime() + 3600000); // eine Stunde, auch ueber Mitternacht korrekt
z.push('DTSTART:' + wanduhr(beginn), 'DTEND:' + wanduhr(ende));
z.push.apply(z, alarm('-P3D', '📅 ' + titel + ' — ' + deDatum(a.termin.datum) + ', ' + zweistellig(h) + ':' + zweistellig(m)));
z.push.apply(z, alarm('-P1D', '📅 ' + titel + ' — ' + deDatum(a.termin.datum) + ', ' + zweistellig(h) + ':' + zweistellig(m)));
} else {
var tTag = ausIso(a.termin.datum);
z.push('DTSTART;VALUE=DATE:' + nurDatum(tTag), 'DTEND;VALUE=DATE:' + nurDatum(new Date(tTag.getTime() + 86400000)));
z.push.apply(z, alarm('-P2DT15H', '📅 ' + titel + ' — ' + deDatum(a.termin.datum)));
z.push.apply(z, alarm('-PT15H', '📅 ' + titel + ' — ' + deDatum(a.termin.datum)));
}
z.push('SUMMARY:' + icsText('📅 ' + titel));
if (a.termin.ort) z.push('LOCATION:' + icsText(a.termin.ort));
z.push('DESCRIPTION:' + icsText('behoerdenklar.de'), 'END:VEVENT'); n++;
}
z.push('END:VCALENDAR');
return n ? z.map(icsFalte).join('\r\n') + '\r\n' : null;
}
function zeigeKalender(a) {
var karte = document.getElementById('ergKalenderKarte');
var frist = a.frist && isoGueltig(a.frist.datum) ? a.frist : null;
var termin = a.termin && isoGueltig(a.termin.datum) ? a.termin : null;
if (!frist && !termin) return;
letzteAnalyse = a;
var box = document.getElementById('ergKalenderText');
box.innerHTML = '';
function zeile(text) { var p = document.createElement('p'); p.setAttribute('dir', 'auto'); p.textContent = text; box.appendChild(p); }
if (frist) zeile('⚠️ Frist: ' + deDatum(frist.datum) + (frist.aktion ? ' — ' + frist.aktion : ''));
if (termin) zeile('📅 Termin: ' + deDatum(termin.datum) + (termin.uhrzeit ? ', ' + termin.uhrzeit + ' Uhr' : '') + (termin.ort ? ' — ' + termin.ort : ''));
karte.style.display = 'block';
}
document.getElementById('kalenderKnopf').addEventListener('click', function () {
if (!letzteAnalyse) return;
var ics = baueIcs(letzteAnalyse);
if (!ics) return;
var url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));
var link = document.createElement('a');
link.href = url; link.download = 'BehoerdenKlar-Termin.ics';
document.body.appendChild(link); link.click(); link.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
});
// ---- Anruf-Hilfe ------------------------------------------------------
function zeigeAnruf(a) {
var karte = document.getElementById('ergAnrufKarte');
var k = a.kontakt || {}, saetze = Array.isArray(a.anruf) ? a.anruf : [];
if (!k.telefon && !saetze.length) return;
var box = document.getElementById('ergAnrufKontakt');
box.innerHTML = '';
if (k.telefon) {
var knopf = document.createElement('a');
knopf.className = 'cta';
knopf.href = 'tel:' + String(k.telefon).replace(/[^\d+]/g, '');
knopf.textContent = '📞 ' + k.telefon;
box.appendChild(knopf);
}
function angabe(label, wert, hervorheben) {
if (!wert) return;
var p = document.createElement('p'); p.className = 'kontakt-zeile';
var b = document.createElement('strong'); b.textContent = label + ': ';
var w = document.createElement('span'); w.textContent = wert;
if (hervorheben) w.className = 'az-wert';
p.appendChild(b); p.appendChild(w); box.appendChild(p);
}
angabe('Ansprechpartner', k.ansprechpartner);
angabe('Erreichbar', k.sprechzeiten);
angabe('Danach werden Sie gefragt', k.aktenzeichen, true);
var liste = document.getElementById('ergAnrufSaetze');
liste.innerHTML = '';
if (saetze.length) {
var kopf = document.createElement('p');
kopf.style.marginTop = '12px';
kopf.innerHTML = '<strong>Das können Sie am Telefon sagen:</strong>';
liste.appendChild(kopf);
var mitBedeutung = zielsprache() !== 'de';
saetze.forEach(function (s) {
var d = document.createElement('div'); d.className = 'anruf-satz';
var de = document.createElement('p'); de.className = 'anruf-de';
de.textContent = '„' + s.deutsch + '“';
d.appendChild(de);
if (mitBedeutung && s.bedeutung && s.bedeutung !== s.deutsch) {
var be = document.createElement('p'); be.className = 'anruf-bed';
be.setAttribute('dir', 'auto');
be.textContent = s.bedeutung;
d.appendChild(be);
}
liste.appendChild(d);
});
}
karte.style.display = 'block';
}
// ---- Guthaben kaufen ------------------------------------------------
function rendrePakete() {
paketListe.innerHTML = '';