Vue template attribute expressions don't allow if-statements; the
short-circuit form (`x && (x = '')`) is the idiomatic equivalent and
runs without a template compiler warning.
- join.vue: "Full Name" -> "Name", "Email Address" -> "Email", drop redundant "Your name" placeholder
- join.vue + accept-invite.vue: unify Circle helper copy to "Where you are in your co-op journey. You can change this anytime."
- join.vue: add .field-note style rule for the new helper paragraph
Replace the inline contribution UI (label, input row, presets, guidance)
with the shared ContributionAmountField component, locking cadence and
suppressing its built-in summary (account.vue has its own change hint).
Fix three computeds that double-applied the cadence conversion now that
Member.contributionAmount is stored in cadence-unit (post-Task 7):
contributionChangeHint, currentContributionLabel, and nextChargeAmount
no longer multiply annual amounts by 12.
Convert form.contributionAmount to a monthly-equivalent before the
payment-setup redirect — that page is monthly-only and would otherwise
attempt an annual-sized monthly charge for annual members.
Drop the now-unused guidanceLabel computed, the CONTRIBUTION_PRESETS and
getGuidanceLabel imports, and the dead contribution-* CSS rules.
One-time script to convert existing annual Member records from the legacy
monthly-equivalent interpretation to cadence-unit. Multiplies
contributionAmount by 12 for billingCadence='annual' members.
Companion to commit 5023fb1 which dropped the server's ×12 on annual
recurringAmount. Must run after deploy, before annual members renew.
Idempotent via a transient `contributionAmountConverted: true` marker
field on each migrated doc — re-runs are safe. Dry-run by default;
`--apply` to write. Skips null/undefined contributionAmount, logs $0
amounts as no-ops.
Add formatContribution helper in app/config/contributions.js and route
all member-facing and admin contribution displays through cadence-aware
expressions so annual members see /yr instead of /mo. Normalize annual
amounts to monthly equivalents in the admin dashboard revenue
aggregate now that contributionAmount is stored in cadence units.
ContributionAmountField now emits cadence-unit values (180 for $180/yr,
15 for $15/mo). Server endpoints were still multiplying annual by 12,
which would have charged $2160/yr instead of $180/yr after the form
ports in Tasks 2–3.
- helcim/subscription.post.js: recurringAmount = contributionAmount
(no more × 12 for annual)
- members/update-contribution.post.js: same drop in both Case 1
(free→paid) and Case 3 (paid→paid)
- slack.ts notifyNewMember: new positional `cadence` param so the
Slack notification suffix renders /yr or /mo instead of hardcoded
/month; all three call sites updated to pass member.billingCadence
- tests updated to match the new contract:
- helcim-subscription.test.js: annual tests now send the cadence-
unit amount (180, 600) and expect the same recurringAmount
- update-contribution.test.js: annual Case 1 and Case 3 tests
updated likewise
Replace cadence radios, contribution input, preset chips, guidance label,
and billing summary block with a single ContributionAmountField usage.
Default contribution updated to 180 to preserve the previous $15/mo
suggested annual default (cadence-unit value now). Updated flowSummary to
format cadence-unit directly. Updated e2e selectors to use the data-testids
the component exposes and new summary copy.
Add data-testid hooks for the contribution amount input and cadence
toggle labels so playwright can target them through useId-generated
ids. Update join-flow.spec.js to use the new selectors and to assert
the new billing-summary copy ('at each annual renewal' / 'each month'),
dropping the obsolete '/month x 12' parenthetical.
Replace inline cadence radios, contribution input + presets, guidance
label, and billing summary with the shared ContributionAmountField
component. Removes duplicated state (guidanceLabel, firstCharge),
unused imports (CONTRIBUTION_PRESETS, getGuidanceLabel), and the
matching CSS rules. The parent retains the cadence ref because
formatContributionAmount (left-column tier list) reads it.
Satori's bundled @shuding/opentype.js@1.4.0-beta.0 throws
"ReferenceError: ltagTable is not defined" when parsing fonts that
contain an Apple ltag (language tag) table, breaking /og/events/*.png
in production. Stripped the unused ltag table from both WOFFs via
fontTools; satori only needs glyph data for layout.
GET /api/events now does find().sort().lean() (no .select()) since the
handler strips registrations in the map step. The mock chain in
members-only-visibility.test.js still expected .select(), causing all
list tests to throw "lean is not a function".
Admin-only route that validates a label + pool via zod, slugifies the
label, returns the existing tag if the slug already exists, otherwise
creates a new active Tag document.
Admin event create form:
- Wraps body in a form-layout/form-main container for upcoming sidebar work.
- Bigger autoresize textareas for description/content; adds an Agenda
textarea (one item per line, persisted as event.agenda).
- Reorganises settings into Event Settings + conditional Cancellation
Message sections.
- Pulls event-type options from EVENT_TYPES; location becomes optional;
passes displayTimezone through to NaturalDateInput.
NaturalDateInput: rewritten to a single always-visible UInput with chrono
parsing and trailing status icon, instead of toggling between input and
parsed-summary blocks. Cleaner state model (rawInput / parsedDate /
isValid / hasError) and timezone-aware update emission.
- Public event detail page renders description/series-description/content
as markdown via useMarkdown, with prose styles; agenda becomes an
unordered list with the same custom bullets.
- Events list API returns `isRegistered` per event (derived from the
requester's registrations, ignoring cancelled rows), and the list page
shows a "Registered" tag. Stops exposing the full registrations array
in the list response.
- Removes the seats/sold-out/limited capacity UI from list and detail
pages.
- EventTicketPurchase: minor template formatting (self-closing inputs,
prettier wrapping).
Replaces the four-value enum (community/workshop/social/showcase) with
seven values: talk, workshop, community-meetup, coworking, peer-session,
skills-share, info-session. Default is now community-meetup.
Adds app/config/eventTypes.js as the single source of truth for value→label
mapping. Updates the model enum, seed scripts, and admin event list/filter
+ admin dashboard to read from it via EVENT_TYPES and eventTypeLabel().
Adds `useSiteMeta()` composable that wraps useSeoMeta with site defaults
(title template, canonical URL, og/twitter image, og:site_name) and
absolute-URL handling via runtimeConfig.public.appUrl.
Adds /og/events/[slug].png route that renders per-event OG images via
satori + @resvg/resvg-js, cached on disk by slug + updatedAt. Bundles
Brygada 1918 + Commit Mono fonts as server assets, ships a fallback
default.png, and patches @shuding/opentype.js via patch-package.
Converts ~25 pages from useHead to useSiteMeta and adds noindex on
private/auth/admin pages.
Two reliability bugs in the natural-language date input:
1. Clicking Edit on a saved-date pill and changing the value immediately
re-showed the saved value. clearAndEdit pre-fills the input with the
formatted saved date so the admin doesn't start over, but chrono
parses that string on the very first keystroke, re-sets parsedDate,
and the auto-hide template flips the pill back. Added an isEditing
flag that keeps the input visible across re-parses and clears on
blur once we have a valid parse.
2. Typing "tomorrow at 2pm" sometimes committed "tomorrow at <current
time>". UInput's template spreads \$attrs onto the inner <input>
alongside its own @input="onInput", and Vue's listener-array merge
intermittently drops the fall-through @input mid-typing — in the
reproduction, the final 'm' never reached parseNaturalInput, so
chrono's last successful read was "tomorrow at 2p" matching just
"tomorrow". Switched to @update:model-value (a declared emit on
UInput, so it goes through the reliable component-emit path) and
made onBlur always re-parse the final value as a backup.
Two more sites that used viewer-local formatting: the admin events
index and the homepage event blocks. Switch both to take the event and
pass event.displayTimezone to the formatter so admins see events at
their intended wall-clock (and admins viewing across the world see
the same time).
Registration, waitlist, and series-pass confirmation emails formatted
dates with Intl.DateTimeFormat in the server's local TZ. Switch to
event.displayTimezone (fallback America/Toronto) so the email shows
the event's intended wall-clock + zone suffix regardless of where the
Resend worker runs.
Inline formatters in three exports collapsed to two module-level
helpers that take a timeZone argument.
Sidebar (EventsMiniSidebar), public events list, member dashboard
"Upcoming" block, and EventTicketPurchase all formatted dates in
viewer-local TZ. Switch each formatter to accept the event (or an
eventTimezone prop) and pass it to Intl.DateTimeFormat so the
displayed wall-clock matches the event's intended zone.
Event detail page formatted dates in viewer-local time, so a Lisbon
viewer of a Toronto ET event saw the time in WEST. Format with
event.displayTimezone instead so attendees see the event's intended
wall-clock + zone suffix ("6:00 AM EDT") regardless of where they sit.
useEventDateUtils.formatDate / formatTime / formatDateRange / isToday
now accept a { timeZone } option and pass it to Intl.DateTimeFormat.
Existing call sites that don't pass timeZone fall through to viewer-
local, matching prior behaviour.
Add a USelectMenu for displayTimezone in Event Details (defaults to
America/Toronto). On submit, convert each datetime-local string
(startDate, endDate, registrationDeadline, earlyBirdDeadline) from the
event's TZ to a UTC ISO string so the wall-clock time the admin entered
is preserved regardless of their browser TZ. On edit, render stored
UTC back through the event's TZ so the round-trip is stable.
Reuses TIMEZONE_OPTIONS from ~/config/timezones and the picker pattern
from member/profile.vue. Auto-imported helpers from app/utils/timezones
do the math via Intl.
Events stored UTC have always been interpreted in viewer-local TZ at
the admin form and render layer. Adding an event-owned IANA timezone
unblocks accurate scheduling and display regardless of the admin's or
viewer's browser TZ.
- Event.displayTimezone (default "America/Toronto") on the model.
- displayTimezone added to admin create/update Zod schemas.
- app/utils/timezones.js: zonedLocalToUTC, utcToZonedLocal,
shortTimezoneName — Intl-based helpers, no new dependencies.
The old "Members always get free access" sat at the bottom of the
Ticketing section next to the top-level Enable Ticketing toggle, which
conflated the member-vs-public audience split with the ticketing
mechanism. Admins read it as "I need to enable ticketing for free
public events," the opposite of how the system works.
Move the note next to Public Tickets Available (where the audience
split actually matters) and rephrase: public pricing applies to
non-members; members register from their dashboard regardless.
The natural-language input box kept its placeholder visible after a
date was parsed, with the green confirmation pill rendering below.
Several admins read this as "the input is empty." Hide the input once
parsedDate is set; show only the green pill with an Edit link that
clears the parse and re-opens the input.
Events are often scheduled before the platform (Zoom link, Slack
channel) is chosen. The current workaround is a placeholder URL like
"https://us02web.zoom.us/j/TBD", which leaks to the public page as a
broken link.
Accept the literal "TBD" (case-insensitive) in both the Mongoose
validator and the form-side validator. The public detail page renders
"Platform TBD" instead of a link when the location matches.
The Event model and Zod schemas already supported membersOnly, but the
admin form never exposed it — public/private was implicit and not
editable from the UI.
Add a fifth checkbox alongside the other Event Settings, hydrate it on
edit, reset it in saveAndCreateAnother.
The breadcrumb's trailing span pulls its label from pageBreadcrumbTitle,
which pages set in script setup after a useFetch. SSR rendered the URL
slug there; client rendered the title; Vue logged a hydration text
mismatch on every detail page.
Wrap the last segment's span in ClientOnly with an nbsp fallback so the
SSR DOM stays the same shape but defers the text to the client. The
prior attempt at this in layouts/default.vue + layouts/admin.vue was
reverted on this branch — it changed segment counts and produced a
worse node-structure mismatch.
ColumnsLayout mounts inside <ClientOnly> on /member/dashboard, so the
SSR fetch returned the empty default and the snapshot assignment to
upcomingEvents never re-ran after the client-side fetch resolved.
Skip the SSR phase explicitly (server: false) and expose data to the
template via a computed so post-fetch updates propagate to
EventsMiniSidebar.
The breadcrumb computed fell back to the URL slug when
pageBreadcrumbTitle was empty. SSR rendered the slug; the client
re-rendered the page-supplied title after useFetch, triggering a
hydration text-content mismatch on span.breadcrumb-current on every
detail page.
Intermediate segments are stable across server and client, so render
them unconditionally. Only include the trailing segment when a page
provides a title.
Top-level $fetch in <script setup> does not forward auth cookies to the
SSR request, so requireAdmin rejected and the form hydrated empty.
Client refetch then triggered hydration mismatches; in dev the
description textarea stayed DOM-empty and the browser's native required
validation blocked saves.
Switch to useFetch (SSR-aware, forwards cookies). Mirror the
admin/members/[id].vue pattern: extract populateEditForm, call it with
the initial payload, watch for client-side updates.
- Hide the location field's static help text when a validation error is
shown so the two near-identical messages stop stacking.
- Replace `process.client` with `import.meta.client` (Nuxt 3+ pattern).
- Accept either String or Date for EventTicketPurchase.eventStartDate;
the parent passes the API's ISO string, which was logging a Vue prop
type warning on every public event page render.
Editing an event was pulling its UTC startDate/endDate, slicing off the
"Z" with toISOString().slice(0, 16), and then handing the bare digits to
a datetime-local input. The input reinterprets them as local time, so
each save shifted the time by the browser's UTC offset. Same pattern
for registrationDeadline and earlyBirdDeadline.
Format the value using local-time components instead so the round-trip
matches what the admin sees.
Admins can now load the public site and their dashboard while coming-soon
mode is on, instead of being redirected to /coming-soon for everything
outside /admin/*.
Visual snapshots were generated on macOS but CI runs on Linux, and
font hinting differences between the two would always produce false
positives. The job was already continue-on-error and the baselines
weren't giving trustworthy signal — remove the spec, baselines, CI
job, and now-unneeded snapshot config / --ignore-snapshots flag.
Functional e2e coverage in the playwright job is unaffected.
Visual baselines were generated as chromium-darwin.png on macOS; CI on
Linux looked for chromium-linux.png and every test failed with
"snapshot doesn't exist". Override snapshotPathTemplate to omit the
platform suffix so darwin and linux share the same baseline. Pixel
diffs from font hinting are an accepted trade-off — visual regression
gives signal for big visual breaks, not 1-pixel differences.
Existing 26 baselines renamed from *-chromium-darwin.png to
*-chromium.png to match.
invite.post.js requires process.env.BASE_URL to build the invite link,
returning 500 when unset. The CI workflow stubbed Resend / Mongo / JWT
but missed BASE_URL, so the admin-pre-registrants invite spec timed
out waiting for the success toast. Set BASE_URL to the test server's
URL on both jobs.
The ALLOW_DEV_TEST_ENDPOINTS short-circuit on create wrote
'dev-stub-<ms>' as the channel ID. boardChannelUpdateSchema requires
^[A-Z0-9]+$, so the very next edit on the same channel hit a 400 from
Zod and the table never updated. Use base36-uppercased timestamp with
a 'CDEV' prefix so the stub survives a round-trip through the patch
route. Live path is unchanged.
The .is-cancelled row used opacity:0.5, which dragged --text-faint
(#665c4b) on the cream background to a 2.1:1 ratio against #f4efe4 —
serious axe violation flagged in CI. Strikethrough on the title and
tagline conveys the cancelled state without crushing contrast; the
existing .cancelled-tag in --ember still flags the row.