The Fieldhouse — Changelog
User-facing release notes. Server changes are bundled with the iOS build that surfaces them — version numbers track the iPhone app build. For ASC "What to Test", paste the matching section verbatim.
1.0.0 (build 87) — 2026-05-23
Log noise pass + super-admin log viewer. Build 86 went out for testing; while it's in TestFlight we cleaned up logging on both sides and added the admin tools we'd want when triaging coach reports.
iOS noise fixes:
- Transport-cancelled errors no longer log.
APIClient.performnow classifiesURLError.cancelled/CancellationErrorand skipsrecordErrorfor them. These were normal SwiftUI lifecycle events (pull-to-refresh superseded mid-flight,.taskview dismount on tab switch, navigation-back during fetch) — they polluted the support ring buffer every time a coach scrolled through tabs. - 401s no longer log. AuthStore catches
.unauthorizedand signs the user out silently; the log line was redundant noise on every cold start where the token had rotated. - PushRegistration
print()→os_log.debug. Four call sites (3 inPushRegistration.swift, 1 inTheFieldhouseApp.swift's APNs delegate) demoted so simulator runs + no-entitlement dev builds don't spam the Xcode console..debuglevel stays out of release Console captures by default.
Server noise fixes:
push.tssend-failure log now skips expected end-of-life responses (Unregistered,BadDeviceToken, 410 GONE). Devices that uninstalled the app were generating one warn line per push-fanout × per dead token. The audit row inpush_delivery_logstill captures everything; the warn is now for genuine deliverability problems only.weather.tsandgeocode.tsdrop transient 5xx + 429 + timeout logs. NWS and Nominatim are free public services that routinely backpressure; the sweepers retry on the next cron tick anyway, and a 503 storm is visible elsewhere (missing weather pills, no map preview).email-verification.tssuccess log removed. Every signup + every "didn't get the email" tap was logging an info line, which buried real failures.
Super-admin log viewer (new at /admin/logs):
- New
client_error_logtable — iOS uploads the in-memory error ring buffer when the user taps "Send logs to support" in More. Indexed on(user_id, occurred_at)+occurred_at+(status_code, occurred_at)for the three common triage queries (per-user history, time-window scan, all 5xx in the last hour). - New
POST /api/v1/me/client-errorsendpoint accepts up to 50 entries per request, validates + truncates fields to schema caps, returns{ ok: true, accepted: N }. Auth required (user can only upload their own buffer). - iOS
APIClient.recordErrorrefactored from string buffer to structuredClientErrorEntry(occurredAt, path, errorClass, statusCode, message). Email composer still renders strings viarecentErrorsSnapshot(); upload usesrecentErrorEntries().SendLogsViewfires upload alongside opening the mail composer — user explicitly consents by tapping the button, no silent background collection. /admin/logspage unifies three sources on one timeline: client_error_log + push_delivery_log (failures only —okrows would drown 50-parent push fanouts) + supportActionsAudit (admin-action history per target user). Filters: user (email substring OR exact UUID), time window (1h / 24h / 7d / 30d), source multi-select. URL-state for shareable/back-buttonable filtered views. Linked from admin nav under "More → Logs".
Login screen polish (iOS):
- Vertical gradient on LoginView. Soft brand-green (
#D8EADC) at top fading into the regular background. Hoisted to aZStackwith.scrollContentBackground(.hidden)on the inner ScrollView so NavigationStack's scroll-edge background can't paint over it (the first attempt with.background(LinearGradient(...))on the ScrollView itself was invisible for exactly that reason). Only native auth surface — signup + forgot-password are ASWebAuthenticationSession / SafariView handoffs to the web.
Marketing site — teen player persona surfaced (web):
- Homepage persona tab strip extended from 3 to 4 tabs
(parents / coaches / schools+clubs / teen players (13+)).
Layout swapped from
flex overflow-x-autotogrid grid-cols-2 md:grid-cols-4— 2×2 on mobile, 1×4 on desktop. NewPERSONA_CONTENT.teenships 6 marquee cards (Account, Schedule, Chat, Stats, Privacy, Boundaries) leaning into the COPPA-safe framing: teen runs their own day, parents stay in the loop on safety bits (chat edits, consent forms, ref payments) without watching over their shoulder for everything. /how-it-works"What each person sees" grid gets a new Teen player (13+) card slotted between Rostered parent and Grandparent. Six condensed bullets mirroring the homepage messaging. Grid now fillslg:grid-cols-4cleanly (4-then-2 instead of the previous 5-card 4-then-1 wrap)./roadmapgets the same 4th-tab treatment. NewROLE_HIGHLIGHTS.teenwith 10 shipped-feature highlights curated for teen-player use cases. The homepage CTA "Everything else teen players get →" now deep-links into the filtered view via?as=teen.
iOS build 86 → 87.
1.0.0 (build 86) — 2026-05-22
Two paper-cut fixes. Both iOS-only (no server schema / behavior changes) — caught after build 85 went to beta coaches.
- Edit-button RBAC fix on iOS event detail. Non-org-staff
(co-parents on real teams, parents without org membership) used
to see the Edit button via the looser
canManageEffectiveflag, but server PATCH/api/v1/events/[id]was already gated onisTeamCoachand would 403 on save.EventDetailView.canEditnow usesisOrgStaffEffectivefor real teams; personal-org family schedules still usecanManageEffective(co-parents on their own family schedule legitimately edit there). The looser gate cascades throughcanEditScore, so score controls + ref picker + ref-pay row also correctly disappear for non-staff on real teams. - Detail-view in-place refresh after edit. Editing time,
location, title, opponent, or notes used to leave the underlying
detail view stale until the user backed out and re-entered. New
GET /api/v1/events/[id]route returns the hydrated event; iOSEventDetailView.eventpromoted fromlet→@Statewith an explicitinit; newreloadEvent()helper fires when EditEventSheet completes withdidChange=true. PATCH still returns{ ok: true }only — we re-GET to pick up the updated shape.
Server:
- New
GET /api/v1/events/[id]endpoint. Same access posture as the rest of event-detail (getEventForUser→hasTeamReadAccess, inclusive of family-schedule co-parent managers). 404 on no-access matches the deny-by-existence pattern elsewhere.
Note in lib/family-schedules.ts:48 (no behavior change) documents
the intentional organizations.type = "personal" defense-in-depth
on isFamilyScheduleCoManager — so a future personal-to-real org
conversion auto-demotes co-parents to read-only without migration
code, and points future contributors at lib/invites.ts:180 for
restoring edit access via the normal coach-invite flow.
iOS build 85 → 86.
1.0.0 (build 85) — 2026-05-18
iPad-native release + Coach Mode (first cut).
Folded the v1.1 iPad milestone into v1.0 after beta coaches asked for live-game scoring on iPad ahead of games Thu-Sat. v1.0 is now a Universal app on a single IPA — same bundle ID, same App Store listing, same TestFlight invite serves both device classes (plus Apple Silicon Macs via "Designed for iPad on Mac," default on).
iOS foundations:
- Flipped
LSRequiresIPhoneOS = falsein bothios/sources/Info.plistandios/TheFieldhouse/Info.plist. AddedUIRequiresFullScreen = falseso Stage Manager / Split View work. TARGETED_DEVICE_FAMILYinproject.pbxprojwas already1,2(iPhone + iPad) and1,2,7on the widget extension — only the Info.plist hard gate was holding iPad back. Single flag flip, immediate Universal binary.
Coach Mode (new iPad-only surface, full-screen):
- New module at
ios/sources/Views/Events/CoachMode/withCoachModeView.swift(top-level),CoachModeStore(@Observablestate graph), and inline panes for bench rail / on-field column / scoreboard / stat grid. Matches the Claude Design canvas atdocs/coach-mode-design/project/pixel-for-pixel — column widths 76 + 240 + 628 + 432pt = 1376pt total (13" iPad logical). - New
CoachModeSportconfig (ios/sources/Models/) mirroring the design'sSPORTSconst — 8 sports (soccer/basketball/baseball/ softball/volleyball/hockey/football/lacrosse) with per-sport stat grids, clock types, on-field defaults. - State machine: pre → live → paused → scored (2s transient) → final. Just-scored pill animates in below the scoreboard tinted in the scoring team's brand color.
- Bench / on-field split + substitution flow. When the coach
taps Go live, the roster pane splits into a 76pt collapsed bench
rail (jersey badges + last name) + 240pt on-field column
(brand-green-tinted, sport-aware "On the pitch / court / etc."
header). Hamburger expands the bench as a 280pt drawer overlay.
Tap-to-sub-in/out, with sub-off arrows pointing left toward the
bench. Open slots show dashed-border placeholders when the team
is short of
activeCount. - Cumulative playing-time ticker. Each player accumulates game-minutes across multiple stints — sub-out freezes the counter, sub-in resumes from the frozen value. Drives the "fair minutes" coaching use case during the game AND surfaces as a Total play time row on the per-event stats card + per-kid season page (server reconstructs from substitutions log timestamps).
- Sport-aware stat grid (2-col, 132pt tiles, brand-green primary scoring stat, amber/red tone overrides for cards/fouls/errors).
- Entry button in
EventDetailView— gated on iPad regular size class + canEditScore + game-type event + sport with a config. iPhone keeps the existing inline live-scoring flow untouched. - Landscape-only canvas. The 4-pane layout is sized for ~1376pt
of width and gets crushed in portrait. On Coach Mode open we ask
the active
UIWindowScenefor landscape viarequestGeometryUpdate(re-fired on app foreground in case the coach backgrounded mid-game). If the device stays portrait (rotation lock on, or a narrow Stage Manager window), an overlay with an animatedrotate.right.fillSF Symbol + "Rotate iPad to landscape" prompt- Close button covers the canvas. The rest of the app keeps full rotation freedom — only Coach Mode requests the flip.
- Removed the running game clock from the scoreboard. There's no
way to keep Coach Mode in sync with the physical clock on the
field, and a free-running mm:ss ticker that drifts away from
reality is worse than no clock at all. The middle column now
shows only the period chip (Q1/SET 1/↑1) with the ▸/▴ stepper —
coach advances periods manually. Per-player playing-time tickers
are unaffected; they're driven by the substitutions log and
remain accurate. (
formatMinSechelper removed; store-level live-seconds accumulator kept, still wired into pause/resume in case we surface "game duration" on End Game later.) - Score digits no longer vertical-stack at double-digit values.
The 96–160pt score Text was hitting SwiftUI's character-by-
character fallback when "12" didn't fit the column at the largest
size. Added
.lineLimit(1)+.minimumScaleFactor(0.5)so 2- and 3-digit scores either fit at full size or shrink uniformly. - Sport-aware period labels + caps. The chip now renders the
actual period name instead of
"<prefix><n>": soccer shows "1st Half / 2nd Half / OT / 2OT", basketball "Q1–Q4 / OT / 2OT", hockey "P1–P3 / OT / SO", volleyball "Set 1–5", football + lacrosse "Q1–Q4 / OT". Baseball / softball keep the computed "↑ Inn N" with extras through inning 12. NewperiodLabels+maxPeriodonCoachModeSport.Configdrive both the chip label and the+stepper cap, so a soccer coach can't accidentally push past "2OT" and a basketball coach can't land on "Q9". - Lacrosse — "Caused TO" tile added to the Coach Mode grid.
Server-side stat key (
caused_turnovers) was already in the lacrosse allowlist; this build surfaces it as a tappable tile. teams.activeCountwired end-to-end (schema column shipped with the substitutions migration but was previously unread):TeamSummary(queries.ts) now selects + returnsactiveCounton bothlistUserTeamsandgetTeamForUser.- iOS
APITeamgainsactiveCount: Int?(back-compat optional). - Coach Mode's
activeCountTargetprefersteam.activeCountover the sport default — so a 7v7 small-sided soccer team renders the "X / 7" chip instead of "X / 11". PATCH /api/v1/teams/[id]acceptsactiveCount(integer 1–30 or null-to-clear) with validation; UI form field stays a post-v1.0 polish item.
- Undo last — fully implemented. Replaced the stub with a 50-
entry in-memory undo stack. Each
recordStatpush records the player, the server-key increments, the signed score bump (if any), and whether it triggered the "just scored" pill. Undo pops the entry, reverses every increment + score delta (clamped at 0), re-pushes the player's corrected blob (the server stores full snapshots — no DELETE endpoint needed), and dismisses the just- scored pill if it's still up from the same tap. Undo button now gates oncanUndo(live game state + non-empty stack), not selected-player — so a coach can undo a tap made on a different player without re-selecting them.
Brand:
- Added missing tokens to
Brand.swiftfrom the Coach Mode design:greenSoft(#D8EADC),amberSoft(#FEF3C7),redAccent(#B91C1C),redSoft(#FEE2E2),lineSoft(#F1F2EE). Web'sglobals.cssalready had--color-brand-softand--color-accent-soft; iOS now matches.
Server:
- New
substitutionstable insrc/db/schema.ts—(id, event_id, player_in_id, player_out_id, marked_by_user_id, at_timestamp). Either side nullable: nullplayerOutId= initial-lineup write at Go-live; nullplayerInId= bench-out- without-replacement (rare). Cascades on event delete; indexes onevent_id+(event_id, at_timestamp)for replay reads and on each player FK for season totals. - New
teams.active_countinteger column — nullable, falls back to sport defaults when unset (soccer 11, basketball 5, volleyball 6, baseball/softball 9, football 11, hockey 6, lacrosse 10). - POST
/api/v1/events/{id}/substitutionsandGETfor replay reads are placeholders this build — the in-memory store handles the live game; server persistence wires in the next iteration before Apple submission.
Pencil signing + in-person witness (consent forms):
- New
signatureIsPencilboolean +witnessedByUserId(FK users)witnessedAttimestamp columns onconsent_forms. Indexed on witnessedByUserId for the "show every form Coach X witnessed" admin audit lookup.db:pushhandles the migration.
signFormlib +/api/v1/consent-forms/{id}/signroute + the websignFormActionnow acceptisPencil+witnessedByUserId. Server validates the witness has coach access to the form's team and rejects self-witness attempts.- iOS
PencilSignatureCanvas(PKCanvasViewwrapper) replaces the finger-touch DragGesture canvas on iPad. Detects Apple Pencil input via PKStrokePath azimuth/altitude probing — finger strokes still work, but only Pencil-touched strokes flip the isPencil flag. iPhone stays on the legacy canvas (well-tested). - iPad consent-sign view gets an "Apple Pencil" inline confirmation chip when Pencil strokes are detected, plus a "Witnessed in person by [issuing coach]" toggle (only shown when caller != the form's issuer, so the parent at the registration table can flip it on with the coach standing next to them).
- Audit-trail UI on the signed-form card surfaces two new badges:
"Apple Pencil" (when
signatureIsPencil) and "Witnessed by Coach X" (when witnessedByName is set). Web's family-side consent form detail also adds witness + Pencil rows to the audit dl.
iOS build 84 → 85.
1.0.0 (build 84) — 2026-05-17
Three bug-fix-bucket items: family-tier dashboard trap + org-creation type gate + parent-of-rostered-kid read-access gap.
Parents now see events on teams where their kid is rostered, even without an org membership.
hasTeamReadAccess()insrc/lib/events.tshad two accept paths (org membership / family-schedule co-manager, and per-team viewer grant) but was missing the parent-of- rostered-kid path thatgetTeamForUseralready had — so the demosam@example.comcould open her kid's team's detail page but the events list, calendar, and per-kid season dashboard all came back empty. Added the third path mirroringgetTeamForUser's co-manager fallback:playerManagers.role = "manager"joined throughteamPlayersto the requested team. Fixes per-kid season stats rendering on the demo (Jordan / Maya / Riley) and for any real parent in the same shape.Dashboard no longer redirects parents-of-someone-else's- team to onboarding. The empty-state redirect on
/apponly checkedlistUserOrgs(user.id)(strictly membership- based), so a parent like the demosam@example.com— who manages kids on a school's team but has zero org memberships of her own — got bounced to/app/onboardinginstead of seeing her dashboard. The redirect now fires only when memberships AND co-managed teams (viaplayerManagers) AND family schedules are all empty. Surface area unchanged for true-empty users.Org-creation type picker gated to Single Team for family-tier callers. A parent whose only memberships are personal-tier orgs (or who has no orgs at all) can now only create a free Single Team org via the "+ New organization" or upgrade flow. Club / School stay reachable via the per-org Upgrade button on billing once the free Team exists. The "you upgrade to a Single Team first, then optionally upgrade tier" path matches the "Add Team org" CTA's behavior from build 53 — now it's consistent across every org-creation entry point.
Server enforcement (mirror of the personal-org chat/consent carve-out in build 83):
src/lib/queries.ts— newisFamilyTierOnly(userId)helper. Returns true if every membership the user has is in a personal-tier org, or they have none. Used by both the UI gate and the action gate.src/app/app/onboarding/actions.ts—createOrgAndTeamActioncoercesorg_typeto"team"when the caller is family-tier-only, regardless of what the form submits. Belt-and-suspenders against a forged POST.
Web:
src/app/app/onboarding/page.tsx—lockedTeamOnlynow fires either when?kind=teamis on the URL (the Family→ Team CTA's existing path) OR when the caller is family- tier-only. Same fixed "Single team · Free forever" pill- footer copy in either case.
src/app/app/page.tsx— fetches orgs + teams + family schedules in parallel before deciding to redirect, so a parent-of-someone-else's-team lands on the dashboard instead of bouncing to onboarding.
iOS:
TeamsView.swift— Teams tab "+ New organization" entry computesfamilyTierOnlyfromauth.organizationsand passeslockedOrgType: "team"toNewOrgSheetwhen true. Existing Family→Team CTA path is unchanged. Server enforces the same rule.
iOS build 83 → 84.
1.0.0 (build 83) — 2026-05-17
Scope-oriented cleanup — chat + consent forms hidden on personal (family-tier) teams.
Personal orgs are one-parent calendars tracking a kid's external activities. Two surfaces never made sense there:
- Team chat presumes a multi-party audience. On a personal org the audience is at most one co-parent, and that pair already has iMessage / text / email.
- Consent forms presume a coach↔parent legal relationship. On a personal org there's no opposing party to sign — you don't sign a concussion baseline to yourself.
Both are now gone from personal-tier team pages on iOS and web, with the same defense-in-depth pattern as the moderator photo carve-out: client hides the UI entry, server-side gate 404s the underlying route so a forged request can't reach it.
Server:
- New
isTeamPersonalOrg(teamId)helper insrc/lib/access.ts. isChatMember()insrc/lib/chat.tsshort-circuits tofalsefor personal-org teams, which closes every chat route that goes through the membership gate (messages, read receipts, reactions, bans, admin-subscription, the team's my-consent-forms inbox)./api/v1/teams/[id]/consent-formsGET + POST and/api/v1/teams/[id]/consent-form-draftsGET + POST both return 404 when the team is personal-org, regardless of role.
Web:
src/app/app/teams/[id]/page.tsx— Team chat and Consent forms links wrapped inteam.orgType !== "personal"gate.src/app/app/teams/[id]/chat/page.tsx— explicitnotFound()on personal orgs (instead of the misleading "Team chat is for parents and coaches" empty state).src/app/app/teams/[id]/consent-forms/page.tsx— same notFound check.my-consent-formspage closes automatically via theisChatMembergate.
iOS:
TeamDetailView.swift— Team chat and Consent formsMenurows now gated by!team.isPersonal. The existingteam.isPersonalflag onAPITeammade this a two-line change per surface.
No data migration; legacy chat history and consent forms (if any ever existed on a personal-tier team due to manual SQL or a pre-gate bug) remain in the DB but are now unreachable.
iOS build 82 → 83.
1.0.0 (build 82) — 2026-05-17
Heads-up cell now shows the event time, not "now."
Same-venue heads-ups on the Today tab and at the top of Parent
friends were silently rendering the current time instead of
the friend's event time. iOS's default ISO8601DateFormatter
doesn't accept the .000Z fractional-seconds suffix that
Node's Date.prototype.toISOString() always emits, so the
parse failed and ?? Date() fell back to "now."
Fix: new FieldhouseISODate.parse(_:) helper in APIModels.swift
tries [.withInternetDateTime, .withFractionalSeconds] first and
falls back to no-fractional for NWS-style strings with numeric
offsets. Both TodayView and FriendsView route through it
and render "soon" instead of Date() on parse failure, so the
silent-drift class of bug can't recur.
iOS build 81 → 82. No server changes.
1.0.0 (build 81) — 2026-05-17
Map preview on event details + same-venue heads-up cell in Parent friends + privacy-first marketing page + admin overview 500 fix + parent-friend list 500 fix.
iOS:
- New
EventLocationMapCellabove the "Where" card on event detail screens. StaticMKMapSnapshotter(no live map view, no location services, no GPS prompt) rendered from the venue coordinates the coach entered. 150pt tall, brand-green pin overlay, NSCache by(lat, lng, width, scale)so re-renders from rotation or scroll-back don't thrash. Tap = same action as the existing Get directions pill. - Same-venue heads-up section now lives at the top of the
Parent friends list (consumes
/api/v1/me/heads-ups). The Today tab already had it from build 76 — this just brings the surface to the place a parent goes to look for it. - Removed the "follow-up builds" footer text on
FriendsView— chat and heads-ups both shipped, the placeholder copy is stale.
Server:
src/lib/events.ts— addedlocationLat/locationLngto the event payload in bothlistTeamEventsInRangeandgetEventForUser. Drizzle's numeric column comes back as string through the postgres driver; coerced at the boundary via aparseLatLnghelper.src/lib/parent-friends.ts— fixed a 500 inlistFriends()introduced in build 80's recency sort. Thesql<Date>aggregate type annotation is TS-only; the postgres driver returnsmax(...)as a string, then the route's.toISOString()crashed. Coerced to Date at the boundary.src/lib/cron-log.ts— fixed a 500 on/adminoverview for super-admins.listCronHealth()uses rawdb.execute(sql\…`)to doDISTINCT ON; raw queries bypass Drizzle's per-column parsers, sostarted_atwas a string androw.startedAt.getTime()` crashed in the page render. Same coerce-at-the-boundary pattern.src/app/app/friends/page.tsx— added the amber same-venue heads-up section to the web friends page (matching the iOS surface). Removed two stale "Coming soon: …" lines.src/components/MobileNav.tsx— added "Parent friends" entry to the Home sidebar so the page is discoverable from the web dashboard.
Web:
- New
/privacy-firstmarketing page with header link. Eight "We don't / We do instead" pairs covering map previews, under-13 name redaction, photo carve-out for moderators, identity cards, no-impersonate stance, parent friends privacy rules, no-third-party-tracking, and two-key admin actions. Cross-linked from the formal/privacylegal page. - Owner labels surfaced everywhere an org renders on the
admin side (
/admin/orgs,/admin/orgs/[id],/admin/broadcastspicker + history,/admin/sponsors,/admin/rosters,/admin/users/[id]) via a newloadOrgOwnershelper insrc/lib/admin.ts. Picks the earliest-joined owner per org. Two orgs both named "Cheer" are now disambiguated by owner name at every admin glance. /adminheader nav: Photo reports + Exports + Consents consolidated into a native<details>"More ▾" dropdown with brand-tinted chevron. Global search input shrinks to a 112px "Search" pill and expands to 288px on focus, so it no longer crowds the new dropdown./sideline#whats-newcondensed: collapsed the v0.9.0–0.10.0 block from 7 → 2 bullets, every v0.10.* block from 4–40 bullets down to 2–8, and pulled the 1.0.0 / server-side items out of the misnamed v0.10.9 block into a proper new v1.0 ReleaseBlock at the top.
iOS build 80 → 81.
1.0.0 (build 80) — 2026-05-16
Parent friends — recency sort + search.
The friends list got two QoL wins for parents who connect with more than a handful of others:
- Friends are now sorted by most-recent chat activity (max of the last message timestamp in either direction, falling back to friendship-created-at for unmessaged pairs). The friend you actually talk to floats to the top — iMessage-style.
- New
.searchable(...)filter on the iOS Parent friends screen and a matching search input on the web/app/friendspage. Filters by name + email case- insensitively.
Server: new fetchLastMessageTimes helper in
src/lib/parent-friends.ts does the max-aggregate join in one
round-trip. Cross-stitched into listFriends() which now
returns a lastActivity field per friend.
iOS build 79 → 80.
1.0.0 (build 79) — 2026-05-16
Friend invite "circles back to Safari" loop fix.
Build 78 wired the fieldhouse://friend?code=… custom URL
scheme, but tapping "Open in The Fieldhouse" from the Safari
fallback caused a loop: the app received the deep-link intent →
FamilyView's handleFriendInvite opened Safari at /go/<code>
→ server redirected to /code/friend?code=… → same Safari page
again → "Open in The Fieldhouse" → app → loop.
Root cause: handleFriendInvite was a build-74 placeholder that
deliberately bounced to Safari because the iOS app didn't yet
have an inline redemption flow. Build 77 added that flow
(POST /api/v1/me/friends/redeem-code); build 78 should have
switched to it but didn't.
Fix: handleFriendInvite now redeems inline via the API and
pushes FriendsView onto the family-tab NavigationPath
(FriendsLandingDestination marker + matching
.navigationDestination(for:)). User taps "Open in The
Fieldhouse" once → app foregrounds on FriendsView with the new
pending request already in the "Pending requests for you" list.
iOS build 78 → 79.
1.0.0 (build 78) — 2026-05-16
Friend invite reliability — AirDrop fallback + idempotent redeem.
Two related fixes after real-world testing of build 77:
- AirDrop'd links opened Safari, not the app. iOS intentionally
does NOT trigger Universal Links from AirDrop'd URLs (or
clipboard pastes, or manually-typed URLs) — only from Messages,
Mail, and a few system contexts. Adding
/code/friendto AASA closed the gap for Messages/Mail but not AirDrop. Fix: register afieldhouse://friend?code=…custom URL scheme handler in iOS NavRouter, and expose an "Open in The Fieldhouse" button on the Safari/code/friendpage. AirDrop'd URLs still open Safari (Apple's call), but one button tap hops into the app via the custom scheme. Works whether the user is signed in or not. - "Exhausted" error on manually-entered codes. Real-world
flow: user taps an AirDrop'd link → Safari renders
/code/friend→ that page burned the code (use_count→1 against maxUses=1) → user then opens iOS app and types the same code manually → server returned "exhausted." Fix:redeemFriendInvitenow usespeekShareCode(read-only) instead ofredeemShareCode(which increments use_count). Idempotency is one level up via the parent_friend_requests unique-pending-per-pair constraint — repeated redemptions return the same pending row instead of burning the code.
Changes
lib/parent-friends.ts: switched the redemption read topeekShareCode. Existing single-use friend codes minted in 76/77 retroactively benefit.- iOS
NavRouter.intent(for:): recognizesfieldhouse://friend?code=…and routes to.openFriendInvite(code:). /code/friendpage (web): "Open in The Fieldhouse →" button rendered for both authenticated and unauthenticated users. Unauthenticated case no longer immediately redirects to /login — shows the deep-link button + a "Sign in on web" fallback.
iOS build 77 → 78.
1.0.0 (build 77) — 2026-05-16
Parent-friend deep linking + manual code entry.
- Deep linking fix. The friend share URL used to be
thefieldhouse.app/go/<code>, which goes through Safari first (the/gopath isn't in AASA), and Safari's server-side redirect to/code/frienddoesn't retroactively trigger Universal Links. The mint endpoints now produce direct/code/friend?code=<code>URLs, and AASA gains a/code/friendentry, so iOS catches the tap on first touch and routes to FriendsView. - Manual entry on iOS. Family tab → Parent friends → new
"Got a friend code?" section with an "Enter a code" row. Tap
→ sheet with a 6-char text field (auto-format XXX-XXX, same
alphabet as web). Submit → POST
/api/v1/me/friends/redeem-code; server creates a pending request; iOS refreshes inline and the new request shows in "Pending requests for you." - Manual entry on web already worked via
/code→ submits to/go/<code>which peek-and-redirects friend codes to/code/friend. No change required.
Changes
api/apple-app-site-association/route.ts— added/code/friendto claimed paths.lib/parent-friends.tscallers updated to build the share URL as/code/friend?code=<code>(web action + iOS API endpoint).- New API:
POST /api/v1/me/friends/redeem-code(returns pending request id + inviter info, oralready_friends). - iOS:
ManualFriendCodeSheet.swift+ new section inFriendsView, plusAPIClient.redeemFriendCode(...).
iOS build 76 → 77.
1.0.0 (build 76) — 2026-05-16
Parent friends — payload features (builds 75 + 76 combined).
Build 74 shipped the connection layer. This build adds both payload features that justify the connection: same-venue heads-ups (the "build 75" scope) AND 1:1 friend chat (the "build 76" scope) in a single iOS archive.
Same-venue heads-up
When you and a parent friend both have events at the same venue within ±6 hours of each other, the dashboard surfaces a heads-up card: "Sam also has an event at Fairgrounds at 11am."
- Privacy: friend display name + venue name + event start time only. No kid names, no team names, no event titles for the friend's side. Minimum disclosure that delivers the value.
- Window: ±6 hours of overlap on the same
locationId. That covers tournament days (multiple games at the same complex spread across the morning) and back-to-back scheduling. - Surfaces: card on
/appdashboard (web) + Schedule tab on iOS (above the view-mode picker). - API:
GET /api/v1/me/heads-ups.
1:1 friend chat
Direct text-only chat thread between two connected parent friends. Pure adult-to-adult; nothing about either party's kids, rosters, schedules, or chats leaks into the conversation.
- MVP scope: text only. Soft-delete by sender (body → null, "Message deleted" placeholder until purge). No edits, no reactions, no GIFs, no media in this build.
- Retention: 90-day rolling — same retention window as team chat. The existing chat-purge cron extends to sweep friend messages too.
- Push: per-message APNs to the recipient. Title is the sender's display name; body is the message (truncated at 120 chars).
- Surfaces:
/app/friends/[id]/chat(web) + tap a friend in the iOS Family tab → FriendsView → tap any friend to push FriendChatView. - APIs:
GET /api/v1/me/friends/[otherUserId]/messages?limit=…POST /api/v1/me/friends/[otherUserId]/messagesDELETE /api/v1/me/friends/messages/[messageId](sender only)
Schema
- New table
parent_friend_messageswith composite-ordered friendship pair, sender, body (nullable for soft-delete), createdAt, deletedAt. - 90-day purge sweep added to
chat-purgecron.
Marketing site
/how-it-worksgains a "Parent friends — for the other team in the same complex" feature card./privacygains a new "From parent friends + 1:1 friend chat" subsection enumerating what data the connection layer- payload features hold.
/roadmap(and persona-tabbed shipped highlights) — both parent-friend bullets added to the Parents shipped list.
No new env vars. iOS build 74 → 76 (skips 75 — both scoped features ship in this archive). db:push lands parent_friend_messages.
1.0.0 (build 74) — 2026-05-16
Parent friends — connection layer (build 1 of 3).
A way for two parents to connect across teams so they can coordinate when their kids are at the same field complex on different teams. This build ships the connection mechanism only; same-venue heads-ups land in build 75 and 1:1 parent chat in build 76.
Connection flow
- Parent A taps "Mint friend code" on the iOS Friends view (or web
/app/friends). Gets a 6-char share code + athefieldhouse.app/go/<code>URL. - Shares the URL or QR via Messages, AirDrop, etc.
- Parent B taps the link. iOS / browser opens
/code/friend?code=…(via /go's peek-without-burn redirect); server redeems the code, creates a pending friend request from A → B. - Parent B sees the request on
/app/friends(web) or the Family tab → Parent friends (iOS) → tap Accept → both parties land in each other's friends list.
Privacy posture
- Friendship is explicitly adult-to-adult. It grants nothing about either party's kids, rosters, schedules, or chats.
- Payload features (same-venue heads-up, 1:1 chat) will surface only what each friend explicitly opts into per-friend.
- Friend invite codes are single-use + 14-day TTL.
- No friend-discovery surface (no "people you may know"); connection is always opt-in via a shared code.
Schema
parent_friend_requeststable — one row per A → B handshake; pending → accepted / declined / cancelled.parent_friendshipstable — composite PK (userAId, userBId) with the invariantuserAId < userBIdenforced via CHECK so we store one canonical row per pair, not two.- New
parent_friend_inviteshare-code kind.
Surfaces
Web:
/app/friendspage with mint-code card + pending/incoming/outgoing lists./code/friend?code=…auth-gated landing for redeemers (used by /go/'s server-side peek-and-redirect).
iOS:
- Family tab → "Parent friends" row → FriendsView.
- FriendsView mints + shares + QR + accept/decline/remove.
- Universal Link handler for
/code/friend?code=…— opens Safari to the server's confirm screen.
API
GET /api/v1/me/friends— list friends + pending in/out.POST /api/v1/me/friends/invite— mint.POST /api/v1/me/friends/requests/:id/acceptPOST /api/v1/me/friends/requests/:id/declineDELETE /api/v1/me/friends/:otherUserId
No new env vars. iOS build 73 → 74. db:push lands the new tables.
1.0.0 (build 73) — 2026-05-15
Final-score lingers on the lock screen after game ends.
End-game push previously auto-dismissed the Live Activity after 60 seconds with no visual distinction from the running game — the "Live" badge stayed up, the "Updated Nm ago" footer kept climbing, and parents couldn't tell at a glance whether the score was live or final.
Three coordinated changes:
- iOS ContentState gains
isFinal: Bool?(optional + defaults to false so in-flight activities keep decoding). Set to true on every end-game push. - Lock screen + Dynamic Island Widget flips its UI when
isFinalis set: the "Live" red badge becomes a "FINAL" gray badge, and the auto-ticking "Updated Nm ago" footer becomes a static "Final" label (a climbing relative-time counter reads as a stale live game). - Server dismissal grace bumped 60s → 10 minutes. Parents and family that didn't see the End-game push instantly still see the final on the lock screen during the post-game wrap-up. Users who don't want it can still swipe to dismiss earlier.
No new env vars; no schema changes. iOS build 72 → 73.
1.0.0 (build 72) — 2026-05-15
Lock-screen "Updated …" footer alignment.
Build 71 centered the Dynamic Island footer cleanly but missed
the lock-screen sibling — the lock-screen call site was missing
.lineLimit(1) and .multilineTextAlignment(.center) modifiers
the Dynamic Island version had. Without them, the concatenated
Text("Updated ") + Text(.relative) + Text(" ago") inherited
the .relative view's wide intrinsic size, wrapped onto multiple
lines, and pinned the wrapped lines to the leading edge of the
centered frame — symptom was the footer stuck in the lower-left
corner of the lock-screen Live Activity.
Fix: add both modifiers to the lock-screen site so it matches the Dynamic Island one. Both surfaces now share an identical modifier stack.
Widget-extension only.
1.0.0 (build 71) — 2026-05-15
Live Activity "Updated …" footer layout + wording.
Two follow-ups to the auto-ticking timer in build 70:
- Layout: "Updated" and the timer were drifting apart and
hugging the left edge. The HStack wrapper was forcing the
Text(date, style: .relative)to claim its widest intrinsic width (room for "about 1 hour ago") which left a gaping margin between the two parts and broke the outer.frame(...alignment: .center). Swapped to single-Text concatenation (Text("Updated ") + Text(date) + Text(" ago")). The whole string now lays out as flowing text and centers cleanly on both the lock screen and the Dynamic Island expanded view. - Wording: "Updated 10 sec" → "Updated 10 sec ago".
SwiftUI's
Text(date, style: .relative)outputs just the duration without the "ago" suffix. Appending " ago" as a static Text concatenation restores the previous phrasing.
Widget-extension only.
1.0.0 (build 70) — 2026-05-15
Live Activity "Updated Nm ago" footer now auto-ticks.
The footer was rendering a one-shot string built from
RelativeDateTimeFormatter.localizedString(...) at push time —
so iOS captured "Updated 5s ago" once and never re-rendered until
the next state push, which made the timer look frozen.
Fix: replace the static Text("Updated ...") with SwiftUI's
auto-updating Text(date, style: .relative) initializer.
iOS drives the re-render schedule from a system timeline, so
the text now ticks forward without a server push.
Applied to both surfaces that show this label:
- Lock screen footer (under the score row, hidden while the scoring pill is up — see build 69)
- Dynamic Island expanded
.bottomregion
relativeUpdate(_:) helper renamed to parseUpdatedAt(_:) and
returns the parsed Date for the time-aware Text initializer
to consume.
Widget-extension only. No server-side change.
1.0.0 (build 69) — 2026-05-15
Live Activity polish.
- Lock screen overflow. The "just scored" pill added a fourth stacked row on top of title / score / footer, which pushed content off both ends of the canvas on smaller iPhones. Fix: hide the "Updated Nm ago" footer when the scoring pill is up. iOS already renders its own freshness attribution along the Live Activity edge, so we don't lose information — and the total row count stays at 3 regardless of scoring state.
- Dynamic Island expanded — vertical digits. Multi-digit
scores (volleyball sets, blowout games) wrapped one character
per line because the score
Texthad no.lineLimit(1)and the column is narrower than the lock-screen layout. AddedlineLimit(1)+minimumScaleFactor(0.5)to both the expanded-region team column and the lock-screen team column. Three-digit scores now shrink to fit on a single line instead of wrapping. - Server-side: auto-clear the scoring pill after 5 seconds.
Live Activities can't run client-side timers, so we now
schedule a deferred APNs push 5 seconds after every scoring
update that clears
scoringSide. The pill shows for ~5s and disappears on its own, no end-game required. Race protection: if another score lands inside the 5s window, the newer push cancels the deferred clear (via anevents.updatedAtmarker check). Resilience: if the server restarts inside the window the clear is lost; the next score push or stale-sweep cron catches it. No client change required — iOS just renders the pushed state.
No iOS rebuild needed for the auto-clear; iOS code in build 69 is the same as the lock-screen/Dynamic-Island fixes.
1.0.0 (build 68) — 2026-05-15
v1.0 — public launch. Smart-redirect "Get the app" page + iOS share rename.
The public release. No major new features beyond polish — the accessibility, admin-support, and ID-display passes that landed through build 67 are the actual content. This build picks up the 1.0 marketing version and adds the cross-platform install path.
Marketing site
- New
/getsmart-redirect route. Server-side UA sniff:- iPhone / iPad: 302 to the App Store (or TestFlight while the listing is in review).
- Android: 302 to the Play Store (or a "coming soon" landing while the listing is in review).
- Desktop / unknown: dual-card landing with both store buttons and an "everything works at thefieldhouse.app" fallback.
- New
GET_APP_URL+APP_STORE_URL+PLAY_STORE_URLconstants insrc/lib/constants.ts. Set the latter two to a real URL when the listings approve and the redirect upgrades automatically — no other code change required. - Indexable so
thefieldhouse.app/getranks for "install the Fieldhouse" / "get The Fieldhouse" searches.
iOS
- More → "Share Fieldhouse Beta with a friend" renamed to
"Share this app with a friend". Now sends
thefieldhouse.app/getinstead of the raw TestFlight URL so the same share works for whoever the recipient is — Android, iPhone, doesn't matter, the smart redirect handles it. - QR alternative below the share row updated to "The Fieldhouse
· Get the app" with the same
/getURL. Scanning from any device's camera lands them in the right store. - Marketing version: 0.10.9 → 1.0.0. Build 67 → 68.
No new env vars; no schema changes.
0.10.9 (build 67) — 2026-05-14
Team / Org ID surfacing — copy-to-clipboard in settings + billing.
Coaches and org admins emailing support@thefieldhouse.app now have a no-ambiguity way to tell us which team or org they mean:
- Team Settings (iOS + web): new "Support reference" section
at the bottom with
Team IDandOrg IDrows. Each row has a Copy button. - Billing & licenses (iOS): every org card grows an
Org IDrow above the footnote so a billing question can land with the right org reference in the first email. - Org page (web): new "Support reference" section with a copyable Org ID.
- /support page gains a "Find your team / org ID for a support email" how-to pointing to the above.
Implementation: new CopyableIdRow SwiftUI view + matching
CopyableId React component, both mirroring the styling and the
1.5-second "Copied" feedback so the web and iOS experiences feel
the same. Web uses navigator.clipboard.writeText with a graceful
prompt() fallback for old Safari.
No new env vars; no schema changes; no API additions.
Server-side — 2026-05-14 (afternoon)
Admin support structure pass — global search, health dashboard, photo reports, org kill switches, per-user push delivery log, exports hub.
A focused build-out of the /admin surface so support triage
goes from "open psql" to "click one button". All ten ideas
on the original support-structure list shipped except impersonate
(security risk) and migration tooling (deferred past 1.0).
New surfaces
- Global search bar (
/admin/_components/GlobalSearchBar) in the admin shell — type a name / email / team / org and jump. Backed byGET /api/admin/search. Searches users, orgs, teams; intentionally does NOT search kid names. - Health dashboard on
/admin— last-run cron rows (auto-highlighted amber if older than 24h), 24h push delivery success rate, 7-day activity (new users / new orgs / kids tracked / fundraiser GMV), open photo reports. - User detail page (
/admin/users/[id]) gains:- Email verification status with super-admin "Resend verification" + "Force-mark verified" buttons.
- Active push tokens count + an audited "Clear push tokens" button (for users stuck with a stale APNs token after a device restore).
- View-only push opt-out and reminder opt-out states.
- Last 20 push attempts with status / kind / failure reason.
- Recent support-action audit collapse.
- Photo report queue (
/admin/photo-reports) — parent reports surface here. Super-admins see the photo + can Keep / Remove with an optional resolution note; moderators see metadata and reason only (existing photo carve-out applies). Parents file reports via a new "Report this photo" button on the album lightbox. - Org detail page (
/admin/orgs/[id]) with feature kill switches — per-org toggles for chat / fundraisers / photos. Softer than suspending the whole org. Enforced at the creation entry points (postChatMessage,publishFundraiser,createPhoto) so existing content remains visible but new creation is blocked. - Exports hub (
/admin/exports) — consolidated CSV downloads. Adds platform-wide "All fundraiser orders" CSV for board reporting + pointers to the existing org-scoped exports.
Instrumentation
cron_runstable +withCronLog()wrapper. Every cron route writes a row at start, updates with duration + result on finish. Powers the health-dashboard cron table.push_delivery_logtable.sendPushToUsersnow writes one row per device attempt (ok / failed / unregistered + APNs status code + reason + kind). Fire-and-forget — telemetry never blocks the hot path.support_actions_audittable. Every super-admin support utility (resend verify, force verify, clear push tokens) writes an audit row visible on the user detail page.
Schema
- New tables:
cron_runs,push_delivery_log,photo_reports,support_actions_audit. - New columns on
organizations:chatDisabled,fundraisersDisabled,photosDisabled(booleans default false). - New enums:
cron_run_status,push_delivery_result,photo_report_status,support_action.
No iOS build bump — all support tooling is web-side.
Server-side — 2026-05-14 (morning)
Ops broadcasts — super-admin push notifications for outages, upgrades, and operational comms.
New /admin/broadcasts surface lets a super-admin fire an APNs
push to:
- All users, or
- One org (all members across all teams of that org).
Each broadcast also drops a system banner with the same body that auto-expires after 24 hours, so users with Do Not Disturb or push muted still see the message on next app open.
Guard rails
- Super-admin only at the server-action layer (moderators can read the history table but can't dispatch).
- Rate-limited to one broadcast per hour per super-admin, with a checkbox override for genuine outages.
- Audit table
ops_broadcastslogs every send: audience, org reference, body, device count, who, when. Cascade-on-org-delete for tidiness; the audit row survives banner deletion. - 500-char body cap. Push uses
interruption-level=time-sensitiveso the notification doesn't sit silently in Notification Center during an active outage; iOS still respects Focus modes that disallow Time Sensitive. - Respects per-user push opt-out via the existing
sendPushToUsersfilter.
Banner backstop
- Banner is a
system_announcementsrow withexpiresAtset 24h ahead. Listing query (listSystemAnnouncements) already filters expired rows so they stop showing instantly when the TTL elapses; an hourly cron sweep (/api/cron/ops-banner-sweep→fieldhouse-ops-banner-sweep.timer) hard-deletes them after. - Bypasses the per-scope
ANNOUNCEMENT_CAP(2) so a critical ops alert lands even if both manual-announcement slots are full.
Schema
- New table
ops_broadcasts(id, audience enum, orgId, body, devicesNotified, bannerAnnouncementId FK, sentById, sentAt). - New enum
ops_broadcast_audience(all | org). - New column
system_announcements.expiresAt(nullable; null = permanent manual banner; set = auto-expiring ops broadcast). - New index
system_ann_expires_idxfor the hourly sweep.
No iOS build bump — clients pick up the broadcast push via the
existing APNs path. interruption-level: time-sensitive works
out of the box.
0.10.9 (build 66) — 2026-05-13
Accessibility pass — VoiceOver, Dynamic Type, Reduce Motion, keyboard nav, color contrast, marketing /accessibility page.
iOS
- VoiceOver labels on Live Activity score columns, "just scored" pill, RSVP chips, share-code displays, QR-code sheet, chat reaction pills, and ShareCodeRow buttons. Score columns read as a single combined element ("Pioneers, 14") instead of three disjoint chunks. Reaction pills read "Thumbs up, 3 people reacted, including you" plus a hint about double-tap to toggle.
- Dynamic Type cap at
.accessibility3applied at the root view so hardcoded font sizes (Live Activity, trading-card stats) don't blow past their containers at AX4/AX5. Body copy still scales. - Reduce Motion wraps the Family-tab Season Card flip and the
Chat auto-scroll. Both check
@Environment(\.accessibilityReduceMotion)and skip animations when the user has motion turned off in iOS Settings. - Color contrast — Live Activity scoring pill backdrop opacity bumped 0.35 → 0.55 with stroke 0.6 → 0.85 so white text on the team-color overlay clears WCAG AA at any reasonable team color.
- ContrastText helper in
Brand.swift— WCAG relative- luminance picker that returns.whiteorBrand.inkfor a given background hex. Mirrorssrc/lib/contrast.tson the web so a team's public page on the web and their Season Card in the iPhone app pick the same legible text color.
Web
- Skip-to-main link in the root layout — invisible until
Tab focus, then jumps the keyboard user past the site
header. Added
id="main-content"to every marketing page. - Global
:focus-visiblering inglobals.css— keyboard- only focus is always visible, mouse users don't see a ring on click. Replaces 30+ instances ofoutline-nonethat had no fallback indicator. - Form accessibility upgrade —
<Field>component now wires uparia-required,aria-invalid,aria-describedbylinking fields to their hint and error text. Error messages render withrole="alert"so screen readers announce them when validation fails. Required fields get a visible asterisk that's hidden from VoiceOver (therequiredattribute is already announced). - Modal accessibility — new
useModalA11y()hook insrc/lib/use-modal-a11y.ts. Wired into the chat ReactionTallyModal, IdentitySheet, and the photo-album PhotoLightbox. Each modal now closes on Escape, restores focus to the trigger on close, and exposesrole="dialog"+aria-modal="true"+aria-labelledby. Photo lightbox also gained a realalttext on the photo (was decorative-empty). - prefers-reduced-motion — global CSS rule that collapses any author-added transitions/animations to near-zero so vestibular sensitivity is respected even on third-party CSS we don't control directly.
- Org-uploaded color contrast — new
src/lib/contrast.tshelper. WCAG relative-luminance algorithm; returns "on-light" or "on-dark" tokens for any team-uploaded hex. Drop-in replacement for anywhere we currently hardcodetext-whiteon a custom-color background.
Marketing site
- New
/accessibilitypage describing the above honestly plus what's still in progress (Switch Control, carpool/snack screen-reader pass, non-US-English layouts). Linked from the site footer.
No new env vars. iOS build 65 → 66.
0.10.9 (build 65) — 2026-05-13
Re-cut of build 64 — Info.plist purpose-string fix only.
App Store Connect rejected build 64 with ITMS-90683 — the Stripe
iOS SDK references the camera API (its optional "Scan card" button
on PaymentSheet) without us having a NSCameraUsageDescription
key in Info.plist. Apple's static analyzer flags symbol references
even when the runtime path is unused.
Also added NSPhotoLibraryAddUsageDescription proactively — the QR
sheet's "Save to Photos" action uses UIImageWriteToSavedPhotosAlbum
which would have tripped the same gate on the next upload.
Both purpose strings are written to be plain-English-accurate so they survive App Review: camera string mentions Stripe + the optional "Scan card" flow; photo-library string mentions the share-QR save flow and explicitly disclaims library reads.
No code changes, no new features. Functionally identical to build 64.
0.10.9 (build 64) — 2026-05-13
Chat reactions — Messages-style 6-emoji set, web + iOS parity.
Six fixed reactions: 👍 ❤️ 😂 🫡 🎉 🙏. Tap a pill to toggle your reaction; long-press a message (iOS) or hit the "+" button (web) for the picker; long-press a pill (iOS) or click "Who?" (web) to see the per-emoji tally of who reacted with what.
iOS
- Long-press a chat message → context menu gains a "React" submenu with the 6 presets above the existing View profile / Edit / Delete entries.
- Reaction pills render below each message body. Mine are highlighted in the team accent color. Tap to toggle.
- Long-press a pill → ChatReactionTallySheet groups the detail by emoji ("👍 — 3 people") with a name list per group.
- Optimistic toggle: pill state flips immediately, a refresh reconciles with server truth — feels instant even on slower connections.
Web
- "+ React" picker button next to the message's edit/delete row pops a 6-emoji inline picker. Click a pill to toggle.
- "Who?" link below the pill row opens a modal with the same grouped tally as iOS. Click outside to close.
- Same optimistic-toggle behavior; the polled feed reconciles.
Schema + API
- New additive table
chat_message_reactionswith composite PK on (message_id, user_id, emoji). Cascade on message/user delete. Drizzle-push lands it on deploy. POST /api/v1/teams/[id]/chat/messages/[mid]/react— toggle.GET /api/v1/teams/[id]/chat/messages/[mid]/reactions— tally for the long-press / "Who?" drill-in.GET /messagesextended to includereactions: [{emoji, count, mine}]per message, batched via a single grouped query so the feed stays a single round-trip.- Server-side allowlist (
src/lib/chat-reactions.ts) enforces the 6-emoji set — extra emojis from a stale/forked client are rejected with 400.
No new env vars. iOS build 63 → 64.
0.10.9 (build 63) — 2026-05-13
Send logs to support, QR on Share-Beta, share code + QR on the team calendar feed.
iOS · Send logs to support
New entry in More → Help → "Send logs to support". Tap → gathers a context dump (app version + build, device + iOS, locale
- timezone, signed-in user email + redacted token preview, last
~20 network errors from APIClient's new ring buffer) → opens
Apple Mail pre-filled to
support@thefieldhouse.appwith the dump in the body and attached asfieldhouse-support.txt. User reviews + sends.
Falls back to "Copy logs to clipboard" if no Mail account is configured on the device.
APIClient now keeps a 20-entry rolling buffer of network errors
(path, status, short blurb, timestamp — no PII, no request body,
no token). Surfaced only via the new recentErrorsSnapshot()
actor method consumed by SupportLogs.compile().
iOS · QR on Share Fieldhouse Beta
The "Share Fieldhouse Beta with a friend" row in
More → Spread the word now sits above a new "Show QR code"
row. Tap → full-screen QR of the TestFlight invite URL with
Save-to-Photos + Share-image actions. Built on the existing
QRCodeSheet; new lightweight wrapper QRLinkButton for cases
where there's no share code to mint, just a public URL.
Team calendar feed · share code + QR
The Calendar feed section in iOS team settings (the iCal /
webcal subscribe URL) now has a ShareCodeRow next to the
existing copy + share button. Mint a 6-char alias OR open the
QR sheet. New team_ical_feed enum value on share_code_kind;
codes never expire (matches the public-calendar pattern).
Underlying URL uses the webcal:// scheme so scanning the QR or
tapping the resolved code triggers the system "Subscribe to
calendar?" sheet in Apple Calendar / macOS Calendar.
Web parity: new <TeamShareIcalPanel> mounted on
/app/teams/[id]/settings right under the existing
TeamShareCalendarPanel.
Server / API
share_code_kindenum gainedteam_ical_feed(additive enum value; drizzle-push lands it).POST /api/v1/share-codeshandles the new kind alongsideteam_public_calendar. Same audience gate (any team viewer).mintTeamIcalFeedCodeActionserver action for the web panel.
Version
iOS build 62 → 63 across Info.plists + pbxproj.
0.10.9 (build 62) — 2026-05-13
Re-cut of build 61 — the build 61 binary uploaded to TestFlight
shipped with a stale ios/TheFieldhouse/TheFieldhouse/ mirror.
Canonical Swift sources at ios/sources/ had every QR + share-code
generation addition, but the mirror Xcode actually reads from
hadn't been rsync'd, so the installed app was missing the new
Swift files entirely. Symptom: every "Show QR code" entry was
absent.
Fixes:
- New script
scripts/sync-ios-sources.sh— rsyncsios/sources/{Models,Network,Views}/+TheFieldhouseApp.swiftinto the Xcode-managed mirror. Run before every archive. Safe to re-run; rsync only copies changed bytes. - iOS build 61 → 62 across Info.plists + pbxproj. Apple rejects duplicate build numbers in the same marketing version, so re-uploading needs the bump.
No code changes from 61 — same feature surface. See build-61 release notes for the QR + share polish content.
0.10.9 (build 61) — 2026-05-13
Share UX polish: QR codes everywhere, inline team-share panel, plus the build-60 server fix for owned-team pages.
This build is the QR + cleanup follow-on to build 60. No new schema, no new env vars.
QR codes on every share surface
Every share affordance that had "Share link" and "Generate share code" now also has a QR code option:
- iOS · TeamSettings — Parent invite, coach invite, and
Shareable-link rows each get a new "Show QR code" entry.
Tapping opens
QRCodeSheet(full-screen presenter, rendered on-device via Core Image — no SDK dependency). The sheet has Save-to-Photos + Share-image actions so coaches can stash the PNG for a flyer. - iOS · Fundraiser detail (staff-only) — same Show-QR entry alongside the existing share affordances.
- Web · Team settings + Fundraiser editor — the
<ShareSheet>panel gets a "Show QR" toggle next to "Share link" and "Generate share code". Inline 224×224 SVG.
QRs encode the canonical underlying URL (e.g.,
/t/<token>, /invite/<code>, /f/<slug>) rather than the
6-char alias — scanning a QR is already the easy path, so the
camera points straight at the destination.
Inline "Share this team" panel on the team detail page
Parents on a team page now have a one-tap path to share the
public calendar without diving into Settings. A collapsible
"🔗 Share this team" <details> block sits inline below the
new "Active fundraisers" banner. Expanding shows the full
ShareSheet (link / code / QR). Visible to any team viewer —
parents, players, viewer-follows, staff — gated on
icalToken && team.publicEnabled && team.orgType !== "personal".
Server fix: owned-team 500
The listLiveFundraisersForTeam helper introduced in the
previous build used a raw sql\...`template for the team-id-or-org-wide clause. Drizzle binds plain strings inside raw templates as **text**, butfundraisers.team_idis a **UUID** column — Postgres rejects theuuid = text`
comparison without an explicit cast. Result: every owner /
admin / coach loading the team detail page on a non-personal
org hit a 500.
Fixed by refactoring to drizzle's typed helpers
(or/and/eq/isNull/lte/gt) which know the column
type and bind UUIDs correctly. Same query intent, just
type-safe.
New dependencies
qrcode(Node SDK, MIT) — server-side SVG generation for/api/qr.- iOS uses Core Image natively (no new SPM dependency).
Listed on /open-source.
0.10.9 (build 60) — 2026-05-13
Universal share codes — every shareable URL in the app now has a 6-char phone-readable code AND the standard share menu.
A grandparent doesn't know how to paste a URL but can read a code over the phone. A coach handing out flyers wants something that fits on paper. The whole-team text chain wants the native share menu. This build adds all three to every share surface.
What's new
- Universal
share_codestable. Every shareable URL can now be aliased to a 6-character code (XXX-XXXfor display, e.g.ABC-K7P). 31-char alphabet (A–Z minus I/O/L, plus 2–9) — no ambiguous letters when dictated. Codes outlive token rotations on the underlying record. /go/<code>— public redirect endpoint. Server-side lookup, 301-style redirect to the target URL. Idempotent; max-uses and expiry enforced atomically via conditionalUPDATEso two concurrent redemptions on a single-use code can't both succeed./code— public "Got a code?" form. Auto-uppercases + auto- dashes as the user types. Pre-fills from?c=query param for deep-links / QR scans.- "Got a code?" entry points — added to:
- The login page footer (so a parent with a code but no account can redeem first, sign up second)
- The post-SIWA welcome chooser at
/app/welcome - The iOS More tab as a new "Got a code?" row in the Account section
- Reusable
<ShareSheet>component (web) +ShareCodeSheet(iOS). Two buttons:- Share link —
navigator.share()on web (mobile native share sheet), falls back to clipboard copy. SwiftUIShareLinkon iOS (UIActivityViewController under the hood). - Generate code — server-action / bearer-authed POST to
/api/v1/share-codes. Returns the code with formatted display- expiry caption. Copy-to-clipboard + nested ShareLink so the code itself can be sent through the native share menu.
- Share link —
Surfaces with the new Share panel
- Web · Team settings → Share section — public calendar (codes never expire for public surfaces), parent invite codes (14d · up to 20 uses), coach invite codes (14d · up to 20 uses, manager- only).
- Web · Fundraiser edit page → Share panel (visible only when status=live; codes never expire — meant for flyers + QR codes living the whole season).
- iOS · Team settings —
ShareCodeRowinline inside each invite section (parent / coach / public-page shareable link) alongside the existing copy + native share button. - iOS · Fundraiser detail (staff-only) — "Share this fundraiser" panel above the buyer flow. ShareLink for the storefront URL + ShareCodeRow for the 6-char alias.
- iOS · More tab → Got a code? — type-and-go redemption.
- iOS · Welcome chooser (post-SIWA new user) — "Got a code?" entry alongside the family / team chooser.
- Native ShareLink on every code-generation result so the code itself can ride through Messages / Mail / AirDrop.
TTL + max-uses defaults
Per the new defaultExpiresAt(kind) + defaultMaxUses(kind)
helpers in src/lib/share-codes.ts:
| Kind | Expires | Max uses |
|---|---|---|
team_public_calendar |
never | unlimited |
fundraiser |
never | unlimited |
team_parent_invite |
14 days | 20 |
team_coach_invite |
14 days | 20 |
family_schedule_share |
14 days | unlimited |
app_invite |
14 days | unlimited |
admin_grant |
14 days | 1 |
Each call site can override; these are the friendly defaults that match how each surface is shared in practice.
Server / API additions
- New table:
share_codes(additive; drizzle-push lands it on deploy). Indexes onkind+created_by. Charset CHECK at the DB level. POST /api/v1/share-codes— bearer-authed mint endpoint for iOS. Discriminated body:{ kind, teamId? / fundraiserId? / orgId? / fundraiserScope? }. Kind-specific auth gate inside.- Web parallel:
src/lib/share-code-actions.tswith one server action per kind (mintTeamPublicCalendarCodeAction,mintTeamParentInviteCodeAction, etc.) used by the ShareSheet panels.
Schema — one additive table. Env vars — no new vars.
0.10.9 (build 59) — 2026-05-13
Fundraisers polish: native Stripe PaymentSheet on iOS, coach-side refund button, abandoned-cart sweep, public Refunds & Disputes policy.
Native Stripe PaymentSheet on iOS
Fundraiser checkout on iPhone now uses Stripe's PaymentSheet
on-device — full native UX (no Safari hand-off) when the
StripePaymentSheet SPM dependency is present in the Xcode
project. The fundraiser detail view now has:
- Inline qty steppers per item — pick directly without leaving the page.
- Buyer name + email fields below the items.
- A live total preview using the exact same
priceFundraiserPurchasemath the server runs, so what you see is what gets charged. - A "Pay $X" button that POSTs to a new
/api/v1/fundraisers/[id]/checkout-intentendpoint, gets back a PaymentIntent client secret + publishable key, and presents STPPaymentSheet from there.
Falls back gracefully: if the Stripe iOS SDK isn't linked
(#if canImport(StripePaymentSheet)), the Pay button opens the
public /f/<slug> page in SFSafariViewController — same flow
as build 58, no broken state.
To activate native PaymentSheet (one-time Xcode step): File
→ Add Package Dependencies → https://github.com/stripe/stripe-ios
→ check "StripePaymentSheet" → Add. Build & run. The Pay button
swaps to the native sheet automatically.
Coach-side refund button — web + iOS
Coaches and org admins can now refund a paid order directly from Fieldhouse instead of jumping to Stripe's dashboard:
- Web: new "Refund" button next to the fulfillment toggle on
every paid order row at
/app/teams/[id]/fundraisers/[fid]/ordersand the org equivalent. Confirmation dialog, then Stripe's refund API runs. - iOS: new "Manage orders" entry on the fundraiser detail
view, visible only when the viewer is staff on the org
(
viewerIsStafffrom the detail endpoint). Lists every order with status pill; paid orders have an inline Refund button + confirm dialog. Pull-to-refresh.
POST /api/v1/fundraiser-orders/[oid]/refund is the new endpoint
(bearer or session auth, isOrgCoach-gated). Behind the scenes:
Stripe's refund.create() with reverse_transfer: true +
refund_application_fee: true so the org's payout + Fieldhouse
platform fee both come back. Stripe's per-transaction 30¢ is
retained per their pricing. charge.refunded webhook handles
the local status flip + inventory release on its own — same
path a dashboard-side refund takes.
Abandoned-cart sweep cron
New cron fieldhouse-cart-sweep.timer runs every 10 min: any
fundraiser order stuck in status='pending' older than 30 min
flips to failed and releases its inventory reservation.
Previously these sat for 24h waiting on Stripe's session expiry,
quietly hiding stock that other buyers couldn't see.
Idempotent. Webhook race with the sweep is safe (UPDATE WHERE status='pending' returns no rows if the webhook won).
Refunds & disputes policy page
New public page at /refunds covers refund eligibility, who
to contact, what gets refunded (item subtotal + platform fee
clawback, Stripe's 30¢ retained), payment disputes &
chargebacks, fraud handling, and the physical-goods-only rule.
Linked from the SiteFooter (next to Privacy / Terms / COPPA) and
inline on the storefront /f/<slug> footer.
Schema — no changes from build 58.
Env vars — no new vars. (STRIPE_* and STRIPE_CONNECT_*
secrets from build 58 cover everything.)
Deploy note — scripts/deploy.sh automatically installs +
enables the new fieldhouse-cart-sweep.timer on first run after
the zip is extracted.
0.10.9 (build 58) — 2026-05-12
Fundraisers — per-team and program-wide storefronts for physical goods, with direct-to-org payouts via Stripe.
A new revenue surface for school programs and clubs, completely separate from the existing licensing rail (which stays on Metahuman License Manager). Coaches and admins can sell shirts, raffle tickets, coupon booklets, or run an equipment / travel drive — buyers check out on a public storefront and Stripe deposits the funds straight into the org's bank account. Fieldhouse takes a flat 2% (capped at $2 per order) — nothing else.
Setup
/app/organizations/[id]/payouts— org owner/admin walks through Stripe Connect Express onboarding (legal name, EIN/SSN, bank account). One-time setup; the page shows five states: not-started, in-progress, pending-review, live, disabled. Refreshes from Stripe on return from the hosted onboarding so the UI doesn't race the webhook.- Coaches cannot start onboarding themselves (it collects the org's legal details, not a coach's). Coaches who land on the page see a read-only status with a "ask your org admin" nudge.
- Family-tier (personal-org) accounts are blocked entirely — every fundraiser entry point hides for personal orgs, and the server rejects the onboarding action.
Building a fundraiser
/app/teams/[id]/fundraisers— coaches manage their team's fundraisers./app/organizations/[id]/fundraisers— managers manage org-wide fundraisers + see the rollup of every team-scoped one.- Editor surfaces a "Physical goods only" banner on every view — coupon booklets, shirts, raffle tickets, money toward equipment or travel. No digital downloads, subscriptions, or in-app content. Apple's rules require this and Stripe enforces it on Connect accounts.
- Publish button is disabled until (a) the org's Stripe is
charges_enabledand (b) at least one item exists.
Buying
/f/<slug>— public storefront. No login required (random fan at the pizza night, grandma without the app — anyone with the URL can buy).- Item picker with qty steppers and optional per-line notes (size, color, pickup preference). Buyer enters name + email for the receipt.
- "Includes payment processing" footnote in the price box — Stripe's 2.9% + 30¢ is bundled into the displayed total rather than itemized as a card surcharge (sidesteps CT/MA/ME surcharge-law edge cases).
- Click checkout → Stripe Checkout (hosted) collects the card →
redirected back to
/f/<slug>?status=paid(or?status=cancelled).
iOS
- Team detail page gets a new "Fundraisers" section listing active fundraisers (renders nothing when the team has none, so no empty-state clutter).
- Tap a row → native
FundraiserDetailViewwith description + item cards. "Browse & buy" opens/f/<slug>in an in-app Safari sheet (SFSafariViewController) where Stripe Checkout collects the card. Physical-goods carve-out — no IAP, no Stripe iOS SDK in this build. Native PaymentSheet is a planned polish for after 1.0.
Fulfillment
- Each fundraiser editor adds an "Orders →" link once it's no longer a draft. Page lists every paid order: buyer name + email, line items + per-line notes, total. Toggle marks an order fulfilled/unfulfilled; a CSV export covers the per-line view for the coach handing out shirts at practice.
- Counts in the page header: paid / awaiting fulfillment / in checkout.
Webhooks + reconciliation
Stripe's current dashboard UX forces account-scope vs Connect-scope events onto separate endpoints. We host two routes — same shared event router, distinct signing secrets:
/api/v1/stripe/webhook(platform-scope)checkout.session.completed— backfill payment_intent id on orderpayment_intent.succeeded— flip order to paidpayment_intent.payment_failed— flip to failed + release inventorycharge.refunded— flip to refunded + release inventory (Stripe automatically claws back the platform fee on full refunds)
/api/v1/stripe/webhook/connect(connected-account-scope)account.updated— refresh the org's onboarding state
All handlers idempotent — Stripe occasionally redelivers.
Marketing
- Homepage: new "Fundraisers" sub-section inside the existing Sponsorships dark block (same audience — treasurers, coaches thinking about money in).
/how-it-works: Fundraisers feature card alongside Sponsors./sideline: workflow column mentions fundraiser storefront management on the web./roadmap: moved from "next" to "shipped".
Schema — additive: org_stripe_accounts, fundraisers,
fundraiser_items, fundraiser_orders, fundraiser_order_items +
fundraiser_status and fundraiser_order_status enums.
drizzle-kit push lands them on deploy.
Env vars — STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
STRIPE_WEBHOOK_SECRET (platform endpoint),
STRIPE_CONNECT_WEBHOOK_SECRET (Connect endpoint). MLM env vars are
unchanged.
0.10.9 (build 57) — 2026-05-11
iOS Billing & licenses + admin invite-by-email.
iOS Billing & licenses (Parity #2)
The iOS More tab now has a "Billing & licenses" entry replacing the older "Manage subscription" button. Lists every non-personal org the caller owns or admins, with the current plan + license source + next renewal:
- Apple-source orgs — shows renewal/expiry date, productId, and
a "Manage on Apple" button that opens StoreKit's
showManageSubscriptions(in:)(deep-links to iOS Settings → Apple ID → Subscriptions). This is Apple's canonical surface for tier changes and cancellation, fully compliant. - MLM/web-source orgs — read-only display of cadence (monthly / annual / lifetime), license status pill, and last-verified date. Closing text instructs the user to sign in at metahuman.network on desktop to manage. No clickable link out — Apple anti-steering doesn't allow it.
- Free orgs — "Upgrade" button that opens the existing SubscriptionSheet (Apple IAP only on iOS).
/api/v1/iap/me extended additively with licenseStatus,
licenseCadence, licenseLastValidAtIso so the new view has the
data it needs without a new endpoint. The existing APIIapOrg
model gains those three optional fields.
Admin invite-by-email
Super-admins can now invite anyone (existing user or not) to
moderator or super-admin access via a single-use email
link. Lives on /admin/users:
- New "Invite by email" form at the top of the page (super-admin only — moderator viewers don't see it)
- Below the user table, a "Recent admin invites" panel with status pills (pending · expires DATE / accepted DATE / expired) and a Revoke button for still-pending invites
- New
pending_admin_grantstable — token PK, email, role (moderator / super_admin), inviter, expires_at (24h TTL), consumed_at, consumed_by_user_id, created_at. Single-use enforced via consumed_at-is-null check inside the consume transaction - New email template (
adminGrantInviteEmail) — color-themed by role (red for super-admin, amber for moderator) - Landing page at
/admin-grant/<token>handles three states: link no longer valid; viewer not signed in (bounces to /login with?nextset); viewer signed in but email mismatch (warns with the matching email needed). Email-binding stops a forwarded link from privilege-escalating someone else - Acceptance applies the role + redirects to
/admin?accepted=<role>
Schema — pending_admin_grants table + admin_grant_role enum
("moderator" | "super_admin"). Additive; drizzle-kit push won't
prompt.
0.10.9 (build 56) — 2026-05-11
"Just scored" callout on the Live Activity.
The lock-screen Live Activity + Dynamic Island now surface which team just put points on the board, not just the running score. Lock screen gets a small pill above the score row — "Spartans just scored" — tinted with the scoring team's brand color. Their score also bumps a couple points larger to draw the eye. Dynamic Island expanded shows the same callout in the bottom region; compact and minimal stay just the score (no room).
The callout stays visible until the next state push lands — next score, score correction, end-of-game, or stale-sweep all clear it. Quiet by default at game start.
ContentState — additive scoringSide: String? field
("home" / "away" / null). Backwards compatible: old widgets
ignore unknown fields, new widgets decode null when the field
is missing from an older payload.
Server diff logic. /api/v1/events/[id]/score reads the
prior homeScore/awayScore from the row before applying the
update, then sets scoringSide to whichever side gained
points. Both up (rare) or any side going down (correction) →
null. Coalesce-debounced taps that collapse a basketball +3 or
football +6 into one push are correctly attributed to the side
that gained the points.
Coach's own widget stays quiet. iOS local-update path
(LiveActivityCenter.localUpdate) always passes scoringSide: nil — the coach knows who scored (they tapped the button) and
doesn't need their own lock screen telling them. Server APNs
fan-out is the single source of truth for the callout to OTHER
watching devices.
0.10.9 (build 55) — 2026-05-11
Moderator role + maintenance/photo carve-out (server-only; iOS binary unchanged from build 53 forward — same TestFlight build hits the new server behavior).
Moderator tier. New users.isModerator boolean default false
flag, parallel to isSuperAdmin. Moderators get read access to
every /admin/* surface (overview, users, orgs, sponsors, rosters,
consents, announcements) so support can troubleshoot without needing
super-admin. Destructive actions (grant/revoke admin, grant/revoke
moderator, delete user) stay super-admin only — the buttons are
hidden in the UI for moderator viewers, and the server actions
re-check via requireSuperAdmin() regardless of UI state.
Photo carve-out. Moderators (and not super-admins) cannot read
any image served by /uploads/... — kid headshots, team gallery
photos, sponsor banners, team logos, all of it. The carve-out lives
at the file-serving chokepoint, returns 404 (not 403) so the
moderator can't probe which images exist. JSON metadata endpoints
still return file URLs; the URLs simply 404 when their browser
fetches them. If a moderator needs to see a specific image for a
support case, they ask the org owner to share or escalate to a
super-admin.
requireSuperAdminOrModerator() — new permissive guard in
src/lib/admin.ts. Used by all /admin/* page reads + the
consents-CSV export. requireSuperAdmin() stays the strict gate
used by every destructive server action.
Promote/demote UI. /admin/users list shows a Moderator
amber badge alongside the existing super-admin red badge. Each row
gets a "Grant moderator" / "Revoke moderator" toggle (super-admin
only). The user-detail page hides the Danger Zone delete button
from moderator viewers.
One-shot promotion script. scripts/make-moderator.ts mirrors
make-superadmin.ts for first-time grants:
set -a && source /etc/fieldhouse/env && set +a
npx tsx scripts/make-moderator.ts support@thefieldhouse.app
After the first one, all promote/demote happens from /admin/users.
Schema — additive column with default false. drizzle-kit push
won't prompt for it during deploy. No data backfill needed.
0.10.9 (build 54) — 2026-05-11
Under-13 redaction sweep on /admin/* (server-only; iOS binary
unchanged from build 53).
Centralized the COPPA-aware "kid name → opaque label" rule into a
single helper at src/lib/admin-redact.ts so future admin pages
can't quietly leak an under-13 name by skipping it. Rule: if the
kid is < 13 (computed from players.dateOfBirth), display
Player #<8 chars> instead of the real name; if 13+, display the
real name; if DOB is unknown (orphaned consent record), keep the
snapshot — it's the only label we have.
Surfaces patched (in addition to /admin/rosters which was
already correct):
/admin/users/[id]"Managed children" section — now uses the helper./admin/consentspage — joinedplayersto fetch DOB; child column renders the redacted label when < 13./admin/consents/exportCSV — same redaction. Newplayer_name_redactedcolumn flags the call so the auditor can tell whether the cell was opaque'd or not.
The page header on /admin/consents now spells out the rule so
auditors aren't surprised by the opaque labels.
0.10.9 (build 53) — 2026-05-11
Family → Team upgrade is Team-only.
The "Add Team org" CTA on the Family tab (iOS) and dashboard (web) now hard-locks the org type to free Single Team. Coaches who want Club or School upgrade from the per-org billing page once the Team org exists. Keeps the parent-becomes-coach path one tap and sidesteps the "create org → need IAP first" trap.
Web — /app/onboarding accepts ?kind=team. When set, the
type radio is replaced with a fixed "Single team · Free forever"
pill and a hidden force_kind=team input rides along on submit.
createOrgAndTeamAction re-normalizes org_type to team on the
server side regardless of what the form posts, so a hand-crafted
POST that flips the value still gets coerced back. CTA on the
dashboard now links to /app/onboarding?kind=team.
iOS — NewOrgSheet accepts an optional lockedOrgType arg.
When non-nil, the type picker is hidden and the form caption flips
to the "free Team plan to start" copy. Submit ignores @State and
sends the locked value to the server. FamilyView passes
lockedOrgType: "team" from its CTA; the existing Teams-tab "+
New organization" entry point keeps the full Single-Team / Club /
School picker.
0.10.9 (build 52) — 2026-05-11
Family → Team org upgrade CTA + Apple-logo polish.
"Coach or run a team too?" card. Parents who only have family
schedules (no team org they own/admin) now see a quiet card on the
Family tab (iOS) and the web dashboard. Tap "Add Team org" → the
existing NewOrgSheet (iOS) or /app/onboarding (web) opens. Free,
one team included; upgrade to Club/School later. Card retires the
moment a non-personal org is created.
Web — new section in src/app/app/page.tsx shown when
orgsForDisplay.length === 0. Routes to /app/onboarding (the
existing org-creation flow handles everything from there).
iOS — new coachOrgUpsellSection in FamilyView.swift, gated
on hasManagedOrg (any owner/admin role on a non-personal org).
Tap presents NewOrgSheet; auth.refreshMe() on dismiss flips
hasNonPersonalTeams and surfaces the Teams tab.
Apple logo SVG fix (web). Swapped the hand-rolled path d="…"
for the canonical Apple-logo path (Bootstrap Icons). The previous
one had the leaf leaning the wrong way.
0.10.9 (build 51) — 2026-05-11
Sign in with Apple — iOS native + web OAuth.
iOS native. Login screen now shows the system
SignInWithAppleButton (.continue, .black). On tap we mint a raw
nonce, hash it (SHA-256), pass the hash as the request nonce, and
ship the unhashed value alongside the identity token to
/api/mobile/auth/apple. Server verifies signature against
https://appleid.apple.com/auth/keys (cached via jose's
createRemoteJWKSet), validates aud ∈ {bundle id, service id},
re-hashes the raw nonce, and link-or-creates via the existing
accounts table.
Web OAuth. NextAuth Apple provider added to src/auth.ts,
gated on the APPLE_SIWA_* env vars being present. Apple's
client_secret JWT is minted at module load with a 5-month TTL
(Apple's max is 6); each deploy re-mints. Buttons appear on
/login and /signup (gated by the same env check).
Returning sign-in handling. Apple only sends email + name
on the very first sign-in for a given (Apple ID, client_id) pair.
For all later sign-ins both fields come back empty. Auth.js's
stock Apple profile() then handed email: undefined to the
Drizzle adapter and tripped users.email NOT NULL. We override
profile() in our Apple provider to synthesize a deterministic
fallback (apple-<sub>@noemail.thefieldhouse.app) so re-sign-ins
resolve to the SAME row — no duplicate accounts.
Post-SIWA account-kind chooser. OAuth bypasses the
family-vs-org radio on /signup, so we ask post-hoc. New page
/app/welcome presented to users who land on /app with zero
orgs AND no passwordHash (proxy for "OAuth signup"). Two cards:
"Track my kids' schedules" → ensurePersonalOrg →
/app/family; "Run a team / club / school" → /app/onboarding.
Password signups are unaffected (passwordHash IS NOT NULL →
straight to /app/onboarding).
Privacy. Apple-relay emails (*@privaterelay.appleid.com)
are stored as-is and treated as emailVerified = now since Apple
has already confirmed them. iOS native flow uses the same
fallback logic in src/lib/apple-siwa-link.ts.
Schema — no migration needed. Reuses the Auth.js accounts
table (provider='apple', providerAccountId=<sub>).
API:
- POST
/api/mobile/auth/apple(iOS native) — returns the same envelope as/api/mobile/auth/loginplus acreatedflag.
Env:
APPLE_SIWA_SERVICE_ID— web OAuth client_idAPPLE_SIWA_TEAM_ID— Apple Developer Team IDAPPLE_SIWA_KEY_ID— SIWA key idAPPLE_SIWA_PRIVATE_KEY_BASE64— base64'd.p8APPLE_SIWA_BUNDLE_ID— optional, defaults tocom.mhn.TheFieldhouse(iOS audience)
iOS entitlement — com.apple.developer.applesignin = ["Default"].
0.10.9 (build 50) — 2026-05-10
Giphy GIFs in chat + coach moderation (mute from chat).
Send a GIF. Composer gets a GIF button (web + iOS). Tap → inline picker (trending on first open, debounced search as you type) → tap a GIF → it sends as the message. Optional caption in the composer field rides along; pure-GIF reactions work too.
- Server proxies Giphy via
/api/v1/giphy/searchso the GIPHY_API_KEY lives in/etc/fieldhouse/env, never on the client.g-rated only, English. - Sent GIFs persist as a
mediaUrl(Giphy CDN) on the chat message — we don't host bytes. Render is hot-linked. - Server validates that any incoming
mediaUrlis on a*.giphy.comhost before accepting. - Without
GIPHY_API_KEYset, the picker shows a graceful "GIFs aren't available on this server" empty state instead of an opaque error.
Coach mute (chat ban). Coaches can now revoke chat-posting privileges from any team member from the identity-card sheet.
- Tap a sender's name → identity card → "Mute from chat" (coach-only section, hidden for non-coaches and self-view). Optional reason note (coach-internal).
- Banned users keep read access (no surprise blackout); every
POST to
/chat/messages403s with a clear "a team coach has muted you" message they see in their composer. - Coaches can't mute other coaches (server enforces).
- Idempotent re-ban updates the row's reason/timestamp/ banned-by; unban deletes the row.
- Surfaces are coach-only via existing
isTeamCoachgates.
Schema: new chat_bans table (composite PK on team_id +
user_id) + media_url / media_kind columns on chat_messages.
The body length CHECK now allows 0-4000 (media-only rows have
empty body); the API layer enforces "body OR media required".
API (all isTeamCoach-gated except Giphy search which is
just any-authed):
- GET
/api/v1/giphy/search?q=&limit= - GET
/api/v1/teams/[id]/chat/bans(list) - POST
/api/v1/teams/[id]/chat/bansbody{ userId, reason? } - DELETE
/api/v1/teams/[id]/chat/bans/[userId]
Env: add GIPHY_API_KEY=… to /etc/fieldhouse/env after
deploying. Free Giphy keys at
https://developers.giphy.com/dashboard/.
DB migration needed before deploy (SQL one-liner pasted in chat).
0.10.9 (build 49) — 2026-05-10
Chat edit + delete: 10-min sender window, unlimited parent-of- teen-sender window, audit log on every action.
- Sender can edit own messages within 10 minutes of posting (was 5). Past the window the PATCH 410s.
- Parents who co-manage a teen-sender's player record can edit AND delete that teen's messages with no time-window cap — surfaces as Edit / Delete in the long-press / hover menu on iOS + web. Coach delete is unchanged (any message, any time).
- "Edited" badge stays neutral — no editor attribution shown to other chat members.
- New
chat_message_editsaudit table snapshots the prior body- editor user ID + action ("edit" / "delete") for every
successful action, including self-edits. Server writes the
audit row before the mutation so the before-state always
survives. Not user-visible; surfaced via
/admintools when a safeguarding question comes up.
- editor user ID + action ("edit" / "delete") for every
successful action, including self-edits. Server writes the
audit row before the mutation so the before-state always
survives. Not user-visible; surfaced via
- Server response now stamps each chat row with
canManageAsParent: booleanso the iOS + web UI can show Edit/Delete on a teen's message without the client needing to compute the relationship.
Schema migration: new chat_message_edit_action enum +
chat_message_edits table. SQL one-liner pasted in chat.
0.10.9 (build 48) — 2026-05-10
Parent permission lockdown + photo moderation queue.
Coach-only team menu items. Parents on iOS used to see no
team ⋯ menu at all (it was wrapped in if canManage); now the
menu shows for everyone, but the entries split by role:
- Coaches see: New event, Import CSV, Roster edit requests, Manage Announcements, Locations, Team settings.
- Parents see: Team chat, Roster (view), Photos, Consent forms (per-team inbox), View Announcements (read-only), Staff list.
On the web, the only ungated link was Branding & settings —
now wrapped in canManage. Server-side gates (isTeamCoach)
on every endpoint were already correct; this matches the UI.
Read-only announcements view for parents — new
TeamAnnouncementsViewerView (iOS) reuses the same GET
endpoint coaches use but renders without the editor chrome.
The compose/delete server endpoints are unchanged (isTeamCoach
gated).
Photo moderation queue. Parents can upload photos to team albums but coach approval is now required before they're visible to the rest of the team:
- Coach uploads → status
approved(immediately visible). - Parent uploads → status
pending(visible only to the uploader and to coaches; parents see a "Pending" badge on their own tile so they know it landed). - Coach taps the photo in the lightbox → Approve (uploader gets a push) or Reject (silent — uploader sees the rejected status next time).
- Album cover + photo count both filter to approved photos so parents see a clean public view.
Schema: new team_photo_status enum + status,
moderated_by_user_id, moderated_at columns on team_photos.
SQL migration needed before deploy.
API:
- GET
/api/v1/teams/[id]/pending-photos— coach moderation queue across all albums. - POST
/api/v1/photos/[id]/moderate— coach approves or rejects a single photo.
0.10.9 (build 47) — 2026-05-10
Parent per-team consent forms inbox. Coaches and parents
both saw a "Consent forms" entry on the team page, but tapping
it routed both to the coach composer (which then 403'd parent
sends server-side via isTeamCoach). Confusing UX.
Now the team-page entry routes by role:
- Coach / admin / owner → composer page (send + sent list + drafts) — same as before.
- Parent (any chat member who isn't a coach) → personal inbox scoped to this team. Pending forms first, completed trail behind. Tap a form to read + sign in the same detail view used by the cross-team Family inbox.
New surface: /app/teams/[id]/my-consent-forms (web) +
TeamConsentFormsParentView (iOS), backed by GET
/api/v1/teams/[id]/my-consent-forms (chat-member auth, returns
forms where caller is the recipient on this team).
Server-side send-gate is unchanged — isTeamCoach already
blocks parent sends; this just makes the UI match.
Consent form drafts + Blank template. Two adds on top of build 45's e-sign feature:
Blank option in the template picker — start a one-off consent form from scratch (no boilerplate, no pre-filled text). For org-specific waivers / acknowledgments that don't fit the six built-in templates.
Drafts — coaches can save a half-finished form for later editing and sending. Each draft holds title + body
- which boilerplate it was forked from (or null for blank). Recipients are picked at send time. Sending promotes the draft into one consent_forms row per recipient and deletes the draft.
Schema: new consent_form_drafts table, indexed by
team + updated_at for the per-team drafts list.
Authz — same gates as the existing forms API:
- Create / update / delete / send draft: must be a coach on
the team (
isTeamCoach) - Drafts are coach-private (no recipient visibility) until sent; parents only see the resulting consent_forms rows
Surfaces:
- Web coach:
/app/teams/[id]/consent-formsnow has a Drafts section above the Sent list. Composer gets "Save as draft" + "Send to N" buttons. Tapping a draft re-opens the composer pre-filled (Save Draft updates in place, Send promotes + drops). - iOS coach: TeamDetailView ⋯ → Consent forms now shows Drafts section. Composer toolbar Done menu has "Save draft" and "Send to N" choices. Same edit/send semantics as web.
0.10.9 (build 45) — 2026-05-10
E-sign consent forms. Coaches send consent forms (concussion baseline, travel auth, photo release, code of conduct, snack allergy, emergency medical) to one or more parents in two taps; parents sign in-app with a typed name OR a finger-drawn signature.
Boilerplates — six built-in templates with vetted-but-edit- able defaults:
- Concussion Baseline Acknowledgment
- Travel Authorization
- Photo & Media Release
- Player & Parent Code of Conduct
- Snack & Food Allergy Acknowledgment
- Emergency Medical Treatment Authorization
Coach can override the title and edit the body before sending.
Each template carries a version string that's persisted with
the signature so the audit trail can prove which wording was
agreed to. A "custom" kind exists for one-off forms with no
boilerplate.
Audit fields stamped server-side at sign time:
- Signed-at timestamp
- Signature method (typed | drawn)
- Signature payload (typed: the name string; drawn: SVG path data — compact, scalable, no base64 bloat)
- Signer IP (from x-forwarded-for chain)
- Signer user-agent
- Policy version of the template at sign time
Push notifications ride the existing APNs path:
- Parent gets a push when the form is sent
- Coach gets a push when the parent signs (or declines)
Schema: new consent_forms table + 3 enums (kind, status,
signature_method). One row per (form, recipient) so a 4-recipient
batch creates 4 trackable rows.
Authz:
- Send: must be a coach on the team
- View: recipient OR any coach on the team
- Sign / decline: recipient only
- Void: pending forms only, coach who issued the team
Signed forms are immutable — the coach can void a pending form they sent (typo'd recipient, etc.) but cannot alter or revoke a signed one. Audit integrity > convenience.
Surfaces:
- Web coach:
/app/teams/[id]/consent-forms(list + composer) - Web parent:
/app/family/consent-forms(inbox + sign view) - iOS coach: TeamDetailView ⋯ → "Consent forms"
- iOS parent: Family tab toolbar → 📄 → Inbox
0.10.9 (build 44) — 2026-05-10
Photos album-tap, take 3. Build 41 + 43 fixed real bugs but neither was the actual album-tap issue. Xcode console finally surfaced the smoking gun:
A navigationDestination for "TheFieldhouse.APIPhotoAlbum" was declared earlier on the stack. Only the destination declared closest to the root view of the stack will be used.
PhotoAlbumsView's .navigationDestination(for: APIPhotoAlbum.self)
was being seen by SwiftUI as duplicated through view-builder
re-evaluation during the push transition — so the closer-to-
root copy (which was effectively the wrong one) won. Tapping
an album fired the wrong destination → wrong (or no) screen.
Switched from value-based push to destination-based:
NavigationLink {
PhotoAlbumView(team: team, album: a) { Task { await load() } }
} label: { albumCard(a) }
No .navigationDestination(for:) to be duplicated, refresh
closure stays in scope. Album tap lands on the photo grid.
iOS-only build; no server change. Bumps to 0.10.9 (44). E-sign feature shifts to build 45.
0.10.9 (build 43) — 2026-05-10
Photos Menu nav + tracker auto-clear. Two iOS Photos polish items:
Real fix for the album-push bug. Build 41's hoist of
.navigationDestination(for:)was correct but didn't address the actual root cause —PhotoAlbumsViewwas pushed via aNavigationLinkinside the team's ⋯ Menu, and SwiftUI's Menu wrapper messes with the destination view's internal.sheetand.navigationDestinationregistrations on first appearance. Tapping an album would slide forward and show the same screen; tapping+did the same. Worked on the second visit. Replaced the Menu'sNavigationLinkwith aButtonthat flips a state flag, then push via.navigationDestination(isPresented:)from TeamDetailView's outer level. Same pattern as the existing APIEvent destination there.Auto-clear done uploads after 5s. The uploads tracker kept "Done" rows around forever, piling up with each upload session. Now successful rows fade out after 5 seconds. Failed rows stick around so the user can tell which upload didn't make it.
iOS-only build; no server change. Ship a 43 to TestFlight.
E2E e-sign work shifts to build 44.
0.10.9 (build 42) — 2026-05-10
Photo upload "Failed" after success. Uploads were saving successfully on the server but the iOS row flipped to "Failed" right after the bar hit 100%. Two-part fix:
APIClient.uploadTeamPhotowas decoding the POST response into a fullAPIPhotostruct, but the server's POST returns a minimal subset (nouploadedByUserId, nocreatedAt). The decode threwkeyNotFound→ propagated out of the async call → composer'scatchflipped the row to.failed. Changed the return type toVoid; the album reload right after the upload is the source of truth, so the response body wasn't actually used.The progress bar was stuck at 5% during the entire upload then snapped to 100% at the end — the row never transitioned from
.compressingto.uploading. Added that transition in theonUploadProgresscallback so the bar tracks real bytes moving over the wire.
iOS-only build; no server change. Ship a 42 to TestFlight.
0.10.9 (build 41) — 2026-05-10
Photos nav fix. Tapping an album in the iOS Photos screen
was pushing the album detail and immediately popping back to
the album grid. Same root cause as the build-28 KidSeasonView
fix: .navigationDestination(for:) was attached inside a
switch loadState branch, so any state recompute (background
refresh, the create-album → reload cycle) was unmounting the
destination registration before the push could settle.
Hoisted the modifier to the outer view alongside .task and
.toolbar — push survives recompute, swipe-back works, no
stutter on first tap.
iOS-only build; no server change. Ship a 41 to TestFlight.
0.10.9 (build 40) — 2026-05-10
Photo galleries. Coaches create named albums on a team; parents upload photos with a caption and (optionally) a kid tag. Tag drives a server-composed overlay on the photo detail view: kid name + jersey + team + season + sport emoji, all auto-pulled from existing roster data so parents only type the caption.
Upload pipeline — friction-down by design:
- Up to 10 MB raw input.
- Client-side compression to ~2 MB before upload (canvas on web, UIImage on iOS), stepping JPEG quality from 88 → 60 until the bytes fit. Long-edge resize to 2400 px.
- Server-side compression as fallback via sharp + mozjpeg with the same quality ladder. Handles HEIC from iOS PhotosUI by transcoding on ingest.
- Per-file progress bar on iOS via URLSession's KVO-
observable
Progress(real bytewise %). Web shows Compressing → Uploading → Done states (browsers don't expose form-POST upload progress). - In-flight uploads survive sheet dismissal on iOS — the tracker lives on the album screen so parents can keep browsing while the photo finishes.
Authz: any chat member of the team can upload + view; coaches create + delete albums; uploader OR a coach can delete a photo.
Quotas (per team v1): 20 albums × 100 photos = 2 K photos. Plenty for a season; bounded enough to keep the disk bill predictable. Album name capped at 80 chars; caption at 280.
Schema: new team_photo_albums and team_photos tables.
Tag overlay composed server-side at read time so a stale tag
(kid removed from roster) silently degrades to "no overlay"
instead of leaking the kid's name.
Surfaces: web /app/teams/[id]/photos (albums grid +
album detail with grid + lightbox + composer); iOS
TeamDetailView ⋯ menu → Photos → albums grid → album detail.
0.10.9 (build 39) — 2026-05-10
Season Card. New trading-card style summary on each kid's season dashboard. Front: photo (or initial badge in the team color) + kid name + season + sport chips. Back: per-sport stat tiles, attendance percentage with games-going-of-total, and a last-game callout. Tap Flip to stats → to spin the card around with a real 3D rotation; ← Show photo flips back. Renders on both web and iOS, mirrors data-shape so a parent who sees one screenshot looks the same on either side.
Hidden when the kid has no past AND no future events on the
calendar (a brand-new add — nothing to summarize yet). Uses
data already on the page (getKidSeason + getPlayerSeasonStats
getPlayerLastGameStats); no new schema, no new API.
Heads-up on what's coming: the Season Card is the first of three features queued for 1.0:
- Build 40: Photo galleries with named albums, parent uploads, client-side compression, and per-photo kid tagging.
- Build 41: E-sign consent forms (concussion, travel, photo release, code of conduct, snack allergy, emergency medical boilerplates), coach-sends-to-team, parent signs in-app.
0.10.9 (build 38) — 2026-05-08
Coaches can now access the ref-pay surface. Originally
gated to owner / admin only via isOrgManager; flipped to
isOrgCoach so the YTD page, mark-paid, clear-payment, and
CSV export all let coaches in. Reasoning: a small school
program with one coach who's also the AD/treasurer
shouldn't have to juggle a separate handoff at 1099 time.
The original spec called for owner / admin / coach access
anyway — this matches it.
iOS auto-login Ref-pay link now lands on the actual ref-pay
page, not the dashboard. Was sending coaches to /app
because iOS doesn't know which orgId to use without a fetch.
New /app/refs server-resolver route picks the user's first
non-personal manageable org (owner / admin / coach role) and
redirects to that org's /refs page. Falls back to /app
for users with only personal/family-schedule orgs.
iOS MoreView "Ref pay & 1099 prep" path updated from
/app → /app/refs. Dashboard (/app) and Full data export
(/app/account) were already correct (user-agnostic paths)
so they're unchanged.
Also rolled into this build (server-only, no iOS changes
needed): the /web-login auto-login route was rewritten to
fire signIn from a server-action context via an auto-
submitting form. Calling signIn directly from the
server-component render path silently dropped the session
cookie in Next.js 16, leaving the user signed-out on the
destination page. Symptom: every iOS auto-login attempt
landed on /login. Fixed.
0.10.9 (build 37) — 2026-05-08
iOS image upload + scale slider for custom activity icon. Web settings already had the full picker since build 36. iOS team settings now reaches parity:
- New
SportIconUploadRow(mirrorsLogoUploadRow) with a PhotosPicker, a 50–200% scale slider, and inline preview. - New APIClient methods:
uploadTeamSportIcon(multipart POST to the existing/api/v1/teams/[id]/sport-iconroute, carries thescalefield on the same form so server saves url + scale in one shot) andclearTeamSportIcon(DELETE). - 1 MB pre-conversion ceiling, 256 KB post-conversion cap so the icon stays cheap to render inline on every event row.
- Slot lives in the existing "Activity icon" section directly below the Custom emoji input. Render priority is unchanged (image > custom emoji > picker emoji > none) — uploading here overrides whatever emoji is set above.
No schema or server changes. Web ↔ iOS surface is now symmetric for custom icons.
0.10.9 (build 36) — 2026-05-08
Auto-login from iOS to web + sport emoji on every event row.
iOS → web auto-login
The three "On the web" rows in More — Full data export, Dashboard, Ref pay & 1099 prep — now drop the user on the destination already signed in. No second password entry.
How it works: iOS mints a single-use exchange token via
POST /api/v1/me/web-login-token (bearer-authed, 60s TTL).
The Safari sheet opens at /web-login?token=X&next=/app/...
which validates the token (consumed on first use, stamped with
usedAt for forensics), calls signIn("mobile-web-token")
inside the auth.js machinery to set the session cookie, and
redirects to next. Token leakage in logs is the main risk —
mitigated by single-use + short TTL + requiring an existing
bearer-authed iOS session to mint.
Schema: new mobile_web_login_tokens table — (token, userId, expiresAt, usedAt, createdAt). Cron-safe purge helper drops
rows older than 24h. Auth surface: new mobile-web-token
provider in auth.ts, separate from the email/password flow
so neither path knows about the other.
Sport emoji on event rows
Every event row across web (EventList, KidEventRow) and
iOS (EventRow, SeasonEventRow) now leads with the team's
sport emoji — ⚽ for soccer, 🏀 for basketball, 🏐 for
volleyball, etc. Pulled from the existing curated catalog via
team.sportSlug; sports without an entry render the row
without the emoji (no placeholder).
Helps multi-kid / multi-team families scan a busy schedule by
sport before reading team names — especially when the team
name is generic ("Varsity 2026" instead of "Varsity Soccer
2026"). The kid-season payload now carries teamSportSlug per
event so the web row can look up the emoji without a separate
team-list fetch.
Custom activity icon (emoji + image + scale)
Below the curated sport picker on team settings, coaches can now drop in their own activity icon for sports outside the catalog (chess club ♟️, robotics 🤖, swim team 🏊, drama 🎭, e-sports 🎮…). Three knobs:
- Custom emoji — single glyph or short label (8 chars max).
- Custom image — PNG / JPEG / WebP / SVG, 256 KB cap. Lets a club ship its real logo / mascot on every event row.
- Render scale — 50% to 200% slider. Thin SVGs that get swallowed by surrounding text bump to 150-200%; chunky bitmaps that overpower the row drop to 50-75%.
Render priority everywhere we surface the icon (every event
row across web + iOS): image > custom emoji > catalog emoji >
none. Centralized in src/lib/team-sport-icon.ts (teamSportIconChoice)
and ios/sources/Views/Shared/TeamSportIcon.swift so the
fallback ladder lives in one place per platform.
Schema: 3 new columns on teams (customSportEmoji,
customSportIconUrl, customSportIconScale). New upload route
POST/DELETE /api/v1/teams/[id]/sport-icon mirroring the
existing logo route, but with a tighter 256 KB cap since it
renders inline on every row. New web component
<TeamSportIcon> and iOS view TeamSportIcon pick the right
render surface (image vs emoji) from a pre-computed choice.
0.10.9 (build 35) — 2026-05-08
Snack rotation suggestions + ref pay tracking with 1099 alerts. Two new headline features for the season-running side of the app — designed to take volunteer-and-pay paperwork out of spreadsheets and into the same place the schedule lives.
Snack rotation auto-suggest
The snack volunteer card on web + iOS now shows a non-binding "Rotation suggests Sam (0 so far)" hint when the slot is open. Drawn from each parent's snack-signup history on this team — parents who've signed up the fewest times so far surface first, RSVP'd-Going for THIS event tie-breaks, email is the deterministic final tie-break.
Anyone can still volunteer regardless of who's suggested — the
hint is a nudge, not a lock. Hidden when the slot is already
claimed, when no past events exist, or when the roster has no
manager-role parents. No schema change — uses the existing
event_snack_signups table.
Ref pay tracking + 1099 alerts
Coaches can mark each ref-assigned game as paid with a dollar
amount on the iOS event detail card. New
/app/organizations/[id]/refs page rolls every ref's pay into
a per-tax-year YTD table — sorted by total descending, with
threshold pills:
- Under threshold (green) — any total under $500
- Approaching $600 (amber) — $500 to $599.99
- 1099 required (rose) — $600 or more (the IRS 1099-NEC reporting threshold for nonemployee compensation)
CSV export at the top of the page dumps every payment for the chosen year — date, ref name, amount, team, event, notes — so the school's finance person has the full audit trail for 1099 prep, not just the per-ref totals.
Schema: new ref_payments table keyed by (orgId, eventId, refKind, refUserId|refPlayerId|refExternalName) with the
payment amount in cents and the date paid. The ref identity is
the same three-way shape as the existing events.ref* columns
— parent (rostered adult), player (rostered student ref),
or external (free-form name). Captured at write-time so an
"external" ref keeps its 1099 identity even if a coach later
clears the event's free-form refCustomName.
Authz: org manager (owner / admin / coach) for both write (mark paid / clear payment) and read (the YTD page). Parents see the assigned ref on the event card the same way they always have, but never see pay amounts.
iOS surface is intentionally light — coaches mark pay courtside via a "Mark ref paid…" tap on the event detail's ref row, but the YTD rollup + CSV export stay on the web (1099 prep is a desk flow, not a game-day flow). The More tab links to the dashboard where the per-org Ref pay button surfaces.
0.10.8 (build 32) — 2026-05-08
Sport-aware live scoring buttons. The +/− pair on the iOS live score card was always +1 regardless of sport. Now each sport surfaces the increments that actually score:
- Basketball — +2 (default basket), +3 (3-pointer), +1 (FT)
- Football — TD (+6), FG (+3), 2pt (safety / 2-pt conversion, +2), XP (+1)
- Soccer / lacrosse / hockey / baseball / softball / volleyball — +1 (unchanged)
A score-keeper at a basketball game can now tap once for a 3
instead of three times for +1. Decrement stays as a single −1
for fast undo on misclicks regardless of sport — no −6
button to confuse a coach reverting a TD.
The catalog lives in ios/sources/Models/SportScoring.swift
and follows the same shape as the existing per-sport stats
catalog. Sports without an entry default to [+1]. Layout
auto-wraps: ≤2 increments stay on one row with the −1 button;
3+ increments (basketball, football) drop the −1 to a second
row so 4 capsules fit half-screen on an iPhone.
Server contract is unchanged — the score column stores integer points, not score events. The Live Activity / lock-screen push flow keeps its existing 5-second debounce.
Web has no live-score editor today (only the public read-only
/t/<token> scoreboard), so this build is iOS-only.
0.10.8 (build 31) — 2026-05-08
Fixed: same-day events on iOS Schedule reshuffling between
visits. Reported by a demo coach with the new multi-sport seed
loaded — tapping an event then backing out to Schedule, the
list's same-day event order would shift each round-trip (and
location/team labels appeared "jumbled" briefly). Root cause:
the cross-team event list was built by flat-mapping
eventsByTeam.values then sorting by startsAt. Swift
Dictionary iteration order isn't stable across separate
instances, and every load() rebuilds eventsByTeam = acc,
giving flatMap a different input order each time. The sort
is stable per startsAt but ties (e.g. seven 4pm Tuesday
practices, one per team) broke by input order — so they
reshuffled visibly. Fix: secondary sort key on event.id so
same-time events pin to a deterministic order across loads.
0.10.8 (build 30) — 2026-05-08
QoL: forecast card on event detail. Web + iOS event detail pages now show a hero forecast card above the existing hourly strip — same data the calendar pill condenses (icon, high/low, summary, precip %), expanded so a parent or coach can decide on rides, layers, snacks without bouncing back to the calendar. Hidden when no forecast (indoor event, no coords, outside the NWS horizon, or the event has already ended).
0.10.8 (build 29) — 2026-05-08
Past events now show on the iOS Schedule tab. Reported by a demo coach: events that were visible on the web team page were showing as "No events" on iOS Schedule (Week + Month views) for days in the past. Two contributing causes, both fixed:
- The team-events API (
GET /api/v1/teams/[id]/events) was defaultingfromto one week back. iOS calls it without an explicitfrom, so any event older than seven days got silently filtered out. Default widened to 90 days back, which matches the web team page's standard fetch window. - The iOS Schedule tab was sourcing the Week + Month grids from
a future-only
upcomingslice, so even if the server had returned past events, the grids would have hidden them. Week- Month now read from a full kid-filtered
allEventslist, while the Upcoming list view keeps its forward-only identity.
- Month now read from a full kid-filtered
The kid filter and per-kid breakdown are unchanged — they still cascade through every view mode.
0.10.8 (build 28) — 2026-05-07
iOS bug fixes from build 26.
- Past-event navigation bouncing back to the kid season page
— fixed. Tapping a past game from a kid's season dashboard
animated forward into the event detail and then bounced back
to the kid screen; subsequent taps no-op'd. FamilyView now
uses value-based navigation for both the kid push and the
event push (
APIChild+APIEventdestinations on the parent stack), routed through the explicit$pathbinding so re-renders don't phantom-fire theForEach(children)link. - RSVP "Not signed in" on past events from kid season page —
fixed. The RSVP loader's guard treated a nil
team(passed through from KidSeasonView) as a missing auth token. It now falls back toevent.teamId, and FamilyView's new destination resolves the full APITeam from its already-loaded team list so EventDetailView renders with proper timezone / score-keeper context.
QoL polish.
- Past events on the kid season page now read in past tense —
Going renders as "Went" and Not going as "Skipped." Maybe
stays "Maybe" since we don't track actual attendance and a
past maybe is still informative. Applies to both web
(
/app/family/players/[id]/season) and iOS (KidSeasonView's SeasonEventRow).
Also: marketing site byline updated app-wide from "Built by a Dad who is also an Uncle" to "Built by some Dads in Oregon" (one shared constant + 9 hardcoded surfaces). Sideline page now calls out that the iOS build is universal — looks and runs great on iPad and Apple Silicon Macs via "Designed for iPad."
0.10.8 (build 26) — 2026-05-06
Last game card on the kid season page. Parents now see a focused "Last game" card on each kid's season dashboard, showing just that kid's stat line from the most recent past game. Tap-through still opens the full team box-score on the event page; this card is the quick "what did Lily do Saturday" glance.
The card sorts by event start time (not stat-edit time), so a coach backfilling Tuesday's stats two days late doesn't shuffle the card unpredictably. If the coach opened the game but logged nothing for this kid that day, the card surfaces a soft "no stats logged" line instead of pretending the game didn't happen.
Also fixed in this build: the team page on web was returning a 404
for some users after 0.10.8 due to a Postgres array-literal
serialization bug in the chat unread-count query
(unreadChatCountsForUser in src/lib/chat.ts). iOS was unaffected
because its unread badge fetch silently no-ops on errors.
iOS also gets a new More → Help → "What's new in this version"
row that opens the marketing site's plain-English release-notes
section (/sideline#whats-new) in an in-app Safari sheet — so the
person tapping doesn't have to remember the URL.
0.10.8 — 2026-05-05
Per-game stats → per-season stat sheet. Coaches and event- delegated score-keepers can now record per-player stats during or after a game; the totals roll up onto each kid's season dashboard.
Sport-aware out of the box (soccer, basketball, baseball, softball, volleyball, hockey, football, lacrosse) — the entry sheet renders exactly the fields that matter for the team's sport. Soccer shows goals + assists + shots + saves + cards; basketball shows points + rebounds + assists + steals + blocks + 3-pointers; football covers passing, rushing, receiving yards + TDs, plus tackles, sacks, INTs; hockey adds +/- and PIM. Sports without stats yet (chess club, music, dance) skip the card entirely.
Surfaces:
- Web event detail (
/app/teams/[id]/events/[eventId]) — coaches see an editable "Game stats" card with one row per rostered player and a sport-specific column per stat. Parents and Family / Friend viewers see the same card read-only. - iOS event detail — inline read-only box-score for everyone showing the top-2 stats per player; coaches/score-keepers get a full edit sheet via "Record stats" / "Edit".
- Web kid season page + iOS KidSeasonView — the "Stats this season" card is no longer a placeholder. Each sport the kid plays surfaces its own card with games-logged + per-stat totals.
Schema: new event_player_stats table with (event_id, player_id)
PK + a JSONB stats blob. Sport-validation happens at write time
in lib/sport-stats.ts so we don't fan-out per-sport tables.
API: GET / PUT /api/v1/events/[id]/stats for per-event entry +
read; GET /api/v1/family/players/[id]/season-stats for per-sport
season totals on a kid.
Authz: same as live scoring — coach OR the event's score-keeper delegate during the live window. Read access is the standard team-read gate (parents, Family / Friend viewers).
0.10.7 — 2026-05-04
Multi-team kid season dashboard. A new per-kid drill-down on the
Family tab (web + iOS): tap Lily and see her whole year — soccer +
basketball + lacrosse — on one calendar, color-striped by team.
Lives at /app/family/players/[id]/season on the web and via
KidSeasonView on iOS. Header card with avatar + age + team chips,
"Next up" surfaces the three nearest events, "Rest of the season"
lists everything coming up, past events tucked behind an expandable
disclosure (newest first), stats placeholder card waiting for the
upcoming Stats feature.
The page detects the kid's team membership across regular team-org rosters AND personal-org family schedules, so a parent who tracks their tennis-only kid in a personal calendar plus their school soccer team sees both rolled up. Co-parent shares carry through — if Mom shared Lily with Dad, Dad's drill-down also surfaces Lily's unified season.
Schema: no changes — reuses players, playerManagers, teamPlayers,
teams, organizations, rsvps. New module src/lib/kid-season.ts
with listManagedKidsWithTeamCounts() and getKidSeason().
API: new GET /api/v1/family/players/[id]/season returning kid +
teams (with primaryColor for striping) + events + RSVPs in one
shot. Includes 7-day NWS weather pills on the events that fall in
the forecast horizon, same as the team-events endpoint.
Web: new server-rendered /app/family/players/[id]/season page;
Family list now links kid names to the season (Profile + safety stays
one tap away via the kid's existing profile page).
iOS: KidSeasonView SwiftUI view with team-colored event rows;
APIClient.playerSeason(). Family tab kid card tap now opens the
season; profile/safety reachable via the toolbar person.text.rectangle.
Kid filter on iPhone Schedule tab. Multi-kid families and parents of kids on multiple teams now get a small pill above the segmented view-mode picker — "All kids" by default, scoped to a single kid when picked. Filter cascades through every Schedule view (Upcoming list, Week strip, Month grid). Hidden entirely for single-kid / single-team families so the chrome stays clean.
Homepage layout polish. Pricing block stays light; sponsorship tiers restored to a standalone full-bleed dark-green section between Pricing and FAQ (was briefly folded into Pricing as a sub-block). Final CTA keeps its parallax basketball backdrop. Net rhythm: hero → light → light → dark → light → dark cinematic close.
0.10.6 — 2026-04-30
Weather pill on calendar views. Month and Week calendars now show the same emoji + temperature pill that's been on List view since 0.10.4 — small on Month (icon-only beside the event title) so day cells stay readable, full color capsule on Week. Same NWS palette across web + iOS.
Map-miss UX (web + iOS). When a venue's address can't be resolved to a pin, the Locations admin now shows an amber "Map miss · weather off" badge with a one-tap "Retry forecast" button. Coaches see at a glance which venues won't get weather forecasts and can re-run the lookup without fake-editing the address. Retry result is shown inline ("Found! Weather will appear..." / "Already pinned" / "Still couldn't find — try editing or drop a manual pin").
Smarter geocoder fallback. Background geocode now tries up to
three query shapes per venue: full address → address with directional
prefix stripped (S Columbia River Hwy → Columbia River Hwy, a
common OSM data discrepancy) → venue name + city/state. Catches
addresses that miss on the literal string but match by name.
Sweep cadence. The daily geocode/weather-cache sweep is now hourly-ish — runs at 00:30, 06:30, 12:30, 18:30. Bad addresses fixed mid-day catch up within hours, not overnight. Existing per-location fire-and-forget geocode on create/edit is unchanged.
Outdoor toggle on event edit (iOS). The toggle that 0.10.5 shipped on event create now also appears on edit — coaches who created an event before the toggle existed (or whose venue moved indoors mid-season) can flip it without re-creating the event.
Family schedules: hide coach-only menu items. Personal-org schedules' overflow menu no longer shows "Roster edit requests" — that flow doesn't apply to single-parent kid-tracking schedules where the queue is always empty.
Bug fixes
getWeatherForEventswas passing a plain template-string todb.execute(), which silently returned no rows under drizzle-orm 0.45 + postgres-js. Switched to a typedinArrayquery — weather pills now actually populate the cache.TodayViewweekday header used["S","M","T","W","T","F","S"]withid: \.self, triggering a SwiftUI duplicate-id warning (S/T repeat). Switched to index-based identity.- iOS
EditEventSheet'sSection("Where") { } footer: { }form isn't a valid SwiftUI init signature; expanded to header + footer trailing closures. - iOS
LocationEditSheetwas bullet-proofed against a brand-new row showing the "Map miss" badge briefly while background geocode is still running — explicitnilcoords on the optimistic insert documents the expected state.
0.10.5 — 2026-04-30
Weather on event cards (Phase 2). Builds on 0.10.4. Four pieces:
events.is_outdoortoggle. Coaches can flip "outdoor" off on an indoor practice or on for a tournament held at an outdoor venue. The new boolean replaces the old "type=team_event ⇒ no weather" heuristic — now it's an explicit per-event signal. New events default ON for game/practice/tournament and OFF for team_event; auto-flips with the type until the coach touches it.- Hourly forecast strip on event detail. Below the When/Where
cards, parents see a horizontal scroll of one-hour weather chips
covering
event_start − 1hthroughevent_end + 2h. Kickoff hour highlighted in green. Same NWS source as the daily pill, cached per (location, dayKey) for 3h. Hidden when indoor / no coords / outside the 7-day NWS horizon. Web + iOS. - Color-coded weather pills. The compact pill on event lists + rows now picks tints to match conditions — amber for sun, blue for rain, sky-blue for snow, red for thunder, etc. Identical palette web + iOS.
- Manual pin-adjust + "use my current location" (web). Location edit form now embeds a small Leaflet/OpenStreetMap picker. Coaches can drag the pin to fix a misgeocoded address (the actual field at the back of a school), click the map to drop a new pin, or tap "Use my current location" to grab their browser's GPS. When a manual pin is set, the address-based geocode is skipped.
Schema: events.is_outdoor boolean not null default true,
location_weather_cache.hourly_json text,
location_weather_cache.hourly_fetched_at timestamp.
API: new GET /api/v1/events/[id]/hourly returning
{ points: HourlyPoint[] }.
Phase 3 (planned): swap to Apple WeatherKit when international demand shows up (NWS is US-only) and a one-tap "Heads up: thunder rolling in" team-chat broadcast.
Unread team chat count. Web "Team chat" button on the team page and iOS ⋯ overflow menu now show a small red badge with the number of messages the user hasn't seen yet. Caps at "9+". Cursor stamped on every chat-page render (web) and every menu tap (iOS); messages the user posted themselves don't count, neither do soft-deleted ones. Single number per team — no per-channel split.
Schema: new chat_read_state (user_id, team_id, last_read_at) table.
API: POST /api/v1/teams/[id]/chat/read and
GET /api/v1/teams/[id]/chat/unread-count.
0.10.4 — 2026-04-30
Weather on event cards (Phase 1). Compact pill on every event within the next 7 days at a geocoded location — sun/cloud/rain emoji + temperature + precip% (only when ≥20% to suppress noise). Web event-list rows render it server-side; iOS event rows show an SF Symbol + temp/precip line.
How it works:
- Geocoding — when a coach saves a location, a fire-and-forget
background job hits Nominatim (OpenStreetMap, free, no API key)
to resolve the address →
(latitude, longitude). Stamped onlocations.latitude/longitudefor permanent reuse. Coach doesn't see any extra step. Failures (typo, rate-limit, network) are silently retried by a daily cron sweep. - Forecast —
src/lib/weather.tswraps the NWS API (api.weather.gov, free, no API key, US-only). Two-step lookup:/points/{lat},{lon}returns a grid endpoint; the grid endpoint returns 7 days of day/night periods. Cached inlocation_weather_cache (locationId, dayKey)for 3h. - Surface —
team_eventtypes skip weather (indoor by default). Daily cron at 03:30 sweeps stale cache rows older than 7 days.
Schema: new location_weather_cache table. New cron route
/api/cron/weather-sweep + systemd unit fieldhouse-weather-sweep
wired into deploy.sh.
Phase 2 (planned): an events.is_outdoor toggle, hourly chart on
event detail, color-coded badges, manual pin-adjust on misgeocoded
locations, "use my current location" picker, and (when
international demand shows up) a swap to Apple WeatherKit.
0.10.3 — 2026-04-29
Email verification on signup. New flow on top of existing signup
paths (web + iOS): account is created normally and the user is
auto-signed-in (no friction added to first launch), AND a
verification email goes out via Resend. The signed-in user sees a
yellow banner across every /app/* page until they click the link.
Tapping the link in any context (logged-in / logged-out / signed in
as someone else) consumes the token in a transaction that stamps
users.emailVerified = now() and routes to the right next page.
60-second cooldown on the resend endpoint server-side prevents
inbox spam from a tap-fest. Schema: new
email_verification_tokens table (token, userId, expiresAt,
usedAt, ipAddress, userAgent).
iOS: APIUser.emailVerified now flows through /api/v1/me;
Account view shows a "Verify your email" banner with a Resend
button when unverified.
Universal Link for the verify URL. AASA now claims
/verify-email/*, so tapping the email link on an iPhone with
the app installed opens the Fieldhouse app instead of Safari. A
new POST /api/v1/auth/verify-email JSON endpoint consumes the
token without bearer auth (the token IS the proof), the iOS
handler refreshes /me, and a success toast slides in at the top
of the tab bar (auto-dismisses in 6s). Web fallback is unchanged
— anyone without the app installed still gets the existing
/verify-email/<token> page.
Share Fieldhouse Beta button (iOS). New "Spread the word"
section in More tab with a SwiftUI ShareLink that opens the
standard iOS share sheet (Messages / Mail / AirDrop / etc.)
prefilled with the TestFlight invite URL + a friendly message.
Single-source URL constant on MoreView.shareURL — swap to the
App Store URL when we ship publicly.
Admin roster view with COPPA redaction. New /admin/rosters
page for super-admins. Every team's roster grouped by org. Kids
under 13 (age computed from players.dateOfBirth) render as
opaque "Player #teen_player_controls), the row gets a "Send password
reset" button that fires the same Resend pipeline as a
self-serve reset (audit row + IP/UA capture preserved).
Admin link on the regular dashboard. Super-admins now see an
"Admin →" pill in the header on /app/*, so they don't have to
type /admin in the URL bar. Hidden for everyone else; the
/admin route stays server-gated regardless (the link is
discovery, not auth).
0.10.2 — 2026-04-29
Roster edit lock + 24-hour parent window. Once a coach has saved a kid's jersey number / position on a team, those fields are coach-locked. The parent can no longer edit directly — they file a request from the kid's profile (with an optional note like "Want to swap Jordan to #11"), every coach on the team gets a push, and any coach can approve or deny. Approval grants the parent a 24-hour edit window stamped on the team_player row; the parent gets a deep-linked push and a countdown banner on the edit form. Past 24 hours, the lock comes back and a fresh request is needed.
Surfaces:
- Web parent:
/app/family/players/<id>now renders a "Teams & jersey" section with a per-team card. Editable when unlocked or when the 24h window is open; otherwise shows a "Request edit" CTA with optional message. - Web coach: pending requests list lands at the top of the team detail page above the calendar — Approve/Deny right there.
- iOS parent: PlayerSafetyEditView gets a "Teams + jersey" Section matching the web semantics.
- iOS coach: ⋯ menu → "Roster edit requests" sheet from team
detail. Auto-presents on tap of a
kind=roster_edit_requestpush.
Schema additions: team_players.coach_locked_at,
team_players.parent_edit_until, new roster_edit_requests table
with status enum.
Marketing site progressive disclosure. New <Foldable> shared
component renders ALL children server-side (so search engines and
deep-links see everything) but visually clips long card grids to a
preview after N items, with a "Show all 12 →" toggle. Auto-expands
when the URL hash points at a child id. Applied to the homepage
features grid (12 → preview 6) and the How It Works "A few things
we thought hard about" grid (12 → preview 6). Roadmap SHIPPED list
and pricing tiers stay un-folded — the wall IS the pitch.
Homepage FAQ → native <details>. Each Q is a closed
<details> with a stable id (#faq-<slug>), so visitors land on
a short page and only expand what they want. The first Q stays
open by default. Zero-JS, free a11y, deep-link friendly.
0.10.1 — 2026-04-29
Privacy controls — five-layer parent-side suite.
- EXIF strip on every uploaded photo. Headshots and team logos are re-encoded server-side via sharp BEFORE storage, dropping GPS, camera serial, and timestamps. EXIF orientation is honored first so photos still display upright. SVG passes through untouched.
- Chat identity-card "why" attribution. Long-press a name in chat and the resulting card now leads with one line explaining the association — "You're both rostered on Varsity Soccer.", "Coach on Varsity Soccer; you have a kid on the team.", etc. — so a viewer never sees a stranger in their team chat without knowing why. Pairs with the prior tightening that scopes kid mentions on the card to the chat's team only.
- Per-kid public-page name redaction. Parents can flip a per-kid toggle in Family → kid that renders the kid's name as "First L." anywhere on the unauthenticated public team page — sponsor labels, ref byline, scoreboard. No effect on any authenticated view.
- Per-account chat identity opt-out. Hide-kids toggle in Account settings (web + iOS). When ON, the chat identity card skips the "Manages …" listing for this user. Display name + role chips still surface. Useful for divorce / custody / safety scenarios.
- Coach-read log for safety fields. When a coach views a kid's
allergies or emergency contact (only possible when the parent
has flipped
shareSafetyWithCoachON), the read is logged. Parents see a "Recent safety-info views" panel on each kid's profile listing every coach view with team + timestamp. Reads by the kid's own managers are NOT logged — that'd be noise.
Web chat fix (deploy gating bug from 0.10.0). The chat + roster
APIs only accepted JWT bearer auth, which 401'd web cookie-auth
calls. Added resolveCallerId(request) in src/lib/mobile-auth.ts
that tries the bearer header then falls back to the Auth.js session,
and routed every chat + roster endpoint through it.
Chat identity card scope tightening. The "Manages …" listing on a long-press card is now the chat's team only — not the union of viewer-target shared teams. So Parent A's kid on Team 2 stays invisible even if Parent B is also rostered on Team 2 elsewhere.
Photo cache. URLCache.shared bumped at iOS app init to 32 MB
memory + 256 MB disk. Server already sends
Cache-Control: public, max-age=31536000, immutable on uploads, so
AsyncImage now re-uses cached headshots across launches.
Family + roster row polish. Tap anywhere on a kid card in the Family tab to open their profile. The card icon moved next to the name and the trash button is pushed to the far right with extra padding so a thumb on the row can't catch the destructive action. Headshots now show next to names in both the Family list and the team roster row (uploaded photo when available, initial-letter badge when not).
0.10.0 — 2026-04-28
Team chat. Per-team flat thread for parents, coaches, and
opted-in org admins. Co-parents and co-managers are auto-added by
schema (anyone with a player_managers row for a kid on the
roster). Read-only viewers (the grandma / aunt invite) are
intentionally excluded — chat is a parent + coach surface. 90-day
rolling history; older messages purge nightly via the new
fieldhouse-chat-purge systemd timer (04:15 daily). Senders edit
within 5 minutes; coaches can delete any message; rate-limited at
30 messages/min/sender. Push fan-out has a per-recipient 30-second
debounce: the first message in a window pushes loud (alert + sound +
badge), the second-and-later within 30s land as silent badge bumps
(interruption-level: passive) so a coach typing four follow-ups
doesn't buzz everyone's lock screen four times. Chat pushes
deep-link straight to the team's chat view via APNs payload
(kind: chat + teamId + messageId + senderUserId); the same
URL also works as a universal link (/app/teams/<id>/chat).
Long-press identity card. Tap (or context-menu on iOS, button on web) a sender's name in chat to see who they are: display name, role chips (Owner / Admin / Coach / Parent), and "Manages [kid] on [team] · [sport]" lines for every kid the sender manages on a team you share with them. Phone and email are intentionally NOT shown in the card — that contact info stays on the team roster page. A parent viewing another parent only sees the kids you're co-on-a-team with, never their full household.
Roster fields. Headshot photo upload (parent-only), allergies, and emergency contact (name / phone / email) per kid. Allergies are visible to all parents on the team — used by the new allergen reminder banner above every event's snack-volunteer card so a parent doesn't sign up for cookies forgetting that Maya has a peanut allergy. Emergency contact stays parent-only by default. The new "Share allergies + emergency contact with coaches" toggle (off by default, on a per-kid basis under Family) lets parents flip visibility on for the kid's coaches without giving them edit power — coaches can read the fields when shared, never edit them.
Org admins opt-in to chat pings. A school AD running 30 teams shouldn't get pinged on every volleyball joke. Admins can read every team's chat history regardless, but only get push notifications for teams they explicitly subscribe to (toggle inside the chat itself).
Schema cleanup. Two foreign-key constraints
(teen_invite_email_codes → teen_player_invites,
team_announcement_dismissals → team_announcements) were
auto-generating names that exceeded Postgres's 63-char identifier
limit, getting truncated on disk, and triggering a per-deploy
DROP+ADD churn from drizzle-kit push. Pinned to explicit short
names (tiec_teen_invite_fk, tad_announcement_fk). Same fix on
the authenticators PK churn. After the next push: silent.
0.9.4 — 2026-04-27
CSV schedule import. Coaches can import a whole season for a team
straight from a CSV (one row per event), and parents can do the same
per-kid for a family schedule. Required columns: date and
start_time. Optional: end_time, type, title, location_name,
location_address, opponent, is_home, notes. Header names are
case-insensitive and tolerant of dashes / underscores / spaces, so
"Start Time", "start_time", and "starttime" all work. Two-step flow:
preview the parsed rows, then commit. We fuzzy-match locations against
the org's saved list (so the Tualatin Hills Park you typed last season
gets reused, not duplicated), flag rows that look like duplicates of
events already on the calendar (±2 days, same opponent or title), and
surface anything that didn't quite parse with a status pill so you can
fix it before committing. Web has the inline-edit table — change a
row's date / time / type / opponent right there and pick a location
from a dropdown or add a new one inline. iPhone files-picks the CSV,
shows the same summary + status pills, and commits the ready rows in
one tap; review-required rows punt to the web import page via a
universal link so you can fix them on the bigger screen. CSV-only for
now (export to CSV from Excel or Google Sheets first); .xlsx support
is on the next-up list.
0.9.3 — 2026-04-26
Live scores on the public team page. Anyone with a team's
public link (/t/<token>) now sees an animated scoreboard during
games — pre-game ("Starting soon · in 23 min"), live (pulsing LIVE
badge + scores updating every 5 seconds), and final ("Win · 3–2").
Each card also shows the team's coach and the assigned ref when set.
Hides itself when there's nothing live, so the page stays clean off
game day. Grandma's bookmark just got a lot more useful.
Player accounts for 13+ kids. Parents can now invite a 13+ kid to claim their own login. Parent enters the kid's first/last/age (locked at invite time — kid can't change them) and picks per-account toggles: RSVP for self, add events to family schedules, edit events, create family schedules, chat (when chat ships). Defaults are RSVP- self ON, everything else OFF; flip more on as the kid earns trust. Generates a shareable URL — drop it in iMessage / email / wherever. Parent flow available on both web and iPhone — iPhone uses the native share sheet so the link drops into Messages with one tap.
Existing-email merge. If the teen already has a Fieldhouse account at the email they enter, we send a 6-digit code to that address (15-min expiry) to confirm they own it. Code goes into the sign-up form, password updates to whatever they typed on the first step, and they're signed straight in. Clean flow for the "I forgot my old password" case too — email ownership is the same standard the password-reset flow uses.
Auto-prompt to share new kids with existing co-parents. When you add a child and your household already has co-parents on your other kids, a sheet pops up: "These adults co-manage your other kids. Co-manage Maya too?" Multi-select, all pre-checked, hit Send. Each co-parent gets a push (+ email fallback) and can accept or decline on their own. Accept inserts them as a manager of the new kid; decline pushes you a notification so you know to invite someone else. No more "I added the kid but forgot to share with my partner."
Family schedules on the web dashboard. The main /app dashboard
now surfaces family schedules (your own + co-parent shared) above any
org-team sections. Coaches who also track their own kid's outside
practices see both in one place without bouncing between tabs.
Smoother iOS signup. "Create an account →" on the iPhone Login
screen now uses Apple's ASWebAuthenticationSession instead of an
embedded Safari sheet. The server hands the new account a fresh
mobile auth token at the end of signup, the system auth sheet
auto-dismisses, and the app drops you onto the Family tab signed
in — no more "I created my account but I'm still stuck on the web
view." Password reset still uses the old in-app browser since it
doesn't need the same token round-trip.
Co-parent can publish a shared family schedule. Toggling "Share with family" (and the rest of Team Settings' org-scoped controls, plus the "View public page" button on the team detail) now works for any co-parent on a shared family schedule, not just the schedule's original creator. Real-org teams stay strictly owner/admin — no behavior change there.
Bug fixes
- Co-parents on a shared family schedule can now add locations to the schedule. Previously hit a 403 because location creation was gated on direct org membership; co-parents are virtual members of the schedule's personal org via the playerManagers join, not the memberships table. Fixed by extending the org-staff check to recognize family-schedule co-parents on personal orgs only.
- "View public page" button on team detail now correctly hides for private schedules (used to render even for private teams and 404 on tap).
- Stale "Tip: kids you add later aren't auto-shared" copy removed from the Family tab + invite preview. The new auto-prompt makes the old workaround unnecessary.
- Date of birth in iOS Account → Profile renders as MM/DD/YYYY instead of YYYY-MM-DD.
0.9.2 — 2026-04-26
Co-parent shared family schedules. When two parents both manage the same kid, a family schedule one creates now auto-shows in the other's Family tab. Either parent can add events, edit events, RSVP, claim snack, and offer carpool on the shared schedule. Counts as one slot of the 8-schedule cap for each parent (no stacking). Schedule deletion stays creator-only.
Bug fixes
- Family schedule kids now show up in the event-detail RSVP card immediately after the schedule is created. Previously the kid only appeared in the roster row, not the RSVP section, until the parent removed and re-added them. A one-shot backfill heals existing schedules; future schedules write the roster row at create time.
- iOS team detail page now hides past events correctly, matching the Schedule tab. Previously the team detail showed the prior month's events too on the wider calendar window the web uses for paging.
- Calendar feed URL on Team Settings now uses
webcal://so iPhone taps open Apple Calendar's subscribe sheet directly instead of bouncing through Safari.
Infra (no behavior change)
- Apple IAP root cert (G2) was corrupt in 0.9.1, blocking JWS
verification of TestFlight subscription receipts. Replaced with the
canonical bytes from Apple's CA page; pinned
@apple/app-store-server-libraryback to ^3.0.0 once the underlying constant was fixed. - Marketing site TestFlight links now point at the public
testflight.apple.com/join/<code>URL instead of asking visitors to email for an invite.
0.9.1 — 2026-04-25
Ref / official assignment per game. From any game's Game Center, coaches can tap "Assign a ref…" and pick from three sources: a parent on the roster, a player on the roster (the student-ref path), or a custom name (visiting officials, hired refs from another school). The chosen ref's name shows on the game card and the Final · 3-2 badge after the game ends. Display-only — no pay tracking, no 1099 thresholds in this release.
Misc
- StoreKit Configuration File for local IAP testing now provides
display-name fallbacks so the price-row labels render correctly when
Xcode's local config returns empty
displayName. - iOS push environment hardened to
productionin entitlements (TestFlight + production builds both target prod APNs). CFBundlePackageType=APPLadded to Info.plists to fix "Invalid Bundle OS Type code" submission errors.
0.9.0 — initial TestFlight
The Fieldhouse iPhone companion to the existing web app. Lets parents RSVP for their kids, snack-volunteer, offer carpool, and watch live game scores via lock-screen Live Activities. Coaches create events, manage rosters, and run live games. Free for parents and players; paid Club / School tiers for the org running a team.