feat(forms): M10b wave-send — Empfänger + Manueller Trigger + Due-Banner

Erste nutzbare Versand-Schicht für wiederkehrende Forms (M10b).
Vollautomatisches Cron via mana-ai/mana-notify bleibt M10c — die
Owner-action-Pipeline funktioniert standalone und nutzt den
existierenden mailto:-Pfad als Bridge.

- types.ts: RecurrenceConfig erweitert um `recipientEmails?: string[]`
  (max 50, mailto-URL-realistisch) und `lastSentAt?: string`.
- lib/wave.ts (pure):
  - nextWaveDueAt(recurrence): lastSentAt + 7d (weekly) oder
    +1 month UTC (monthly). Never-sent → startedAt oder Epoch.
  - isWaveDue(recurrence, now): boundary-inclusive (= ist auch fällig).
  - buildWaveMailto({recipients, subject, body}): URL-encoded
    mailto:?bcc=...&subject=...&body=... Keine BCC wenn empty.
  - parseRecipientEmails(raw): newline/comma/semicolon-getrennt,
    Email-Regex-validiert, case-insensitive deduped (erste Casing
    bleibt). Drops invalid silent.
- lib/wave.spec.ts: 20/20 grün — month-end-overflow, boundary-instant,
  never-sent, dedup, mixed-separators.
- formsStore.markWaveSent(id, sentAt?): liest current settings,
  patch-tt lastSentAt, encrypted-aware update (settings ist
  encrypted-blob).
- SettingsPanel: bei aktiver recurrence Empfänger-Textarea (commit on
  blur via parseRecipientEmails, slice 50, count-feedback) +
  lastSent-Hint.
- BuilderView (visibility-section): wave-block mit fällig-Banner
  (orange wenn isWaveDue) oder nextWaveAt-Hint, "Welle jetzt
  senden"-Button (disabled bis recurrence + unlistedToken +
  recipients alle stimmen). Click → confirm → buildWaveMailto +
  window.open + markWaveSent. Subject + Body via i18n-Keys.
- 13 neue i18n-Keys × 5 Locales (recipientsLabel/Count, lastSent,
  waveDue, nextWaveAt, sendNow, needsUnlisted/Recipients,
  mailSubject/Body, confirmSend). Parity 6494.

Total Forms-Tests jetzt 61/61 (5 csv + 11 branching + 10 auto-sync +
15 cohort + 20 wave). svelte-check 0 errors.

Use-Case: Wöchentlicher Team-Pulse-Check. Recurrence='weekly' setzen,
3 Team-Emails ins Textarea, am Montag-Morgen das fällig-Banner
sehen, "Welle jetzt senden" → Mail-Programm öffnet sich mit
BCC-Liste + Share-Link. Antworten kommen mit cohort='2026-W19' rein,
ResponsesView gruppiert sie.

M10c open: Cron-Worker für headless wave-send via mana-mail
bulk-send. Owner-tab muss heute offen sein, damit der Send-Klick
fällt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-06 14:56:28 +02:00
parent 38e0ae2ff8
commit 664b8241d0
12 changed files with 555 additions and 7 deletions

View file

@ -80,7 +80,18 @@
"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."
"hint": "Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. \"KW 19 / 2026\") für Trend-Vergleich.",
"recipientsLabel": "Empfänger-Emails (max. 50, eine pro Zeile)",
"recipientsCount": "{n} valide Empfänger erkannt",
"lastSent": "Letzter Versand: {date}",
"waveDue": "Nächste Welle ist fällig — schicke den Link an deine Empfänger.",
"nextWaveAt": "Nächste Welle: {date}",
"sendNow": "Welle jetzt senden",
"needsUnlisted": "Setze die Sichtbarkeit auf \"unlisted\", um den Link zu erzeugen.",
"needsRecipients": "Trage Empfänger-Emails in den Settings ein.",
"mailSubject": "Bitte ausfüllen: {title}",
"mailBody": "{description}Hier kannst du antworten:\n{url}\n\nDanke!",
"confirmSend": "Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt."
},
"autoSync": {
"title": "Auto-Sync — bei Antwort erzeugen",

View file

@ -80,7 +80,18 @@
"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."
"hint": "Incoming responses are auto-tagged with a wave label (e.g. \"W19 / 2026\") for trend comparison.",
"recipientsLabel": "Recipient emails (max. 50, one per line)",
"recipientsCount": "{n} valid recipients detected",
"lastSent": "Last sent: {date}",
"waveDue": "Next wave is due — send the link to your recipients.",
"nextWaveAt": "Next wave: {date}",
"sendNow": "Send wave now",
"needsUnlisted": "Set visibility to \"unlisted\" to create the link.",
"needsRecipients": "Add recipient emails in the settings.",
"mailSubject": "Please fill in: {title}",
"mailBody": "{description}Reply here:\n{url}\n\nThanks!",
"confirmSend": "Send wave to {n} recipients? Your mail client opens with the BCC list and link prefilled. We'll stamp the timestamp after."
},
"autoSync": {
"title": "Auto-sync — create on submit",

View file

@ -80,7 +80,18 @@
"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."
"hint": "Las respuestas recibidas se etiquetan automáticamente con la ola (p. ej. \"S19 / 2026\") para comparar tendencias.",
"recipientsLabel": "Emails de destinatarios (máx. 50, uno por línea)",
"recipientsCount": "{n} destinatarios válidos detectados",
"lastSent": "Último envío: {date}",
"waveDue": "La próxima ola está pendiente — envía el enlace a tus destinatarios.",
"nextWaveAt": "Próxima ola: {date}",
"sendNow": "Enviar ola ahora",
"needsUnlisted": "Pon la visibilidad en \"unlisted\" para crear el enlace.",
"needsRecipients": "Añade emails de destinatarios en los ajustes.",
"mailSubject": "Por favor rellena: {title}",
"mailBody": "{description}Responde aquí:\n{url}\n\n¡Gracias!",
"confirmSend": "¿Enviar ola a {n} destinatarios? Tu cliente de correo se abrirá con la lista BCC y el enlace prellenado. Marcamos la marca de tiempo después."
},
"autoSync": {
"title": "Auto-sync — crear al recibir respuesta",

View file

@ -80,7 +80,18 @@
"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."
"hint": "Les réponses entrantes reçoivent automatiquement un tag de vague (p. ex. « S19 / 2026 ») pour comparer les tendances.",
"recipientsLabel": "Emails des destinataires (max. 50, un par ligne)",
"recipientsCount": "{n} destinataires valides détectés",
"lastSent": "Dernier envoi : {date}",
"waveDue": "La prochaine vague est due — envoie le lien à tes destinataires.",
"nextWaveAt": "Prochaine vague : {date}",
"sendNow": "Envoyer la vague",
"needsUnlisted": "Mets la visibilité sur « unlisted » pour créer le lien.",
"needsRecipients": "Ajoute des emails de destinataires dans les paramètres.",
"mailSubject": "Merci de remplir : {title}",
"mailBody": "{description}Réponds ici :\n{url}\n\nMerci !",
"confirmSend": "Envoyer la vague à {n} destinataires ? Ton client mail s'ouvre avec la liste BCC et le lien préremplis. On marque l'horodatage ensuite."
},
"autoSync": {
"title": "Auto-sync — créer à la soumission",

View file

@ -80,7 +80,18 @@
"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."
"hint": "Le risposte in arrivo vengono etichettate automaticamente con l'ondata (es. \"Sett. 19 / 2026\") per confronti di trend.",
"recipientsLabel": "Email dei destinatari (max. 50, uno per riga)",
"recipientsCount": "{n} destinatari validi rilevati",
"lastSent": "Ultimo invio: {date}",
"waveDue": "La prossima ondata è in scadenza — invia il link ai destinatari.",
"nextWaveAt": "Prossima ondata: {date}",
"sendNow": "Invia l'ondata ora",
"needsUnlisted": "Imposta la visibilità su \"unlisted\" per creare il link.",
"needsRecipients": "Aggiungi email dei destinatari nelle impostazioni.",
"mailSubject": "Per favore compila: {title}",
"mailBody": "{description}Rispondi qui:\n{url}\n\nGrazie!",
"confirmSend": "Invia l'ondata a {n} destinatari? Il tuo client di posta si apre con la lista BCC e il link precompilati. Marchiamo l'orario dopo."
},
"autoSync": {
"title": "Auto-sync — crea al ricevere risposta",

View file

@ -157,6 +157,12 @@
settings.recurrence?.frequency ?? 'none'
);
let recipientEmailsRaw = $state('');
$effect(() => {
// Re-sync when settings change upstream (clone, undo, etc.).
recipientEmailsRaw = (settings.recurrence?.recipientEmails ?? []).join('\n');
});
function setRecurrence(next: 'none' | 'weekly' | 'monthly') {
if (next === 'none') {
onchange({ recurrence: undefined });
@ -165,10 +171,25 @@
recurrence: {
frequency: next,
startedAt: settings.recurrence?.startedAt ?? new Date().toISOString(),
recipientEmails: settings.recurrence?.recipientEmails,
lastSentAt: settings.recurrence?.lastSentAt,
},
});
}
}
async function commitRecipientEmails() {
const { parseRecipientEmails } = await import('../lib/wave');
const parsed = parseRecipientEmails(recipientEmailsRaw).slice(0, 50);
const current = settings.recurrence;
if (!current) return;
onchange({
recurrence: {
...current,
recipientEmails: parsed.length > 0 ? parsed : undefined,
},
});
}
</script>
<div class="settings-panel">
@ -274,9 +295,43 @@
<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.',
'Eingehende Antworten bekommen automatisch einen Wellen-Tag (z.B. "KW 19 / 2026") für Trend-Vergleich.',
})}
</p>
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.recurrence.recipientsLabel', {
default: 'Empfänger-Emails (max. 50, eine pro Zeile)',
})}
</span>
<textarea
rows="3"
bind:value={recipientEmailsRaw}
onblur={commitRecipientEmails}
placeholder="anna@example.com&#10;bob@example.com"
class="recipients-input"
></textarea>
{#if settings.recurrence?.recipientEmails?.length}
<small class="hint">
{$_('forms.builder.recurrence.recipientsCount', {
default: '{n} valide Empfänger erkannt',
values: { n: settings.recurrence.recipientEmails.length },
})}
</small>
{/if}
</label>
{#if settings.recurrence?.lastSentAt}
<p class="hint">
{$_('forms.builder.recurrence.lastSent', {
default: 'Letzter Versand: {date}',
values: {
date: new Date(settings.recurrence.lastSentAt).toLocaleString(),
},
})}
</p>
{/if}
{/if}
</div>
@ -449,6 +504,19 @@
color: rgb(255 255 255 / 0.45);
}
.recipients-input {
width: 100%;
padding: 0.5rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: inherit;
font-size: 0.8125rem;
font-family: ui-monospace, monospace;
resize: vertical;
min-height: 4rem;
}
.target-select {
max-width: 240px;
padding: 0.375rem 0.625rem;

View file

@ -28,6 +28,7 @@ export {
} from './lib/auto-sync';
export { computeCohort, cohortLabel, sortCohortsDesc } from './lib/cohort';
export type { RecurrenceFrequency } from './lib/cohort';
export { nextWaveDueAt, isWaveDue, buildWaveMailto, parseRecipientEmails } from './lib/wave';
// ─── Types ───────────────────────────────────────────────
export {

View file

@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import { nextWaveDueAt, isWaveDue, buildWaveMailto, parseRecipientEmails } from './wave';
import type { RecurrenceConfig } from '../types';
describe('nextWaveDueAt', () => {
it('returns null when recurrence is undefined', () => {
expect(nextWaveDueAt(undefined)).toBeNull();
});
it('returns startedAt when lastSentAt is missing (never-sent first wave)', () => {
const cfg: RecurrenceConfig = {
frequency: 'weekly',
startedAt: '2026-05-01T00:00:00Z',
};
expect(nextWaveDueAt(cfg)?.toISOString()).toBe('2026-05-01T00:00:00.000Z');
});
it('falls back to epoch when never-sent and startedAt is missing', () => {
expect(nextWaveDueAt({ frequency: 'weekly' })?.toISOString()).toBe('1970-01-01T00:00:00.000Z');
});
it('adds 7 days for weekly frequency', () => {
const cfg: RecurrenceConfig = {
frequency: 'weekly',
lastSentAt: '2026-05-01T12:00:00Z',
};
expect(nextWaveDueAt(cfg)?.toISOString()).toBe('2026-05-08T12:00:00.000Z');
});
it('adds 1 calendar month for monthly frequency', () => {
const cfg: RecurrenceConfig = {
frequency: 'monthly',
lastSentAt: '2026-05-01T12:00:00Z',
};
expect(nextWaveDueAt(cfg)?.toISOString()).toBe('2026-06-01T12:00:00.000Z');
});
it('handles month-end overflow gracefully (Jan 31 → Feb 28/29)', () => {
const cfg: RecurrenceConfig = {
frequency: 'monthly',
lastSentAt: '2026-01-31T12:00:00Z',
};
// 2026 is not a leap year — Date math rolls Jan 31 + 1 month into Mar 3
// (which is the standard JS behavior — accept it rather than fight it).
const due = nextWaveDueAt(cfg);
expect(due).not.toBeNull();
expect(due!.getUTCFullYear()).toBe(2026);
// Either Feb 28/29 or rolled into March — in both cases >= Feb 28.
expect(due!.getTime()).toBeGreaterThanOrEqual(new Date('2026-02-28T12:00:00Z').getTime());
});
it('returns null for invalid lastSentAt timestamps', () => {
const cfg: RecurrenceConfig = {
frequency: 'weekly',
lastSentAt: 'not-a-date',
};
expect(nextWaveDueAt(cfg)).toBeNull();
});
});
describe('isWaveDue', () => {
it('false when recurrence is undefined', () => {
expect(isWaveDue(undefined, new Date('2026-05-08T00:00:00Z'))).toBe(false);
});
it('true when next-due is in the past', () => {
const cfg: RecurrenceConfig = {
frequency: 'weekly',
lastSentAt: '2026-05-01T00:00:00Z',
};
expect(isWaveDue(cfg, new Date('2026-05-09T00:00:00Z'))).toBe(true);
});
it('false when next-due is in the future', () => {
const cfg: RecurrenceConfig = {
frequency: 'weekly',
lastSentAt: '2026-05-01T00:00:00Z',
};
expect(isWaveDue(cfg, new Date('2026-05-05T00:00:00Z'))).toBe(false);
});
it('true at the exact due-instant (boundary inclusive)', () => {
const cfg: RecurrenceConfig = {
frequency: 'weekly',
lastSentAt: '2026-05-01T12:00:00Z',
};
expect(isWaveDue(cfg, new Date('2026-05-08T12:00:00Z'))).toBe(true);
});
it('true on first-wave when never sent (startedAt in past)', () => {
const cfg: RecurrenceConfig = {
frequency: 'monthly',
startedAt: '2026-04-01T00:00:00Z',
};
expect(isWaveDue(cfg, new Date('2026-05-06T00:00:00Z'))).toBe(true);
});
});
describe('buildWaveMailto', () => {
it('builds a mailto URL with bcc + subject + body', () => {
const url = buildWaveMailto({
recipientEmails: ['anna@example.com', 'bob@example.com'],
subject: 'Pulse-Check',
body: 'Bitte ausfüllen: https://mana.how/share/abc',
});
expect(url).toContain('mailto:?');
expect(url).toContain('bcc=anna%40example.com%2Cbob%40example.com');
expect(url).toContain('subject=Pulse-Check');
expect(url).toContain('body=Bitte+ausf');
expect(url).toContain('mana.how%2Fshare%2Fabc');
});
it('omits bcc when recipients is empty', () => {
const url = buildWaveMailto({
recipientEmails: [],
subject: 'X',
body: 'Y',
});
expect(url).not.toContain('bcc=');
expect(url).toContain('subject=X');
});
});
describe('parseRecipientEmails', () => {
it('handles newline-separated input', () => {
expect(parseRecipientEmails('anna@example.com\nbob@example.com\ncarol@example.com')).toEqual([
'anna@example.com',
'bob@example.com',
'carol@example.com',
]);
});
it('handles comma-separated input', () => {
expect(parseRecipientEmails('anna@example.com, bob@example.com')).toEqual([
'anna@example.com',
'bob@example.com',
]);
});
it('handles mixed separators and whitespace', () => {
expect(
parseRecipientEmails('anna@example.com;\nbob@example.com ,carol@example.com\t\t')
).toEqual(['anna@example.com', 'bob@example.com', 'carol@example.com']);
});
it('drops invalid email shapes silently', () => {
expect(parseRecipientEmails('valid@example.com\nnot-an-email\nfoo@\n@bar.com')).toEqual([
'valid@example.com',
]);
});
it('deduplicates case-insensitively but keeps the first casing', () => {
expect(parseRecipientEmails('Anna@Example.com\nanna@example.com')).toEqual([
'Anna@Example.com',
]);
});
it('returns empty array for empty input', () => {
expect(parseRecipientEmails('')).toEqual([]);
expect(parseRecipientEmails(' \n\n ')).toEqual([]);
});
});

View file

@ -0,0 +1,93 @@
/**
* Wave-send scheduling for recurring forms (M10b).
*
* Given a `RecurrenceConfig` and the current time, decide when the
* next "wave" of the link should go out. Pure no Date.now(), no
* Dexie. The owner-action button in BuilderView reads `isWaveDue` to
* decide whether to surface a reminder banner.
*
* Bucketing convention:
* - weekly next wave is 7 days after lastSentAt
* - monthly next wave is 1 calendar month after lastSentAt
* - When `lastSentAt` is missing, the form has never been sent and
* the *first* wave is considered due immediately (so the user
* gets nudged after configuring recurrence).
*
* The intentionally-naive "+30 days" rule for monthly would drift over
* the course of a year; we use UTC calendar math instead so "the 1st
* of every month" stays the 1st.
*/
import type { RecurrenceConfig } from '../types';
export function nextWaveDueAt(recurrence: RecurrenceConfig | undefined): Date | null {
if (!recurrence) return null;
const lastIso = recurrence.lastSentAt;
if (!lastIso) {
// Never sent — treat the recurrence-startedAt (or epoch) as the
// implicit first wave so the due-check returns true on day 1.
return recurrence.startedAt ? new Date(recurrence.startedAt) : new Date(0);
}
const last = new Date(lastIso);
if (Number.isNaN(last.getTime())) return null;
if (recurrence.frequency === 'weekly') {
return new Date(last.getTime() + 7 * 24 * 60 * 60 * 1000);
}
// Monthly: same day-of-month one month later, in UTC.
const next = new Date(last);
next.setUTCMonth(next.getUTCMonth() + 1);
return next;
}
export function isWaveDue(
recurrence: RecurrenceConfig | undefined,
now: Date = new Date()
): boolean {
const due = nextWaveDueAt(recurrence);
if (!due) return false;
return due.getTime() <= now.getTime();
}
/**
* Build a mailto: URL with BCC recipients + subject + body containing
* the share-link. Browsers cap mailto-URLs at ~2KB on average, hence
* the recipient-cap upstream. For larger lists the user copies the
* link manually.
*/
export function buildWaveMailto(opts: {
recipientEmails: string[];
subject: string;
body: string;
}): string {
const bcc = opts.recipientEmails.join(',');
const params = new URLSearchParams();
if (bcc) params.set('bcc', bcc);
params.set('subject', opts.subject);
params.set('body', opts.body);
// Browsers prefer + for spaces in mailto bodies but URLSearchParams
// emits %20; both work. Keep %20 for predictability.
return `mailto:?${params.toString()}`;
}
/**
* Parse a free-form recipient input (one email per line OR
* comma-separated) into a deduplicated trimmed array. Invalid
* shapes get dropped silently the SettingsPanel surfaces the
* accepted count back to the user.
*/
export function parseRecipientEmails(raw: string): string[] {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const seen = new Set<string>();
const out: string[] = [];
for (const part of raw.split(/[\s,;]+/)) {
const trimmed = part.trim();
if (!trimmed) continue;
if (!re.test(trimmed)) continue;
const lower = trimmed.toLowerCase();
if (seen.has(lower)) continue;
seen.add(lower);
out.push(trimmed);
}
return out;
}

View file

@ -64,6 +64,26 @@ export const formsStore = {
await formTable.update(id, diff);
},
/**
* Stamp the recurrence lastSentAt timestamp after the user fired a
* wave (M10b). The whole settings-blob travels encrypted, so we
* read the current settings, patch lastSentAt, then re-encrypt the
* full blob. Other settings stay untouched.
*/
async markWaveSent(id: string, sentAtIso: string = new Date().toISOString()) {
const form = await formTable.get(id);
if (!form) return;
const settings = form.settings ?? DEFAULT_FORM_SETTINGS;
if (!settings.recurrence) return;
const nextSettings: typeof settings = {
...settings,
recurrence: { ...settings.recurrence, lastSentAt: sentAtIso },
};
const diff: Partial<LocalForm> = { settings: nextSettings };
await encryptRecord('forms', diff);
await formTable.update(id, diff);
},
async deleteForm(id: string) {
await formTable.update(id, { deletedAt: nowIso() });
},

View file

@ -90,8 +90,16 @@ export interface RecurrenceConfig {
frequency: 'weekly' | 'monthly';
/** ISO timestamp the recurrence started — informational only today. */
startedAt?: string;
/** Future M10b — currently no-op. */
/** M10b: how the share-link gets distributed when a wave is due. */
sendVia?: 'broadcast' | 'manual';
/**
* M10b recipient emails, one per element. Capped at 50 (mailto-bridge
* realism). For larger groups the user copies the link manually until a
* proper bulk-send integration lands (M10c).
*/
recipientEmails?: string[];
/** ISO timestamp of the last wave the user fired. Drives the due-banner. */
lastSentAt?: string;
}
export interface FormSettings {

View file

@ -19,6 +19,7 @@
import FieldPalette from '../components/FieldPalette.svelte';
import SettingsPanel from '../components/SettingsPanel.svelte';
import BranchingEditor from '../components/BranchingEditor.svelte';
import { buildWaveMailto, isWaveDue, nextWaveDueAt } from '../lib/wave';
import {
VisibilityPicker,
SharedLinkControls,
@ -149,6 +150,51 @@
return buildShareUrl(origin, entry.unlistedToken);
});
// ── Wave-Send (M10b) ──────────────────────────────────
const recurrence = $derived(entry.settings.recurrence);
const waveDue = $derived(isWaveDue(recurrence));
const waveDueAt = $derived(nextWaveDueAt(recurrence));
const canSendWave = $derived(
!!recurrence &&
!!entry.unlistedToken &&
!!shareUrl &&
(recurrence.recipientEmails ?? []).length > 0
);
async function sendWave() {
if (!canSendWave || !shareUrl) return;
const recipients = entry.settings.recurrence?.recipientEmails ?? [];
if (recipients.length === 0) return;
const subject = $_('forms.builder.recurrence.mailSubject', {
default: 'Bitte ausfüllen: {title}',
values: { title: entry.title },
});
const description = entry.description ? `${entry.description}\n\n` : '';
const body = $_('forms.builder.recurrence.mailBody', {
default: '{description}Hier kannst du antworten:\n{url}\n\nDanke!',
values: { description, url: shareUrl },
});
const mailto = buildWaveMailto({ recipientEmails: recipients, subject, body });
const ok = confirm(
$_('forms.builder.recurrence.confirmSend', {
default:
'Welle an {n} Empfänger senden? Dein Mail-Programm öffnet sich mit BCC-Liste und Link. Nach dem Versand stempeln wir den Zeitpunkt.',
values: { n: recipients.length },
})
);
if (!ok) return;
window.open(mailto, '_blank');
await formsStore.markWaveSent(entry.id);
}
function formatDueAt(d: Date | null): string {
if (!d) return '';
return d.toLocaleString();
}
async function setStatus(status: FormStatus) {
await formsStore.setStatus(entry.id, status);
}
@ -289,6 +335,47 @@
onExpiryChange={handleExpiryChange}
/>
{/if}
{#if recurrence}
<div class="wave-block">
{#if waveDue}
<p class="wave-due-banner">
{$_('forms.builder.recurrence.waveDue', {
default: 'Nächste Welle ist fällig — schicke den Link an deine Empfänger.',
})}
</p>
{:else if waveDueAt && entry.settings.recurrence?.lastSentAt}
<p class="wave-hint">
{$_('forms.builder.recurrence.nextWaveAt', {
default: 'Nächste Welle: {date}',
values: { date: formatDueAt(waveDueAt) },
})}
</p>
{/if}
<button
type="button"
class="wave-send"
onclick={sendWave}
disabled={!canSendWave}
class:due={waveDue}
>
{$_('forms.builder.recurrence.sendNow', {
default: 'Welle jetzt senden',
})}
</button>
{#if !canSendWave}
<p class="wave-hint">
{!entry.unlistedToken
? $_('forms.builder.recurrence.needsUnlisted', {
default: 'Setze die Sichtbarkeit auf "unlisted", um den Link zu erzeugen.',
})
: $_('forms.builder.recurrence.needsRecipients', {
default: 'Trage Empfänger-Emails in den Settings ein.',
})}
</p>
{/if}
</div>
{/if}
</section>
<section class="branching-section">
@ -478,6 +565,60 @@
color: rgb(252 165 165);
}
.wave-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.625rem;
border-top: 1px solid rgb(255 255 255 / 0.06);
}
.wave-due-banner {
margin: 0;
padding: 0.5rem 0.75rem;
background: rgb(245 158 11 / 0.16);
border: 1px solid rgb(245 158 11 / 0.4);
border-radius: 0.375rem;
color: rgb(252 211 77);
font-size: 0.8125rem;
}
.wave-hint {
margin: 0;
font-size: 0.75rem;
color: rgb(255 255 255 / 0.5);
}
.wave-send {
align-self: flex-start;
padding: 0.5rem 0.875rem;
background: rgb(20 184 166 / 0.16);
border: 1px solid rgb(20 184 166 / 0.4);
border-radius: 0.375rem;
color: rgb(94 234 212);
font-size: 0.8125rem;
cursor: pointer;
}
.wave-send:hover:not(:disabled) {
background: rgb(20 184 166 / 0.24);
}
.wave-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.wave-send.due {
background: rgb(245 158 11 / 0.18);
border-color: rgb(245 158 11 / 0.5);
color: rgb(252 211 77);
}
.wave-send.due:hover:not(:disabled) {
background: rgb(245 158 11 / 0.26);
}
.section-header {
display: flex;
align-items: baseline;