diff --git a/.changeset/optional-decline-note.md b/.changeset/optional-decline-note.md
new file mode 100644
index 00000000..fa3d3a67
--- /dev/null
+++ b/.changeset/optional-decline-note.md
@@ -0,0 +1,7 @@
+---
+'@when/web': patch
+'@when/worker': patch
+'@when/cli': patch
+---
+
+Let hosts add an optional note of up to 500 characters when declining an individual appointment request. Save it with the decline action and show it in decline emails and appointment details without changing the general note or bulk decline behavior. Use concise note dialogs and consistent live character counts across notes and reasons, retaining existing limits and requiredness.
diff --git a/apps/web/e2e/approval.e2e.ts b/apps/web/e2e/approval.e2e.ts
index 377a2107..e9b51b8e 100644
--- a/apps/web/e2e/approval.e2e.ts
+++ b/apps/web/e2e/approval.e2e.ts
@@ -37,15 +37,28 @@ test('the host declines a request', async ({ page }) => {
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
- await page.getByRole('button', { name: 'Decline' }).click();
+ await page.getByRole('button', { name: 'Decline', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
+ await expect(dialog).toBeVisible();
+ const note = dialog.getByLabel('Note (optional)', { exact: true });
+ await expect(note).toHaveAttribute('name', 'decline_note');
+ await expect(note).toHaveAttribute('maxlength', '500');
+ await expect(note).not.toHaveAttribute('required');
+ await expect(note).toHaveValue('');
+ await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
+ await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
const row = await readAppointment(appointment.id);
expect(row.status).toBe('declined');
- expect(parseActionLog(row.action_log).at(-1)).toMatchObject({
+ const decline = parseActionLog(row.action_log).at(-1);
+ expect(decline).toMatchObject({
action: 'decline',
actor: 'host'
});
+ expect(decline?.payload?.note).toBeUndefined();
expect(readQueuedWorkflows(`${appointment.id}:declined:${row.ics_sequence}`)).toHaveLength(1);
});
diff --git a/apps/web/e2e/cancel.e2e.ts b/apps/web/e2e/cancel.e2e.ts
index 71a8c71f..b398fe5f 100644
--- a/apps/web/e2e/cancel.e2e.ts
+++ b/apps/web/e2e/cancel.e2e.ts
@@ -18,13 +18,19 @@ test('a guest cancels a confirmed appointment', async ({ page }) => {
await page.goto(`/appointment/${appointment.id}?token=${appointment.cancel_token}`);
await page.getByRole('button', { name: 'Appointment actions' }).click();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
- await page.locator('textarea[name="reason"]').fill('Something came up');
- await page.getByRole('button', { name: 'Submit' }).click();
+ const dialog = page.getByRole('dialog', { name: 'Cancel appointment', exact: true });
+ const reason = dialog.getByLabel('Reason (required)', { exact: true });
+ await expect(reason).toHaveAttribute('required', '');
+ await expect(reason).toHaveAttribute('maxlength', '500');
+ const initialReason = await reason.inputValue();
+ await expect(dialog.getByText(`${initialReason.length}/500`, { exact: true })).toBeVisible();
+ await reason.fill('Something came up');
+ await expect(dialog.getByText('17/500', { exact: true })).toBeVisible();
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Cancelled', exact: true })).toBeVisible();
- // The reason never appears on the page, so the row is the only place it can be
- // proved to have survived the form post.
+ // The action explanation is stored in history, not the general appointment note.
const row = await readAppointment(appointment.id);
expect(row.status).toBe('cancelled');
expect(parseActionLog(row.action_log).at(-1)).toMatchObject({
diff --git a/apps/web/e2e/decline-note.e2e.ts b/apps/web/e2e/decline-note.e2e.ts
new file mode 100644
index 00000000..84274a93
--- /dev/null
+++ b/apps/web/e2e/decline-note.e2e.ts
@@ -0,0 +1,241 @@
+import { expect, test } from '@playwright/test';
+import { appendActionLogSql, openDb, parseActionLog } from '@when/db';
+import { signInAsAdmin } from './support/auth.ts';
+import { APP_DB_PATH, readAppointment, readQueuedWorkflows } from './support/database.ts';
+import { seedAppointment } from './support/seed.ts';
+
+async function seedWithPrivateNote() {
+ const appointment = await seedAppointment({ status: 'pending' });
+ const db = openDb(APP_DB_PATH);
+ try {
+ await db
+ .updateTable('appointments')
+ .set({
+ note: `Private appointment note ${appointment.id}`,
+ action_log: appendActionLogSql({
+ action: 'edit',
+ actor: 'host',
+ at: new Date().toISOString(),
+ payload: { metadata: { changes: ['note_updated'] } }
+ })
+ })
+ .where('id', '=', appointment.id)
+ .execute();
+ } finally {
+ await db.destroy();
+ }
+ const seeded = await readAppointment(appointment.id);
+ return seeded;
+}
+
+test('a decline note is stored separately and rendered as plain text to host and guest', async ({
+ page,
+ browser
+}) => {
+ const appointment = await seedWithPrivateNote();
+ const note = 'Sorry, not this time.\n';
+ await signInAsAdmin(page);
+ await page.goto(`/appointment/${appointment.id}`);
+ const origin = new URL(page.url()).origin;
+ const guest = await browser.newContext();
+ try {
+ const guestPage = await guest.newPage();
+ const guestUrl = `${origin}/appointment/${appointment.id}?token=${appointment.cancel_token}`;
+ await guestPage.goto(guestUrl);
+ await expect(guestPage.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
+ await expect(guestPage.getByRole('button', { name: 'Decline', exact: true })).toHaveCount(0);
+ const forbidden = await guest.request.post(
+ `${origin}/admin/appointment/${appointment.id}?/decline&token=${appointment.cancel_token}&role=host`,
+ {
+ headers: { origin, accept: 'application/json', 'x-sveltekit-action': 'true' },
+ form: {
+ decline_note: note,
+ token: appointment.cancel_token,
+ role: 'host',
+ isAdmin: 'true'
+ },
+ maxRedirects: 0
+ }
+ );
+ expect(forbidden.status()).toBe(403);
+ const afterForbidden = await readAppointment(appointment.id);
+ expect(afterForbidden).toEqual(appointment);
+
+ await page.getByRole('button', { name: 'Decline', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
+ await dialog.getByLabel('Note (optional)', { exact: true }).fill(note);
+ await expect(dialog.getByText(`${note.length}/500`, { exact: true })).toBeVisible();
+ await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
+ const hostNote = page.locator('.state-note-line');
+ await expect(hostNote).toBeVisible();
+ await expect(hostNote.locator('.state-note-text')).toHaveCSS('white-space', 'pre-wrap');
+ const hostText = await hostNote.textContent();
+ expect(hostText).toContain(note);
+ await expect(hostNote.locator('b, script')).toHaveCount(0);
+ await expect(page.getByText(appointment.note!, { exact: true })).toBeVisible();
+
+ const row = await readAppointment(appointment.id);
+ expect(row.status).toBe('declined');
+ expect(row.note).toBe(appointment.note);
+ const log = parseActionLog(row.action_log);
+ expect(log.slice(0, -1)).toEqual(parseActionLog(appointment.action_log));
+ expect(log.filter((entry) => entry.action === 'decline')).toHaveLength(1);
+ expect(log.at(-1)).toMatchObject({ action: 'decline', actor: 'host', payload: { note } });
+ expect(log.at(-1)?.payload).toEqual({ note });
+ expect(readQueuedWorkflows(`${appointment.id}:declined:${row.ics_sequence}`)).toEqual([
+ { workflow_name: 'reconcile-appointment' }
+ ]);
+
+ const guestResponse = await guestPage.goto(guestUrl);
+ expect(guestResponse?.status()).toBe(200);
+ const guestHtml = await guestResponse!.text();
+ expect(guestHtml).not.toContain(appointment.note!);
+ expect(guestHtml).not.toContain('note_updated');
+ await expect(guestPage.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
+ const guestNote = guestPage.locator('.state-note-line');
+ await expect(guestNote).toBeVisible();
+ await expect(guestNote.locator('.state-note-text')).toHaveCSS('white-space', 'pre-wrap');
+ const guestText = await guestNote.textContent();
+ expect(guestText).toContain(note);
+ await expect(guestNote.locator('b, script')).toHaveCount(0);
+ await expect(guestPage.getByText(appointment.note!, { exact: true })).toHaveCount(0);
+ await expect(guestPage.getByText('Activity log', { exact: true })).toHaveCount(0);
+ } finally {
+ await guest.close();
+ }
+});
+
+test('server rejection of 501 characters keeps the decline dialog and draft for a 500-character retry', async ({
+ page
+}) => {
+ const appointment = await seedAppointment({ status: 'pending' });
+ await signInAsAdmin(page);
+ await page.goto(`/appointment/${appointment.id}`);
+ await page.getByRole('button', { name: 'Decline', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
+ await expect(dialog).toBeVisible();
+ const field = dialog.getByLabel('Note (optional)', { exact: true });
+ await expect(field).not.toHaveAttribute('required');
+ await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
+ const draft = 'Please try another day.\nThank you for understanding.';
+ await field.fill(draft);
+ await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
+ await expect(field).toHaveAttribute('maxlength', '500');
+ // Bypass only native maxlength, leaving the real enhanced action and visible draft intact.
+ await dialog.locator('form').evaluate((element) => {
+ if (!(element instanceof HTMLFormElement)) throw new Error('Expected decline form');
+ element.addEventListener(
+ 'formdata',
+ (event) => {
+ event.formData.set('decline_note', 'x'.repeat(501));
+ },
+ { once: true }
+ );
+ });
+ const actionPath = `/admin/appointment/${appointment.id}`;
+ const rejectionPromise = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ new URL(response.url()).pathname === actionPath &&
+ new URL(response.url()).searchParams.has('/decline')
+ );
+ await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
+ const rejection = await rejectionPromise;
+ const result = await rejection.json();
+ expect(result).toMatchObject({ type: 'failure', status: 400 });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole('alert')).toHaveText(
+ 'Decline note must be 500 characters or fewer.'
+ );
+ await expect(field).toHaveValue(draft);
+ await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
+ await expect(field).toBeEnabled();
+ await expect(page.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
+ const unchanged = await readAppointment(appointment.id);
+ expect(unchanged).toEqual(appointment);
+ expect(parseActionLog(unchanged.action_log).some((entry) => entry.action === 'decline')).toBe(
+ false
+ );
+ expect(
+ readQueuedWorkflows(`${appointment.id}:declined:${appointment.ics_sequence + 1}`)
+ ).toHaveLength(0);
+
+ const corrected = 'x'.repeat(500);
+ await field.fill(corrected);
+ await expect(dialog.getByText('500/500', { exact: true })).toBeVisible();
+ await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
+ await expect(page.getByRole('alert')).toHaveCount(0);
+ const saved = await readAppointment(appointment.id);
+ expect(saved.status).toBe('declined');
+ expect(saved.note).toBe(appointment.note);
+ expect(parseActionLog(saved.action_log).at(-1)).toMatchObject({
+ action: 'decline',
+ actor: 'host',
+ payload: { note: corrected }
+ });
+ expect(readQueuedWorkflows(`${appointment.id}:declined:${saved.ics_sequence}`)).toHaveLength(1);
+});
+
+test('accepting in another host tab makes the stale decline fail without writing its note', async ({
+ page
+}) => {
+ const appointment = await seedWithPrivateNote();
+ await signInAsAdmin(page);
+ await page.goto(`/appointment/${appointment.id}`);
+ await page.getByRole('button', { name: 'Decline', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
+ await expect(dialog).toBeVisible();
+ const draft = 'Do not save this stale decline.\nThe request has already been accepted.';
+ const field = dialog.getByLabel('Note (optional)', { exact: true });
+ await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
+ await field.fill(draft);
+ await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
+
+ const otherHost = await page.context().newPage();
+ try {
+ await otherHost.goto(page.url());
+ await otherHost.getByRole('button', { name: 'Accept', exact: true }).click();
+ await expect(otherHost.getByRole('heading', { name: 'Confirmed', exact: true })).toBeVisible();
+ } finally {
+ await otherHost.close();
+ }
+ const confirmed = await readAppointment(appointment.id);
+ expect(confirmed.status).toBe('confirmed');
+ expect(confirmed.note).toBe(appointment.note);
+ const actionPath = `/admin/appointment/${appointment.id}`;
+ const conflictPromise = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'POST' &&
+ new URL(response.url()).pathname === actionPath &&
+ new URL(response.url()).searchParams.has('/decline')
+ );
+ await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
+ const conflict = await conflictPromise;
+ const result = await conflict.json();
+ expect(result).toMatchObject({ type: 'failure', status: 409 });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole('alert')).toHaveText('This appointment can no longer be declined.');
+ await expect(field).toHaveValue(draft);
+ await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
+ await expect(field).toBeEnabled();
+ const unchanged = await readAppointment(appointment.id);
+ expect(unchanged).toEqual(confirmed);
+ expect(parseActionLog(unchanged.action_log).some((entry) => entry.action === 'decline')).toBe(
+ false
+ );
+ expect(readQueuedWorkflows(`${appointment.id}:confirmed:${confirmed.ics_sequence}`)).toHaveLength(
+ 1
+ );
+ expect(readQueuedWorkflows(`${appointment.id}:declined:${confirmed.ics_sequence}`)).toHaveLength(
+ 0
+ );
+ expect(
+ readQueuedWorkflows(`${appointment.id}:declined:${confirmed.ics_sequence + 1}`)
+ ).toHaveLength(0);
+});
diff --git a/apps/web/e2e/note-inputs.e2e.ts b/apps/web/e2e/note-inputs.e2e.ts
new file mode 100644
index 00000000..ea9d3101
--- /dev/null
+++ b/apps/web/e2e/note-inputs.e2e.ts
@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test';
+import { signInAsAdmin } from './support/auth.ts';
+import { readAppointment } from './support/database.ts';
+import { seedAppointment } from './support/seed.ts';
+
+function futureWeekday(retry: number): string {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() + 2 + retry * 7);
+ while (date.getUTCDay() === 0 || date.getUTCDay() === 6) {
+ date.setUTCDate(date.getUTCDate() + 1);
+ }
+ return date.toISOString().slice(0, 10);
+}
+
+test('general notes and existing bulk cancellation use the same counter without changing requiredness', async ({
+ page
+}, testInfo) => {
+ const day = futureWeekday(testInfo.retry);
+ const appointment = await seedAppointment({
+ status: 'confirmed',
+ start: new Date(`${day}T23:00:00Z`)
+ });
+ await signInAsAdmin(page);
+ await page.goto(`/appointment/${appointment.id}`);
+ await page.getByRole('button', { name: 'Appointment actions', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Add Note', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: 'Add Note', exact: true });
+ const note = dialog.getByLabel('Note (optional)', { exact: true });
+ await expect(note).not.toHaveAttribute('required');
+ await expect(note).toHaveAttribute('maxlength', '1000');
+ await expect(dialog.getByText('0/1000', { exact: true })).toBeVisible();
+ await note.fill('A note');
+ await expect(dialog.getByText('6/1000', { exact: true })).toBeVisible();
+ await note.clear();
+ await expect(dialog.getByText('0/1000', { exact: true })).toBeVisible();
+ await dialog.getByRole('button', { name: 'Close', exact: true }).click();
+
+ await page.goto('/admin/appointments/upcoming');
+ const row = page.locator('.appointment-row').filter({
+ has: page.getByRole('link', { name: appointment.guest_name, exact: true })
+ });
+ await row.getByRole('checkbox', { name: 'Select appointment', exact: true }).check();
+ await page.getByRole('button', { name: 'Cancel 1', exact: true }).click();
+ const cancellation = page.getByRole('dialog', { name: 'Cancel 1 Appointment?', exact: true });
+ const reason = cancellation.getByLabel('Reason for cancelling', { exact: true });
+ await expect(reason).toHaveAttribute('required', '');
+ await expect(reason).toHaveAttribute('maxlength', '500');
+ const initial = await reason.inputValue();
+ await expect(cancellation.getByText(`${initial.length}/500`, { exact: true })).toBeVisible();
+ await reason.fill('Testing');
+ await expect(cancellation.getByText('7/500', { exact: true })).toBeVisible();
+ await cancellation.getByRole('button', { name: 'Cancel', exact: true }).click();
+ const unchanged = await readAppointment(appointment.id);
+ expect(unchanged).toEqual(appointment);
+});
+
+test('rescheduling still requires a reason and shows the shared 500-character counter', async ({
+ page
+}, testInfo) => {
+ const day = futureWeekday(testInfo.retry);
+ const appointment = await seedAppointment({
+ status: 'pending',
+ start: new Date(`${day}T14:00:00Z`)
+ });
+ const slot = encodeURIComponent(`${day}T15:00:00Z`);
+ await page.goto(
+ `/appointment/${appointment.id}/reschedule?token=${appointment.cancel_token}&slot=${slot}`
+ );
+ await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
+ const reason = page.getByLabel('Reason for rescheduling');
+ await expect(reason).toHaveAttribute('required', '');
+ await expect(reason).toHaveAttribute('maxlength', '500');
+ await expect(page.getByText('0/500', { exact: true })).toBeVisible();
+ await reason.fill('A reason');
+ await expect(page.getByText('8/500', { exact: true })).toBeVisible();
+ const unchanged = await readAppointment(appointment.id);
+ expect(unchanged).toEqual(appointment);
+});
diff --git a/apps/web/src/lib/components/AppointmentsBulkDialog.svelte b/apps/web/src/lib/components/AppointmentsBulkDialog.svelte
index 5f3121e2..1dfd0aca 100644
--- a/apps/web/src/lib/components/AppointmentsBulkDialog.svelte
+++ b/apps/web/src/lib/components/AppointmentsBulkDialog.svelte
@@ -2,6 +2,8 @@
+
+{value.length}/{limit}
+
+
diff --git a/apps/web/src/lib/components/CharacterCount.test.ts b/apps/web/src/lib/components/CharacterCount.test.ts
new file mode 100644
index 00000000..d0319abd
--- /dev/null
+++ b/apps/web/src/lib/components/CharacterCount.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from 'vitest';
+import { render } from 'svelte/server';
+import CharacterCount from './CharacterCount.svelte';
+
+describe('CharacterCount', () => {
+ test.each([
+ ['', 500, '0/500'],
+ ['A note', 500, '6/500'],
+ ['A note', 1000, '6/1000'],
+ ['π', 500, '2/500']
+ ])('renders the existing character-count format for %j', (value, limit, count) => {
+ const rendered = render(CharacterCount, {
+ props: { value: String(value), limit: Number(limit) }
+ });
+ expect(rendered.body).toContain(String(count));
+ });
+});
diff --git a/apps/web/src/lib/components/FormFieldControl.svelte b/apps/web/src/lib/components/FormFieldControl.svelte
index 16ec20f0..bc1f57f2 100644
--- a/apps/web/src/lib/components/FormFieldControl.svelte
+++ b/apps/web/src/lib/components/FormFieldControl.svelte
@@ -1,6 +1,7 @@