Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5fac141
fix(demo): mobile layout, fetch timeout, and broken-link audit
RJK134 Apr 29, 2026
ff1ffce
feat(demo): EQUISMILE_LIVE_MAPS override + iPhone Pinggy demo runbook
claude Apr 30, 2026
7a509c5
chore(demo): integration-status report in DEMO.bat + vet UAT persona/…
claude Apr 30, 2026
0bd6bf2
feat(uat): browser-fillable UAT feedback form at /uat-feedback.html
claude Apr 30, 2026
19836d6
fix(demo): AUTH_SECRET fallback + service-worker cache caveat in runbook
claude Apr 30, 2026
2c1f127
fix(demo): trust host in DEMO_MODE so Auth.js stops 500-ing /api/auth/*
claude Apr 30, 2026
d59833b
fix(dashboard): translate task.taskType in Triage Tasks panel
claude May 1, 2026
95deafe
refactor: extract taskTypeLabel into shared utility
cursoragent May 1, 2026
b259a71
fix(seed): point demo-inv-001 visitOutcomeId to existing demo-outcome…
RJK134 May 4, 2026
9f1de0d
fix(seed-demo): link demo-inv-001 to Jean-Luc visit outcome
cursoragent May 7, 2026
63b581b
fix(demo): align live maps env parsing
cursoragent May 7, 2026
b300455
fix(demo): address review follow-ups
Copilot May 7, 2026
cf0c28f
fix(uat): address review feedback for accessibility and docs naming
Copilot May 7, 2026
1554951
fix(demo): harden timeout fallback helper
Copilot May 7, 2026
7baf104
Update lib/utils/task-type-label.ts
RJK134 May 7, 2026
7125c7b
fix(demo): simplify timeout helper guards
Copilot May 7, 2026
908de76
test(seed-demo): guard invoice outcome ownership
cursoragent May 7, 2026
47152c3
fix(task-type-label): return null for unknown types and add unit tests
Copilot May 7, 2026
967e38b
refactor(task-type-label): avoid double lookup in map callbacks
Copilot May 7, 2026
e579b51
Merge PR #117 fix/seed-demo-invoice-fk: link demo invoice to matching…
May 8, 2026
312e017
Merge PR #118 feature/demo-live-maps-override: LIVE_MAPS, UAT form, A…
May 8, 2026
34f2d3d
Merge PR #116 demo-mobile-fixes: mobile layout, fetch timeouts, broke…
May 8, 2026
9cd21b8
Merge PR #85 cursor/task-type-label-utility: extract taskTypeLabel ut…
May 8, 2026
e116500
refactor(triage): drop dead null-check on taskTypeLabel call site (re…
May 8, 2026
97cdde7
Merge branch 'main' into copilot/automate-pr-resolution-and-branch-ra…
RJK134 May 8, 2026
3e0af9d
Potential fix for pull request finding
RJK134 May 8, 2026
aa4973e
Potential fix for pull request finding
RJK134 May 8, 2026
cf0cb74
Potential fix for pull request finding
RJK134 May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# EquiSmile Demo — iPhone Handoff Note

_Last updated: 2026-04-29_

## How to reach the demo

1. Run `DEMO.bat` (Windows) or `docker compose up` with `DEMO_MODE=true`.
2. Open `http://localhost:3000/en/login` and click **Continue as Demo Vet**.
3. Share the LAN/tunnel URL (e.g. via ngrok) to test on iPhone.

> **No public demo URL is maintained here.** The app is typically run
> self-hosted via Docker. To get a shareable demo URL, use the repo's
> existing deployment config/docs and set the env vars from `.env.example`
> plus `DEMO_MODE=true`, `AUTH_SECRET`, `AUTH_URL` (as needed), and
> `DATABASE_URL`.

---

## ✅ Works on iPhone (tested at 375 px — iPhone SE / 14 viewport)

| Screen | Status | Notes |
|--------|--------|-------|
| `/login` | ✅ | Demo Vet button renders cleanly; locale redirect honours `/en/` vs `/fr/` |
| `/dashboard` | ✅ | Cards stack vertically; no overflow |
| `/customers` | ✅ | List scrolls, search bar usable |
| `/horses` | ✅ | Same as customers |
| `/yards` | ✅ | Same as customers |
| `/enquiries` | ✅ | Triage status chips fit on one line |
| `/visit-requests` | ✅ | Urgency + status filters display |
| `/appointments` | ✅ | Calendar list view renders |
| `/triage` | ✅ | Cards readable |
| `/privacy` + `/terms` | ✅ | Static prose, no layout issues |
| Soft-delete modal | ✅ | Confirmation dialog full-width, close on Escape fixed (Phase 16) |
| `/admin/observability` | ✅ | DLQ/audit/backup panels scroll |

---

## ⚠️ Caveats / Still needs work on iPhone

| Item | Severity | Detail |
|------|----------|--------|
| `/demo` — Data Counts grid | Fixed in this PR | Was `grid-cols-3 sm:grid-cols-7`, overflowed at 375 px. Now `grid-cols-2 sm:grid-cols-4`. |
| `/demo` — Full Day button label | Fixed in this PR | Long bilingual string clipped on xs. Now abbreviated to "Simulate Full Day" below `sm:` breakpoint. |
| `/demo` — infinite loading spinner | Fixed in this PR | `fetchStatus` had no timeout; now uses `AbortSignal.timeout(8 s)`. |
| `/demo` — action fetch hung indefinitely | Fixed in this PR | `runAction` had no timeout; now uses `AbortSignal.timeout(30 s)`. |
| `/planning` — route map | ⚠️ Caveat | Google Maps embed is desktop-oriented; pinch-zoom works but the map control buttons overlap the sidebar. `NEXT_PUBLIC_GOOGLE_MAPS_BROWSER_KEY` must be set — absent key shows a grey tile. |
| `/route-runs` — route detail map | ⚠️ Caveat | Same as planning; map controls overlap on narrow viewports. |
| PWA offline queue ordering | ⚠️ Caveat | KI-003: mutations queued while offline replay out-of-order on reconnect. Visible if you tap several actions quickly in airplane mode. |
| n8n workflow panel in `/admin` | ⚠️ Caveat | Requires `N8N_API_KEY` env var; without it, the workflow status cards show "unconfigured" — expected, but may confuse a demo viewer. |
| WhatsApp webhook | ℹ️ Info | KI-004: real WhatsApp intake requires a public URL (ngrok etc.). In demo mode the simulate buttons replace this. |

---

## Flows that were hanging / showing loading — root causes found

1. **`/demo` status endpoint** — `fetch('/api/demo/status')` had no signal/timeout.
If the DB seed hadn't run, the request timed out at the OS level (~2 min),
keeping the spinner visible the whole time. **Fixed: 8-second abort.**

2. **Full Day Workflow button** — six sequential API calls with no per-call timeout.
If `generate-routes` stalled (n8n not configured), the button stayed in
`running === 'full-day'` state with no escape. **Fixed: 30-second per-call abort.**

3. **`/api/setup` — 410 Gone** (Phase 16 slice 7) — the removed route is
`GET /api/setup`, and in demo mode it now returns 410 with operator
guidance. Removed the stale `/api/setup` reference from the demo client
so operators no longer trip the dead route.

4. **`/api/status` n8n probe** (Phase 16 slice 2) — previously probed n8n
even when `N8N_API_KEY` was unset, burning a 3 s timeout every poll.
Already resolved upstream; documented here for completeness.

---

_Filed by: Perplexity AI assistant, 2026-04-29_
9 changes: 9 additions & 0 deletions __tests__/unit/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ describe('prisma/seed-demo.ts (demo)', () => {
expect(demoContent).toContain('postcode');
});

it('links the first demo invoice to the matching customer visit outcome', () => {
expect(demoContent).toMatch(
/id: 'demo-inv-001'[^\n]*customerId: 'demo-cust-jeanluc'[^\n]*visitOutcomeId: 'demo-outcome-005'/
);
expect(demoContent).not.toMatch(
/id: 'demo-inv-001'[^\n]*customerId: 'demo-cust-jeanluc'[^\n]*visitOutcomeId: 'demo-outcome-001'/
);
});

it('creates horses linked to customers and yards', () => {
expect(demoContent).toContain('customerId');
expect(demoContent).toContain('primaryYardId');
Expand Down
32 changes: 32 additions & 0 deletions __tests__/unit/utils/task-type-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { taskTypeLabel } from '@/lib/utils/triage-task-type';

describe('taskTypeLabel', () => {
it('maps URGENT_REVIEW to urgentReview', () => {
expect(taskTypeLabel('URGENT_REVIEW')).toBe('urgentReview');
});

it('maps ASK_FOR_POSTCODE to askPostcode', () => {
expect(taskTypeLabel('ASK_FOR_POSTCODE')).toBe('askPostcode');
});

it('maps ASK_HORSE_COUNT to askHorseCount', () => {
expect(taskTypeLabel('ASK_HORSE_COUNT')).toBe('askHorseCount');
});

it('maps CLARIFY_SYMPTOMS to clarifySymptoms', () => {
expect(taskTypeLabel('CLARIFY_SYMPTOMS')).toBe('clarifySymptoms');
});

it('maps MANUAL_CLASSIFICATION to manualClassification', () => {
expect(taskTypeLabel('MANUAL_CLASSIFICATION')).toBe('manualClassification');
});

it('falls back to the raw value for an unknown task type', () => {
expect(taskTypeLabel('UNKNOWN_FUTURE_TYPE')).toBe('UNKNOWN_FUTURE_TYPE');
});

it('falls back to the raw value for an empty string', () => {
expect(taskTypeLabel('')).toBe('');
});
});
103 changes: 79 additions & 24 deletions app/[locale]/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ interface ActionResult {
timestamp: string;
}

const FETCH_TIMEOUT_MS = 8_000;
const ACTION_TIMEOUT_MS = 30_000;

function createTimeoutSignal(timeoutMs: number) {
if (typeof globalThis.AbortSignal?.timeout === 'function') {
return { signal: AbortSignal.timeout(timeoutMs), clear: () => {} };
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

return {
signal: controller.signal,
clear: () => clearTimeout(timeoutId),
};
}

export default function DemoPage() {
const [status, setStatus] = useState<DemoStatus | null>(null);
const [loading, setLoading] = useState(true);
Expand All @@ -36,8 +53,14 @@ export default function DemoPage() {
const format = useFormatter();

const fetchStatus = useCallback(async () => {
const { signal, clear } = createTimeoutSignal(FETCH_TIMEOUT_MS);

try {
const res = await fetch('/api/demo/status');
// Timeout prevents the loading spinner from hanging indefinitely
// when the demo database is not yet seeded or the server is slow.
const res = await fetch('/api/demo/status', {
signal,
});
if (res.status === 403) {
setStatus(null);
setLoading(false);
Expand All @@ -48,6 +71,7 @@ export default function DemoPage() {
} catch {
setStatus(null);
} finally {
clear();
setLoading(false);
}
}, []);
Expand All @@ -56,13 +80,24 @@ export default function DemoPage() {
fetchStatus();
}, [fetchStatus]);

const runAction = async (action: string, url: string, body?: Record<string, unknown>) => {
setRunning(action);
const runAction = async (
action: string,
url: string,
body?: Record<string, unknown>,
options?: { preserveRunning?: boolean },
) => {
if (!options?.preserveRunning) {
setRunning(action);
}

const { signal, clear } = createTimeoutSignal(ACTION_TIMEOUT_MS);

try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal,
});
const data = await res.json();
setActionResults((prev) => [
Expand All @@ -76,7 +111,10 @@ export default function DemoPage() {
...prev.slice(0, 9),
]);
} finally {
setRunning(null);
clear();
if (!options?.preserveRunning) {
setRunning(null);
}
}
};

Expand All @@ -91,10 +129,13 @@ export default function DemoPage() {
{ action: 'Routes', url: '/api/demo/generate-routes' },
];

for (const step of steps) {
await runAction(step.action, step.url, step.body);
try {
for (const step of steps) {
await runAction(step.action, step.url, step.body, { preserveRunning: true });
}
} finally {
setRunning(null);
}
setRunning(null);
};

if (loading) {
Expand All @@ -105,7 +146,7 @@ export default function DemoPage() {
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main id="main-content" className="flex-1 overflow-y-auto p-4 pb-20 lg:p-6 lg:pb-6">
<p className="text-sm text-muted">Loading demo status...</p>
<p className="text-sm text-muted">Loading demo status</p>
</main>
</div>
<MobileNav />
Expand All @@ -124,7 +165,9 @@ export default function DemoPage() {
<Card>
<h1 className="text-lg font-bold text-danger">Demo Mode Disabled</h1>
<p className="mt-2 text-sm text-muted">
Set <code className="rounded bg-surface px-1 py-0.5 font-mono text-xs">DEMO_MODE=true</code> in your environment to enable the demo control panel.
Set{' '}
<code className="rounded bg-surface px-1 py-0.5 font-mono text-xs">DEMO_MODE=true</code>{' '}
in your environment to enable the demo control panel.
</p>
</Card>
</main>
Expand All @@ -143,9 +186,7 @@ export default function DemoPage() {
<main id="main-content" className="flex-1 overflow-y-auto p-4 pb-20 lg:p-6 lg:pb-6">
{/* Demo Banner */}
<div className="mb-4 rounded-lg border-2 border-amber-400 bg-amber-50 px-4 py-3">
<p className="text-sm font-bold text-amber-800">
DEMO MODE &mdash; Simulated integrations
</p>
<p className="text-sm font-bold text-amber-800">DEMO MODE &mdash; Simulated integrations</p>
<p className="text-xs text-amber-700">
All external APIs (WhatsApp, Email, Google Maps, n8n) are using simulated responses.
/ Toutes les API externes utilisent des réponses simulées.
Expand Down Expand Up @@ -174,10 +215,12 @@ export default function DemoPage() {
</div>
</Card>

{/* Data Counts */}
{/* Data Counts
Changed from grid-cols-3 sm:grid-cols-7 which overflowed at 375 px
(iPhone SE / 14). 2-col default wraps gracefully; sm bumps to 4-col. */}
<Card className="mb-4">
<h2 className="mb-2 text-sm font-semibold">Data Counts / Données</h2>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-7">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{Object.entries(status.counts).map(([name, count]) => (
<div key={name} className="rounded border border-border p-2 text-center">
<p className="text-xs text-muted">{name}</p>
Expand All @@ -189,66 +232,78 @@ export default function DemoPage() {

{/* Simulation Actions */}
<Card className="mb-4">
<h2 className="mb-3 text-sm font-semibold">Simulation Actions / Actions de simulation</h2>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<h2 className="mb-3 text-sm font-semibold">
Simulation Actions / Actions de simulation
</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<Button
variant="secondary"
size="sm"
disabled={running !== null}
onClick={() => runAction('WhatsApp EN', '/api/demo/simulate-whatsapp', { language: 'en' })}
>
{running === 'WhatsApp EN' ? 'Sending...' : 'Simulate WhatsApp (EN)'}
{running === 'WhatsApp EN' ? 'Sending' : 'Simulate WhatsApp (EN)'}
</Button>
<Button
variant="secondary"
size="sm"
disabled={running !== null}
onClick={() => runAction('WhatsApp FR', '/api/demo/simulate-whatsapp', { language: 'fr' })}
>
{running === 'WhatsApp FR' ? 'Envoi...' : 'Simulate WhatsApp (FR)'}
{running === 'WhatsApp FR' ? 'Envoi' : 'Simulate WhatsApp (FR)'}
</Button>
<Button
variant="secondary"
size="sm"
disabled={running !== null}
onClick={() => runAction('Email EN', '/api/demo/simulate-email', { language: 'en' })}
>
{running === 'Email EN' ? 'Sending...' : 'Simulate Email (EN)'}
{running === 'Email EN' ? 'Sending' : 'Simulate Email (EN)'}
</Button>
<Button
variant="secondary"
size="sm"
disabled={running !== null}
onClick={() => runAction('Email FR', '/api/demo/simulate-email', { language: 'fr' })}
>
{running === 'Email FR' ? 'Envoi...' : 'Simulate Email (FR)'}
{running === 'Email FR' ? 'Envoi' : 'Simulate Email (FR)'}
</Button>
<Button
variant="secondary"
size="sm"
disabled={running !== null}
onClick={() => runAction('Triage', '/api/demo/trigger-triage')}
>
{running === 'Triage' ? 'Processing...' : 'Trigger Triage'}
{running === 'Triage' ? 'Processing' : 'Trigger Triage'}
</Button>
<Button
variant="secondary"
size="sm"
disabled={running !== null}
onClick={() => runAction('Routes', '/api/demo/generate-routes')}
>
{running === 'Routes' ? 'Generating...' : 'Generate Routes'}
{running === 'Routes' ? 'Generating' : 'Generate Routes'}
</Button>
</div>

<div className="mt-3 border-t border-border pt-3">
{/* Long bilingual label is abbreviated on xs to avoid single-line overflow */}
<Button
variant="primary"
size="md"
disabled={running !== null}
onClick={runFullDayWorkflow}
>
{running === 'full-day' ? 'Running workflow...' : 'Simulate Full Day Workflow / Simuler une journée complète'}
{running === 'full-day' ? (
'Running…'
) : (
<>
<span className="sm:hidden">Simulate Full Day</span>
<span className="hidden sm:inline">
Simulate Full Day Workflow / Simuler une journée complète
</span>
</>
)}
</Button>
</div>
</Card>
Expand All @@ -273,7 +328,7 @@ export default function DemoPage() {
{format.dateTime(new Date(result.timestamp), { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
<pre className="mt-1 max-h-20 overflow-auto whitespace-pre-wrap font-mono text-xs text-muted">
<pre className="mt-1 max-h-20 overflow-auto whitespace-pre-wrap break-all font-mono text-xs text-muted">
{JSON.stringify(result.data, null, 2)}
</pre>
</div>
Expand Down
2 changes: 0 additions & 2 deletions app/[locale]/triage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,6 @@ function TriagePageContent() {
});
};


// Apply filters
let filtered = [...tasks];
if (filterUrgency !== 'ALL') {
Expand Down Expand Up @@ -301,7 +300,6 @@ function TriagePageContent() {
const age = timeAgo(task.createdAt);
const isUrgent = task.visitRequest.urgencyLevel === 'URGENT';
const isOverdue = isUrgent && age.isOverdue;

return (
<Card
key={task.id}
Expand Down
Loading
Loading