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
- On the draft review page, choosing a slot now opens it for changes there (#504). Each row is a button that opens the builder's own slot editor under it, with every field and **Capacity**. Its name for screen readers is "Edit slot — " and what the row shows, plus the group's value in a grouped list, so rows in different groups differ. Nothing is saved until **Done**, a choice added with "Add to list" included. **Done** first saves any new list choices through the field route, sending only the list's `config`, so a field whose type changed meanwhile is refused rather than changed back; then it saves the slot in one `PATCH /api/slots/[id]`, the route the builder saves through, sending `values` only when a value changed and `capacity` only when it did. Like the builder's, a save replaces the slot's values whole, so a change made meanwhile to another field of the same slot, by an AI assistant say, is overwritten. The page then refreshes, and the slot closes once it's back, with "Saved" beside it, read out from a live region, and focus on its row. A save that moves the slot to another group, as a new date does in a list grouped by date, closes it the same way: focus goes to its new row, opening that group if it is folded, or to the list if the slot moved past the fold. **Cancel**, or Escape, closes the slot without saving, new list choices included, and **Done** with nothing changed just closes. Leaving the page with a slot open and changed drops the change, without asking. One slot is open at a time: choosing another while one is open saves the open one first, and opens the other only if that worked. A refusal keeps the slot open and says why in the Build tab's words. Duplicate and Delete aren't offered on this page; adding and removing slots and fields stays in the builder, through "Edit everything in the builder". The editor's code loads when a slot is first opened or pointed at, so it isn't in the page's first load, and only the slots whose rows are drawn go to the page's script. `SlotEditor` takes Duplicate and Delete as optional and an optional Cancel. The number handling for a slot's values moved to `src/components/build-shared/slotValues.ts` and the field mapping to `buildFields.ts` beside it, so the builder and the review page build the same request from the same fields. A save writes `slot.updated`, as the builder's do; `docs/telemetry.md` says the payload doesn't name the page, and that each refresh after a save on the review page writes `signup.editor_opened` and `review.notice_shown` again. The help page "Create and publish your first signup" says how to change a slot on the review page, and its walkthrough changes one, moves one to another date's group and cancels another.
- Slots that are copies of each other can now be merged into one on the draft review page (#503). The notice for copies ("3 slots are the same: …", or "3 slots differ only by a number, like “Helper 1” and “Helper 2”") has **Merge into one slot**, named for screen readers by the slots' values, in place of **Fix in the builder**. It keeps the first copy, gives it all their places (no limit when any copy had none), deletes the others, and for numbered rows drops the number ("Helper 1", "Helper 2" and "Helper 3" become one "Helper" slot for 3 people); identical rows keep their values. The page then says "Merged 3 slots into one for 3 people." with **Undo**, which puts the deleted slots back at their places with their values and places, and gives the kept slot back its own. A new service, `src/services/slot-merge.ts`, does both in one transaction each: `mergeSlots(db, actor, signupId, { keepSlotId, slotIds, values? })`, which works out the total from the slots as locked and returns the undo, and `unmergeSlots`, which takes it. Both work on drafts only and refuse closed slots and any slot someone has signed up for. Under its locks, `mergeSlots` runs the review page's copy check again and refuses unless the slots sent are exactly one set of copies, and, for numbered rows, the values sent are the ones they now share; a copy changed or added since is refused and nothing is deleted. The undo carries the kept slot's `updatedAt` as the merge left it, and `unmergeSlots` refuses once that has moved: the kept slot's values, places or position changed since, or the undo was made already, so a later rename isn't overwritten and the slots never come back twice. As with the other fixes, the merge runs only while the draft still has exactly those copies ("That changed since you opened this page, so nothing was done."), and only for up to 500 copies at once. They write `slot.merged` (`{ slotId, mergedSlotIds, capacity }`) and `slot.unmerged` (`{ slotId, restoredSlotIds, capacity }`), and the page's `review.notice_accepted` and `review.fix_undone` with `kind: 'duplicate_slots'`; `docs/telemetry.md` lists them. The help page "Create and publish your first signup" names the button, and its walkthrough merges two copies and undoes it. Adding and bulk-adding a slot now share one insert, `insertSlot`, which the undo uses too.
- The draft review page now fixes four of the problems it points out in one tap (#503). A field empty on every slot has **Remove field**; slots with nothing filled in have **Remove it** (**Remove them** for several); dates that repeat on a page that isn't grouped have **Group by “Date”** (the date field's own name); and a field with no name, or still called "Untitled", has a box for its name and **Save**. The other kinds keep **Fix in the builder**. Each fix goes through the services the builder uses (`deleteField`, `deleteSlot`, `updateField`, `updateSignup`), from two server actions beside Publish, `applyDraftFixAction` and `undoDraftFixAction`, via `src/services/draft-fixes.ts`. It works on drafts only, and only while the draft still has exactly that problem: the checks run again on the signup as it is now, and the request has to match a notice by kind and ids, so a stale page or a change made meanwhile by an AI assistant can't have a fix applied to something else ("That changed since you opened this page, so nothing was done."). Once a fix is made, a line above the list says what it did ("Removed “Notes”.", "Removed 2 empty slots.", "Named the field “Shift”.", "Grouped the slots by “Date”.") with **Undo**, which takes focus. Undo puts back what the fix changed through the same services, not from a copy of the signup: the field in front of the field that followed it, with the reminder date and grouping that named it (a field that had no name comes back called "Untitled", as a field needs one); the slots at their old positions, with their spots, closed again if they were; the old name; the old grouping. It refuses when the signup has changed since in a way it would overwrite ("That changed again since, so it can’t be undone."). Only the last fix can be undone, and only while the page stays open: the undo lives in the page, not on the server. A viewer is told "You don’t have edit access."; a signup published meanwhile, "This signup isn’t a draft any more. Change it in the builder." Each fix writes `review.notice_accepted` with `{ kind }`, and each Undo `review.fix_undone`, best effort and outside the fix's own transactions, so shown, taken and undone can be compared by kind. `docs/telemetry.md` lists both, and the privacy page says the log records which one-tap fixes were taken or undone. The help page "Create and publish your first signup" says which problems can be fixed on the page, and that Undo puts back the last fix; its walkthrough removes an empty slot there and undoes it. The checks and their wording stay on the server: the page's script gets only each notice's words and its fix.
- Organizers get one email about a draft they haven't published (#506). It goes out 3 hours after the draft was started (`DRAFT_NUDGE_DELAY_HOURS` in `src/lib/draft-nudge.ts`), only while the signup is still an undeleted draft with at least one slot filled in, and only for drafts started in the last 48 hours, so the first run after an upgrade doesn't email every old draft. Drafts a connected AI assistant created get none: the assistant is already giving the organizer its links. The email, "Your signup is ready to publish", gives the review page's headline ("12 slots · 3 days · 3 people each") under "So far", a **Check it and publish** button that opens the review page, and a link to the builder; a signed-out organizer signs in and lands back on the signup. The worker finds due drafts with a scan every 10 minutes (`draft-nudges.dispatch`) and sends each from its own job (`draft-nudges.send`), which checks every rule again first; a `draft.nudge_sent` activity row (`{ channel: 'email', trigger }`) stops a second send, which, as with reminders, holds only with one worker process. "Not yet. Email me a link to finish later" on the review page sends the same email at once, to the organizer who pressed it, and stops the scheduled one; it is limited to 5 an hour per organizer. Every draft email carries a link, and `List-Unsubscribe` headers, that turn these emails off for all of the organizer's drafts: the link opens `/unsubscribe/drafts`, which asks first and can turn them back on, and one-click unsubscribe posts to `POST /api/public/draft-email-optout`. Both are token-checked and rate-limited per IP. Turning it off stores the time in a new `organizers.draft_emails_opted_out_at` column (migration `0013`, applied by `pnpm db:migrate`) and writes `draft_email.opted_out` (or `draft_email.opted_in`). The privacy page, `docs/telemetry.md` and the help page "Create and publish your first signup" say so. `pnpm drafts:due` lists the drafts whose email is due, like `pnpm reminders:due`.
Expand Down
6 changes: 3 additions & 3 deletions budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
"/s/[slug]": 132,
"/s/[slug]/c/[id]": 110,
"/": 133,
"/login": 109,
"/login": 110,
"/help/[slug]": 113,
"/app/signups/new": 114,
"/app/signups/[id]": 113,
"/app/signups/[id]/build": 151,
"/app/signups/[id]": 114,
"/app/signups/[id]/build": 153,
"/app/signups/[id]/edit": 124,
"/app/signups/[id]/preview": 132,
"/app/signups/[id]/responses": 125,
Expand Down
6 changes: 3 additions & 3 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ by `clientDomain` instead.
| `signup.archived` | organizer | `{ from, to }` | `services/signups.ts` |
| `signup.deleted` | organizer | `{ status }` (the status it had when deleted) | `services/signups.ts` |
| `signup.draft_started` | organizer | `{ mode: 'magic' \| 'manual', device }` (`mode` is the form the page showed: Magic Compose, or the manual title-and-description form) | RSC at `/app/signups/new` |
| `signup.editor_opened` | organizer | `{ section: 'settings' \| 'responses' \| 'review' \| 'share', device }` (`review` is a draft's "Check your signup, then share it" page; `share` is the step Publish lands on, "It's live. Now send the link.") | RSC at `/app/signups/[id]/settings`, `/app/signups/[id]/responses`, `/app/signups/[id]` and `/app/signups/[id]/share` |
| `signup.editor_opened` | organizer | `{ section: 'settings' \| 'responses' \| 'review' \| 'share', device }` (`review` is a draft's "Check your signup, then share it" page, written again each time that page refreshes after a save, as `review.notice_shown` is; `share` is the step Publish lands on, "It's live. Now send the link.") | RSC at `/app/signups/[id]/settings`, `/app/signups/[id]/responses`, `/app/signups/[id]` and `/app/signups/[id]/share` |
| `signup.previewed` | organizer | `{ device }` | RSC at `/app/signups/[id]/preview` |
| `signup.viewed` | system | `{ uaClass, refererHost, isReturning, signupStatus }` | RSC at `/s/[slug]` |
| `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' \| 'share_step' \| 'share_message', status }` (where the link was copied from, and the signup's status then: a draft's link only says the signup isn't ready). `header` is the copy button by the signup's link at the top of its tabs, `share_step` the share step's Copy link, and `share_message` its Copy message and link, which copies the message with the link in it. Written once the clipboard took it | client beacon from the organizer's pages, via `POST /api/signups/[id]/link-events?event=copied&surface=…`, metered per organizer (60 an hour for copies and shares together; past that, they go uncounted) |
| `signup.link_shared` | organizer | `{ surface: 'share_step', status }` (the share step's Share…, which opens the phone's own share sheet with the link; shown only where the browser has one). Written once the sheet reports it sent; closing the sheet without sending writes nothing. Which app it went to is never known | client beacon, via `POST /api/signups/[id]/link-events?event=shared&surface=share_step`, metered with the copies |
| `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 |
| `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, and every save made on the page refreshes it (a slot's Done, a card's Save, a one-tap fix and its Undo), which writes `signup.editor_opened` with `section: 'review'` again too. So compare kinds, and count opens of the page, by distinct `signup_id` | RSC at `/app/signups/[id]`, in one insert after the page is sent |
| `review.notice_accepted` | organizer | `{ kind }`: one row each time the organizer takes a notice's one-tap fix on the review page and it's made (`empty_field`, `blank_slot`, `unnamed_field`, `ungrouped_dates`, and `duplicate_slots` for copies merged into one; `DraftFixKind` in `src/schemas/draft-fixes.ts`). Never the values or a field's new name. Compare with `review.notice_shown` by kind. The fix itself is logged by the service that makes it, as any other change is (`field.deleted`, `slot.deleted`, `field.updated`, `signup.updated`, `slot.merged`). A fix refused (a viewer, a signup no longer a draft, a problem already gone) writes nothing | `services/draft-fixes.ts` (`applyDraftFix`), from the review page's server action, after the fix |
| `review.fix_undone` | organizer | `{ kind }`: the organizer chose Undo on the last fix and it was put back, through the same services (`field.created`, `slot.created`, `slot.updated` for a slot closed again, `field.updated`, `signup.updated`, `slot.unmerged`): a fix they didn't want. An undo refused because the signup changed since writes nothing | `services/draft-fixes.ts` (`undoDraftFix`), from the review page's server action, after the undo |
| `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 |
Expand All @@ -81,7 +81,7 @@ outside any transaction, and a failed write never fails the request.
| event | actor | payload | fired from |
|---|---|---|---|
| `slot.created` | organizer | `{ slotId }` from `addSlot`; `{ count, bulk: true, slotIds, beforeSlotId? }` from `addSlotsBulk` (the bulk endpoint and the `add_slots` tool), one row per call | `services/slots.ts` |
| `slot.updated` | organizer | `{ slotId, changed }` (the keys the update sent) | `services/slots.ts` |
| `slot.updated` | organizer | `{ slotId, changed }` (the keys the update sent). A slot changed on the review page goes through the same route as one changed in the Build tab, so the payload doesn't say which page it came from | `services/slots.ts` |
| `slot.reordered` | organizer | `{ slotIds }` (every slot, in the new order; written even when the order did not change) | `services/slots.ts` (`reorderSlots`, from the `reorder_slots` tool; a drag in the Build tab writes a `slot.updated` per moved slot instead) |
| `slot.deleted` | organizer | `{ slotId, commitmentsRemoved, places }` (the sign-ups deleted with it, as commitments and as places) | `services/slots.ts` |
| `slot.merged` | organizer | `{ slotId, mergedSlotIds, capacity }`: copies of a slot on a draft made into one, the review page's fix for identical rows. `slotId` is the slot kept, `mergedSlotIds` the ones deleted, and `capacity` the kept slot's new capacity, their total (`null` when any had no limit). Drafts only, and never a slot anyone has signed up for | `services/slot-merge.ts` (`mergeSlots`) |
Expand Down
17 changes: 17 additions & 0 deletions src/components/build-shared/buildFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { SlotFieldDefinition } from '@/schemas/slot-fields';
import type { BuildField } from './useBuildState';

/**
* Fields as the slot editor holds them, from the fields as the services
* return them. Not a client module and no hooks, so the review page's server
* component maps them here too, for the slot editor it opens.
*/
export function toBuildFields(fields: SlotFieldDefinition[]): BuildField[] {
return fields.map((f) => ({
id: f.id,
ref: f.ref,
name: f.label,
config: f.config,
sortOrder: f.sortOrder,
}));
}
Loading
Loading