Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to OpenSignup are documented here. Format follows [Keep a Ch
## [Unreleased]

### Added
- The draft review page now points out problems it can prove (#503), over every slot, folded or not: slots that still say "TBD", "TBC" or "TBA"; a field empty on every slot; slots with nothing filled in; slots that are copies of each other; a field with no name; dates that repeat on a page that isn't grouped by them; and times typed into a text field. Each says what's wrong in plain words, under "N things to fix before you publish", with a link to fix it in the builder; with none, the card says "Nothing to fix. This is the list people will see." The checks are pure functions in `src/lib/draft-checks.ts`, exact by design: copies are never offered for rows holding a placeholder or missing a value other slots have, numbered rows ("Helper 1", "Helper 2") count only when each takes one person and are described as differing only by a number, closed slots are never copies, repeated dates are pointed out only when most dates repeat, and a bare clock time counts only on a five ("9:05", not "John 3:16"). Each kind shown writes `review.notice_shown` with `{ kind }`, and the privacy page says the log records which kinds were pointed out.
- A draft with a slot filled in now opens on "Check your signup, then share it" (#502), at `/app/signups/[id]`, instead of the Build tab. It shows the signup's name and description, a headline in fixed words ("12 slots · 3 days · 3 people each"), the values every slot shares said once ("Same on every slot: School library"), and the slots as the public page lists them, folded to the first group or six rows with "Show all N slots". "What happens when someone signs up" says whether people must give an email address and whether reminders go out. The name and description and that card each have Change, which saves in place. "Publish and get the link" publishes it, and "Edit everything in the builder" opens the Build tab. Magic Compose now lands here, and shows its "A few things to double-check" warnings on the slots card. Opening a draft from the dashboard lands here too. A draft with nothing filled in yet, like a new signup from the blank form, still opens in the builder, as does every published signup. The AI connector's `links.edit` is now the signup's own address, `/app/signups/[id]`, which opens whichever of the two fits. The help page "Create and publish your first signup" has a new section, "Come back to a draft". The Build, Responses and Settings tabs moved into a `(tabs)` route group so this page can sit at the signup's address without them; their URLs are unchanged. Opening the page writes `signup.editor_opened` with `section: 'review'`.
- Activity events for the steps where new organizers stop (#500), so they can be counted: `signup.link_copied` when an organizer copies a signup's link from its header (sent from the browser to `POST /api/signups/[id]/link-events`, which takes only a signed-in member of the signup's workspace, 60 an hour each), `magic_compose.failed` with the AI draft's error code, `refusal`, `not_saved` or `over_limit`, and `signup.publish_refused` when a publish is turned down for having no slots or because the signup was closed or archived meanwhile, from the browser, the API or a connected assistant. A publish of a signup that's already open isn't counted as refused. The publish refusal for a signup with no slots now names `field: 'slots'`. The organizer's page views (`signup.draft_started`, `signup.viewed` on the Build tab, `signup.editor_opened`, `signup.previewed`) now say whether they were opened on a phone or a computer (`device`), and `signup.draft_started` says which form the page showed (`mode: 'magic' | 'manual'`). Payloads hold codes and kinds only: no prompt, no email, no User-Agent. `docs/telemetry.md` lists them, and the privacy page says the log records these.
- `pnpm eval:mcp`, an opt-in eval of what a connected AI assistant builds through the MCP tools (#254). It sends each case in `evals/magic-compose/dev-evals.json` to a tool-calling model with the server instructions and the tool list, takes the arguments of its `create_signup` call, and scores them with the checks the Magic Compose evals use, now shared in `scripts/lib/eval-harness.ts`. It reuses the `LLM_*` settings and needs no database. Like `pnpm eval:magic-compose`, it is run by hand, never in CI. Two new cases check that a signup for parents at each game gets one dated slot per game, with the right capacity, and no fields for names, emails or what someone brings.
Expand Down
7 changes: 4 additions & 3 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ by `clientDomain` instead.
| `signup.viewed` | organizer | `{ device }` (the organizer opening the Build tab, not a participant view; filter on `actor_type = 'system'` to leave these out) | RSC at `/app/signups/[id]/build` |
| `signup.publish_refused` | organizer | `{ code: 'conflict', reason: 'no_slots' }`, or `{ code: 'conflict', reason: 'not_draft', status }` with the status it had by then (`closed` or `archived` in another tab). A publish of a signup that's already open (a second click, or a retry after one that worked) isn't recorded | `services/signups.ts` (`publishSignup`), after its transaction. A viewer's publish is refused by the policy guard before that and writes nothing |
| `signup.link_copied` | organizer | `{ surface: 'header', status }` (where the link was copied from, and the signup's status then: a draft's link only says the signup isn't ready) | client beacon from the organizer's pages, via `POST /api/signups/[id]/link-events?event=copied&surface=…`, metered per organizer (60 an hour; past that, copies go uncounted) |
| `review.notice_shown` | organizer | `{ kind }`: one row per kind of problem the review page found on a draft and showed, each time the page renders (`placeholder`, `empty_field`, `blank_slot`, `duplicate_slots`, `unnamed_field`, `ungrouped_dates`, `time_in_text`; `DRAFT_CHECK_KINDS` in `src/lib/draft-checks.ts`). Never the values. A refresh writes them again, so compare kinds by distinct `signup_id` | RSC at `/app/signups/[id]`, in one insert after the page is sent |
| `magic_compose.failed` | organizer | `{ code }`: the AI client's `not_configured` \| `rate_limited` \| `upstream` \| `invalid_json` \| `schema_mismatch` \| `timeout` \| `aborted`; `schema_mismatch` too for a draft of the wrong shape; `refusal` when the model declined; `not_saved` when the draft couldn't be saved; `over_limit` when the organizer had used up this instance's Magic Compose quota for the hour. Never the prompt or the model's words | `POST /api/signups/magic-compose`. `signup_id` is `NULL`: no signup was made |

`device` is `'phone'`, `'computer'` or `'unknown'`, from the browser's
Expand All @@ -66,8 +67,8 @@ phone can be told apart from ones abandoned at a desk; public views carry
`uaClass` instead, which separates browsers from bots and says nothing about
the device.

`signup.publish_refused`, `signup.link_copied` and `magic_compose.failed`
describe no change, so each is written on its own,
`signup.publish_refused`, `signup.link_copied`, `magic_compose.failed` and
`review.notice_shown` describe no change, so each is written on its own,
outside any transaction, and a failed write never fails the request.

### Slot / field lifecycle
Expand Down Expand Up @@ -369,7 +370,7 @@ edits, `auth.signed_in`, **and** the organizer-side RSC pageview events
(`signup.editor_opened`, `signup.previewed`, `signup.draft_started`, and the
Build tab's `signup.viewed`) which are written with the organizer as actor,
and the organizer's telemetry rows (`signup.publish_refused`,
`signup.link_copied`, `magic_compose.failed`).
`signup.link_copied`, `magic_compose.failed`, `review.notice_shown`).
The public page's `signup.viewed` is system-actor and does not advance
`last_active_at`.

Expand Down
Binary file modified public/help/create-and-publish-a-signup/check-your-signup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions src/app/(legal)/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function PrivacyPage() {
<>
<header className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Privacy policy</h1>
<p className="text-sm text-ink-muted">Last updated: 20 October 2026</p>
<p className="text-sm text-ink-muted">Last updated: 21 October 2026</p>
</header>

<section className="space-y-3">
Expand Down Expand Up @@ -58,7 +58,8 @@ export default function PrivacyPage() {
An append-only activity log of organizer actions (sign-ins, creating and editing
signups, sending magic links, copying a signup&rsquo;s link, a publish or an AI draft
that didn&rsquo;t go through), which of your own pages you open and whether on a phone
or a computer (never the browser&rsquo;s full identifying string), participant
or a computer (never the browser&rsquo;s full identifying string), which kinds of problem
the review page pointed out in a draft (never the values themselves), participant
commitments on your signups, and reminder dispatches. This exists so an organizer can
audit what happened in their own workspace, so the operator can investigate abuse, and
so the operator can count, in aggregate, where organizers get stuck between starting a
Expand Down
30 changes: 27 additions & 3 deletions src/app/app/(chrome)/signups/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { after } from 'next/server';
import { requireOrganizerSession, toActor } from '@/auth/session';
import { ReviewDetailsCard } from '@/components/review/ReviewDetailsCard';
import { ReviewSignUpCard } from '@/components/review/ReviewSignUpCard';
import { ReviewSlotsCard } from '@/components/review/ReviewSlotsCard';
import { SignupLoadError } from '@/components/signup/SignupLoadError';
import { StatusPill } from '@/components/status-pill';
import { AsyncSubmitButton } from '@/components/ui/async-submit-button';
import { hasFilledSlot, reminderReach, whatHappensOnSignUp } from '@/lib/signup-summary';
import { trackOrganizerView } from '@/lib/view-tracker';
import { checkDraft } from '@/lib/draft-checks';
import { noticeText } from '@/lib/draft-notices';
import { groupByRef, hasFilledSlot, reminderReach, whatHappensOnSignUp } from '@/lib/signup-summary';
import { recordNoticesShown, trackOrganizerView } from '@/lib/view-tracker';
import {
DESCRIPTION_MAX_LENGTH,
REMINDER_BEFORE,
Expand Down Expand Up @@ -57,13 +60,33 @@ export default async function SignupPage({ params, searchParams }: PageProps) {
redirect(builderHref);
}

// Every slot is checked, not only the ones the fold shows.
const groupRef = groupByRef(sig.settings);
const found = checkDraft({
fields: sig.fields,
slots,
settings: { groupByFieldRefs: groupRef ? [groupRef] : [] },
});
const notices = found.map((n) => noticeText(n, { fields: sig.fields, slots }));

const actor = { actorId: session.organizerId, actorType: 'organizer' } as const;
await trackOrganizerView({
actor: { actorId: session.organizerId, actorType: 'organizer' },
actor,
signupId: sig.id,
workspaceId: sig.workspaceId,
eventType: 'signup.editor_opened',
payload: { section: 'review' },
});
if (found.length > 0) {
after(() =>
recordNoticesShown({
actor,
signupId: sig.id,
workspaceId: sig.workspaceId,
kinds: found.map((n) => n.kind),
}),
);
}

// Both read malformed settings as their defaults rather than failing.
const requireEmail = requiresEmail(sig.settings);
Expand Down Expand Up @@ -111,6 +134,7 @@ export default async function SignupPage({ params, searchParams }: PageProps) {
settings={sig.settings}
showAll={all === '1'}
builderHref={builderHref}
notices={notices}
/>
<ReviewSignUpCard
signupId={id}
Expand Down
3 changes: 2 additions & 1 deletion src/components/build-wysiwyg/InlineFieldForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FIELD_TYPE_META } from '../build-shared/fieldTypes';
import {
FIELD_TYPES,
TEXT_DEFAULT_MAX_LENGTH,
UNTITLED_FIELD_LABEL,
type FieldType,
type SlotFieldConfig,
} from '@/schemas/slot-fields';
Expand Down Expand Up @@ -79,7 +80,7 @@ export function InlineFieldForm({
}, []);

const save = () => {
const trimmed = name.trim() || 'Untitled';
const trimmed = name.trim() || UNTITLED_FIELD_LABEL;
// When the type changes on an existing field, reset its config to the
// type's default. Enum options land via the inline EnumPicker, not here.
const config: SlotFieldConfig =
Expand Down
1 change: 1 addition & 0 deletions src/components/magic-compose/AiDraftWarningList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function AiDraftWarningList({
return (
<div
role="status"
data-ai-warnings
className="border-warn/30 bg-warn/10 text-ink flex items-start gap-3 rounded-lg border px-5 py-4 text-sm"
>
<div className="min-w-0 flex-1">
Expand Down
18 changes: 13 additions & 5 deletions src/components/review/ReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ import type { ReactNode } from 'react';
import { Check, Pencil } from 'lucide-react';

/**
* One card of the review page: a round mark on the left (a check, or a
* pencil while its Change form is open), its heading, and what it holds.
* One card of the review page: a round mark on the left (a check; a pencil
* while its Change form is open; "!" while it has something to fix), its
* heading, and what it holds.
* No hooks, so the server cards and the client ones share it.
*/
export function ReviewCard({
heading,
headingId,
editing = false,
attention = false,
aside,
action,
children,
}: {
heading: string;
headingId: string;
editing?: boolean;
/** Something on the card needs fixing before the signup is published. */
attention?: boolean;
/** Next to the heading, such as "Saved". */
aside?: ReactNode;
/** Top right, such as the Change button. */
Expand All @@ -26,17 +30,21 @@ export function ReviewCard({
return (
<li
aria-labelledby={headingId}
className={`flex gap-4 rounded-xl border bg-white px-4 py-4 shadow-card sm:px-5 ${
className={`group/card flex gap-4 rounded-xl border bg-white px-4 py-4 shadow-card sm:px-5 ${
editing ? 'border-brand' : 'border-surface-sunk'
}`}
>
<span
aria-hidden="true"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${
editing ? 'bg-brand-soft text-brand' : 'bg-success/10 text-success'
editing
? 'bg-brand-soft text-brand'
: attention
? 'bg-warn/10 text-[15px] font-bold text-warn'
: 'bg-success/10 text-success'
}`}
>
{editing ? <Pencil size={13} /> : <Check size={15} strokeWidth={3} />}
{editing ? <Pencil size={13} /> : attention ? '!' : <Check size={15} strokeWidth={3} />}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex min-h-7 items-center gap-2.5">
Expand Down
48 changes: 48 additions & 0 deletions src/components/review/ReviewNotices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Link from 'next/link';
import type { NoticeText } from '@/lib/draft-notices';
import { noticeHeading } from '@/lib/draft-notices';

/**
* The problems `checkDraft` found on a draft, each said in plain words, with
* the builder to fix them in. Nothing found says so: the list below is what
* people will see.
*/
export function ReviewNotices({
notices,
builderHref,
}: {
notices: NoticeText[];
builderHref: string;
}) {
if (notices.length === 0) {
// Not under Magic Compose's own warnings, which the card shows just above.
return (
<p className="text-[13px] text-success group-has-[[data-ai-warnings]]/card:hidden">
Nothing to fix. This is the list people will see.
</p>
);
}
return (
<section aria-labelledby="review-notices" className="flex flex-col gap-2">
<h3 id="review-notices" className="text-[13px] font-semibold text-warn">
{noticeHeading(notices.length)}
</h3>
<ul className="flex flex-col gap-2">
{notices.map((notice, i) => (
<li
key={`${notice.kind}-${i}`}
className="flex flex-wrap items-center justify-between gap-x-3.5 gap-y-2.5 rounded-lg border border-warn/30 bg-warn/10 px-3.5 py-3"
>
<p className="min-w-0 flex-[1_1_260px] text-[13.5px] leading-normal">{notice.text}</p>
<Link
href={builderHref}
className="inline-flex h-9 shrink-0 items-center rounded-lg border border-brand/30 bg-white px-3.5 text-[13px] font-semibold text-brand transition hover:bg-brand-soft/40"
>
Fix in the builder
</Link>
</li>
))}
</ul>
</section>
);
}
12 changes: 11 additions & 1 deletion src/components/review/ReviewSlotsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {
sharedValues,
signupHeadline,
} from '@/lib/signup-summary';
import type { NoticeText } from '@/lib/draft-notices';
import type { SlotFieldDefinition } from '@/schemas/slot-fields';
import { AiDraftWarnings } from './AiDraftWarnings';
import { ReviewCard } from './ReviewCard';
import { ReviewNotices } from './ReviewNotices';

interface ReviewSlot {
id: string;
Expand All @@ -38,13 +40,16 @@ export function ReviewSlotsCard({
settings,
showAll,
builderHref,
notices,
}: {
signupId: string;
fields: SlotFieldDefinition[];
slots: ReviewSlot[];
settings: unknown;
showAll: boolean;
builderHref: string;
/** What `checkDraft` found, in words, over every slot. */
notices: NoticeText[];
}) {
const summary = { fields, slots, settings };
const groupRef = groupByRef(settings);
Expand All @@ -60,14 +65,19 @@ export function ReviewSlotsCard({
);

return (
<ReviewCard heading="What people sign up for" headingId="review-slots">
<ReviewCard
heading="What people sign up for"
headingId="review-slots"
attention={notices.length > 0}
>
<p className="text-base font-semibold">{signupHeadline(summary)}</p>
{shared.length > 0 ? (
<p className="text-sm text-ink-muted">
Same on every slot: {shared.map((v) => v.text).join(' · ')}
</p>
) : null}
<AiDraftWarnings signupId={signupId} />
<ReviewNotices notices={notices} builderHref={builderHref} />
<div id="slots" className="flex scroll-mt-4 flex-col gap-3.5 pt-1">
{groups.map((group, i) => {
const fold = plan.groups[i];
Expand Down
1 change: 1 addition & 0 deletions src/db/schema/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const ACTIVITY_EVENTS = [
'signup.publish_refused',
'signup.link_copied',
'magic_compose.failed',
'review.notice_shown',
'slot.created',
'slot.updated',
'slot.deleted',
Expand Down
8 changes: 6 additions & 2 deletions src/help/articles/create-and-publish-a-signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,15 @@ export function CreateAndPublishASignup() {
in it, the draft opens on <Ui>{UI.reviewHeading}</Ui>. It shows your slots the way
people will see them.
</p>
<p>
If something needs fixing first, the page says so above your slots. A slot that still
says TBD is one example.
</p>
<Screenshot
src="/help/create-and-publish-a-signup/check-your-signup.png"
alt="Check your signup, then share it, with the signup's name, its two slots and what happens when someone signs up."
alt="Check your signup, then share it, with the signup's name, its two slots, nothing to fix, and what happens when someone signs up."
width={760}
height={529}
height={557}
/>
</Step>
<Step>
Expand Down
Loading
Loading