mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
38e0ae2ff8
commit
664b8241d0
12 changed files with 555 additions and 7 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
162
apps/mana/apps/web/src/lib/modules/forms/lib/wave.spec.ts
Normal file
162
apps/mana/apps/web/src/lib/modules/forms/lib/wave.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
93
apps/mana/apps/web/src/lib/modules/forms/lib/wave.ts
Normal file
93
apps/mana/apps/web/src/lib/modules/forms/lib/wave.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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() });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue