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 } : {}),

View file

@ -316,7 +316,11 @@ async function buildFormBlob(recordId: string): Promise<Record<string, unknown>>
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<string, unknown> = {
title: decrypted.title,
description: decrypted.description ?? null,
fields: decrypted.fields ?? [],
@ -326,4 +330,8 @@ async function buildFormBlob(recordId: string): Promise<Record<string, unknown>>
successMessage: settings.successMessage,
},
};
if (settings.recurrence?.frequency) {
blob.recurrence = { frequency: settings.recurrence.frequency };
}
return blob;
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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(),
},
});
}
}
</script>
<div class="settings-panel">
@ -230,6 +248,38 @@
</span>
</label>
<div class="recurrence-block">
<p class="block-title">
{$_('forms.builder.recurrence.title', { default: 'Wiederkehr — Antworten in Wellen' })}
</p>
<select
class="target-select"
value={recurrenceFrequency}
onchange={(e) =>
setRecurrence(
(e.currentTarget as HTMLSelectElement).value as 'none' | 'weekly' | 'monthly'
)}
>
<option value="none">
{$_('forms.builder.recurrence.none', { default: 'Einmalig' })}
</option>
<option value="weekly">
{$_('forms.builder.recurrence.weekly', { default: 'Wöchentlich' })}
</option>
<option value="monthly">
{$_('forms.builder.recurrence.monthly', { default: 'Monatlich' })}
</option>
</select>
{#if recurrenceFrequency !== 'none'}
<p class="hint">
{$_('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.',
})}
</p>
{/if}
</div>
<div class="auto-sync-block">
<p class="block-title">
{$_('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;

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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));
}

View file

@ -39,6 +39,7 @@ function makeResponse(overrides: Partial<FormResponse> = {}): FormResponse {
submitterMeta: null,
status: 'new',
syncedTargets: [],
cohort: null,
createdAt: '2026-04-28T12:00:00Z',
updatedAt: '2026-04-28T12:00:00Z',
...overrides,

View file

@ -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),
};

View file

@ -77,6 +77,23 @@ export interface AutoSyncConfig {
mapping: Record<string, string>;
}
/**
* 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;
}

View file

@ -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<FilterTab>('all');
let activeCohort = $state<string | null>(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<string | null>(null);
const detailResponse = $derived(
detailResponseId ? responses.find((r) => r.id === detailResponseId) : null
@ -122,6 +143,32 @@
<p class="auto-sync-toast">{autoSyncSummary}</p>
{/if}
{#if recurrenceFrequency && cohorts.length > 0}
<div class="cohort-bar" role="group" aria-label="Wellen">
<button
type="button"
class="cohort-chip"
class:active={activeCohort === null}
onclick={() => selectCohort(null)}
>
{$_('forms.responses.cohort.all', { default: 'Alle Wellen' })}
<span class="cohort-count">{responses.length}</span>
</button>
{#each cohorts as c (c)}
{@const cCount = responses.filter((r) => r.cohort === c).length}
<button
type="button"
class="cohort-chip"
class:active={activeCohort === c}
onclick={() => selectCohort(c)}
>
{cohortLabel(c, recurrenceFrequency)}
<span class="cohort-count">{cCount}</span>
</button>
{/each}
</div>
{/if}
<nav class="tabs" role="tablist">
{#each [['all', counts.all], ['new', counts.new], ['reviewed', counts.reviewed], ['archived', counts.archived], ['spam', counts.spam]] as const as [tab, count]}
<button
@ -271,6 +318,43 @@
font-size: 0.8125rem;
}
.cohort-bar {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.cohort-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 999px;
color: rgb(255 255 255 / 0.55);
font-size: 0.75rem;
cursor: pointer;
}
.cohort-chip:hover {
background: rgb(255 255 255 / 0.07);
}
.cohort-chip.active {
background: rgb(20 184 166 / 0.18);
color: rgb(94 234 212);
border-color: rgb(20 184 166 / 0.4);
}
.cohort-count {
min-width: 0.875rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-size: 0.6875rem;
opacity: 0.7;
}
.tabs {
display: flex;
gap: 0.25rem;