feat(forms): M10a wiederkehrende Forms — cohort-tagging + UI

Plan: docs/plans/forms-module.md M10. Erste Hälfte (Datenshape +
Submit-Stamping + ResponsesView-Filter); cron-basierter
Broadcast-Versand bleibt M10b.

- types.ts:
  - FormSettings.recurrence?: { frequency: 'weekly'|'monthly',
    startedAt?, sendVia? }. Im Settings-Blob mitverschlüsselt.
  - LocalFormResponse.cohort?: string. Plaintext (sortier-/filter-
    Feld, kein PII). FormResponse.cohort: string|null.
  - queries.toFormResponse maps null durch.
- lib/cohort.ts (pure):
  - computeCohort(iso, frequency) → "YYYY-WNN" (ISO-Woche, padded für
    lex-sort) | "YYYY-MM". Behandelt Year-Boundary korrekt
    (2027-01-01 → 2026-W53).
  - cohortLabel(cohort, frequency, now=new Date()) → "Diese Woche" /
    "Letzte Woche" / "KW NN / YYYY" für weekly; "Dieser Monat" /
    "Letzter Monat" / "Monatsname YYYY" für monthly. Falsche Keys
    fallen auf Roh-Wert zurück (inkl. Bounds-Check 1-12 für month).
  - sortCohortsDesc(): immutable, lex-sort newest-first.
  - 15/15 Vitest-Cases (compute, label-current/prev/older, malformed,
    sort, no-mutation).
- buildFormBlob (data/unlisted/resolvers.ts) zieht recurrence.frequency
  in den Public-Snapshot. Frequenz ist nicht-sensitive Metadaten —
  kann öffentlich diskoverabel sein, der Server braucht es zum
  cohort-stamping.
- apps/api/src/modules/forms/public-routes.ts:
  - Inline computeCohort (Mirror der pure-Funktion — apps/api kann
    apps/mana nicht importieren).
  - Bei jeder Submission: wenn snapshot.recurrence.frequency gesetzt,
    stamp data.cohort = computeCohort(submittedAt, freq). Cohort
    landet im sync_changes-Insert und reist zum Owner-Client.
  - errorResponse-Aufrufe: { code, field } → { code, details: { field } }
    weil der Hono-helper die details-Struktur erzwingt (M3b
    type-safety-fix mitgenommen).
- SettingsPanel: Recurrence-Block mit Dropdown (Einmalig / Wöchentlich
  / Monatlich) + Hint-Erklärung. Setzt startedAt automatisch beim
  Aktivieren.
- ResponsesView: cohort-chip-bar oberhalb der Status-Tabs. Zeigt
  "Alle Wellen" + ein Chip pro distincter cohort (newest-first), je
  mit Anzahl. Klick filtert die responses-Liste — alle bestehenden
  Status-Counts berechnen sich auf den cohort-gefilterten Set, also
  CSV-Export + Detail-Modal-Status-Pills bleiben konsistent.
- 8 neue i18n-Keys × 5 Locales (forms.builder.recurrence.* +
  forms.responses.cohort.all). i18n-parity 6483 keys aligned.

Tests: 41/41 forms-tests grün (5 csv + 11 branching + 10 auto-sync +
15 cohort). svelte-check 0 errors. apps/api buildet.

M10b open: cron-worker in mana-ai oder mana-notify, der
recurrence-Forms scant + Share-Link via broadcasts an die
Recipients-Liste sendet. Schemata stehen, Pipeline fehlt noch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-06 14:09:55 +02:00
parent cb9d79d2c9
commit 38e0ae2ff8
15 changed files with 470 additions and 11 deletions

View file

@ -88,6 +88,30 @@ interface FormSnapshotBlob {
}>;
branching?: unknown[];
settings?: { submitButtonLabel?: string; successMessage?: string };
recurrence?: { frequency?: 'weekly' | 'monthly' };
}
/**
* Compute the cohort label for a response based on the form's
* recurrence frequency. Mirror of `lib/cohort.ts` in the webapp
* duplicated here because the apps/api surface can't import from
* apps/mana/. ISO 8601 week math.
*/
function computeCohort(submittedAtIso: string, frequency: 'weekly' | 'monthly'): string {
const date = new Date(submittedAtIso);
if (Number.isNaN(date.getTime())) return '';
if (frequency === 'monthly') {
const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1;
return `${year}-${String(month).padStart(2, '0')}`;
}
const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const dayOfWeek = utc.getUTCDay() || 7;
utc.setUTCDate(utc.getUTCDate() + 4 - dayOfWeek);
const year = utc.getUTCFullYear();
const yearStart = Date.UTC(year, 0, 1);
const week = Math.ceil(((utc.getTime() - yearStart) / 86_400_000 + 1) / 7);
return `${year}-W${String(week).padStart(2, '0')}`;
}
interface SubmitBody {
@ -168,7 +192,7 @@ routes.post('/:token/submit', async (c) => {
) {
return errorResponse(c, `Feld "${field.label ?? field.id}" ist erforderlich`, 400, {
code: 'REQUIRED_MISSING',
field: field.id,
details: { field: field.id },
});
}
}
@ -177,7 +201,7 @@ routes.post('/:token/submit', async (c) => {
if (cleanAnswers[field.id] !== true) {
return errorResponse(c, `Einwilligung "${field.label ?? field.id}" ist erforderlich`, 400, {
code: 'CONSENT_REQUIRED',
field: field.id,
details: { field: field.id },
});
}
}
@ -214,6 +238,10 @@ routes.post('/:token/submit', async (c) => {
};
if (submitterEmail) data.submitterEmail = submitterEmail;
if (submitterName) data.submitterName = submitterName;
if (blob.recurrence?.frequency) {
const cohort = computeCohort(submittedAt, blob.recurrence.frequency);
if (cohort) data.cohort = cohort;
}
if (ipHash || userAgent || referrer) {
data.submitterMeta = {
...(ipHash ? { ipHash } : {}),