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>
- Add metadataBase to root layout so OG/Twitter/canonical URLs resolve
to absolute https URLs (fixes broken social previews)
- New src/lib/seo.ts with SITE_URL + JSON-LD builders
- New robots.ts (disallow api/admin/private app paths) and sitemap.ts
(marketing pages + blog posts with real lastmod dates)
- JSON-LD: Organization/WebSite/SoftwareApplication on home,
Blog+Breadcrumb on blog list, BlogPosting+Breadcrumb on posts
- Per-page canonical + Open Graph on all marketing pages; article OG
+ Twitter cards on blog posts; per-post dynamic OG image
- noindex on (app) and admin layouts; richer PWA manifest
- Fix CSP to allow plausible.io in script-src/connect-src (analytics
was silently blocked)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove continuous animate-cta-pulse ring — too distracting.
On hover: button lifts 0.5px, scales 1.03×, shadow grows with a
rose-200/60 tint. Fades back on mouse-out. Active state still
scales down to 0.95 for tactile press feel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the static placeholder with an auto-cycling mockup that shows
three real app screens — Home/Quick Log, Vaccinations (IAP), and
Memories — mirroring the actual app card/row UI patterns.
- 2500ms auto-advance, pauses on hover
- Smooth opacity crossfade (duration-500) between screens
- Active dot indicator stretches to pill shape (w-4)
- Floating label pill above phone changes with active screen
- All pure CSS/Tailwind — zero external assets, static page unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Homepage:
- Wrap "Continue with Google" in a relative group; add rose-300 pulse
ring (animate-cta-pulse) that fades out smoothly on hover
- @keyframes cta-pulse added to globals.css (2.6s, scale 1→1.22, opacity 0.45→0)
Blog listing (/blog):
- Remove breadcrumb from above the hero header
- Place it in a pt-5 strip directly above the 3-column content grid
so it reads as "you are here" navigation rather than floating chrome
Blog post (/blog/[slug]):
- Remove breadcrumb from inside the hero gradient section
- Place it in the same pt-5 strip between hero and 3-col grid for
consistent placement across listing + post pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration (0009): notifications table with unique daily/weekly slots,
is_read column, metadata JSONB for vaccine info. No more localStorage
for read state — syncs across devices.
API GET /api/notifications?childId=:
- Generates vaccine notifications (upsert, filtered by given vaccines at query time)
- log_nudge: if no feed/diaper/sleep logged today after noon IST
- memory_nudge: if no photo added to memories today
- garment_nudge: if wardrobe < 10 items (once per week slot)
- Returns unread first, then recent read, limit 60
API PATCH /api/notifications — mark all read for family+child
API PATCH /api/notifications/[id] — mark single notification read
Page /notifications:
- Fetches from real API (no hardcoded mock data)
- Optimistic mark-read on tap, navigates to actionUrl
- Colored cards per type (red=vaccine, amber=log, purple=memory, pink=garment)
- Unread badge + Mark all read button in sticky header
- Legend row at bottom
debug-migration: added notifications table CREATE IF NOT EXISTS for hot-apply
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replaced hardcoded mock data with real call to /api/notifications?childId=
- Read/unread state stored in localStorage (tia_read_notifications) since
notifications are computed on-the-fly from vaccination schedule, no DB row
- Tapping a notification marks it read individually
- Mark all read button appears in header only when there are unread items
- Unread count badge shown in header
- Empty state now says "All caught up!" instead of generic "No notifications"
- Shows 🚨 for overdue vaccines, 💉 for due-today
- Added due date display in IST-formatted date
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old behavior: dismiss once -> never shown again forever.
New behavior:
- Only shows after 3+ sessions (user has seen value first)
- "Later" -> snoozes for 7 days, re-asks after that
- "No thanks" -> snoozes for 30 days
- Once actually installed (Android accepted) -> permanently hidden
- iOS: two buttons (Later / No thanks) instead of bare X, so intent is clear
- Android: tapping X now snoozes rather than permanently suppressing
- Dark mode support added to both banners
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /api/img: add garments/ to ALLOWED_PREFIXES so garment images proxy correctly
- garments upload: resolve Android empty MIME type from file extension; return
/api/img proxy URLs instead of raw pub-*.r2.dev (blocked by Cloudflare Bot Mgmt)
- garments route + [id] route: toDto() now builds /api/img?key= proxy URLs
- date-ist.ts: add toUTCDate() helper -- strings without Z/offset treated as UTC,
preventing browser local-time misinterpretation; used in fmtTime, fmtDate, dateIST
- LogModal: add editTime to SmartDefault; pre-fill time picker (custom preset) when
editing an existing log instead of defaulting to now
- activity page: pass editTime: log.loggedAt in handleEdit so LogModal pre-fills
- wardrobe/add: explicit Camera and Gallery buttons via separate hidden inputs
(one with capture=environment for direct camera, one without for media picker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wardrobe add page:
- Remove capture="environment" so Android shows the Camera/Gallery chooser
instead of opening the camera directly
- Vision AI no longer blocks the UI: photo preview + form appear instantly
after selecting an image; upload spinner shows on the thumbnail while R2
upload runs; vision AI fires in the background and fills in tags when done
without interrupting the user (they can pick size/occasions in parallel)
- "AI tagging…" pulsing badge in header while vision runs; "✨ AI pre-filled"
badge when done; form fields are only overwritten if the user hasn't already
typed/selected something (functional state updater with prev-value guard)
- Save button is disabled (with "Uploading photo…" label) until R2 upload
completes — prevents saving a garment with no imageKey
/api/time endpoint (GET, no auth):
- Returns { utc, istDate, istTime, ist, offsetMinutes } for the current server
time in Asia/Kolkata so the app can verify server clock and surface IST time
reliably (can be called from browser console at /api/time)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: postgres.js v3 parses `timestamp without timezone` columns as
`new Date("YYYY-MM-DD HH:mm:ss")` (space format, no Z). V8 treats this as
*local time*, so on a non-UTC server (Dokploy host = Europe/Helsinki UTC+3)
the parsed Date object is 3 hours off, making logged times show as server
time instead of the user's IST.
Fixes:
- db/index.ts: add custom `timestamp` type parser that forces UTC by
converting space-format to ISO with 'Z' before calling new Date().
Also set `connection: { TimeZone: "UTC" }` so PostgreSQL sessions always
store/return timestamps in UTC regardless of server OS timezone.
- CalendarView.tsx: use `dateIST()` for day grouping (fixes midnight-boundary
bug where a 12:30 AM IST entry appeared on the previous UTC day) and
`fmtTime()` for time display (replaces toLocaleTimeString without timezone).
- MedicineTab.tsx: replace toLocaleString() with fmtDate/fmtTime (IST-aware).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete UploadProgress component (was debug UI, no longer needed)
- All 3 pages (home, memories, profile) now use simple inline error state
instead of the step-by-step toast
- /api/img proxy: fetch R2 objects server-side to bypass Cloudflare Bot
Management 503s on pub-xxx.r2.dev cross-origin img requests
- All API responses (memories, children, profile) now return /api/img proxy
URLs via toProxyUrl() helper in src/lib/r2-proxy.ts
- Fix memory pipeline: vision failure now marks status='ready' instead of
'failed'; thumbnail failure no longer blocks vision via .catch() separation
- Reset stuck 'processing' memories via debug-migration endpoint
- memories page: replace full-screen overlay with small ⏳ badge on tile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs fixed:
1. Vision failure was marking memories as 'failed', triggering orphan
cleanup that permanently deleted them. Vision is optional — on error,
now marks 'ready' so the image stays visible without AI captions.
2. Thumbnail failure was blocking vision from running (rejected promise
swallowed the chain). Fixed by catching thumbnail error separately so
processMemoryVision always executes and always sets a final status.
3. /api/img proxy was rejecting thumbnail keys (families/{id}/thumbnails/…)
because 'families/' was not in ALLOWED_PREFIXES. Added it.
Also: replaced the full-bleed 'Processing…' overlay with a small corner
badge so the uploaded photo is visible immediately after upload.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pub-xxx.r2.dev returns 503 for cross-origin sub-resource requests (img tags,
fetch) due to Cloudflare Bot Management on the r2.dev dev domain. Direct
browser navigation works but programmatic loading fails, so all uploaded
images appeared as placeholders after upload.
Fix: route all image display through a same-origin /api/img?key=... proxy that
fetches from R2 via the S3 API server-side. API responses (profile, children,
memories) now return proxy URLs. After upload, UI state is updated with proxy
URLs directly rather than raw R2 URLs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each upload now shows a persistent card with 3 labelled steps and their
live status (pending → active → done / error). Errors include the exact
HTTP status code + raw response body (handles non-JSON from Traefik,
nginx, etc. that return HTML error pages). The card stays visible after
failure so the user can read the diagnostic before dismissing.
Changes per surface:
- src/components/UploadProgress.tsx — new shared step-tracker component
- profile/page.tsx — step card rendered below avatar; safeResponseText()
reads raw body so a Traefik 413 shows "HTTP 413: <html>..." not just
"Upload failed"
- memories/page.tsx — fixed toast expands to show all 3 steps; dismissible
after done/error; same safeResponseText pattern
- home/page.tsx (baby photo) — same fixed toast as memories; 3 steps with
HTTP codes and raw body on error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Memories "count 2 but no images" root cause:
- DB rows exist with processing_status='failed'/'uploading' from aborted uploads
whose R2 objects never actually landed. The img onError fires and hides the
tile, but the count still includes these orphaned rows.
- Fix: GET /api/memories now excludes failed rows and uploading rows older than
30 min from both the SELECT and the count. Also fires a background DELETE to
clean up orphaned rows so they stop accumulating.
Profile / memories upload silent failures:
- Some Android cameras return file.type="" which caused the avatar API to reject
the upload with a 400 error. Error was caught but shown in a small text node
buried below the form — invisible when looking at the avatar area.
- Fix: added resolveContentType() helper (used in profile, memories, home) that
falls back to extension-based detection when file.type is empty/octet-stream.
- Fix: profile page now uses a separate uploadMsg state rendered immediately
below the avatar so errors/success are always visible on mobile without scroll.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-pronged fix for Android PWA shell launching to the wrong page:
1. middleware.ts: if a logged-in user (valid tia_session cookie) visits /,
immediately redirect them to /home — catches all existing installs whose
cached start_url still points to /?source=pwa
2. manifest.ts: change start_url from /?source=pwa to /home?source=pwa
so any fresh install or reinstall opens directly to the app home
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bug 2: raise all FABs (activity, memories, circle) to z-50 and bottom-24
so they sit above the bottom nav (z-40, ~56px tall)
- Bug 1: add onError handlers to baby photo (home) and parent avatar (profile)
so a broken R2 URL shows the 👶 / initials placeholder instead of a broken
browser icon; reset error flag after a successful upload
- Bug 3: replace ⏳ emoji spinner with a proper CSS spinner on home page baby
photo; add a fixed upload-progress toast to the memories page that appears
at the top of the screen for the full duration of the upload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- API: dynamic SET clause (only updates fields present in body) fixes
undefined param bug and allows clearing fields; replaces blanket COALESCE
- Settings UI: display/edit toggle — saved details shown with Edit button,
inputs open on first visit or when editing; Save shows inline error on
failure and brief "Saved!" on success
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- quota.ts: checkChildLimit() mirrors checkMemberLimit() using families.max_children
- POST /api/children: returns 403 child_limit_reached when free family is at limit
- ChildLimitBanner: new banner component for the family page
- /family page: shows banner + hides Add Baby button when at limit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New families.pediatrician_name column (migration 0008)
- Settings card: Name input above Phone input, single Save
- Emergency page: shows doctor's name above the call button
- AI medical redirects: personalised "Call Dr. X: +91…" message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Feature A — Storage quota (1 GiB per family):
- src/lib/quota.ts: enforcement library with pure functions (fully unit-tested)
and DB-bound helpers; isPaidFamily() is the single payment abstraction gate
- src/lib/format-bytes.ts: extracted formatBytes() — safe for client imports
- POST /api/upload: quota check before presigned URL issuance (HTTP 402 + reason code)
- POST /api/memories/[id]/confirm: HeadObject reconciles actual R2 size; deletes
over-quota objects and marks row failed rather than silently exceeding limit
- GET /api/storage-usage: storage info endpoint for UI meter
- src/components/StorageMeter.tsx: meter bar + StorageQuotaBanner + MemberLimitBanner
- memories/page.tsx: quota banner, FAB disabled (⊘) when exceeded, compact meter in header
- settings/page.tsx: always-visible StorageMeter + MemberLimitBanner in invite section
Feature B — Member limit (2 per family, free tier):
- invites/route.ts: replaced ad-hoc inline check with checkMemberLimit() from quota lib
Structured 403 response: { reason, currentCount, limit }
- Freeze rule: paid→free downgrade leaves all members intact; only new invites blocked
Migration:
- drizzle/0007_subscription_status.sql: ADD COLUMN subscription_status varchar(20)
- debug-migration/route.ts: step added for hot-apply without full redeploy
- src/db/schema/family.ts: subscriptionStatus field added to Drizzle schema
Tests: 44 unit tests in src/__tests__/quota.test.ts, all passing
- Pure function tests (no DB): isPaidFamily, wouldExceedQuota, isAtMemberLimit, formatBytes
- DB-bound tests (mocked @/db): getFamilyStorageUsage, checkStorageQuota,
checkMemberLimit, getStorageInfo, tenant isolation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Favicon → 🌸 cherry blossom SVG (replaces smiley)
- Nav: removed Pricing/Privacy links; nav is now invisible at top and
slides in after scrolling 75% past the hero (scroll-reveal client component)
- Hero CTA: white background + proper 4-color Google G (matches login page)
- Hero subtitle: font-light, text-xl, leading-loose for a more editorial feel
- Feature cards: hover border highlight + emoji scale on group-hover
- Heirloom vision cards: hover border on hover
- Privacy items: bg-rose-50 on hover
- Final CTA button: hover shadow lift + active:scale-95
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add (marketing) route group: /, /pricing, /privacy, /terms
- Add (app) route group: moves all authenticated pages, app home → /home
- Root / is now a static marketing page (zero DB imports, zero auth)
- NavAuthButton client component: shows "Open Tia →" if logged in, else "Continue with Google"
- Plausible analytics hook in marketing layout
- Auto-generated OG image via opengraph-image.tsx
- Middleware updated to allowlist marketing routes
- All /-redirects updated to /home (login, onboarding, invite, circle join)
- BottomNav home tab updated: / → /home
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Primary sender: tia@tia.manohargupta.com (verified subdomain in Resend)
- sendWithFallback() retries with onboarding@resend.dev if Resend rejects
the primary domain (covers the window while SPF is still propagating)
- Both sendFamilyInviteEmail() and sendVerificationEmail() use the fallback
Update EMAIL_FROM in Dokploy to: Tia <tia@tia.manohargupta.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLAUDE.md:
- Add RESEND_API_KEY, EMAIL_FROM, NEXT_PUBLIC_APP_URL to required env vars
- Document DB migration pattern (journal + hot-fix via debug-migration POST)
- Document R2 3-step proxy upload pattern (CORS note)
- Document users.image (NOT avatar_url) and two separate photo features
- Document admin auth server-component pattern
- Document family_invites fix, invite flow, cancel invite
- Full data storage table with all localStorage keys
Invite route:
- Return emailStatus in POST response so caller can see if Resend fired
or why it failed (noKey / error message)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New DELETE /api/invites/[id] endpoint — only the owning family can delete
- Settings page shows expiry date on each pending invite
- Cancel button removes invite instantly from list (optimistic UI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ALTER TABLE steps to debug-migration POST so the columns can be applied
immediately via the running app without waiting for a full Dokploy redeploy.
Also revert invites routes to use display_name/accepted_at now that the
hot-fix path exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>