mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
cb9d79d2c9
commit
38e0ae2ff8
15 changed files with 470 additions and 11 deletions
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
95
apps/mana/apps/web/src/lib/modules/forms/lib/cohort.spec.ts
Normal file
95
apps/mana/apps/web/src/lib/modules/forms/lib/cohort.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
apps/mana/apps/web/src/lib/modules/forms/lib/cohort.ts
Normal file
112
apps/mana/apps/web/src/lib/modules/forms/lib/cohort.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue