Skip to main content

Changelog

The full log.

Every release, top to bottom, in the language we use when we ship. For the parent-friendly recap, see What’s new on the Sideline page.

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.perform now classifies URLError.cancelled / CancellationError and skips recordError for them. These were normal SwiftUI lifecycle events (pull-to-refresh superseded mid-flight, .task view 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 .unauthorized and 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 in PushRegistration.swift, 1 in TheFieldhouseApp.swift's APNs delegate) demoted so simulator runs + no-entitlement dev builds don't spam the Xcode console. .debug level stays out of release Console captures by default.

Server noise fixes:

  • push.ts send-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 in push_delivery_log still captures everything; the warn is now for genuine deliverability problems only.
  • weather.ts and geocode.ts drop 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.ts success 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_log table — 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-errors endpoint 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.recordError refactored from string buffer to structured ClientErrorEntry (occurredAt, path, errorClass, statusCode, message). Email composer still renders strings via recentErrorsSnapshot(); upload uses recentErrorEntries(). SendLogsView fires upload alongside opening the mail composer — user explicitly consents by tapping the button, no silent background collection.
  • /admin/logs page unifies three sources on one timeline: client_error_log + push_delivery_log (failures only — ok rows 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 a ZStack with .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-auto to grid grid-cols-2 md:grid-cols-42×2 on mobile, 1×4 on desktop. New PERSONA_CONTENT.teen ships 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 fills lg:grid-cols-4 cleanly (4-then-2 instead of the previous 5-card 4-then-1 wrap).
  • /roadmap gets the same 4th-tab treatment. New ROLE_HIGHLIGHTS.teen with 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 canManageEffective flag, but server PATCH /api/v1/events/[id] was already gated on isTeamCoach and would 403 on save. EventDetailView.canEdit now uses isOrgStaffEffective for real teams; personal-org family schedules still use canManageEffective (co-parents on their own family schedule legitimately edit there). The looser gate cascades through canEditScore, 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; iOS EventDetailView.event promoted from let@State with an explicit init; new reloadEvent() helper fires when EditEventSheet completes with didChange=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 (getEventForUserhasTeamReadAccess, 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 = false in both ios/sources/Info.plist and ios/TheFieldhouse/Info.plist. Added UIRequiresFullScreen = false so Stage Manager / Split View work.
  • TARGETED_DEVICE_FAMILY in project.pbxproj was already 1,2 (iPhone + iPad) and 1,2,7 on 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/ with CoachModeView.swift (top-level), CoachModeStore (@Observable state graph), and inline panes for bench rail / on-field column / scoreboard / stat grid. Matches the Claude Design canvas at docs/coach-mode-design/project/ pixel-for-pixel — column widths 76 + 240 + 628 + 432pt = 1376pt total (13" iPad logical).
  • New CoachModeSport config (ios/sources/Models/) mirroring the design's SPORTS const — 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 UIWindowScene for landscape via requestGeometryUpdate (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 animated rotate.right.fill SF 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. (formatMinSec helper 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. New periodLabels + maxPeriod on CoachModeSport.Config drive 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.activeCount wired end-to-end (schema column shipped with the substitutions migration but was previously unread):
    • TeamSummary (queries.ts) now selects + returns activeCount on both listUserTeams and getTeamForUser.
    • iOS APITeam gains activeCount: Int? (back-compat optional).
    • Coach Mode's activeCountTarget prefers team.activeCount over the sport default — so a 7v7 small-sided soccer team renders the "X / 7" chip instead of "X / 11".
    • PATCH /api/v1/teams/[id] accepts activeCount (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 recordStat push 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 on canUndo (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.swift from the Coach Mode design: greenSoft (#D8EADC), amberSoft (#FEF3C7), redAccent (#B91C1C), redSoft (#FEE2E2), lineSoft (#F1F2EE). Web's globals.css already had --color-brand-soft and --color-accent-soft; iOS now matches.

Server:

  • New substitutions table in src/db/schema.ts(id, event_id, player_in_id, player_out_id, marked_by_user_id, at_timestamp). Either side nullable: null playerOutId = initial-lineup write at Go-live; null playerInId = bench-out- without-replacement (rare). Cascades on event delete; indexes on event_id + (event_id, at_timestamp) for replay reads and on each player FK for season totals.
  • New teams.active_count integer 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}/substitutions and GET for 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 signatureIsPencil boolean + witnessedByUserId (FK users)
    • witnessedAt timestamp columns on consent_forms. Indexed on witnessedByUserId for the "show every form Coach X witnessed" admin audit lookup. db:push handles the migration.
  • signForm lib + /api/v1/consent-forms/{id}/sign route + the web signFormAction now accept isPencil + witnessedByUserId. Server validates the witness has coach access to the form's team and rejects self-witness attempts.
  • iOS PencilSignatureCanvas (PKCanvasView wrapper) 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.

  1. Parents now see events on teams where their kid is rostered, even without an org membership. hasTeamReadAccess() in src/lib/events.ts had two accept paths (org membership / family-schedule co-manager, and per-team viewer grant) but was missing the parent-of- rostered-kid path that getTeamForUser already had — so the demo sam@example.com could 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 mirroring getTeamForUser's co-manager fallback: playerManagers.role = "manager" joined through teamPlayers to the requested team. Fixes per-kid season stats rendering on the demo (Jordan / Maya / Riley) and for any real parent in the same shape.

  2. Dashboard no longer redirects parents-of-someone-else's- team to onboarding. The empty-state redirect on /app only checked listUserOrgs(user.id) (strictly membership- based), so a parent like the demo sam@example.com — who manages kids on a school's team but has zero org memberships of her own — got bounced to /app/onboarding instead of seeing her dashboard. The redirect now fires only when memberships AND co-managed teams (via playerManagers) AND family schedules are all empty. Surface area unchanged for true-empty users.

  3. 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 — new isFamilyTierOnly(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.tscreateOrgAndTeamAction coerces org_type to "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.tsxlockedTeamOnly now fires either when ?kind=team is 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 computes familyTierOnly from auth.organizations and passes lockedOrgType: "team" to NewOrgSheet when 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 in src/lib/access.ts.
  • isChatMember() in src/lib/chat.ts short-circuits to false for 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-forms GET + POST and /api/v1/teams/[id]/consent-form-drafts GET + 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 in team.orgType !== "personal" gate.
  • src/app/app/teams/[id]/chat/page.tsx — explicit notFound() 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-forms page closes automatically via the isChatMember gate.

iOS:

  • TeamDetailView.swift — Team chat and Consent forms Menu rows now gated by !team.isPersonal. The existing team.isPersonal flag on APITeam made 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 EventLocationMapCell above the "Where" card on event detail screens. Static MKMapSnapshotter (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 — added locationLat / locationLng to the event payload in both listTeamEventsInRange and getEventForUser. Drizzle's numeric column comes back as string through the postgres driver; coerced at the boundary via a parseLatLng helper.
  • src/lib/parent-friends.ts — fixed a 500 in listFriends() introduced in build 80's recency sort. The sql<Date> aggregate type annotation is TS-only; the postgres driver returns max(...) as a string, then the route's .toISOString() crashed. Coerced to Date at the boundary.
  • src/lib/cron-log.ts — fixed a 500 on /admin overview for super-admins. listCronHealth() uses raw db.execute(sql\…`)to doDISTINCT ON; raw queries bypass Drizzle's per-column parsers, so started_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-first marketing 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 /privacy legal page.
  • Owner labels surfaced everywhere an org renders on the admin side (/admin/orgs, /admin/orgs/[id], /admin/broadcasts picker + history, /admin/sponsors, /admin/rosters, /admin/users/[id]) via a new loadOrgOwners helper in src/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.
  • /admin header 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-new condensed: 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/friends page. 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/friend to AASA closed the gap for Messages/Mail but not AirDrop. Fix: register a fieldhouse://friend?code=… custom URL scheme handler in iOS NavRouter, and expose an "Open in The Fieldhouse" button on the Safari /code/friend page. 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: redeemFriendInvite now uses peekShareCode (read-only) instead of redeemShareCode (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 to peekShareCode. Existing single-use friend codes minted in 76/77 retroactively benefit.
  • iOS NavRouter.intent(for:): recognizes fieldhouse://friend?code=… and routes to .openFriendInvite(code:).
  • /code/friend page (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 /go path isn't in AASA), and Safari's server-side redirect to /code/friend doesn't retroactively trigger Universal Links. The mint endpoints now produce direct /code/friend?code=<code> URLs, and AASA gains a /code/friend entry, 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/friend to claimed paths.
  • lib/parent-friends.ts callers 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, or already_friends).
  • iOS: ManualFriendCodeSheet.swift + new section in FriendsView, plus APIClient.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 /app dashboard (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]/messages
    • DELETE /api/v1/me/friends/messages/[messageId] (sender only)

Schema

  • New table parent_friend_messages with composite-ordered friendship pair, sender, body (nullable for soft-delete), createdAt, deletedAt.
  • 90-day purge sweep added to chat-purge cron.

Marketing site

  • /how-it-works gains a "Parent friends — for the other team in the same complex" feature card.
  • /privacy gains 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

  1. Parent A taps "Mint friend code" on the iOS Friends view (or web /app/friends). Gets a 6-char share code + a thefieldhouse.app/go/<code> URL.
  2. Shares the URL or QR via Messages, AirDrop, etc.
  3. 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.
  4. 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_requests table — one row per A → B handshake; pending → accepted / declined / cancelled.
  • parent_friendships table — composite PK (userAId, userBId) with the invariant userAId < userBId enforced via CHECK so we store one canonical row per pair, not two.
  • New parent_friend_invite share-code kind.

Surfaces

Web:

  • /app/friends page 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/accept
  • POST /api/v1/me/friends/requests/:id/decline
  • DELETE /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 isFinal is 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 .bottom region

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 Text had no .lineLimit(1) and the column is narrower than the lock-screen layout. Added lineLimit(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 an events.updatedAt marker 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 /get smart-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_URL constants in src/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/get ranks 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/get instead 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 /get URL. 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 ID and Org ID rows. Each row has a Copy button.
  • Billing & licenses (iOS): every org card grows an Org ID row 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 by GET /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_runs table + withCronLog() wrapper. Every cron route writes a row at start, updates with duration + result on finish. Powers the health-dashboard cron table.
  • push_delivery_log table. sendPushToUsers now 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_audit table. 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_broadcasts logs 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-sensitive so 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 sendPushToUsers filter.

Banner backstop

  • Banner is a system_announcements row with expiresAt set 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-sweepfieldhouse-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_idx for 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 .accessibility3 applied 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 .white or Brand.ink for a given background hex. Mirrors src/lib/contrast.ts on 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-visible ring in globals.css — keyboard- only focus is always visible, mouse users don't see a ring on click. Replaces 30+ instances of outline-none that had no fallback indicator.
  • Form accessibility upgrade<Field> component now wires up aria-required, aria-invalid, aria-describedby linking fields to their hint and error text. Error messages render with role="alert" so screen readers announce them when validation fails. Required fields get a visible asterisk that's hidden from VoiceOver (the required attribute is already announced).
  • Modal accessibility — new useModalA11y() hook in src/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 exposes role="dialog" + aria-modal="true" + aria-labelledby. Photo lightbox also gained a real alt text 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.ts helper. WCAG relative-luminance algorithm; returns "on-light" or "on-dark" tokens for any team-uploaded hex. Drop-in replacement for anywhere we currently hardcode text-white on a custom-color background.

Marketing site

  • New /accessibility page 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_reactions with 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 /messages extended to include reactions: [{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.app with the dump in the body and attached as fieldhouse-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_kind enum gained team_ical_feed (additive enum value; drizzle-push lands it).
  • POST /api/v1/share-codes handles the new kind alongside team_public_calendar. Same audience gate (any team viewer).
  • mintTeamIcalFeedCodeAction server 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 — rsyncs ios/sources/{Models,Network,Views}/ + TheFieldhouseApp.swift into 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_codes table. Every shareable URL can now be aliased to a 6-character code (XXX-XXX for 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 conditional UPDATE so 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 linknavigator.share() on web (mobile native share sheet), falls back to clipboard copy. SwiftUI ShareLink on 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.

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 settingsShareCodeRow inline 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 on kind + 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.ts with 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 priceFundraiserPurchase math 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-intent endpoint, 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]/orders and 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 (viewerIsStaff from 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 notescripts/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_enabled and (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 FundraiserDetailView with 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 order
    • payment_intent.succeeded — flip order to paid
    • payment_intent.payment_failed — flip to failed + release inventory
    • charge.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 varsSTRIPE_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_grants table — 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 ?next set); 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>

Schemapending_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/consents page — joined players to fetch DOB; child column renders the redacted label when < 13.
  • /admin/consents/export CSV — same redaction. New player_name_redacted column 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.

iOSNewOrgSheet 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/login plus a created flag.

Env:

  • APPLE_SIWA_SERVICE_ID — web OAuth client_id
  • APPLE_SIWA_TEAM_ID — Apple Developer Team ID
  • APPLE_SIWA_KEY_ID — SIWA key id
  • APPLE_SIWA_PRIVATE_KEY_BASE64 — base64'd .p8
  • APPLE_SIWA_BUNDLE_ID — optional, defaults to com.mhn.TheFieldhouse (iOS audience)

iOS entitlementcom.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/search so 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 mediaUrl is on a *.giphy.com host before accepting.
  • Without GIPHY_API_KEY set, 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/messages 403s 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 isTeamCoach gates.

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/bans body { 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_edits audit 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 /admin tools when a safeguarding question comes up.
  • Server response now stamps each chat row with canManageAsParent: boolean so 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:

  1. 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.

  2. 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-forms now 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:

  1. Real fix for the album-push bug. Build 41's hoist of .navigationDestination(for:) was correct but didn't address the actual root cause — PhotoAlbumsView was pushed via a NavigationLink inside the team's ⋯ Menu, and SwiftUI's Menu wrapper messes with the destination view's internal .sheet and .navigationDestination registrations 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's NavigationLink with a Button that flips a state flag, then push via .navigationDestination(isPresented:) from TeamDetailView's outer level. Same pattern as the existing APIEvent destination there.

  2. 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:

  1. APIClient.uploadTeamPhoto was decoding the POST response into a full APIPhoto struct, but the server's POST returns a minimal subset (no uploadedByUserId, no createdAt). The decode threw keyNotFound → propagated out of the async call → composer's catch flipped the row to .failed. Changed the return type to Void; the album reload right after the upload is the source of truth, so the response body wasn't actually used.

  2. The progress bar was stuck at 5% during the entire upload then snapped to 100% at the end — the row never transitioned from .compressing to .uploading. Added that transition in the onUploadProgress callback 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 (mirrors LogoUploadRow) 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-icon route, carries the scale field on the same form so server saves url + scale in one shot) and clearTeamSportIcon (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 defaulting from to one week back. iOS calls it without an explicit from, 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 upcoming slice, so even if the server had returned past events, the grids would have hidden them. Week
    • Month now read from a full kid-filtered allEvents list, while the Upcoming list view keeps its forward-only identity.

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 + APIEvent destinations on the parent stack), routed through the explicit $path binding so re-renders don't phantom-fire the ForEach(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 to event.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 HwyColumbia 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

  • getWeatherForEvents was passing a plain template-string to db.execute(), which silently returned no rows under drizzle-orm 0.45 + postgres-js. Switched to a typed inArray query — weather pills now actually populate the cache.
  • TodayView weekday header used ["S","M","T","W","T","F","S"] with id: \.self, triggering a SwiftUI duplicate-id warning (S/T repeat). Switched to index-based identity.
  • iOS EditEventSheet's Section("Where") { } footer: { } form isn't a valid SwiftUI init signature; expanded to header + footer trailing closures.
  • iOS LocationEditSheet was bullet-proofed against a brand-new row showing the "Map miss" badge briefly while background geocode is still running — explicit nil coords 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:

  1. events.is_outdoor toggle. 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.
  2. Hourly forecast strip on event detail. Below the When/Where cards, parents see a horizontal scroll of one-hour weather chips covering event_start − 1h through event_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.
  3. 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.
  4. 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:

  1. 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 on locations.latitude/longitude for permanent reuse. Coach doesn't see any extra step. Failures (typo, rate-limit, network) are silently retried by a daily cron sweep.
  2. Forecastsrc/lib/weather.ts wraps 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 in location_weather_cache (locationId, dayKey) for 3h.
  3. Surfaceteam_event types 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 #" — the actual name is NOT serialized to the client. Parent name + email always visible so support can still reach the household. Kids 13+ show full name + DOB + jersey/position; if they have a linked teen-player user account (via 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_request push.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Coach-read log for safety fields. When a coach views a kid's allergies or emergency contact (only possible when the parent has flipped shareSafetyWithCoach ON), 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_codesteen_player_invites, team_announcement_dismissalsteam_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-library back 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 production in entitlements (TestFlight + production builds both target prod APNs).
  • CFBundlePackageType=APPL added 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.