diff --git a/ai-act-kompass/src/engine/ics.test.ts b/ai-act-kompass/src/engine/ics.test.ts new file mode 100644 index 0000000..b06722c --- /dev/null +++ b/ai-act-kompass/src/engine/ics.test.ts @@ -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'); + }); +}); diff --git a/ai-act-kompass/src/engine/ics.ts b/ai-act-kompass/src/engine/ics.ts new file mode 100644 index 0000000..bf0e7a2 --- /dev/null +++ b/ai-act-kompass/src/engine/ics.ts @@ -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'; +} diff --git a/ai-act-kompass/src/i18n/ui.cs.ts b/ai-act-kompass/src/i18n/ui.cs.ts index b7cb0ec..ebb1c69 100644 --- a/ai-act-kompass/src/i18n/ui.cs.ts +++ b/ai-act-kompass/src/i18n/ui.cs.ts @@ -41,6 +41,8 @@ export const CS: UIStrings = { tasksEmpty: 'Žádné úkoly — nejprve posuďte systém AI.', tasksAllDone: 'V této skupině je vše hotovo.', 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', wizardTitleRe: 'Nové posouzení: {name}', diff --git a/ai-act-kompass/src/i18n/ui.es.ts b/ai-act-kompass/src/i18n/ui.es.ts index 040b96d..32c5699 100644 --- a/ai-act-kompass/src/i18n/ui.es.ts +++ b/ai-act-kompass/src/i18n/ui.es.ts @@ -41,6 +41,8 @@ export const ES: UIStrings = { tasksEmpty: 'No hay tareas — evalúe primero un sistema de IA.', tasksAllDone: 'Todo hecho en este grupo.', 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', wizardTitleRe: 'Reevaluación: {name}', diff --git a/ai-act-kompass/src/i18n/ui.fr.ts b/ai-act-kompass/src/i18n/ui.fr.ts index be9deb2..8f97e51 100644 --- a/ai-act-kompass/src/i18n/ui.fr.ts +++ b/ai-act-kompass/src/i18n/ui.fr.ts @@ -41,6 +41,8 @@ export const FR: UIStrings = { tasksEmpty: 'Aucune tâche — évaluez d’abord un système d’IA.', tasksAllDone: 'Tout est fait dans ce groupe.', 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 d’IA', wizardTitleRe: 'Réévaluation : {name}', diff --git a/ai-act-kompass/src/i18n/ui.it.ts b/ai-act-kompass/src/i18n/ui.it.ts index a3bc03d..b6c4439 100644 --- a/ai-act-kompass/src/i18n/ui.it.ts +++ b/ai-act-kompass/src/i18n/ui.it.ts @@ -41,6 +41,8 @@ export const IT: UIStrings = { tasksEmpty: 'Nessuna attività — valutate prima un sistema di IA.', tasksAllDone: 'Tutto fatto in questo gruppo.', 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', wizardTitleRe: 'Rivalutazione: {name}', diff --git a/ai-act-kompass/src/i18n/ui.pl.ts b/ai-act-kompass/src/i18n/ui.pl.ts index cee873d..22d21fb 100644 --- a/ai-act-kompass/src/i18n/ui.pl.ts +++ b/ai-act-kompass/src/i18n/ui.pl.ts @@ -41,6 +41,8 @@ export const PL: UIStrings = { tasksEmpty: 'Brak zadań — najpierw oceń system AI.', tasksAllDone: 'Wszystko zrobione w tej grupie.', 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', wizardTitleRe: 'Ponowna ocena: {name}', diff --git a/ai-act-kompass/src/i18n/ui.ro.ts b/ai-act-kompass/src/i18n/ui.ro.ts index eaa0011..6f8657c 100644 --- a/ai-act-kompass/src/i18n/ui.ro.ts +++ b/ai-act-kompass/src/i18n/ui.ro.ts @@ -41,6 +41,8 @@ export const RO: UIStrings = { tasksEmpty: 'Nicio sarcină — evaluați mai întâi un sistem de IA.', tasksAllDone: 'Totul este finalizat în acest grup.', 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', wizardTitleRe: 'Reevaluare: {name}', diff --git a/ai-act-kompass/src/i18n/ui.sk.ts b/ai-act-kompass/src/i18n/ui.sk.ts index 399a908..d0bdd1d 100644 --- a/ai-act-kompass/src/i18n/ui.sk.ts +++ b/ai-act-kompass/src/i18n/ui.sk.ts @@ -41,6 +41,8 @@ export const SK: UIStrings = { tasksEmpty: 'Žiadne úlohy — najprv posúďte systém AI.', tasksAllDone: 'V tejto skupine je všetko hotové.', 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', wizardTitleRe: 'Opätovné posúdenie: {name}', diff --git a/ai-act-kompass/src/i18n/ui.ts b/ai-act-kompass/src/i18n/ui.ts index 361a542..d0250b9 100644 --- a/ai-act-kompass/src/i18n/ui.ts +++ b/ai-act-kompass/src/i18n/ui.ts @@ -41,6 +41,8 @@ const DE = { tasksEmpty: 'Keine Aufgaben — bewerten Sie zuerst ein KI-System.', tasksAllDone: 'Alles erledigt in dieser Gruppe.', 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', wizardTitleRe: 'Neubewertung: {name}', @@ -214,6 +216,8 @@ const EN: UIStrings = { tasksEmpty: 'No tasks — assess an AI system first.', tasksAllDone: 'Everything done in this group.', 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', wizardTitleRe: 'Reassessment: {name}', diff --git a/ai-act-kompass/src/ui/ChecklistView.tsx b/ai-act-kompass/src/ui/ChecklistView.tsx index d95504a..5e28533 100644 --- a/ai-act-kompass/src/ui/ChecklistView.tsx +++ b/ai-act-kompass/src/ui/ChecklistView.tsx @@ -1,10 +1,11 @@ import { useMemo } from 'react'; import { buildChecklist } from '../engine/checklist'; import type { ChecklistEntry } from '../engine/checklist'; +import { checklistIcs } from '../engine/ics'; import type { AISystem, CompanyProfile } from '../engine/types'; import { useI18n } from '../i18n/context'; import { fmt } from '../i18n/ui'; -import { todayISO, uiDate } from './common'; +import { downloadText, todayISO, uiDate } from './common'; interface ChecklistViewProps { readonly systems: readonly AISystem[]; @@ -103,6 +104,29 @@ export function ChecklistView({
+ {t.calHint} +