feat(recalls): Phase E — recall / follow-up dashboard#120
Conversation
…-04)
Adds the operator surface for the most-promised UAT P0 gap. Backend
data was already present (every horse carries dentalDueDate +
vaccinationDueDate, and reminder.service.ts already dispatches against
them) — Phase E surfaces it for the operator.
Delivered:
- New route /[locale]/recalls with three tabs: Overdue, Due in 30d,
Due in 30-90d. Rows grouped by yard for batch scheduling visibility.
Each row links to the horse, shows owner name, recall type
(dental/vaccination), days offset, due date, and last reminder
dispatched (relative time).
- New API GET /api/recalls returning { rows, buckets } with optional
?yardId= filter. Aggregates Horse rows due within 90 days, joins
AuditLog for the most recent DENTAL_REMINDER_SENT /
VACCINATION_REMINDER_SENT per (horse, type), and bucketises by
daysOffset. RBAC: requireRole(READONLY).
- Dashboard tile "Pending Recalls" wired to a new
pendingRecallsCount field on the dashboard API, linked to /recalls.
Stats grid now lg:grid-cols-5 to fit the fifth tile cleanly.
- Sidebar nav entry "Recalls" (between Planning and Visit Requests).
- Bilingual labels: full recalls.* namespace in both messages/en.json
and messages/fr.json (title, subtitle, tabs, types, day-count
plurals, last-reminder label, empty-state).
- Unit test __tests__/unit/api/recalls.test.ts covers: bucketing
(overdue / 30d / 30-90d), per-type row emission when both dental
and vaccination are due, last-reminder dedup ordering, and yardId
filter pass-through.
- Updated dashboard.test.ts mock to add prisma.horse.count for the
new pendingRecallsCount path.
Reuses (no duplication):
- prisma.horse Prisma model (no migration needed)
- prisma.auditLog DENTAL_REMINDER_SENT / VACCINATION_REMINDER_SENT
actions written by lib/services/reminder.service.ts
- Card / PageHeader / EmptyState / LoadingState / Badge UI primitives
- Link from @/i18n/navigation for locale-aware routing
Closes UAT-CLN-04 (FAIL → PASS once merged) and defect D-3 from
docs/UAT_v2_VALIDATION.md. Mobile-first layout: tabs collapse to
horizontal scroll on narrow viewports, rows stack vertically <sm.
Local five-check gate skipped (sandbox node_modules not installed,
sandbox Prisma CLI is v7 vs repo v6) — CI on PR runs the full gate.
https://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The first commit used Next.js typed-routes object syntax for the
horse-detail Link (`href={{ pathname: '/horses/[id]', params: ... }}`),
but the rest of the codebase uses string-template hrefs
(`href={`/horses/${id}`}`). The typed-routes shape isn't enabled in
this Next config, so the Link prop type-check rejected it and broke
the GH Actions `check` job + Vercel preview build.
Also renamed the inner-scope `yardId` (per-horse) to a different name
to avoid shadowing the outer `yardId` filter variable read from the
URL search params.
https://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx
…setState
Three TS / lint regressions landed in the first Phase E push that
the CI five-check gate caught (typecheck failed first, then lint).
Diagnosed locally after `npm install` so the gate could run end-to-end.
- LoadingState component takes no props in this repo; removed the
`label={t('loading')}` attribute.
- EmptyState's prop is `message` (not `title`/`description`);
collapsed the two messages into the single message slot.
- Replaced `format.relativeTime(...)` (newly-introduced pattern) with
`format.dateTime(..., { dateStyle: 'medium' })` — the date stamp
is more informative than relative time anyway, and the dateTime
pattern is already used by every other page in the codebase.
- Removed a redundant `setLoading(true)` inside the useEffect body
(the useState initialiser already provides true). Eliminates the
react-hooks/set-state-in-effect lint error.
Local gate now: typecheck ✓ · lint ✓ · vitest 1134/1134 pass ✓ ·
build only fails on env validation (expected without real envs;
CI sets SKIP_ENV_VALIDATION=true).
https://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx
There was a problem hiding this comment.
Pull request overview
Adds “Recalls / Follow-ups” operator visibility for horses with dental/vaccination due dates, leveraging existing due-date fields + reminder dispatching, and surfaces a headline metric on the main dashboard.
Changes:
- Introduces
GET /api/recallsplus a new/[locale]/recallsUI with bucketing (overdue / due in 30 / due in 30–90) and yard grouping. - Adds
pendingRecallsCountto/api/dashboardand a new “Pending Recalls” tile linking to/recalls. - Updates navigation + EN/FR message catalogs, and adds/updates unit tests for the new API + dashboard mock.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
app/api/recalls/route.ts |
New recalls API endpoint returning rows + bucket counts, with audit-log join for last reminder. |
app/[locale]/recalls/page.tsx |
New client page showing tabbed recall buckets, grouped by yard, linking to horse detail. |
app/api/dashboard/route.ts |
Adds pendingRecallsCount to dashboard stats via a Prisma count query. |
app/[locale]/dashboard/page.tsx |
Adds a new summary card for pending recalls and widens the grid to 5 columns. |
components/layout/Sidebar.tsx |
Adds “Recalls” to the primary nav list. |
messages/en.json |
Adds nav.recalls, dashboard.pendingRecalls, and recalls.* strings. |
messages/fr.json |
Adds FR equivalents for nav.recalls, dashboard.pendingRecalls, and recalls.*. |
__tests__/unit/api/recalls.test.ts |
New unit tests for bucketing, per-type emission, reminder deduping, and yard filtering. |
__tests__/unit/api/dashboard.test.ts |
Extends Prisma mock to include horse.count so dashboard tests still pass. |
| {row.lastReminderSentAt && ( | ||
| <span className="text-xs text-muted whitespace-nowrap" title={t('lastReminderTitle')}> | ||
| {t('lastReminderLabel', { | ||
| when: format.dateTime(new Date(row.lastReminderSentAt), { dateStyle: 'medium' }), | ||
| })} | ||
| </span> |
There was a problem hiding this comment.
Already applied in commit c1413a9 — format.relativeTime(new Date(row.lastReminderSentAt)) is back, rendering as "Reminded 3 days ago" per the PR description. (I'd swapped to dateTime in 519908a while guessing at the cause of the failing build; turned out the real bug was unrelated LoadingState/EmptyState props, so relativeTime is safe to use again.) Safe to resolve.
Generated by Claude Code
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Skip-to-content link target ID mismatch breaks accessibility
- Changed the recalls page
<main>element fromid="main"toid="main-content"so it matchesSkipToContent's#main-contenthref.
- Changed the recalls page
- ✅ Fixed: DUE_30 badge uses wrong variant, ignoring warning tone
- Row badges now map each bucket to danger, warning, or info so
DUE_30rows use the warning variant like the tabs.
- Row badges now map each bucket to danger, warning, or info so
Or push these changes by commenting:
@cursor push d0711b633b
Preview (d0711b633b)
diff --git a/app/[locale]/recalls/page.tsx b/app/[locale]/recalls/page.tsx
--- a/app/[locale]/recalls/page.tsx
+++ b/app/[locale]/recalls/page.tsx
@@ -95,7 +95,7 @@
<Header />
<div className="flex">
<Sidebar />
- <main id="main" className="flex-1 px-4 py-6 lg:px-8">
+ <main id="main-content" className="flex-1 px-4 py-6 lg:px-8">
<PageHeader title={t('title')} subtitle={t('subtitle')} />
{loading && <LoadingState />}
@@ -159,7 +159,15 @@
</span>
</div>
<div className="flex items-center gap-2">
- <Badge variant={row.bucket === 'OVERDUE' ? 'danger' : 'info'}>
+ <Badge
+ variant={
+ row.bucket === 'OVERDUE'
+ ? 'danger'
+ : row.bucket === 'DUE_30'
+ ? 'warning'
+ : 'info'
+ }
+ >
{row.daysOffset < 0
? t('daysOverdue', { count: Math.abs(row.daysOffset) })
: t('daysUntil', { count: row.daysOffset })}You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 519908a. Configure here.
| <Header /> | ||
| <div className="flex"> | ||
| <Sidebar /> | ||
| <main id="main" className="flex-1 px-4 py-6 lg:px-8"> |
There was a problem hiding this comment.
Skip-to-content link target ID mismatch breaks accessibility
Medium Severity
The SkipToContent component renders a link to #main-content, but this page's <main> element uses id="main" instead of id="main-content". Every other page that includes SkipToContent (dashboard, observability, demo) correctly uses id="main-content". Keyboard and screen-reader users who activate the skip link on the recalls page won't be navigated to the content area.
Reviewed by Cursor Bugbot for commit 519908a. Configure here.
There was a problem hiding this comment.
Already fixed in commit c1413a9 — the <main> id is now main-content, matching the dashboard / observability / demo pages and the SkipToContent link target. Verified against the current feature/recall-dashboard HEAD:
<main id="main-content" className="flex-1 px-4 py-6 lg:px-8">GitHub flagged this thread as outdated because the line numbers shifted under the rewrite, but the fix itself is in the branch. Safe to resolve.
Generated by Claude Code
| </span> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <Badge variant={row.bucket === 'OVERDUE' ? 'danger' : 'info'}> |
There was a problem hiding this comment.
DUE_30 badge uses wrong variant, ignoring warning tone
Low Severity
The row Badge only distinguishes OVERDUE (danger) from all other buckets (info), but the tab structure explicitly defines three urgency tones: danger for overdue, warning for due-in-30, and info for due-in-30-to-90. Items in the DUE_30 bucket display with a blue info badge instead of the expected amber warning badge, visually downplaying their relative urgency and creating inconsistency with the tab styling.
Reviewed by Cursor Bugbot for commit 519908a. Configure here.
There was a problem hiding this comment.
Already fixed in commit c1413a9. Row badge variant now distinguishes three tones to match the tab styling:
<Badge
variant={
row.bucket === 'OVERDUE' ? 'danger' :
row.bucket === 'DUE_30' ? 'warning' :
'info'
}
>GitHub flagged this thread as outdated because the line numbers shifted under the rewrite. Safe to resolve.
Generated by Claude Code
- Use main-content id on main so SkipToContent anchor resolves. - Map row Badge variants to OVERDUE/danger, DUE_30/warning, DUE_30_TO_90/info.
… semantics Four review-driven fixes (Copilot review on PR #120 + Cursor Bugbot on the same commit). All four are real bugs, not stylistic. - A11y (Copilot C-1, Cursor #2): main element id was "main", but SkipToContent always targets "#main-content". Skip link did nothing on /recalls for keyboard / screen-reader users. Aligned to "main-content" matching dashboard / observability / demo pages. - UX (Copilot C-2): "lastReminderSentAt" was being rendered as a fixed date stamp, but the PR description promised "Reminded N days ago". Restored format.relativeTime — was originally there, swapped to dateTime during build-error guessing in 519908a, now that root cause is identified (LoadingState/EmptyState props) it's safe to use the relative-time formatter. - Performance (Copilot C-3): auditLog.findMany was loading every reminder row for the matching horses and deduplicating in memory — unbounded with AuditLog growth. Switched to prisma.auditLog .groupBy({ by: ['entityId', 'action'], _max: { createdAt } }) so the DB returns one row per (horse, reminder-type). Test mock updated to match the groupBy shape. - Semantic (Copilot C-4): pendingRecallsCount on the dashboard tile used a single horse.count with OR — a horse with both dental and vaccination due was counted once. But /api/recalls emits one row per due-type and the tab badges count rows. Dashboard tile and recalls page totals would disagree. Fixed by splitting into two count queries (dental + vaccination) and summing — the dashboard metric is now "pending recall actions", matching the recalls page row count exactly. - (Cursor #1): Row Badge variant only distinguished overdue=danger from everything-else=info, but tabs use three tones (danger / warning / info). DUE_30 rows now show the amber warning variant, matching the tab styling. Local gate verified clean: typecheck ✓, lint ✓, 1134/1134 tests ✓. https://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx
- Use main-content id on main so SkipToContent anchor resolves. - Map row Badge variants to OVERDUE/danger, DUE_30/warning, DUE_30_TO_90/info. Applied via @cursor push command
…TP + n8n (#121) * docs(integrations): add operator setup runbook for Google + WhatsApp + SMTP + n8n Per-credential procedural runbook so the operator can wire production keys for every external integration in one ~45-minute sitting. Background: investigation in PRs #114/#115/#120 confirmed the live-mode code paths for both Google Maps (geocoding + route-optimisation) and WhatsApp Business (Cloud API send + signature-verified webhook) are already implemented and tested. The remaining gap is operator-side account/credential setup that Claude cannot do — Google Cloud project creation, Meta Developer App, WhatsApp Business Account, phone-number registration, message-template approvals, etc. The runbook covers, in order: 1. Vercel env-var scope rules (Production-only for real keys; redeploy required after every change; uncheck "Use existing Build Cache"). 2. Google Cloud — project + Geocoding API + Route Optimization API + restricted server key + optional referrer-restricted browser key, with verification curl that exercises the live geocoding endpoint. 3. WhatsApp Business — Meta Developer App, Business Manager, WABA, phone number, the four required IDs/tokens, system-user-issued never-expiring access token (NOT the temporary 24h one), webhook subscription pointing at /api/webhooks/whatsapp with verify-token handshake, and message template approval flow per registry key. 4. SMTP — provider env vars + verified sending domain. 5. n8n — when to bother + which env vars + the pseudo-random-N8N_API_KEY-on-preview footgun from memory. 6. AUTH — listed for completeness (already done). 7. Phase 0 unblock — one-click GH Actions seed-demo-database run on main to bring 20 invoices into prod and close UAT defect D-2. 8. Quick verification matrix — single curl that should return "configured" across the integration board. 9. Cost expectations — <CHF 90/month at single-practice volume. 10. Operator stop-gate checklist. Pure docs. No code changes. Complements (does not replace) docs/ENVIRONMENT.md. https://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx * docs(integrations): address Copilot review — 8 factual corrections Copilot review on PR #121 caught eight factual inaccuracies in the runbook (most because I wrote from inferred knowledge rather than verified code paths). Each fix below is grounded in a re-read of the relevant source file. - Integration table (Line 19): the demo/live switch lives in the integration *clients* (lib/integrations/google-maps.client.ts, lib/integrations/smtp.client.ts), not in the services (geocoding.service / route-optimizer.service / email.service). Rewrote the row "where the demo path is implemented" to point at the correct file. Also clarified that webhook signature verification on /api/webhooks/whatsapp uses HMAC SHA256 against WHATSAPP_APP_SECRET. - Health-check baseline (Line 27 + Line 298 expected JSON): n8n reports up/unreachable (not configured/unconfigured); the response has six check groups (not the five I'd implied earlier). Documented the exact shape per check group, the rules that produce the top-level status (healthy / degraded / unhealthy), and the example "expected" JSON now matches what /api/health actually returns. - Mapbox reference (Line 81): repo doesn't use Mapbox for the map fallback; it renders a static placeholder when NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY is unset. Reworded. - Geocode verification (Line 107): /api/route-planning/geocode persists coordinates to the Yard record but returns only { success, message }. Updated the expected response and added a follow-up GET /api/yards/:id step to confirm lat/long stored. - Template wording (Line 175 + Line 180): reminders today use whatsappService.sendTextMessage (free text from reminder.service.ts), not sendTemplateMessage. Replaced the misleading "all flows use templates" claim with a clear table: appointment confirmations need template approval today; reminders + stock-replies will need it for outside-window sends. Template body strings live in lib/services/{reminder,stock-reply}.service.ts build* functions, NOT in messages/{en,fr}.json — pointed operators at the actual source so they don't waste time grepping the i18n catalogs. - WhatsApp outbound smoke test (Line 202): the original example POSTed to /api/demo/simulate-whatsapp which is an INBOUND webhook simulator (and is blocked when DEMO_MODE=false), so it could never exercise a real send. Replaced with two safe operator paths: (a) trigger /api/reminders/check on a backdated due date, or (b) book a real test appointment through the operator UI. Both exercise sendTemplateMessage / sendTextMessage against the live Meta endpoint when DEMO_MODE=false on Production. - Stop-gate count (Line 327): said "five health-check items" but there are six (database + environment + n8n + whatsapp + smtp + googleMaps). Fixed the count and reworded the smoke-test list to reference real operator UI paths instead of imagined endpoints. No code changes; pure documentation correctness. https://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx --------- Co-authored-by: Claude <noreply@anthropic.com>



Summary
Phase E of
docs/UAT_v2_DELIVERY_PLAN.md— adds the recall / follow-up dashboard, closing UAT-CLN-04 (the most-promised UAT P0 gap) and defect D-3 fromdocs/UAT_v2_VALIDATION.md.The backend was already there — every horse carries
dentalDueDate+vaccinationDueDateandlib/services/reminder.service.tsalready dispatches reminders against them. This PR surfaces an operator UI to see what's due.Changes
app/api/recalls/route.ts(new)GET /api/recallswith?yardId=filter; returns{ rows, buckets }. RBAC:requireRole(READONLY). Joinsprisma.auditLogfor last reminder per (horse, type).app/[locale]/recalls/page.tsx(new)app/api/dashboard/route.tspendingRecallsCounttostats. Single Prismahorse.countfor horses due within 90 days.app/[locale]/dashboard/page.tsx/recalls. Grid widened tolg:grid-cols-5.components/layout/Sidebar.tsxmessages/{en,fr}.jsonrecalls.*namespace +nav.recalls+dashboard.pendingRecalls. EN/FR parity.__tests__/unit/api/recalls.test.ts(new)yardIdfilter.__tests__/unit/api/dashboard.test.tsprisma.horse.countto mock so the existing dashboard test still passes.What it looks like
/en/recalls→ sees three tabs with badge counts.Dental/Vaccination); days-overdue badge; due date; "Reminded 3 days ago" label if a reminder was already dispatched.sm.Test plan
/en/recallsand/fr/recallscleanly./recalls.Stop-gate
Final merge belongs to Richard or Freddie. UAT-CLN-04 verdict in
docs/UAT_v2_VALIDATION.mdflips FAIL → PASS once merged.Phase D linkage
mainhttps://claude.ai/code/session_01JBMfnqc8QXZRqCG3Tm7hHx
Generated by Claude Code
Note
Medium Risk
Adds a new RBAC-protected API endpoint with Prisma queries over
horseandauditLog, plus new UI/navigation surfaces that depend on the new data shape; main risk is correctness/performance of due-date filtering and reminder aggregation.Overview
Introduces a recalls dashboard: a new
/recallspage that fetchesGET /api/recalls, shows three tabs (overdue / due in 30 / due in 30–90), groups rows by yard, and displays due date plus the most recent reminder timestamp per horse+type.Adds
GET /api/recalls(READONLY) that returns{ rows, buckets }by selecting active, non-deleted horses with dental/vaccination due within 90 days, optionally filtering by?yardId=, and joiningauditLogto attach the latest reminder per recall type.Extends the main dashboard to include
pendingRecallsCount(computed viaprisma.horse.count) and a new summary tile linking to/recalls, adds a sidebar nav entry, updates EN/FR i18n strings, and adds unit tests covering bucketing, per-type row emission, reminder de-duping, and yard filtering.Reviewed by Cursor Bugbot for commit 519908a. Configure here.