#1 Admin tier change uses real grant logic:
- families PATCH calls grantPremium()/revokeToFree() on tier change instead of
hardcoded maxMembers:10. Manual/comp upgrades now match real grant (50GB/6/3).
subscription_status records 'admin_comp'/'admin_downgrade'. Explicit
maxChildren/maxMembers overrides still honored. Client sends tier only.
#2 Failed-payment / churn Telegram alerts (webhook):
- subscription.pending -> warn "Payment failing (grace)" — reach out pre-churn
- subscription.halted -> error "Subscription HALTED (churn)"
- subscription.cancelled-> warn; activated -> "New subscriber"; charged -> silent
All include family name + sub id.
#3 Webhook freshness in admin health:
- New "Razorpay Webhooks" check: last event age (Xm/Xh/Xd ago). warn if >35d
silence while subs exist (renewals should keep it fresh). Also added a
"Razorpay" config-presence check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Premium granted unlimited storage because quota.ts treated any paid tier as
Infinity. Plan sells 50 GB — now enforced:
- quota.ts: PAID_STORAGE_LIMIT_BYTES = 50 GiB; getStorageInfo/checkStorageQuota/
reconcileActualSize use it for paid families instead of Infinity. Meter/
warn/exceeded now computed for both tiers.
- storage-usage route: return real limit for paid (was forcing "Unlimited"/null)
- StorageMeter: show meter for premium too (had `isPaid -> return null`);
exceeded message drops the Upgrade link for premium (delete-only)
Free 1 GB unchanged. Premium = 50 GB / 6 members / 3 babies, all enforced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE actual root cause of "charged but still free". reconcile surfaced it:
TypeError: The "string" argument must be of type string... Received Date
postgres.js in this repo binds timestamp params via a custom string serializer
— passing a raw JS Date object throws. The webhook built currentStart/
currentEnd/endedAt/cancelledAt as Date objects and bound them into the
UPDATE family_subscriptions query, so EVERY charged/activated/authenticated
event crashed in processing → 500 → (with the now-fixed idempotency) retries
also failed → entitlement never applied.
Fix: all timestamp params are now ISO strings (.toISOString()):
- webhook: unixToDate -> unixToISO; revoke branch ended_at/cancelled_at as ISO
- reconcile endpoint: same toISO conversion
Combined with the previous idempotency fix, live charges now grant correctly
and a transient failure can be retried successfully.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ROOT CAUSE of "charged but still free": the webhook logged each event BEFORE
processing, then early-returned 200 on duplicate. So when processing threw
after the log insert, every Razorpay retry hit the duplicate guard and skipped
processing forever — entitlement never applied. Diagnostic confirmed: 6 events
logged with correct sub_ids + status=active, but family_subscriptions still
'created' and no paid families.
Fix:
- Webhook no longer early-returns on duplicate. Log is best-effort (never blocks
or fails the request); processing always runs. All processing ops are
idempotent (status UPDATE + grantPremium/revokeToFree upserts) so reprocessing
a redelivered event is safe. Now a transient error → 500 → retry actually
reprocesses and lands the grant.
- NEW POST /api/admin/reconcile-subscriptions: admin recovery. For each
subscription, replays its latest logged webhook event, reapplies grant/revoke
with per-sub error capture, returns resulting paid families. Recovers the two
families already stuck in 'created' despite successful charges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 Checkout branding (within Razorpay's hosted-UI limits):
- image: /icons/192.png — Tia logo in the checkout header
- theme color #fb7185 (Tia rose, matches app theme-color) — was terracotta
- Note: Razorpay Checkout is their hosted UI; logo + brand color + name are
the only customisable bits. Fonts/layout cannot be changed (platform limit).
#2 Auto-prefill name/email/phone:
- UpgradeButton now fetches /api/auth/profile on mount and passes
prefill {name, email, contact} to Razorpay. User can still edit in checkout.
- Saves manual entry; uses the phone we now collect.
(#3 "seller doesn't support recurring payments" is a Razorpay ACCOUNT setting,
not code — needs subscriptions enabled on the account. Handled separately.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Issue 1 — "An active subscription already exists" lockout:
Clicking Upgrade creates a 'created' row + Razorpay sub BEFORE payment. If the
user closes checkout without paying, that row persisted forever and the partial
unique index blocked all future upgrade attempts (Razorpay also refuses to
cancel a 'created' sub — "no billing cycle"). Real users hit this on every
abandoned checkout.
Fix: create route now inspects existing non-terminal sub:
- active/authenticated/pending -> block (genuinely subscribed)
- created -> REUSE it (return same sub_id so checkout reopens) instead of lock
- halted -> retire to 'expired' so a fresh sub can be created
Issue 2 — iOS PWA stuck on "Opening checkout…":
- loadCheckout() could hang forever if a script tag existed but its load event
already fired (listeners never run). Rewrote with a polling fallback +10s
timeout so it always resolves.
- Button stayed loading until dismiss; now clears loading right after rzp.open()
so it never sticks in an iOS standalone PWA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Small, non-intrusive upgrade entry point before SOS + dark-mode toggle:
- Free tier: muted gray 👑 crown with a pulsing rose dot (discoverable hint,
not a nag) — links to /settings#upgrade
- Premium tier: amber ✨ sparkle (status reward, no upsell) — links to the
same Plan section showing their grants + cancel
Reads tier from useFamily(). Both states link to the working Plan section
built in Task 8, so it's wired into the real upgrade/cancel flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- components/UpgradeButton.tsx: client component running the full flow —
lazy-loads checkout.js (warmed on mount), POST /create, opens Razorpay with
subscription_id, handler POSTs to /verify for UX, terracotta theme #C26B4E.
Shows "activating shortly" success state; never touches entitlement.
- settings page: dedicated #upgrade Plan section (anchor target for all the
existing /settings#upgrade CTAs from StorageMeter/MemberLimitBanner/etc):
free -> pitch + UpgradeButton
pro -> shows grants + Cancel subscription (cancel_at_cycle_end)
Replaced the old dead "Upgrade" button in Family section with #upgrade anchor.
Completes the 8-task build. Live acceptance gates (create returns sub_id,
webhook flips tier, full test-mode checkout) run after Task 0 (dashboard +
env vars) per the handoff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 6 — POST /api/subscriptions/verify (UX only):
- HMAC of payment_id|subscription_id (SUBSCRIPTION order, not order_id flavour)
- uses the subscription_id WE stored for the family, not the client's
- grants NOTHING — webhook is source of truth; returns "activating shortly"
- 200 valid / 400 tampered
Task 7 — POST /api/subscriptions/cancel:
- family_id from session (IDOR-safe), cancels family's own live sub only
- RZP cancel with cancel_at_cycle_end=1 — keep premium until period end
- actual downgrade happens later via subscription.cancelled webhook
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client
Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)
middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).
Verified locally: HMAC valid/tampered, unix->date, event routing maps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 2:
- lib/billing/config.ts: reads 4 Razorpay env vars (throws at call time, not
boot), premium grant constants (50GB / 6 members / 3 children / ₹199),
razorpayAuthHeader() Basic-auth helper
- POST /api/admin/seed-plan: admin-only idempotent upsert of the Premium plan
row from env + constants (GET shows current plans). Re-runnable, no DB shell
Task 3:
- lib/billing/entitlements.ts: grantPremium() / revokeToFree() sync onto
families.tier + max_members + max_children. Existing quota.ts guards UNCHANGED
— they already read these via isPaidFamily(). revoke = limit downgrade only,
data untouched (freeze-not-demote)
- ENTITLED_STATUSES (active/authenticated/pending=grace) + TERMINAL_STATUSES
No guard refactor needed: chosen "sync to families.tier" approach means the
3 existing guards (storage/member/child) keep working as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three tables + lifecycle enum for Razorpay subscriptions:
- subscription_plans: maps razorpay_plan_id -> grants (price/storage/member/child)
- family_subscriptions: per-family sub state mirrored from Razorpay
- razorpay_webhook_events: append-only log, razorpay_event_id = idempotency key
- subscription_status_enum: mirrors Razorpay's lifecycle states exactly
- partial unique index family_live_sub_idx: at most one non-terminal sub/family
Notes:
- Raw-SQL + Drizzle schema both added (repo uses raw sql`` at runtime;
schema file keeps drizzle-kit + type inference working)
- child_limit added to plan (not in original handoff) since premium lifts the
free 1-baby cap to 3 per product decision
- Migration 0012 idempotent; also added to debug-migration hot-apply steps
- when=1780100000000 (> last entry, per journal drift rule)
Entitlement will sync onto families.tier (Task 3/5) so existing quota.ts
guards stay unchanged — these tables are audit + Razorpay state mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Answer to "is phone visible in admin?": it wasn't — added it.
- /api/admin/users: SELECT u.phone, return phone in the DTO
- admin users page: new Phone column (tap-to-call tel: link, "—" when empty),
searchable by phone, included in CSV export (now properly quoted so
commas/empty cells don't shift columns)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Existing users (signed up before phone collection) won't have a number.
Show a dismissable home banner prompting them to add one.
- Checks /api/auth/profile on load; shows banner only if user.phone is empty
- "Add" links to /profile; ✕ dismisses
- Dismissal remembered in localStorage (tia_phone_nudge_dismissed) so it
never nags repeatedly
- Dark-mode styled, sits below the vaccine reminder banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Google OAuth cannot provide phone numbers (no scope returns them reliably),
so we collect it ourselves. Optional, stored unverified.
- Migration 0011: users.phone text column (+ debug-migration hot-apply step)
- schema/auth.ts: add phone field
- onboarding: optional phone input on step 1; saved to users.phone via the
onboarding API (normalised: leading + then digits, 8-15 digit validation)
- profile page: editable Phone field; loaded from + saved to /api/auth/profile
- /api/auth/profile: GET returns phone; POST accepts & normalises it
(empty string clears, undefined leaves untouched)
Capture point covers both Google and email/password signups since both land
on onboarding. Verification (OTP) and marketing-consent flag intentionally
deferred per product decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avatar (baby card):
- Switch tia-portrait.jpg → tia-portrait.png (white/transparent bg, no dark edges)
- bg-white on container so PNG transparency shows correctly
- object-position: 65% top to shift face right and center it in the circle
Memories grid (all local, no external URLs):
- memory-bath.jpg: baby feet in towel (downloaded from Unsplash, stored locally)
- memory-milestone.jpg: baby in pool float with sunglasses (local)
- Captions updated: 'Tiny toes 🛁' and 'Summer fun 😎'
About page P.S.:
- tia-portrait.jpg → tia-portrait.png (clean white background)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Loading:
- Fraunces, Newsreader, JetBrains_Mono added to (marketing)/layout.tsx
as CSS variables -- one load point, cached for all marketing pages
- 3 utility classes added to globals.css: .font-fraunces, .font-newsreader,
.font-jetbrains
- about/page.tsx: removed duplicate font loading (now from layout)
Font roles applied:
Fraunces → all h1/h2/h3 headings + blog card titles + hero h1 (italic)
Newsreader → long prose blocks: TheProblem, FounderStory card, Privacy
intro, HeirloomVision description, blog article paragraphs,
blog post excerpt
JetBrains → all small uppercase eyebrow labels across every section
Geist → nav, buttons, feature card body, short UI text (unchanged)
Pages updated: homepage, blog listing, blog articles, pricing, partners
About page: fonts resolve identically via layout variables, no visual change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The dark #1f1b16 full-bleed section broke the continuous letter feel.
Replaced with a standard letter article on var(--paper):
- tia-subhead 'What we believe' (same as other subheadings)
- tia-pull.accent for the opening line
- Paragraphs in tia-col as usual
- Creed list (01-06) retains mono numbers in marigold + ink body text,
hairline borders — now reads as part of the letter, not a separate block
Removed all .tia-mani* CSS rules and --mani-bg/fg tokens.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Content: complete "A Letter to Every New Parent" letter by Yashika &
Manohar — four named sections (Why We Named the App, Modern Families,
What We Believe, To the Parent Reading This) + signature
Images:
- /images/family-illustration.jpg — watercolour illustration of the
family in the hero, 2-col layout at lg (copy left, image right)
- /images/tia-portrait.jpg — Tia's portrait cropped to circle in
the closing signature
Design:
- Hero: rose→amber gradient, 2-col at lg, family illustration bottom-aligned
- Breadcrumb below hero intro
- Letter body max-w-2xl, generous leading, section borders
- "Nani in Lucknow / Dadu in Jaipur" in amber callout card
- Beliefs rendered as rose dot list
- Key line "Tia is here to help you remember your baby" in rose-700
- Closing parenthetical about Tia in a rose-50 italic card
- CTA: Get started → with hover lift
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The home banner read fields (.status, .vaccineName) that the DB-backed
notifications API never returns — its DTO exposes {type, title, message,
metadata,...}. So .vaccineName was undefined → "undefined due today", and
.status was undefined → the ternary always fell to the "due today" branch
even for overdue/nudge items.
Two issues, both fixed:
1. Contract mismatch: render the real `message` field instead of the
non-existent `.vaccineName`/`.status`. message is already correctly built
server-side ("BCG is due today" / "BCG is 3 days overdue").
2. Wrong source set: the banner consumed the FIRST notification of ANY type.
Since the API also returns log/memory/garment nudges, a nudge could land
at index 0 and render under the vaccine header. Now filtered to
type starting with "vaccine_" before use.
Also hardened the vaccine upsert: ON CONFLICT DO NOTHING -> DO UPDATE SET
title/message/action_url/metadata, so future copy/day-count changes refresh
existing rows instead of freezing the first version (preserves id/is_read).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace flex+ml-auto with grid-cols-[1fr_auto_1fr]:
- Col 1 (1fr): logo, left-aligned
- Col 2 (auto): nav links, truly centered regardless of button width
- Col 3 (1fr): auth button, flex justify-end
Works on mobile too: About sits perfectly centered between logo and button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Both About and Blog: text-rose-500 at rest, no border, no background
- Hover: border-rose-300 + bg-rose-50 + text-rose-600 (border space
always pre-reserved via border-transparent to prevent layout shift)
- Active page (usePathname): font-semibold text-rose-600, no visual
container — bold text only, clean
- About: always visible on all breakpoints including mobile
- Blog: hidden on mobile, sm:inline-flex on desktop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MarketingNav:
- About: always visible on all breakpoints; styled as outlined rose pill
(border-rose-300, rose-600 text) — pairs naturally with the solid rose
CTA button beside it; hover fills bg-rose-50
- Blog: hidden on mobile (sm:inline-flex), plain text gray-600 → rose-600
- Use ml-auto mr-3 on nav so links sit right of center, next to the button
Homepage FounderStory:
- Remove 👨💻 / Manohar Gupta / Founder block — author details move to About
- Story content and amber card remain unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MarketingNav:
- Add center nav with About and Blog links (hidden on mobile xs,
visible from sm: breakpoint)
- Logo + nav + auth button now use justify-between with gap-4
Homepage FounderStory section:
- Replace [PLACEHOLDER] blocks with Manohar's real founder story
- Section opens with bold "TIA began with a promise." heading
- Key line "TIA isn't here to track your child. It's here to help
you remember them." highlighted in rose-700
- Closes with "Built by parents. Inspired by our daughter. Made for
families." in a bordered amber footer strip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two stacked causes:
1. ROOT CAUSE (home page field mismatch): the DB-backed notifications rewrite
(678cf65) changed the API response shape, but the home page banner still read
the OLD fields. It checked `notif.status === "overdue"` (no longer returned —
now it's `title`) so it always fell to the else branch `${notif.vaccineName}
due today`, and `vaccineName` no longer exists at top level (now in
`metadata.vaccineName`) → rendered "undefined due today".
Fix: render the complete server-built `message` ("BCG is N days overdue" /
"BCG is due today"). Also filter to vaccine_* notifications so the banner can't
show a log/memory/garment nudge under a "Vaccine Reminder" header.
2. Stale message data: an earlier generator had stored "undefined …" in the
message itself, frozen by ON CONFLICT DO NOTHING. Delete legacy "undefined%"
rows and switch vaccine upsert to DO UPDATE so messages (and overdue
day-counts) stay correct instead of freezing the first version. is_read / id /
created_at preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Launch-critical monitoring wiring — alerts go to tiaBaby_Bot via Telegram.
- src/lib/alert.ts: sendAlert(level, title, detail?, {fields, silent}) — HTML
formatted, IST timestamped, best-effort (never throws). Env: TELEGRAM_BOT_TOKEN,
TELEGRAM_CHAT_ID
- GET /api/healthz: public, no-auth liveness probe (200 ok / 503 down) for
Uptime Kuma + Dokploy healthcheck. No sensitive detail
- cron/backup: alert on failure (fatal), warn if dump < 1KB (empty), silent
success confirmation with file + size
- cron/monitor: error-spike rising-edge detection (last 1h > 5 and > 2x prior
hour — stateless, no re-alert on flat rate), DB/migrations/integration checks.
?test=1 sends a Telegram test ping
- cron/visitor-summary: polls Umami REST API (login -> stats/metrics/active),
posts visitor digest to Telegram. ?hours=N window (default 24)
- CLAUDE.md: new env vars + Monitoring & Alerting section
Health up/down flip detection is delegated to Uptime Kuma (pings /api/healthz);
this code covers what Kuma can't see from outside.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browser was blocking the POST to analytics.manohargupta.com/api/send
with 'Failed to fetch' because the Content-Security-Policy connect-src
only listed plausible.io (old analytics tool, now removed).
- connect-src: replace plausible.io with analytics.manohargupta.com
- script-src: remove plausible.io (no longer needed, Umami script is self-hosted)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
document.currentScript is always null for async scripts (which is how
next/script afterInteractive works). Umami's first check was:
const{currentScript:l}=c; if(!l)return;
This caused an immediate exit — zero tracking.
Fix: fall back to querySelector('script[data-website-id]') when
currentScript is null, so Umami finds its own script tag and reads
the data-website-id / data-host-url attributes correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
analytics.manohargupta.com/script.js returns 503 when loaded as a
browser sub-resource from tia.manohargupta.com (same Cloudflare Bot
Management issue as R2 images). Fix: serve the script from /umami.js
(same origin, no cross-origin block) and use data-host-url to tell it
where to POST events back to the Umami instance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drizzle's migrator applies a migration only when its journal `when` is greater
than the max created_at already recorded. Entries 0003-0010 were given 2025-era
timestamps (smaller than the 2026 baseline), so drizzle silently skipped them —
they only applied via the debug-migration hot-apply endpoint. Document the rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-family R2 storage usage (the basis for usage billing), computed from
SUM(size_bytes) over memories + attachments — same basis as the quota system,
so admin numbers match what users are charged on.
- GET /api/admin/storage: per-family bytes/objects (sorted by heaviest),
totals, over/approaching free-limit counts, est. R2 cost, 30-day upload trend.
- /admin/storage page: summary cards, daily upload chart, per-family table with
% of free quota bar and paid/over-limit flags.
- Sidebar: added Storage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each probe now runs independently (pgvector check first and standalone) and
reads the drizzle journal from the correct "drizzle" schema, so the endpoint
returns a usable diagnostic instead of crashing on the first missing relation.
Also reports whether error_events exists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Migration 0010 (error_events) didn't land via the drizzle journal on prod, so
add an idempotent CREATE TABLE + indexes to the /api/debug-migration steps so
the table can be created instantly via the documented hot-apply endpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The baseline schema needs the `vector` extension (memories.vision_embedding),
but nothing created it on deploy — a fresh DB hit "could not access file
vector" and migrations failed.
- migrate.ts now runs CREATE EXTENSION IF NOT EXISTS vector (superuser) before
applying migrations, with a clear error if the Postgres image lacks pgvector.
- /api/debug-migration GET now reports pgvector status (binaryAvailable /
installed) so the image/extension can be checked from the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Analytics showed blank despite real data because one failing sub-query made
the whole route fall back to an empty shape, and the page dropped the error.
- Each sub-query (adoption, families, AI, daily) now runs in its own
try/catch; failures are collected and returned in `error` while the other
sections still render (partial data instead of all-or-nothing).
- Cast all MAX() timestamps to timestamptz inside GREATEST() so mixed
timestamp/timestamptz columns can't error.
- Dedicated COUNT(*) for total families (robust denominator/summary total).
- Analytics page now surfaces the `error` string in a banner instead of
silently rendering empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the admin panel into a real monitoring tool so production bugs are
visible instead of silent.
- Error & crash tracking: error_events table (migration 0010) + logError()
helper + /api/errors ingest; global-error.tsx and (app)/error.tsx report
crashes automatically; /admin/errors viewer (recent + grouped, filters).
- Full audit-log viewer at /admin/audit over the existing audit_log (all
actions, not just auth) with action/resource/family/user/text filters.
- AI observability at /admin/ai over ai_usage: per-intent latency (avg/p95),
tokens, cost, daily trend, slowest calls, medical-redirect count.
- System health at /admin/health: DB latency, migration status, recent error
volume, and integration config presence.
- Sidebar updated with Health / Errors / Audit Log / AI Usage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /admin/analytics page calls /api/admin/engagement, whose per-family
query LEFT JOINed feeds × diapers_logs × sleeps × vaccinations × growth ×
chat_sessions before GROUP BY — a cartesian explosion that times out for
any family with real activity, 500ing the route. The page then ran
[...data.families] on the error object and white-screened.
- Pre-aggregate each log table to one row per child (CTEs) before joining,
eliminating the row explosion; memories aggregated per family.
- Route error fallback now returns the full shape (safe empties) not {error}.
- Page normalizes the response into the full EngagementData shape so a bad
response renders an empty state instead of crashing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Focusing an input with font-size <16px makes mobile browsers auto-zoom
the viewport, which distorted the home "Ask AI" popup UI. Enforce a 16px
minimum on form controls for touch devices via globals.css; desktop and
pinch-zoom accessibility are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Input component renders its own wrapper div, so flex-1 passed to
<Input> only hit the inner <input> (already w-full) while the wrapper
stayed content-width, leaving the box narrow with empty space beside it.
Wrap Input in a flex-1 div so the flex child actually stretches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add pb-16 to /ai root so the input clears the fixed BottomNav
(only page using h-screen instead of min-h-screen+pb-24).
- Normalize new sessions with messages:[] in createNewSession so
render no longer hits undefined.length and crashes on first chat.
- Make render defensive (messages?.length ?? 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>