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:
parent
9b25985281
commit
3a08392669
105
ai-act-kompass/src/engine/ics.test.ts
Normal file
105
ai-act-kompass/src/engine/ics.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
157
ai-act-kompass/src/engine/ics.ts
Normal file
157
ai-act-kompass/src/engine/ics.ts
Normal 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';
|
||||
}
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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}',
|
||||
|
||||
@ -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({
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</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>
|
||||
|
||||
<Group
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user