feat(ai-act-kompass): calendar reminders via iCalendar (.ics) export

- New RFC 5545 generator: all future obligation deadlines (per system)
  plus upcoming AI Act application dates as all-day events, each with
  two DISPLAY alarms (30 and 7 days ahead), stable UIDs for
  re-import/update, proper escaping and 75-octet line folding, CRLF
- Export button in the tasks view downloads ai-act-fristen-<date>.ics
  (text/calendar) — opens in Outlook, Apple Calendar, Google Calendar,
  Thunderbird on phone and desktop; no server involved (local-first)
- Event titles and descriptions localized via the active content pack
  (all nine languages); button and hint translated in all dictionaries
- 5 new tests: calendar frame, future-only filter, stable UIDs, alarm
  count, escaping/folding, localized summaries (63 total)

Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv
This commit is contained in:
Claude 2026-07-04 11:02:28 +00:00
parent 9b25985281
commit 3a08392669
No known key found for this signature in database
11 changed files with 305 additions and 1 deletions

View File

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { CONTENT_PACK, getContentPack } from '../content/pack';
import { buildChecklist } from './checklist';
import { checklistIcs } from './ics';
import { classify } from './classify';
import type { AISystem, AssessmentAnswers } from './types';
const answers: AssessmentAnswers = {
isAISystem: true,
role: 'deployer',
isGPAIModel: false,
prohibitedPractices: [],
annexIIICategories: ['a3-employment'],
art63Exception: false,
involvesProfiling: false,
isAnnexISafetyComponent: false,
transparencyTriggers: [],
};
const system: AISystem = {
id: 'sys-1',
name: 'Bewerber-Screening; GmbH, Test',
purpose: 'Vorauswahl',
vendor: '',
owner: '',
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-01T00:00:00.000Z',
answers,
classification: classify(answers, CONTENT_PACK),
obligationStatus: {},
};
const TODAY = '2026-07-04';
const TIMESTAMP = '2026-07-04T12:00:00.000Z';
function makeIcs(): string {
const checklist = buildChecklist([system], CONTENT_PACK, ['DE'], TODAY);
return checklistIcs({
entries: [...checklist.dueNow, ...checklist.upcoming],
deadlines: CONTENT_PACK.deadlines,
today: TODAY,
timestamp: TIMESTAMP,
calendarName: 'AI-Act-Kompass',
});
}
describe('checklistIcs', () => {
it('erzeugt einen gültigen VCALENDAR-Rahmen mit CRLF', () => {
const ics = makeIcs();
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true);
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ics).toContain('VERSION:2.0');
expect(ics).toContain('X-WR-CALNAME:AI-Act-Kompass');
expect(ics.includes('\n') && !ics.replace(/\r\n/g, '').includes('\n')).toBe(true);
});
it('exportiert nur zukünftige Fristen als ganztägige Termine mit stabiler UID', () => {
const ics = makeIcs();
// Art.-26-Pflichten (2027-12-02) sind enthalten …
expect(ics).toContain('DTSTART;VALUE=DATE:20271202');
expect(ics).toContain('DTEND;VALUE=DATE:20271203');
expect(ics).toContain('UID:sys-1-ob-deployer-oversight@ai-act-kompass');
// … verstrichene (KI-Kompetenz 2025-02-02) nicht:
expect(ics).not.toContain('DTSTART;VALUE=DATE:20250202');
// Allgemeine zukünftige Geltungsfristen sind enthalten:
expect(ics).toContain('UID:deadline-dl-annex3@ai-act-kompass');
});
it('setzt zwei Erinnerungen (30 und 7 Tage vorher) je Termin', () => {
const ics = makeIcs();
const eventCount = (ics.match(/BEGIN:VEVENT/g) ?? []).length;
expect((ics.match(/TRIGGER:-P30D/g) ?? []).length).toBe(eventCount);
expect((ics.match(/TRIGGER:-P7D/g) ?? []).length).toBe(eventCount);
expect(eventCount).toBeGreaterThan(3);
});
it('maskiert Sonderzeichen und faltet lange Zeilen RFC-konform', () => {
const ics = makeIcs();
// Systemname enthält ; und , — müssen maskiert sein:
expect(ics).toContain('Bewerber-Screening\\;');
expect(ics.replace(/\r\n /g, '')).toContain('GmbH\\, Test');
// Keine entfaltete Zeile länger als 75 Oktette:
for (const line of ics.split('\r\n')) {
expect(new TextEncoder().encode(line).length).toBeLessThanOrEqual(75);
}
});
it('lokalisiert Termintitel über den Content-Pack', () => {
const packFr = getContentPack('fr');
const checklist = buildChecklist(
[{ ...system, classification: classify(answers, packFr, 'fr') }],
packFr,
[],
TODAY,
);
const ics = checklistIcs({
entries: [...checklist.dueNow, ...checklist.upcoming],
deadlines: packFr.deadlines,
today: TODAY,
timestamp: TIMESTAMP,
calendarName: 'AI-Act-Kompass',
});
expect(ics.replace(/\r\n /g, '')).toContain('contrôle humain');
});
});

View File

@ -0,0 +1,157 @@
import type { ChecklistEntry } from './checklist';
import type { Deadline } from './types';
/**
* iCalendar-Export (RFC 5545) für Fristen und Pflichten.
*
* Erzeugt eine .ics-Datei mit ganztägigen Terminen und eingebauten
* Erinnerungen (VALARM, 30 und 7 Tage vor Fälligkeit). Die Datei lässt
* sich in Outlook, Apple Kalender, Google Kalender, Thunderbird u. a.
* importieren auf Smartphone und Desktop, komplett ohne Server.
*/
/** Erinnerungszeitpunkte in Tagen vor der Frist. */
const ALARM_DAYS_BEFORE: readonly number[] = [30, 7];
/** Maskiert Text nach RFC 5545 (Backslash, Semikolon, Komma, Zeilenumbruch). */
function escapeIcs(text: string): string {
return text
.replaceAll('\\', '\\\\')
.replaceAll(';', '\\;')
.replaceAll(',', '\\,')
.replaceAll('\r\n', '\\n')
.replaceAll('\n', '\\n');
}
/**
* Faltet eine Content-Zeile nach RFC 5545 (max. 75 Oktette je Zeile,
* Fortsetzungszeilen beginnen mit einem Leerzeichen).
*/
function foldLine(line: string): string {
const bytes = new TextEncoder().encode(line);
if (bytes.length <= 75) return line;
const parts: string[] = [];
let current = '';
let currentBytes = 0;
for (const char of line) {
const charBytes = new TextEncoder().encode(char).length;
const limit = parts.length === 0 ? 75 : 74; // Folgezeilen: 1 Oktett für das Leerzeichen
if (currentBytes + charBytes > limit) {
parts.push(current);
current = char;
currentBytes = charBytes;
} else {
current += char;
currentBytes += charBytes;
}
}
if (current !== '') parts.push(current);
return parts.join('\r\n ');
}
/** YYYY-MM-DD → YYYYMMDD (DATE-Wert nach RFC 5545). */
function toIcsDate(isoDate: string): string {
return isoDate.replaceAll('-', '');
}
/** ISO-Zeitstempel → YYYYMMDDTHHMMSSZ (UTC, DTSTAMP). */
function toIcsTimestamp(isoDateTime: string): string {
return `${isoDateTime.slice(0, 19).replace(/[-:]/g, '')}Z`;
}
/** Folgetag eines ISO-Datums (DTEND ganztägiger Termine ist exklusiv). */
function nextDay(isoDate: string): string {
const [y, m, d] = isoDate.split('-').map(Number);
const date = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}
interface IcsEvent {
readonly uid: string;
readonly date: string;
readonly summary: string;
readonly description: string;
}
function eventBlock(event: IcsEvent, dtstamp: string): readonly string[] {
const lines = [
'BEGIN:VEVENT',
`UID:${event.uid}`,
`DTSTAMP:${dtstamp}`,
`DTSTART;VALUE=DATE:${toIcsDate(event.date)}`,
`DTEND;VALUE=DATE:${toIcsDate(nextDay(event.date))}`,
`SUMMARY:${escapeIcs(event.summary)}`,
`DESCRIPTION:${escapeIcs(event.description)}`,
'CATEGORIES:AI-Act-Kompass',
'TRANSP:TRANSPARENT',
];
for (const days of ALARM_DAYS_BEFORE) {
lines.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`TRIGGER:-P${days}D`,
`DESCRIPTION:${escapeIcs(event.summary)}`,
'END:VALARM',
);
}
lines.push('END:VEVENT');
return lines;
}
/** Parameter für {@link checklistIcs}. */
export interface ChecklistIcsParams {
/** Offene Aufgaben (fällig + anstehend) aus der Checkliste. */
readonly entries: readonly ChecklistEntry[];
/** Allgemeine Geltungsfristen des AI Act (Content-Pack). */
readonly deadlines: readonly Deadline[];
/** Stichtag im ISO-Format — nur Termine ab diesem Tag werden exportiert. */
readonly today: string;
/** Erzeugungszeitpunkt (ISO-Datetime) für DTSTAMP. */
readonly timestamp: string;
/** Anzeigename des Kalenders. */
readonly calendarName: string;
}
/**
* Erzeugt eine iCalendar-Datei mit allen zukünftigen Pflicht-Fristen
* (je System) und den allgemeinen AI-Act-Geltungsfristen jeweils als
* ganztägiger Termin mit Erinnerungen 30 und 7 Tage vorher.
*
* @returns Inhalt der .ics-Datei (CRLF-Zeilenenden, RFC-5545-Faltung)
*/
export function checklistIcs(params: ChecklistIcsParams): string {
const dtstamp = toIcsTimestamp(params.timestamp);
const taskEvents: IcsEvent[] = params.entries
.filter((e) => e.obligation.deadline >= params.today)
.map((e) => ({
uid: `${e.systemId}-${e.obligation.id}@ai-act-kompass`,
date: e.obligation.deadline,
summary: `AI Act: ${e.obligation.title}${e.systemName}`,
description: `${e.obligation.articles} · ${e.obligation.description}\n\nAI-Act-Kompass`,
}));
const deadlineEvents: IcsEvent[] = params.deadlines
.filter((d) => d.date >= params.today)
.map((d) => ({
uid: `deadline-${d.id}@ai-act-kompass`,
date: d.date,
summary: `AI Act: ${d.label}`,
description: `${d.description}\n\nAI-Act-Kompass`,
}));
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//AI-Act-Kompass//Compliance Reminders//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${escapeIcs(params.calendarName)}`,
...[...taskEvents, ...deadlineEvents].flatMap((e) => eventBlock(e, dtstamp)),
'END:VCALENDAR',
];
return lines.map(foldLine).join('\r\n') + '\r\n';
}

View File

@ -41,6 +41,8 @@ export const CS: UIStrings = {
tasksEmpty: 'Žádné úkoly — nejprve posuďte systém AI.', tasksEmpty: 'Žádné úkoly — nejprve posuďte systém AI.',
tasksAllDone: 'V této skupině je vše hotovo.', tasksAllDone: 'V této skupině je vše hotovo.',
tasksDue: 'Lhůta', tasksDue: 'Lhůta',
calExport: 'Přidat do kalendáře (.ics)',
calHint: 'Exportuje všechny otevřené lhůty jako soubor kalendáře s připomínkami (30 a 7 dní předem) — lze importovat do Outlooku, kalendáře Apple i Google, v telefonu i počítači.',
wizardTitleNew: 'Posoudit nový systém AI', wizardTitleNew: 'Posoudit nový systém AI',
wizardTitleRe: 'Nové posouzení: {name}', wizardTitleRe: 'Nové posouzení: {name}',

View File

@ -41,6 +41,8 @@ export const ES: UIStrings = {
tasksEmpty: 'No hay tareas — evalúe primero un sistema de IA.', tasksEmpty: 'No hay tareas — evalúe primero un sistema de IA.',
tasksAllDone: 'Todo hecho en este grupo.', tasksAllDone: 'Todo hecho en este grupo.',
tasksDue: 'Plazo', tasksDue: 'Plazo',
calExport: 'Añadir al calendario (.ics)',
calHint: 'Exporta todos los plazos abiertos como archivo de calendario con recordatorios (30 y 7 días antes) — importable en Outlook, Apple y Google Calendar, en el móvil y el ordenador.',
wizardTitleNew: 'Evaluar un nuevo sistema de IA', wizardTitleNew: 'Evaluar un nuevo sistema de IA',
wizardTitleRe: 'Reevaluación: {name}', wizardTitleRe: 'Reevaluación: {name}',

View File

@ -41,6 +41,8 @@ export const FR: UIStrings = {
tasksEmpty: 'Aucune tâche — évaluez dabord un système dIA.', tasksEmpty: 'Aucune tâche — évaluez dabord un système dIA.',
tasksAllDone: 'Tout est fait dans ce groupe.', tasksAllDone: 'Tout est fait dans ce groupe.',
tasksDue: 'Échéance', tasksDue: 'Échéance',
calExport: 'Ajouter au calendrier (.ics)',
calHint: 'Exporte toutes les échéances ouvertes sous forme de fichier calendrier avec rappels (30 et 7 jours avant) — importable dans Outlook, Apple et Google Agenda, sur téléphone et ordinateur.',
wizardTitleNew: 'Évaluer un nouveau système dIA', wizardTitleNew: 'Évaluer un nouveau système dIA',
wizardTitleRe: 'Réévaluation : {name}', wizardTitleRe: 'Réévaluation : {name}',

View File

@ -41,6 +41,8 @@ export const IT: UIStrings = {
tasksEmpty: 'Nessuna attività — valutate prima un sistema di IA.', tasksEmpty: 'Nessuna attività — valutate prima un sistema di IA.',
tasksAllDone: 'Tutto fatto in questo gruppo.', tasksAllDone: 'Tutto fatto in questo gruppo.',
tasksDue: 'Scadenza', tasksDue: 'Scadenza',
calExport: 'Aggiungi al calendario (.ics)',
calHint: 'Esporta tutte le scadenze aperte come file di calendario con promemoria (30 e 7 giorni prima) — importabile in Outlook, Apple e Google Calendar, su telefono e computer.',
wizardTitleNew: 'Valutare un nuovo sistema di IA', wizardTitleNew: 'Valutare un nuovo sistema di IA',
wizardTitleRe: 'Rivalutazione: {name}', wizardTitleRe: 'Rivalutazione: {name}',

View File

@ -41,6 +41,8 @@ export const PL: UIStrings = {
tasksEmpty: 'Brak zadań — najpierw oceń system AI.', tasksEmpty: 'Brak zadań — najpierw oceń system AI.',
tasksAllDone: 'Wszystko zrobione w tej grupie.', tasksAllDone: 'Wszystko zrobione w tej grupie.',
tasksDue: 'Termin', tasksDue: 'Termin',
calExport: 'Dodaj do kalendarza (.ics)',
calHint: 'Eksportuje wszystkie otwarte terminy jako plik kalendarza z przypomnieniami (30 i 7 dni wcześniej) — do zaimportowania w Outlooku, kalendarzu Apple i Google, na telefonie i komputerze.',
wizardTitleNew: 'Oceń nowy system AI', wizardTitleNew: 'Oceń nowy system AI',
wizardTitleRe: 'Ponowna ocena: {name}', wizardTitleRe: 'Ponowna ocena: {name}',

View File

@ -41,6 +41,8 @@ export const RO: UIStrings = {
tasksEmpty: 'Nicio sarcină — evaluați mai întâi un sistem de IA.', tasksEmpty: 'Nicio sarcină — evaluați mai întâi un sistem de IA.',
tasksAllDone: 'Totul este finalizat în acest grup.', tasksAllDone: 'Totul este finalizat în acest grup.',
tasksDue: 'Termen', tasksDue: 'Termen',
calExport: 'Adaugă în calendar (.ics)',
calHint: 'Exportă toate termenele deschise ca fișier de calendar cu mementouri (cu 30 și 7 zile înainte) — importabil în Outlook, Apple și Google Calendar, pe telefon și pe computer.',
wizardTitleNew: 'Evaluează un sistem de IA nou', wizardTitleNew: 'Evaluează un sistem de IA nou',
wizardTitleRe: 'Reevaluare: {name}', wizardTitleRe: 'Reevaluare: {name}',

View File

@ -41,6 +41,8 @@ export const SK: UIStrings = {
tasksEmpty: 'Žiadne úlohy — najprv posúďte systém AI.', tasksEmpty: 'Žiadne úlohy — najprv posúďte systém AI.',
tasksAllDone: 'V tejto skupine je všetko hotové.', tasksAllDone: 'V tejto skupine je všetko hotové.',
tasksDue: 'Lehota', tasksDue: 'Lehota',
calExport: 'Pridať do kalendára (.ics)',
calHint: 'Exportuje všetky otvorené lehoty ako súbor kalendára s pripomienkami (30 a 7 dní vopred) — možno importovať do Outlooku, kalendára Apple aj Google, v telefóne aj počítači.',
wizardTitleNew: 'Posúdiť nový systém AI', wizardTitleNew: 'Posúdiť nový systém AI',
wizardTitleRe: 'Opätovné posúdenie: {name}', wizardTitleRe: 'Opätovné posúdenie: {name}',

View File

@ -41,6 +41,8 @@ const DE = {
tasksEmpty: 'Keine Aufgaben — bewerten Sie zuerst ein KI-System.', tasksEmpty: 'Keine Aufgaben — bewerten Sie zuerst ein KI-System.',
tasksAllDone: 'Alles erledigt in dieser Gruppe.', tasksAllDone: 'Alles erledigt in dieser Gruppe.',
tasksDue: 'Frist', tasksDue: 'Frist',
calExport: 'In Kalender übernehmen (.ics)',
calHint: 'Exportiert alle offenen Fristen als Kalenderdatei mit Erinnerungen (30 und 7 Tage vorher) — importierbar in Outlook, Apple- und Google-Kalender auf Handy und Rechner.',
wizardTitleNew: 'Neues KI-System bewerten', wizardTitleNew: 'Neues KI-System bewerten',
wizardTitleRe: 'Neubewertung: {name}', wizardTitleRe: 'Neubewertung: {name}',
@ -214,6 +216,8 @@ const EN: UIStrings = {
tasksEmpty: 'No tasks — assess an AI system first.', tasksEmpty: 'No tasks — assess an AI system first.',
tasksAllDone: 'Everything done in this group.', tasksAllDone: 'Everything done in this group.',
tasksDue: 'Deadline', tasksDue: 'Deadline',
calExport: 'Add to calendar (.ics)',
calHint: 'Exports all open deadlines as a calendar file with reminders (30 and 7 days ahead) — importable into Outlook, Apple and Google Calendar on phone and desktop.',
wizardTitleNew: 'Assess a new AI system', wizardTitleNew: 'Assess a new AI system',
wizardTitleRe: 'Reassessment: {name}', wizardTitleRe: 'Reassessment: {name}',

View File

@ -1,10 +1,11 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { buildChecklist } from '../engine/checklist'; import { buildChecklist } from '../engine/checklist';
import type { ChecklistEntry } from '../engine/checklist'; import type { ChecklistEntry } from '../engine/checklist';
import { checklistIcs } from '../engine/ics';
import type { AISystem, CompanyProfile } from '../engine/types'; import type { AISystem, CompanyProfile } from '../engine/types';
import { useI18n } from '../i18n/context'; import { useI18n } from '../i18n/context';
import { fmt } from '../i18n/ui'; import { fmt } from '../i18n/ui';
import { todayISO, uiDate } from './common'; import { downloadText, todayISO, uiDate } from './common';
interface ChecklistViewProps { interface ChecklistViewProps {
readonly systems: readonly AISystem[]; readonly systems: readonly AISystem[];
@ -103,6 +104,29 @@ export function ChecklistView({
<div className="progress-track"> <div className="progress-track">
<div className="progress-fill" style={{ width: `${pct}%` }} /> <div className="progress-fill" style={{ width: `${pct}%` }} />
</div> </div>
<div className="toolbar" style={{ margin: '14px 0 0' }}>
<button
className="ghost"
onClick={() =>
downloadText(
`ai-act-fristen-${today}.ics`,
checklistIcs({
entries: [...checklist.dueNow, ...checklist.upcoming],
deadlines: pack.deadlines,
today,
timestamp: new Date().toISOString(),
calendarName: 'AI-Act-Kompass',
}),
'text/calendar',
)
}
>
{t.calExport}
</button>
</div>
<p className="hint" style={{ marginBottom: 0 }}>
{t.calHint}
</p>
</div> </div>
<Group <Group