From 38e0ae2ff82b2d75dc01b7183cc76b54d7af701d Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 6 May 2026 14:09:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(forms):=20M10a=20wiederkehrende=20Forms=20?= =?UTF-8?q?=E2=80=94=20cohort-tagging=20+=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/modules/forms/public-routes.ts | 32 ++++- .../web/src/lib/data/unlisted/resolvers.ts | 10 +- .../web/src/lib/i18n/locales/forms/de.json | 10 ++ .../web/src/lib/i18n/locales/forms/en.json | 10 ++ .../web/src/lib/i18n/locales/forms/es.json | 10 ++ .../web/src/lib/i18n/locales/forms/fr.json | 10 ++ .../web/src/lib/i18n/locales/forms/it.json | 10 ++ .../forms/components/SettingsPanel.svelte | 53 ++++++++- .../apps/web/src/lib/modules/forms/index.ts | 10 +- .../src/lib/modules/forms/lib/cohort.spec.ts | 95 +++++++++++++++ .../web/src/lib/modules/forms/lib/cohort.ts | 112 ++++++++++++++++++ .../web/src/lib/modules/forms/lib/csv.spec.ts | 1 + .../apps/web/src/lib/modules/forms/queries.ts | 1 + .../apps/web/src/lib/modules/forms/types.ts | 21 ++++ .../modules/forms/views/ResponsesView.svelte | 96 ++++++++++++++- 15 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/forms/lib/cohort.spec.ts create mode 100644 apps/mana/apps/web/src/lib/modules/forms/lib/cohort.ts diff --git a/apps/api/src/modules/forms/public-routes.ts b/apps/api/src/modules/forms/public-routes.ts index a79770aac..239f7cc3f 100644 --- a/apps/api/src/modules/forms/public-routes.ts +++ b/apps/api/src/modules/forms/public-routes.ts @@ -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 } : {}), diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts index 42fdd5f16..4d68ca1f0 100644 --- a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -316,7 +316,11 @@ async function buildFormBlob(recordId: string): Promise> zkMode: false, }; - return { + // Whitelisted snapshot fields. `recurrence` is intentionally + // included even though most settings are redacted: the server-side + // public-submit endpoint reads it to compute the response cohort + // (M10), and the frequency itself is not sensitive metadata. + const blob: Record = { title: decrypted.title, description: decrypted.description ?? null, fields: decrypted.fields ?? [], @@ -326,4 +330,8 @@ async function buildFormBlob(recordId: string): Promise> successMessage: settings.successMessage, }, }; + if (settings.recurrence?.frequency) { + blob.recurrence = { frequency: settings.recurrence.frequency }; + } + return blob; } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json index 474fd1220..8b637f122 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -75,6 +75,13 @@ "title": "Sichtbarkeit & Teilen", "publishHint": "Setze den Status auf \"Veröffentlicht\", um zu teilen." }, + "recurrence": { + "title": "Wiederkehr — Antworten in Wellen", + "none": "Einmalig", + "weekly": "Wöchentlich", + "monthly": "Monatlich", + "hint": "Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. \"KW 19 / 2026\") für Trend-Vergleich. Versand des Links via Broadcast kommt im nächsten Schritt." + }, "autoSync": { "title": "Auto-Sync — bei Antwort erzeugen", "targetNone": "Nichts", @@ -112,6 +119,9 @@ "tabs": { "all": "Alle" }, + "cohort": { + "all": "Alle Wellen" + }, "col": { "submittedAt": "Eingegangen", "status": "Status", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json index e3a66ff88..c2b80933c 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -75,6 +75,13 @@ "title": "Visibility & Sharing", "publishHint": "Set the status to \"Published\" to share." }, + "recurrence": { + "title": "Recurrence — group responses into waves", + "none": "One-off", + "weekly": "Weekly", + "monthly": "Monthly", + "hint": "Incoming responses are auto-tagged with a wave label (e.g. \"W19 / 2026\") for trend comparison. Broadcast-based sending will come in a follow-up step." + }, "autoSync": { "title": "Auto-sync — create on submit", "targetNone": "None", @@ -112,6 +119,9 @@ "tabs": { "all": "All" }, + "cohort": { + "all": "All waves" + }, "col": { "submittedAt": "Received", "status": "Status", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json index d5a69f84f..63174e2ba 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -75,6 +75,13 @@ "title": "Visibilidad y compartir", "publishHint": "Pon el estado en \"Publicado\" para compartir." }, + "recurrence": { + "title": "Recurrencia — agrupa respuestas en olas", + "none": "Una vez", + "weekly": "Semanal", + "monthly": "Mensual", + "hint": "Las respuestas recibidas se etiquetan automáticamente con la ola (p. ej. \"S19 / 2026\") para comparar tendencias. El envío automático por broadcast llegará en un paso posterior." + }, "autoSync": { "title": "Auto-sync — crear al recibir respuesta", "targetNone": "Nada", @@ -112,6 +119,9 @@ "tabs": { "all": "Todas" }, + "cohort": { + "all": "Todas las olas" + }, "col": { "submittedAt": "Recibida", "status": "Estado", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json index fcdd38dca..ee662728f 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -75,6 +75,13 @@ "title": "Visibilité et partage", "publishHint": "Mets le statut sur \"Publié\" pour partager." }, + "recurrence": { + "title": "Récurrence — regroupe les réponses en vagues", + "none": "Ponctuel", + "weekly": "Hebdomadaire", + "monthly": "Mensuel", + "hint": "Les réponses entrantes reçoivent automatiquement un tag de vague (p. ex. « S19 / 2026 ») pour comparer les tendances. L'envoi par broadcast arrive à l'étape suivante." + }, "autoSync": { "title": "Auto-sync — créer à la soumission", "targetNone": "Rien", @@ -112,6 +119,9 @@ "tabs": { "all": "Toutes" }, + "cohort": { + "all": "Toutes les vagues" + }, "col": { "submittedAt": "Reçue", "status": "Statut", diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json index 4baa5b06d..e8c918791 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -75,6 +75,13 @@ "title": "Visibilità e condivisione", "publishHint": "Imposta lo stato su \"Pubblicato\" per condividere." }, + "recurrence": { + "title": "Ricorrenza — raggruppa le risposte in ondate", + "none": "Una tantum", + "weekly": "Settimanale", + "monthly": "Mensile", + "hint": "Le risposte in arrivo vengono etichettate automaticamente con l'ondata (es. \"Sett. 19 / 2026\") per confronti di trend. L'invio via broadcast arriva nel passo successivo." + }, "autoSync": { "title": "Auto-sync — crea al ricevere risposta", "targetNone": "Nessuno", @@ -112,6 +119,9 @@ "tabs": { "all": "Tutte" }, + "cohort": { + "all": "Tutte le ondate" + }, "col": { "submittedAt": "Ricevuta", "status": "Stato", diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte index 45b646049..8b47f3038 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/components/SettingsPanel.svelte @@ -151,6 +151,24 @@ }, }); } + + // ── Recurrence (M10) ───────────────────────────────── + const recurrenceFrequency = $derived<'none' | 'weekly' | 'monthly'>( + settings.recurrence?.frequency ?? 'none' + ); + + function setRecurrence(next: 'none' | 'weekly' | 'monthly') { + if (next === 'none') { + onchange({ recurrence: undefined }); + } else { + onchange({ + recurrence: { + frequency: next, + startedAt: settings.recurrence?.startedAt ?? new Date().toISOString(), + }, + }); + } + }
@@ -230,6 +248,38 @@ +
+

+ {$_('forms.builder.recurrence.title', { default: 'Wiederkehr — Antworten in Wellen' })} +

+ + {#if recurrenceFrequency !== 'none'} +

+ {$_('forms.builder.recurrence.hint', { + default: + 'Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. "KW 19 / 2026") für Trend-Vergleich. Versand des Links via Broadcast kommt im nächsten Schritt.', + })} +

+ {/if} +
+

{$_('forms.builder.autoSync.title', { default: 'Auto-Sync — bei Antwort erzeugen' })} @@ -381,7 +431,8 @@ cursor: pointer; } - .auto-sync-block { + .auto-sync-block, + .recurrence-block { display: flex; flex-direction: column; gap: 0.5rem; diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts index 46c138cf6..251defa28 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/index.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts @@ -20,7 +20,14 @@ export { formTable, formResponseTable } from './collections'; export { makeDefaultField } from './lib/field-defaults'; export { resolveVisibleFields } from './lib/branching'; export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv'; -export { buildContactFromAnswers, applyAutoSync, runAutoSyncSweep } from './lib/auto-sync'; +export { + buildContactFromAnswers, + buildEventGuestFromAnswers, + applyAutoSync, + runAutoSyncSweep, +} from './lib/auto-sync'; +export { computeCohort, cohortLabel, sortCohortsDesc } from './lib/cohort'; +export type { RecurrenceFrequency } from './lib/cohort'; // ─── Types ─────────────────────────────────────────────── export { @@ -43,6 +50,7 @@ export type { BranchAction, AutoSyncConfig, AutoSyncTarget, + RecurrenceConfig, LocalFormResponse, FormResponse, ResponseStatus, diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/cohort.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/cohort.spec.ts new file mode 100644 index 000000000..0c8bfb321 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/cohort.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { computeCohort, cohortLabel, sortCohortsDesc } from './cohort'; + +describe('computeCohort', () => { + it('returns YYYY-WNN for weekly frequency', () => { + // 2026-05-06 is a Wednesday → ISO week 19 of 2026 + expect(computeCohort('2026-05-06T12:00:00Z', 'weekly')).toBe('2026-W19'); + }); + + it('returns YYYY-MM for monthly frequency', () => { + expect(computeCohort('2026-05-06T12:00:00Z', 'monthly')).toBe('2026-05'); + }); + + it('handles ISO-week year boundary (Jan 1st belongs to W52/W53 of prior year)', () => { + // 2027-01-01 is a Friday → ISO week 53 of 2026 + expect(computeCohort('2027-01-01T12:00:00Z', 'weekly')).toBe('2026-W53'); + // But monthly still says 2027-01 — calendar months don't slide. + expect(computeCohort('2027-01-01T12:00:00Z', 'monthly')).toBe('2027-01'); + }); + + it('week numbers are zero-padded for lex sort', () => { + // Early-January stays in 2026 ISO-year if it falls before Thu + expect(computeCohort('2026-01-08T00:00:00Z', 'weekly')).toMatch(/^\d{4}-W\d{2}$/); + expect(computeCohort('2026-03-01T00:00:00Z', 'weekly')).toMatch(/^\d{4}-W\d{2}$/); + }); + + it('returns empty string for invalid timestamps', () => { + expect(computeCohort('not-a-date', 'weekly')).toBe(''); + expect(computeCohort('not-a-date', 'monthly')).toBe(''); + }); +}); + +describe('cohortLabel', () => { + it('labels the current week as "Diese Woche"', () => { + const now = new Date('2026-05-06T12:00:00Z'); // W19/2026 + expect(cohortLabel('2026-W19', 'weekly', now)).toBe('Diese Woche'); + }); + + it('labels the previous week as "Letzte Woche"', () => { + const now = new Date('2026-05-06T12:00:00Z'); // W19/2026 + expect(cohortLabel('2026-W18', 'weekly', now)).toBe('Letzte Woche'); + }); + + it('falls back to "KW NN / YYYY" for older weeks', () => { + const now = new Date('2026-05-06T12:00:00Z'); + expect(cohortLabel('2026-W10', 'weekly', now)).toBe('KW 10 / 2026'); + }); + + it('labels the current month as "Dieser Monat"', () => { + const now = new Date('2026-05-06T12:00:00Z'); + expect(cohortLabel('2026-05', 'monthly', now)).toBe('Dieser Monat'); + }); + + it('labels the previous month as "Letzter Monat"', () => { + const now = new Date('2026-05-06T12:00:00Z'); + expect(cohortLabel('2026-04', 'monthly', now)).toBe('Letzter Monat'); + }); + + it('falls back to "Monatsname YYYY" for older months', () => { + const now = new Date('2026-05-06T12:00:00Z'); + expect(cohortLabel('2026-01', 'monthly', now)).toBe('Januar 2026'); + expect(cohortLabel('2025-12', 'monthly', now)).toBe('Dezember 2025'); + }); + + it('returns the raw key for malformed cohorts', () => { + const now = new Date('2026-05-06T12:00:00Z'); + expect(cohortLabel('garbage', 'weekly', now)).toBe('garbage'); + expect(cohortLabel('2026-99', 'monthly', now)).toBe('2026-99'); + }); +}); + +describe('sortCohortsDesc', () => { + it('sorts weekly cohorts newest-first', () => { + expect(sortCohortsDesc(['2026-W18', '2026-W19', '2025-W52'])).toEqual([ + '2026-W19', + '2026-W18', + '2025-W52', + ]); + }); + + it('sorts monthly cohorts newest-first', () => { + expect(sortCohortsDesc(['2026-04', '2026-05', '2025-12'])).toEqual([ + '2026-05', + '2026-04', + '2025-12', + ]); + }); + + it('does not mutate the input', () => { + const input = ['2026-W10', '2026-W11']; + const out = sortCohortsDesc(input); + expect(input).toEqual(['2026-W10', '2026-W11']); + expect(out).not.toBe(input); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/cohort.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/cohort.ts new file mode 100644 index 000000000..50fca0572 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/cohort.ts @@ -0,0 +1,112 @@ +/** + * Cohort math for recurring forms (M10). + * + * A cohort is a string label that buckets a form-response into a time + * window matching the form's `recurrence.frequency`. Examples: + * + * weekly → "2026-W18" (ISO 8601 week-of-year) + * monthly → "2026-04" (calendar month) + * + * Why ISO weeks: the week-anchor is consistent across years (avoids the + * "week 53 vs week 1" mess of naive day-of-year math) and cohorts sort + * lexicographically into chronological order, which matters for the + * ResponsesView chip rendering. + * + * Pure — no Dexie, no Date.now() side effects. The caller passes in + * the submission ISO timestamp; tests can pin time without mocks. + */ + +export type RecurrenceFrequency = 'weekly' | 'monthly'; + +export function computeCohort(submittedAtIso: string, frequency: RecurrenceFrequency): string { + const date = new Date(submittedAtIso); + if (Number.isNaN(date.getTime())) { + // Defensive — bad timestamps should not poison the bucket index. + return ''; + } + switch (frequency) { + case 'weekly': + return isoWeekKey(date); + case 'monthly': + return monthKey(date); + } +} + +/** + * ISO 8601 year-week. The "ISO year" can differ from the calendar year + * around January 1st (e.g. 2027-01-01 may belong to 2026-W53). The + * algorithm is the canonical one from RFC 8601 §3.1.4 and matches what + * `Intl.DateTimeFormat` would produce on Node ≥ 22. + */ +function isoWeekKey(date: Date): string { + const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + // Make Sunday → 7 so the offset to Thursday is straightforward. + 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')}`; +} + +function monthKey(date: Date): string { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; + return `${year}-${String(month).padStart(2, '0')}`; +} + +/** + * Human-readable label for a cohort key. Used by the ResponsesView + * filter chips. Returns the raw key as fallback so unknown shapes + * (legacy responses with stale cohorts) still render cleanly. + */ +export function cohortLabel( + cohort: string, + frequency: RecurrenceFrequency, + now: Date = new Date() +): string { + if (frequency === 'weekly') { + const m = cohort.match(/^(\d{4})-W(\d{2})$/); + if (!m) return cohort; + const year = Number(m[1]); + const week = Number(m[2]); + const currentYear = now.getUTCFullYear(); + const currentWeek = Number(isoWeekKey(now).split('-W')[1]); + if (year === currentYear && week === currentWeek) return 'Diese Woche'; + if (year === currentYear && week === currentWeek - 1) return 'Letzte Woche'; + return `KW ${week} / ${year}`; + } + const m = cohort.match(/^(\d{4})-(\d{2})$/); + if (!m) return cohort; + const year = Number(m[1]); + const month = Number(m[2]); + if (month < 1 || month > 12) return cohort; + const monthNames = [ + 'Januar', + 'Februar', + 'März', + 'April', + 'Mai', + 'Juni', + 'Juli', + 'August', + 'September', + 'Oktober', + 'November', + 'Dezember', + ]; + const currentYear = now.getUTCFullYear(); + const currentMonth = now.getUTCMonth() + 1; + if (year === currentYear && month === currentMonth) return 'Dieser Monat'; + if (year === currentYear && month === currentMonth - 1) return 'Letzter Monat'; + return `${monthNames[month - 1]} ${year}`; +} + +/** + * Sort cohorts newest-first (descending) for the chip-bar. Lexicographic + * order works because both "YYYY-WNN" and "YYYY-MM" preserve the + * chronological ordering as strings. + */ +export function sortCohortsDesc(cohorts: readonly string[]): string[] { + return [...cohorts].sort((a, b) => b.localeCompare(a)); +} diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts index 264eb5da3..b31b5bc3a 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts @@ -39,6 +39,7 @@ function makeResponse(overrides: Partial = {}): FormResponse { submitterMeta: null, status: 'new', syncedTargets: [], + cohort: null, createdAt: '2026-04-28T12:00:00Z', updatedAt: '2026-04-28T12:00:00Z', ...overrides, diff --git a/apps/mana/apps/web/src/lib/modules/forms/queries.ts b/apps/mana/apps/web/src/lib/modules/forms/queries.ts index 6e5eb77d5..43b7377ea 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/queries.ts @@ -43,6 +43,7 @@ export function toFormResponse(local: LocalFormResponse): FormResponse { submitterMeta: local.submitterMeta ?? null, status: local.status, syncedTargets: local.syncedTargets ?? [], + cohort: local.cohort ?? null, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: deriveUpdatedAt(local), }; diff --git a/apps/mana/apps/web/src/lib/modules/forms/types.ts b/apps/mana/apps/web/src/lib/modules/forms/types.ts index 489b92ca2..81ad1145e 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/types.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/types.ts @@ -77,6 +77,23 @@ export interface AutoSyncConfig { mapping: Record; } +/** + * Recurring-form configuration (M10). When set, public-submit attaches + * a cohort label (`YYYY-WNN` for weekly, `YYYY-MM` for monthly) to + * each response so the ResponsesView can group + compare waves. + * + * The `sendVia` field anchors a future cron-worker (M10b) that will + * blast the share-link via broadcasts; today the field is recorded + * but the cron is not yet implemented. + */ +export interface RecurrenceConfig { + frequency: 'weekly' | 'monthly'; + /** ISO timestamp the recurrence started — informational only today. */ + startedAt?: string; + /** Future M10b — currently no-op. */ + sendVia?: 'broadcast' | 'manual'; +} + export interface FormSettings { submitButtonLabel: string; successMessage: string; @@ -88,6 +105,7 @@ export interface FormSettings { responseLimit?: number; autoSync?: AutoSyncConfig; responsesPublic?: boolean; + recurrence?: RecurrenceConfig; } export type FormStatus = 'draft' | 'published' | 'closed'; @@ -133,6 +151,8 @@ export interface LocalFormResponse extends BaseRecord { submitterMeta?: SubmitterMeta; status: ResponseStatus; syncedTargets?: SyncedTarget[]; + /** Recurrence bucket label (M10). Empty/undefined = ungrouped. */ + cohort?: string; } // ─── Domain Types ─────────────────────────────────────────── @@ -163,6 +183,7 @@ export interface FormResponse { submitterMeta: SubmitterMeta | null; status: ResponseStatus; syncedTargets: SyncedTarget[]; + cohort: string | null; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte index 0322af0f7..4c145c5b8 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte @@ -11,6 +11,7 @@ import { useFormResponses } from '../queries'; import { downloadResponsesCsv } from '../lib/csv'; import { runAutoSyncSweep } from '../lib/auto-sync'; + import { cohortLabel, sortCohortsDesc } from '../lib/cohort'; import { RESPONSE_STATUS_LABELS } from '../types'; import type { Form, FormResponse, ResponseStatus } from '../types'; import ResponseDetailModal from '../components/ResponseDetailModal.svelte'; @@ -51,19 +52,39 @@ type FilterTab = 'all' | ResponseStatus; let activeTab = $state('all'); + let activeCohort = $state(null); + + const recurrenceFrequency = $derived(form.settings.recurrence?.frequency ?? null); + + /** Distinct cohorts present on the response set, newest-first. */ + const cohorts = $derived( + recurrenceFrequency + ? sortCohortsDesc( + Array.from(new Set(responses.map((r) => r.cohort).filter((c): c is string => !!c))) + ) + : [] + ); + + const cohortFiltered = $derived( + activeCohort ? responses.filter((r) => r.cohort === activeCohort) : responses + ); const counts = $derived({ - all: responses.length, - new: responses.filter((r) => r.status === 'new').length, - reviewed: responses.filter((r) => r.status === 'reviewed').length, - archived: responses.filter((r) => r.status === 'archived').length, - spam: responses.filter((r) => r.status === 'spam').length, + all: cohortFiltered.length, + new: cohortFiltered.filter((r) => r.status === 'new').length, + reviewed: cohortFiltered.filter((r) => r.status === 'reviewed').length, + archived: cohortFiltered.filter((r) => r.status === 'archived').length, + spam: cohortFiltered.filter((r) => r.status === 'spam').length, }); const filtered = $derived( - activeTab === 'all' ? responses : responses.filter((r) => r.status === activeTab) + activeTab === 'all' ? cohortFiltered : cohortFiltered.filter((r) => r.status === activeTab) ); + function selectCohort(cohort: string | null) { + activeCohort = cohort; + } + let detailResponseId = $state(null); const detailResponse = $derived( detailResponseId ? responses.find((r) => r.id === detailResponseId) : null @@ -122,6 +143,32 @@

{autoSyncSummary}

{/if} + {#if recurrenceFrequency && cohorts.length > 0} +
+ + {#each cohorts as c (c)} + {@const cCount = responses.filter((r) => r.cohort === c).length} + + {/each} +
+ {/if} +