================ ONE-SHOT BUILD CONTRACT (read first) ================
Build this in Google AI Studio "Build" in ONE shot — a complete, working app,
no follow-up turns. These are hard rules, not suggestions:
1. TARGET = Full-Stack Web (Node server runtime, secrets, Firebase allowed).
If you target Native Android instead, you MUST drop all server/DB/Workspace/
secrets and persist locally (Room / SharedPreferences) only.
2. PIN EVERY MODEL CALL — never let the agent auto-select (it downgrades on quota):
- Reasoning / text -> gemini-3.5-flash (thinkingLevel: minimal|low|medium|high)
- 4K image + legible text -> gemini-3-pro-image (image_size "4K", up to 14 refs)
- High-volume image -> gemini-3.1-flash-image
- Expressive TTS -> gemini-3.1-flash-tts-preview (inline tags e.g. [whispers])
- Realtime audio/video (WebSocket) -> gemini-3.1-flash-live-preview
- Sandboxed agent -> antigravity-preview-05-2026
3. DIVISION OF LABOR — the model ONLY parses/extracts to a strict responseSchema.
ALL math, money (store currency as integer minor units / cents), sorting,
balancing and graph logic run in deterministic TypeScript/Python. The model
must never compute totals, splits or balances itself.
4. responseSchema sanitation — no regex patterns, no fixed-length tuples, no
format validators in the schema (they crash the OpenAPI engine). Enforce those
in server-side code AFTER parsing the JSON.
5. responseSchema and google_search grounding are MUTUALLY EXCLUSIVE in one call.
6. CODEGEN — split large output into modular, single-responsibility files so no
file is truncated by the output-token cap.
7. Every external call gets a graceful fallback (e.g. manual paste if a Workspace
read fails). Never a silent dead end.
8. ROBUST STORAGE & CANVAS — Wrap all `localStorage`/`sessionStorage` operations (especially JSON parsing and writes) in `try-catch` blocks to prevent crashes in private windows or quota overflows. Canvas drawing elements must dynamically handle window resize and scale pixel density (`window.devicePixelRatio`) to avoid blurry graphics on retina displays.
=====================================================================
# MUST OBEY — Mobile-first build requirements
This app's PRIMARY surface is a mobile phone. Build it impeccably on mobile FIRST, then verify on tablet and desktop. Treat the rules below as non-negotiable hard constraints, not suggestions.
## Viewports to verify (every screen, every state)
- 320 px, 360 px, 375 px, 390 px, 414 px, 480 px
- 768 px, 834 px (iPad portrait / Pro 11)
- 1024 px, 1280 px, 1440 px, 1920 px, 2560 px
- Plus: 200% browser zoom, landscape orientation on every mobile width, iPhone with safe-area insets visible
## Hard layout rules
- Mobile-first CSS. Default styles target mobile; `@media (min-width: ...)` for larger viewports.
- Use `dvh` and `svh` instead of `vh` for full-height surfaces (iOS Safari URL-bar bug).
- Use `clamp()` for fluid typography across all viewports.
- Prefer container queries (`@container`) over media queries for component-level responsiveness.
- Use `min(100%, ...)` widths so content never overflows. Zero horizontal overflow at any viewport.
- Add `` to every page.
- Apply `padding: max(safe-area-inset-X, fallback)` on every edge-bleeding container so notched iPhones in landscape never clip content.
- Wide tables and code blocks scroll INSIDE their container (`overflow-x: auto`), never push the body.
- Use `background-attachment: scroll` on mobile, not `fixed` (iOS Safari repaint bug).
- Avoid `backdrop-filter` on animated elements. Use it sparingly on static surfaces only.
- **Canvas Scaling**: Canvases must dynamically scale with window resize events and properly handle high-DPI screens (`window.devicePixelRatio`). Set physical dimensions (`canvas.width`/`canvas.height`) using pixel ratio and render relative to this grid, using CSS to control responsive viewport scaling.
- **Robust Storage**: Every access to `localStorage`/`sessionStorage` (especially `JSON.parse` of loaded state or writes) MUST be wrapped in a `try-catch` block to handle disabled storage, private browsing mode, quota limits, or corrupted JSON gracefully. Fall back to a robust in-memory object store.
## Touch & accessibility
- Tap targets ≥ 44 × 44 px on touch (Apple HIG). Increase to 48 px under `@media (hover: none) and (pointer: coarse)`.
- All interactive controls reachable by keyboard with a visible focus ring; respect `:focus-visible`.
- Color contrast ≥ 4.5:1 for body text, 3:1 for UI components.
- All images have meaningful `alt`. Decorative images use `alt=""`.
- Respect `prefers-reduced-motion: reduce` — zero animation durations under that query.
- Forms validate inline; error messages are specific, not "Invalid input".
- Modals: focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, focus restored on close.
## Performance bar (Lighthouse mobile, throttled 3G/4G)
- LCP < 2.5 s · INP < 200 ms · CLS < 0.1
- JS bundle gzip < 200 KB mobile-first; lazy-load non-critical screens via `React.lazy` / dynamic imports.
- No render-blocking resources above the fold.
- Images: WebP/AVIF preferred, `loading="lazy"`, explicit `width`/`height` attributes (zero CLS), `srcset` for retina.
- Videos: `preload="metadata"`, low-resolution poster, max 720p mobile fallback. Never autoplay with audio.
- Fonts: `font-display: swap`; preload only the one used above the fold.
- Smooth scroll honoured via CSS `scroll-behavior: smooth` with reduced-motion fallback.
## Pre-ship mobile checklist (the deployer MUST verify before declaring done)
1. Open at 375 px in DevTools — every screen scrolls vertically only; zero horizontal scroll.
2. Browser zoom 200% — layout reflows without overlap.
3. iPhone Safari with the URL bar visible AND landscape — no content under the home indicator; no notch clipping.
4. iPad portrait (768 px) and landscape (1024 px) — no awkward gaps; tablet-specific breakpoints land cleanly.
5. Tap every interactive element with a thumb at real-device size — every target is easy to hit.
6. `prefers-reduced-motion: reduce` — every transition / animation skips cleanly, scroll-behavior becomes instant.
7. Lighthouse mobile score ≥ 90 across all 4 categories.
8. Zero `console.error` and zero CLS shift in real-device testing on a mid-tier Android (e.g. Pixel 6a) and an iPhone SE.
---
The original template starts below. All rules above apply on TOP of whatever this template specifies.
---
# Tradie Diary
## 1. Project
**Tradie Diary** is a between-jobs voice notebook for self-employed
tradespeople — plumbers, sparkies, gas engineers, locksmiths, HVAC
techs, mobile mechanics, tilers, roofers — who run nine, ten, twelve
jobs a day out of a van. The user finishes a job, walks back to the
vehicle, presses one button, and talks for thirty seconds: what they
did, what they used, what they charged, what the customer's name was,
which line on the street, what's outstanding. The app turns each
memo into a structured per-job log: customer, address, arrival and
departure times the phone already knew, labour described in the
tradesperson's own words, materials line-itemised, the price the
tradesperson quoted on the doorstep, payment status, photos taken on
site, follow-ups due. On Friday afternoon the week has already
written itself into nine invoices, each ready to send, each in the
tradesperson's voice.
This is the kind of app a plumber in Manila opens between a clogged
kitchen sink in Quezon City and a leaking ball-cock in San Juan,
because at the end of a Saturday she has nine ₱-quoted jobs to invoice
before the kids get home and her wrist hurts from writing. It is
also the kind of app a gas engineer in Manchester opens between a
boiler service in Chorlton and a no-heat callout in Levenshulme,
because at the end of the week he has nine jobs to bill and the
paperwork is the part that costs him the Saturday. Same shape of
day, different city, different currency, same dread of Friday
admin.
The single demo that proves the magic: stand at the van after the
fourth job of the day, press one button, talk for thirty seconds
("Mrs Reyes, 24 Mabini Street, replaced the P-trap under the kitchen
sink, tightened the basket strainer, used one 50 mm chrome P-trap
and a pair of plumber's putty rolls, in for an hour, charged her
₱1,400 cash, paid"). Three seconds later the job appears in the
day's list with the customer card, the address geocoded, the
materials parsed into two line items at the prices the tradesperson
charged, the labour described verbatim — *not* an AI estimate — the
duration calculated from arrival to departure, and a small "paid"
chip in green. On Friday afternoon, the week's nine jobs roll up
into nine pre-drafted invoices, addressed and ready, each one
quoting back the tradesperson's own description of what they did.
And in the harder cases — multi-day jobs that span three visits,
warranty callbacks that the tradesperson isn't going to charge for,
split-payment jobs where the landlord pays labour and the tenant
pays materials, jobs the customer disputes a week later — the app
holds onto every voice memo and every photo as evidence, lets the
tradesperson reconstruct what happened from a single sentence search
("the one with the cracked manifold in Levenshulme"), and never,
ever invents an hour they didn't work.
**Tagline:** _The week's invoices write themselves between jobs — in any trade, any city, in the words you actually spoke._
## 2. Target audience
- Solo plumbers, gas engineers, electricians, locksmiths, HVAC techs, mobile mechanics, roofers, tilers, glaziers running a one-van operation
- The same trades in countries where 8-12 small cash-and-bank-transfer jobs a day is the normal rhythm — plumbers in Manila, Lagos, São Paulo, Hanoi, Karachi, Mexico City, Cairo, Dakar, Mumbai, Manchester
- Tradeswomen running their own operation against a male-defaulted admin software market — the language the app speaks must default to no-gender
- Apprentices and second-year tradies working under a self-employed master who still wants the master's voice on the invoice
- Multi-trade subcontractors who do plumbing on Tuesday and tiling on Wednesday and don't want two different apps
- Diaspora tradespeople whose customer base speaks one language and whose accountant speaks another — the memo stays in Tagalog or Igbo or Punjabi, the invoice goes out in English, both are stored
- Trades-couples (one in the field, one doing books on Sundays) where the books partner needs to read a week of work without re-interviewing
- Recently-self-employed tradies who left a firm and have not built admin discipline yet — the app is the discipline
- Older tradies whose dyslexia or arthritis means typing nine job entries on a phone has been a daily tax for thirty years
## 3. Core value propositions
Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons users pick this app.
- **Never invents hours, never invents prices** — the labour duration is the literal time between arrival and departure your phone already knew; the price is the price the tradesperson named in the memo; the materials are the materials the tradesperson listed by name. The app does not "estimate labour at the going rate" because the tradesperson already named the figure on the doorstep. This is the single hardest rule and it never bends.
- **Speak in any language, invoice in another** — record the memo in Tagalog, Hokkien, Yoruba, Punjabi, Bengali, Vietnamese, Swahili, Hausa, Arabic, Cantonese, Brazilian Portuguese, Mexican Spanish, Igbo, Tamil, Urdu, or the tradesperson's English. The structured fields and the eventual invoice are produced in whatever language the customer is billed in (commonly English, Spanish, Portuguese, Arabic, French). The verbatim transcript in the original language is kept forever.
- **The clock is the phone's clock** — arrival time is when the user pressed "Start job at this address"; departure time is when they pressed "Finished, on to the next". The app never asks the tradesperson to remember how long they were on a job. If they forgot to press start, the memo's own phrase ("got there at half ten") is parsed into a `duration_uncertain` flag and surfaced for confirmation, not silently filled in.
- **Materials line-itemise themselves** — "one 50 mm chrome P-trap, two rolls of plumber's putty, half a tube of silicone" parses into three line items with quantities and units, leaving the unit price for the tradesperson's price book (auto-built over time from their own past memos) to apply.
- **The week assembles itself on Friday** — at 16:00 local time on Friday (configurable) the app produces a week-end worksheet: every job grouped by customer, by paid status, by trade-type. The tradesperson skims and presses send. Each invoice quotes the tradesperson's own description back to the customer in the customer's language.
- **The memo is the source of truth** — every figure on the invoice traces back to a specific spoken sentence in the memo; tap any line and the relevant audio plays from the right second. Disputes are settled by playing what the tradesperson actually said at the time. The audio is private to the tradesperson.
- **The price book is built from your own work** — after the third "₱1,400 for a P-trap swap" the app proposes ₱1,400 as the default for similar jobs. The default is a suggestion; the tradesperson confirms or overrides every time. The app does not import a national-average rate sheet.
- **No-signal-friendly** — memos record offline and queue. Parsing runs on reconnect. A tradie in a basement, on a roof, in a tunnel, in a rural village still gets their job logged.
## 4. Features to build
- Big one-button voice memo on every screen — "Done with a job" — captures audio, GPS, timestamp instantly, no setup
- Per-job arrival / departure tracking using the phone's clock — auto-stop at end of memo, manual edit allowed
- Offline-first capture — memos and photos queue when there's no signal; parse on reconnect; nothing lost in a basement
- Photo capture at job site — before, after, the offending part, the meter reading — attached to the job
- Voice transcript preserved verbatim in the source language — every "y'know" and "right" and "ang" and "tapos" preserved
- Multilingual parse — speak Tagalog, Cantonese, Igbo, Punjabi, Yoruba, Bengali, Vietnamese, Swahili, Arabic, Brazilian Portuguese, Mexican Spanish, Tamil, Urdu, Hausa, or English; structured fields come out in the invoicing language
- Customer card auto-resolution — same address + similar name across two memos folds into one customer; the tradesperson confirms or splits
- Materials extraction — "one P-trap, two rolls of putty, half a tube of silicone" → three line items with quantity, unit, item name. The unit price comes from the tradesperson's price book.
- Labour duration calculation — pure phone-clock arithmetic; the AI never names a duration the audio didn't anchor
- Price-book builder — after the third occurrence of "₱1,400 for a P-trap swap" the app suggests a default price; tradesperson confirms or overrides
- Payment status capture — "paid cash", "paid via GCash", "invoiced", "she's paying half tomorrow" parses into `payment_status` with optional `partial_payment[]`
- Multi-visit jobs — same address, same customer, "callback on the radiator", three visits stitch into one job with a single invoice option
- Warranty callback handling — "this is the one I came back to fix" auto-zeroes the labour line, surfaces the original invoice for cross-reference, requires explicit override to charge
- Friday roll-up — at user-set local time (default 16:00 Friday), the week's jobs render as a draft worksheet ready to invoice
- Invoice draft per customer — bundles all of a customer's week-jobs into one invoice in their preferred language, with the tradesperson's logo, payment method, due terms
- Send invoice — email PDF, WhatsApp PDF link, SMS link to a hosted invoice page; copy as plain text for chat apps
- Follow-up due list — "she's paying half tomorrow", "ring her about the manifold" parse into `follow_ups[]` with due dates; surface on the home screen the morning of
- One-sentence search across the archive — "the one with the cracked manifold in Levenshulme" returns the right job in two seconds
- Dispute mode — when a customer queries an invoice, the tradesperson opens the job and plays back the audio at the moment they named the price; receipts hold up in conversation
- Yearly export for the accountant — every job, every customer, every payment, in CSV + PDF, in whichever currency the country uses
## 4b. Required Gemini capabilities + backend services
**This template's intelligence comes from the Gemini capabilities below. Wire them up explicitly — don't substitute generic LLM calls.**
### Gemini capabilities (the load-bearing intelligence)
- **Audio input + multilingual ASR** (Gemini 3.5 Flash) — accepts the raw memo as `inlineData` (m4a) or via the Files API for memos over 90 seconds. Handles code-switching mid-sentence ("Mrs Reyes na yung kitchen sink, charged her ₱1,400 cash, paid") which is the dominant pattern in Manila, Lagos, Karachi, Mumbai. Produces a verbatim transcript in the source script first, then the structured Job object.
- **Structured output / JSON Schema** — the response matches the `Job` schema below. Every field is typed; the schema is converted to Gemini's `Schema` type via the SDK helper and passed as `responseSchema`. Numeric constraints inside the schema are documentation only; clamp on the server.
- **Long context (1M tokens)** — for the Friday roll-up call and for the price-book consolidation. A typical week has 35-50 jobs, each ~500 tokens of transcript + parsed JSON, well within budget. Across a full tax year (1,800-2,500 jobs) the price-book consolidation chunks by trade-type before the call — pure prudence; year-long contexts exceed the comfortable working zone.
- **Search grounding** (Gemini 3.5 Flash) — for address geocoding only, with explicit country bias from the user's profile. Periodically a memo says "the place behind the petrol station on Gen. Luis", and grounded search resolves it to a usable address. Geocoding is the only call that needs grounding; the parse call does not.
- **Gemini TTS** (`gemini-3.1-flash-tts-preview`) — for read-back of the Friday roll-up while the tradesperson drives home. "Mrs Reyes, ₱1,400, paid. Mr Cruz, ₱2,800, invoiced. Mrs De Leon, ₱950, paid via GCash." The tradesperson confirms with a tap or a "send all".
- **Multilingual translation** (Gemini 3.5 Flash, low thinking) — to render the invoice in the customer's language while preserving the tradesperson's voice in the description. "Pinalitan ko ang P-trap sa ilalim ng lababo sa kusina" → "I replaced the P-trap under your kitchen sink" — not a sterile "Performed P-trap replacement services".
- **Thinking levels** — `medium` for the primary memo-parse call (multilingual + numeric extraction + payment-status inference + materials list). `low` for translation, customer resolution, and price-book proposal. Omit `thinkingConfig` entirely on the TTS call.
### Backend services
- **Auth — Required.** Firebase Auth with Google sign-in (auto-provisioned by AI Studio Build). Phone-number sign-in is recommended for tradies who don't habitually use email — this requires enabling Phone provider in the Firebase console and a working SMS budget. **Apple sign-in is optional but user-configured**: it requires an Apple Developer account, Service ID, Key ID, and private key wired into the Firebase Auth console.
- **Database — Required.** Firestore for `users`, `jobs`, `customers`, `materials_price_book`, `invoices`, `follow_ups`, `weeks`.
- **File storage — Required.** Firebase Storage for the raw audio memo (m4a), the job-site photographs (original resolution), and rendered invoice PDFs. **Storage is NOT auto-provisioned by AI Studio Build today** — enable it in the Firebase console and wire the bucket name into the AIS Build project before first memo upload. Audio + photos are private to the tradesperson; only pre-signed URLs are used.
- **Email — Required (transactional).** Sending the invoice PDF to the customer; sending the weekly worksheet to the tradesperson's own accountant email; password-reset. **Magic-link email** requires the sender domain to be authorised in Firebase Auth — flag this in the capabilities panel.
- **WhatsApp / SMS — Optional, user-configured.** Many tradies invoice via WhatsApp link rather than email. The MVP ships a "copy invoice as message" button that drops a formatted summary + a hosted-invoice URL to the clipboard. A v2 tier integrates WhatsApp Business API or a country-specific SMS gateway (Twilio, Africa's Talking, Globe Labs, MessageBird) — those are user-config, not auto-provisioned.
- **Payments — Optional, user-configured.** A "pay this invoice" link routes to Stripe, GCash, Paystack, Razorpay, M-Pesa, depending on which the tradesperson has set up in their region. The MVP supports a static "bank details" block and Stripe; other rails are stubbed and user-config.
- **External APIs:** Gemini API for all intelligence; optional Google Maps Geocoding for higher-fidelity address resolution (the grounded-search path is the offline default).
**Environment variables:** every secret (Gemini API key, Firebase service-account JSON, Stripe key, Twilio/Africa's Talking/Globe Labs credentials if used, WhatsApp Business token if used) lives in environment variables — never in client bundle. Include a `.env.example`.
**Auth + data privacy reminders:** never log secrets · never store passwords in plain text · use HTTPS everywhere · honour 'delete my account' inside the UI · explicit opt-in for any analytics · the tradesperson's voice memos, customer details, and invoices are never sent to Gemini for model training (use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms) · audio recordings are the tradesperson's working notes and are treated like a private notebook.
**Read this first — prompt-craft rules that apply to every call in this template:**
1. **Name the model variant explicitly** in every Gemini API call. Do not let the agent pick the model. See the per-call matrix below.
2. **Pin `thinkingLevel` explicitly** per call. See the matrix.
3. **Seed the JSON Schema as a fenced TypeScript / Zod block** in the system instruction or `responseSchema` field. The literal schema is below. **Convert the Zod schema to Gemini's `Schema` type via the SDK helper** before passing to `responseSchema` — do NOT pass raw Zod. **Numeric `min`/`max` constraints are documentation only inside `responseSchema`; clamp on the server after the response arrives.**
4. **Pin the system instruction separately** from user input. Use the `systemInstruction` field for persona + behavioural rules; use `contents` for user input. Never concatenate.
5. **Pre-declare tools as an enable/disable list** per call. The matrix below names which tools are enabled per call. Tools NOT listed for a call should be disabled.
6. **State negative constraints explicitly** — they are listed below. They are NOT "be careful" suggestions; they are hard rules the model must follow.
7. **Grounded responses can wrap JSON in ```json fences or add prose preamble.** Server-side, strip fences and brace-extract:
```typescript
function safeExtractJSON(raw: string): T {
const clean = raw.replace(/```json\s*|```/gi, '').trim();
const s = clean.indexOf('{'); const e = clean.lastIndexOf('}');
if (s === -1 || e === -1) throw new Error('No JSON boundaries in grounded response');
return JSON.parse(clean.slice(s, e + 1)) as T;
}
```
8. **Strip unsupported Zod modifiers before passing to `responseSchema`** — Gemini's OpenAPI subset rejects `.regex()` / `pattern`, fixed-length `z.tuple()`, and other custom validators. Use a sanitizer that flattens tuples to length-2 arrays and removes regex patterns before serializing. Validate those constraints in middleware AFTER parsing.
### Per-call model + tools matrix
| Call | Model | thinkingLevel | Tools enabled |
|------|-------|---------------|---------------|
| Parse voice memo → `Job` schema | `gemini-3.5-flash` | medium | (none) |
| Resolve customer (same address / similar name → one record) | `gemini-3.5-flash` | low | (none) |
| Translate the tradesperson's description for the invoice | `gemini-3.5-flash` | low | (none) |
| Geocode an ambiguous address from the memo | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema` on this call — see note) |
| Propose a price-book default after N repetitions | `gemini-3.5-flash` | low | (none) |
| Friday roll-up — read out the week's pending invoices | `gemini-3.1-flash-tts-preview` | n/a | n/a |
| Year-end consolidation (long context over all jobs) | `gemini-3.5-flash` | medium | (none) — long-context over the year |
*Note for builders:* on TTS calls, omit `thinkingConfig` entirely — the field is not supported on that model. The `n/a` cells in this matrix are documentation only; do not serialise them into the request body. The geocoding call uses `google_search` grounding and therefore CANNOT specify `responseSchema` (the two are mutually exclusive in one Gemini call) — instruct the model to emit JSON in the text body, parse server-side, and read citation URLs from `response.groundingMetadata.groundingChunks[].web.uri`.
### Primary structured-output schema (seed this verbatim in the prompt)
```typescript
import { z } from "zod";
const PaymentMethod = z.enum([
"cash",
"bank_transfer",
"gcash",
"paymaya",
"mpesa",
"paystack",
"razorpay",
"card_on_site",
"stripe_link",
"cheque",
"invoiced_unpaid",
"split_payment",
"warranty_no_charge",
"other",
]);
const PaymentStatus = z.enum([
"paid_in_full",
"paid_partial",
"invoiced_unpaid",
"no_charge_warranty",
"no_charge_goodwill",
"disputed",
"unknown",
]);
const PartialPayment = z.object({
amount: z.number().min(0),
currency: z.string(), // ISO-4217: "PHP", "GBP", "NGN", "INR"
method: PaymentMethod,
received_at_iso: z.string().nullable(),
promised_for_iso: z.string().nullable(),
});
const MaterialLine = z.object({
item_verbatim: z.string(), // "50 mm chrome P-trap" — as spoken
item_canonical: z.string().nullable(), // "p-trap, chrome, 50mm" — normalised
quantity: z.number().min(0),
unit: z.string(), // "each", "roll", "tube", "metre", "litre"
unit_price_charged: z.number().nullable(), // null if tradesperson did not name a per-item price
line_total_charged: z.number().nullable(), // what was actually charged for this line, if stated
notes: z.string().nullable(), // "half a tube — won't full-charge"
});
const FollowUp = z.object({
description: z.string(), // "ring her about the manifold"
due_iso: z.string().nullable(), // "2026-05-29" if extractable
customer_id_ref: z.string().nullable(),
source_quote: z.string(), // verbatim from the transcript
});
const PhotoRef = z.object({
uri: z.string(), // Firebase Storage gs:// URI
taken_at_iso: z.string(),
caption_spoken: z.string().nullable(), // if the tradesperson narrated the photo
category: z.enum([
"before", "after", "the_part", "meter_reading",
"damage", "completed_work", "other",
]),
});
const Customer = z.object({
customer_id: z.string(),
name_verbatim: z.string(), // "Mrs Reyes" — as spoken
name_canonical: z.string().nullable(), // "Maria Reyes" if disambiguated later
address_verbatim: z.string(), // "24 Mabini Street, Quezon City"
address_geocoded: z.object({
lat: z.number().nullable(),
lng: z.number().nullable(),
formatted: z.string().nullable(),
confidence: z.number().min(0).max(1),
}),
phone: z.string().nullable(),
email: z.string().nullable(),
preferred_invoice_language: z.string().nullable(), // BCP-47
preferred_payment_method: PaymentMethod.nullable(),
notes: z.string().nullable(), // "landlord pays labour, tenant pays parts"
});
const Job = z.object({
job_id: z.string(),
recorded_at_iso: z.string(), // when the memo was captured
arrived_at_iso: z.string().nullable(), // phone-clock arrival, if user pressed Start
departed_at_iso: z.string().nullable(), // phone-clock departure, end of memo
duration_minutes: z.number().nullable(), // pure arithmetic; null if user didn't press Start
duration_uncertain: z.boolean(), // true when only the spoken phrase ("about an hour") anchors it
duration_source: z.enum([
"phone_clock",
"spoken_phrase_only",
"user_overrode",
"unknown",
]),
trade: z.enum([
"plumbing", "electrical", "gas", "hvac", "locksmith",
"mobile_mechanic", "roofing", "tiling", "glazing",
"carpentry", "decorating", "appliance_repair", "other",
]),
customer_ref: z.object({
existing_customer_id: z.string().nullable(), // populated if this matches a known customer
proposed_new_customer: Customer.nullable(), // populated if it doesn't
}),
visit_kind: z.enum([
"first_visit",
"follow_up_same_job",
"warranty_callback",
"goodwill_check",
"quote_visit",
]),
labour_description_verbatim: z.string(), // the tradesperson's own words, lightly cleaned only
labour_description_translated: z.string().nullable(), // populated later, when invoice is drafted
materials: z.array(MaterialLine),
total_price_quoted: z.number().nullable(), // the price the tradesperson NAMED, not estimated
currency: z.string(), // ISO-4217
payment_status: PaymentStatus,
payment_method: PaymentMethod.nullable(),
partial_payments: z.array(PartialPayment),
multi_visit_job_id: z.string().nullable(), // if part of a multi-visit job
follow_ups: z.array(FollowUp),
photos: z.array(PhotoRef),
source_language: z.string(), // BCP-47, "tl-PH", "en-GB", "yo-NG"
transcript_verbatim: z.string(), // exactly as spoken, source language, no cleanup
parse_confidence: z.number().min(0).max(1),
flagged_for_user_review: z.array(z.object({
field_path: z.string(), // "total_price_quoted"
reason: z.string(), // "no price stated — user must enter before invoicing"
})),
});
type Job = z.infer;
```
### Common failure modes (and how to avoid them)
- **Model invents a labour-hour estimate** when the tradesperson didn't say one. The hardest rule. If `arrived_at_iso` is null AND no spoken duration is given, `duration_minutes` is null, `duration_source` is `"unknown"`, and a `flagged_for_user_review` entry is added — the model does NOT fall back to "30 minutes seems reasonable for a P-trap swap".
- **Model invents a price** by interpolating "the going rate". Same severity. If the tradesperson did not name a figure, `total_price_quoted` is null, `payment_status` is `"unknown"`, and a flag is raised. Never substitute a market estimate.
- **Code-switched memos silently translated** — "Mrs Reyes na yung kitchen sink, charged her ₱1,400" gets transcribed as "Mrs Reyes the kitchen sink, charged her ₱1,400". Hard rule: `transcript_verbatim` preserves the source-language tokens exactly. The English translation only happens on a separate call when the invoice is drafted.
- **Currency assumed** when the tradesperson says a bare number. The model defaults to USD. Wrong. The user profile carries the operating currency (PHP, GBP, NGN, KES, INR…); the parse call receives that as a hard system-instruction note and uses it unless the memo explicitly overrides ("she paid me in dollars, I'll convert later").
- **Materials hallucinated** — "tightened the strainer" gets a "Strainer × 1" material line. Wrong: the tradesperson did not say they used a new strainer. Materials only enter `materials[]` when the tradesperson explicitly named the consumable.
- **Address geocoded to the wrong country** — "24 Mabini Street" is geocoded to a Mabini Street in some other country. Pin `country_bias` from the user profile in the grounded geocoding call; if confidence is below 0.7, leave the geocoding null and surface for review rather than picking the wrong city.
- **Warranty callback charged as a fresh job** — the tradesperson says "this is the one I came back to fix on the radiator, no charge". The model interprets that as informational and still produces a price line. Hard rule: when the memo contains warranty-callback language, `visit_kind` becomes `warranty_callback`, `payment_status` becomes `no_charge_warranty`, `total_price_quoted` is null. Override requires an explicit user tap.
- **Partial payments collapsed into one figure** — "she gave me half today, paying the rest Friday" becomes `total_price_quoted: full, payment_status: paid_in_full`. Wrong: split it. `payment_status: paid_partial`, `partial_payments[]` carries the half, a follow-up due Friday is created.
- **Customer disambiguation across two memos fails or over-matches** — "Mrs Reyes on Mabini" and "Reyes po sa Mabini" should fold. "Mr Reyes at #14 Mabini" and "Mrs Reyes at #24 Mabini" should not. Conservative resolution: address numeral must match within ±2 AND name overlap is non-trivial; otherwise propose, don't auto-merge.
- **Transcription confidence too low to risk a number** — when the memo audio is muddled and the model isn't sure whether the tradesperson said ₱1,400 or ₱1,800, the model emits `null` on the price and adds a flag. It does NOT pick the more probable number silently.
- **Multi-visit job not stitched** — three visits to the same boiler over the week show up as three jobs invoiced separately. Fix: when a memo references an earlier visit ("back to the Cruz boiler"), the customer-resolution call also surfaces the most-recent open `multi_visit_job_id` for that customer and the new job joins it.
### Negative constraints (hard rules)
- Do NOT invent labour hours. If the tradesperson did not press Start AND did not name a duration in the memo, `duration_minutes` is null. Never fall back to a trade-average.
- Do NOT invent prices. If the tradesperson did not name a figure, `total_price_quoted` is null and `payment_status` is `"unknown"`. Never insert a market estimate.
- Do NOT translate the verbatim transcript. `transcript_verbatim` is the raw source-language audio transcribed character-for-character. Translation is a separate call, gated on the invoice draft, with the tradesperson's tone preserved.
- Do NOT translate first names, brand names, or the tradesperson's verbatim phrasing of what they did. "Mrs Reyes", "GCash", "Worcester Bosch", "pinalitan ang P-trap" stay verbatim in the structured record.
- Do NOT assume currency. The user's profile carries it; the parse call uses that as the default unless overridden in the memo.
- Do NOT silently mark a job paid. "She'll pay me Friday" is `invoiced_unpaid` with a follow-up, not `paid_in_full`.
- Do NOT add materials the tradesperson didn't name. "Tightened the strainer" doesn't yield a strainer line.
- Do NOT geocode below 0.7 confidence. Leave it null and surface for review.
- Do NOT treat a warranty callback as a chargeable job. The phrase "no charge" or "this is the callback" forces `visit_kind: warranty_callback` and `payment_status: no_charge_warranty`.
- Do NOT use the tradesperson's audio, photos, customer data, or invoices to train or fine-tune any model. Use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms.
- Do NOT auto-send invoices. The Friday roll-up produces drafts; the tradesperson presses send for each, or "send all" after a visible review.
- Do NOT round prices to a "nicer" number. If the tradesperson said ₱1,397, the invoice says ₱1,397.
### Per-call `systemInstruction` strings
Use these as the literal `systemInstruction` field for each Gemini API call the built app makes. They complement the series-wide rules already uploaded as the global instructions file (`00-series-instructions.txt`).
### Call: Parse voice memo → `Job` schema
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none)
```
You are listening to a between-jobs voice memo recorded by a
self-employed tradesperson — plumber, sparky, gas engineer, locksmith,
HVAC tech, mobile mechanic, roofer, tiler, glazier. The memo is
between 10 seconds and three minutes long. It was recorded next to
a van between two jobs.
Languages encountered include English (UK, US, Indian, Filipino,
Nigerian, Kenyan, Singaporean accents), Tagalog (Manila, code-switched
heavily with English), Cebuano, Hokkien, Cantonese, Mandarin, Yoruba,
Igbo, Hausa, Swahili, Amharic, Punjabi (Gurmukhi or Shahmukhi),
Bengali, Hindi, Urdu, Tamil, Telugu, Malayalam, Marathi, Vietnamese,
Khmer, Thai, Bahasa Indonesia, Bahasa Malayu, Brazilian Portuguese,
European Portuguese, Mexican Spanish, Castilian Spanish, Arabic
(Egyptian, Levantine, Maghrebi, Gulf), French (Parisian, West
African), Farsi, Turkish, Polish, Romanian, Ukrainian. Code-switching
mid-sentence is the norm, not the exception.
The tradesperson's operating currency is supplied in the
`user_profile_currency` variable. Use it as the default unless the
memo explicitly overrides ("she paid me in dollars").
Produce one Job JSON object matching the supplied schema. The
following are HARD RULES — they are not preferences:
1. `transcript_verbatim` is the memo audio transcribed exactly as
spoken, in the source language(s), preserving every code-switch,
every "y'know", every "tapos", every "alright". Do NOT translate
inside this field. Do NOT clean filler words. Do NOT correct
grammar.
2. `labour_description_verbatim` is the tradesperson's own words
describing what they did, lightly trimmed of pure filler ("uh",
"y'know") but otherwise preserved in the source language(s). It
appears on the eventual invoice, possibly translated by a later
call.
3. `total_price_quoted` is ONLY populated if the tradesperson named
a figure in the memo. If they did not, set it to null AND add a
`flagged_for_user_review` entry: `{ "field_path":
"total_price_quoted", "reason": "no price stated in memo — enter
before invoicing" }`. Never substitute an estimate. There is no
"going rate" fallback.
4. `duration_minutes` is computed from `arrived_at_iso` and
`departed_at_iso` supplied by the client. If those are missing,
look for a spoken duration ("about an hour", "took me twenty
minutes"). If a spoken duration is given, set `duration_minutes`
from it, set `duration_source` to `"spoken_phrase_only"`, and
set `duration_uncertain` to true. If neither is available, set
`duration_minutes` to null, `duration_source` to `"unknown"`, and
`duration_uncertain` to true.
5. `materials[]` contains ONLY materials the tradesperson explicitly
named in the memo as having been used or installed. Items merely
touched, tightened, or inspected are NOT materials. If the memo
says "used one 50 mm chrome P-trap and a roll of plumber's putty",
those are two material lines. If it says "tightened the basket
strainer", that is NOT a material line.
6. `visit_kind` defaults to `first_visit`. If the memo contains
warranty / callback / goodwill phrasing ("this is the callback",
"came back to fix the leak from last week", "no charge", "on the
house") set `visit_kind` accordingly AND set `payment_status` to
`no_charge_warranty` (or `no_charge_goodwill`) AND
`total_price_quoted` to null.
7. `payment_status`:
- "she paid cash" / "paid via GCash" / "transferred" → `paid_in_full`
- "she gave me half, paying the rest Friday" → `paid_partial`
with a `partial_payments[]` entry and a follow-up due Friday
- "I'll send the invoice tomorrow" / no payment mentioned →
`invoiced_unpaid`
- "no charge" → `no_charge_warranty` or `no_charge_goodwill`
- "she's disputing it" → `disputed`
8. `follow_ups[]` captures anything the tradesperson said about
future action: "ring her about the manifold", "need to come back
on Friday with the part", "she'll Venmo me Sunday". `source_quote`
is verbatim from the transcript.
9. Code-switching is preserved. "Mrs Reyes na yung kitchen sink,
charged her ₱1,400" stays code-switched in `transcript_verbatim`
and is parsed normally — the price is ₱1,400, the customer is
Mrs Reyes, the work was on a kitchen sink.
10. `parse_confidence` is overall confidence (0-1).
`flagged_for_user_review` lists every field below 0.7 confidence
with a one-sentence human-readable reason.
11. The `Customer` block under `customer_ref.proposed_new_customer`
is only populated if no `existing_customer_id` is supplied by
the client. The customer-resolution call runs separately.
Output ONLY the Job JSON. No commentary. No prose before or after.
```
---
### Call: Resolve customer (same address / similar name → one record)
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive a parsed Job's `customer_ref.proposed_new_customer` and
the tradesperson's existing customer list (up to ~500 records). Your
task: either return an `existing_customer_id` for the best match, or
confirm a new customer.
Hard rules:
- Conservative resolution. A match requires BOTH:
(a) the address numeral matches within ±2 (e.g. "24 Mabini Street"
matches "22-26 Mabini Street"), AND
(b) the street name + city match, AND
(c) the customer name shares at least one significant token
(surname or distinctive first name).
- If only address matches but the name differs (a different tenant at
the same building), return `propose_new` and add a note that the
address is shared.
- If the new memo phrases "the place behind the petrol station on
Gen. Luis" and an existing customer is on "Gen. Luis Street", do
not auto-match — surface as a candidate for the user to confirm.
- Two "Mrs Reyes" entries at different addresses are different
customers; do not collapse.
- If multiple candidates exist with equal confidence, return all
candidates and let the user choose.
- Do NOT modify existing customer records. The match call is
read-only on the existing list.
Output a single JSON object:
{
"decision": "match" | "propose_new" | "ambiguous",
"matched_customer_id": string | null,
"candidates": [{ "customer_id": string, "confidence": number,
"reason": string }],
"rationale": string
}
No commentary outside the JSON.
```
---
### Call: Translate the tradesperson's description for the invoice
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive the tradesperson's verbatim labour description (in the
source language(s) they spoke into the memo) and a target invoice
language. Translate the description for an invoice to a customer,
preserving the tradesperson's voice.
The priority is voice. The translation should sound like the
tradesperson speaking to the customer, not like a service-industry
template.
Hard rules:
- Preserve the first person and the past tense the tradesperson used.
"Pinalitan ko ang P-trap" → "I replaced the P-trap", never
"Replacement of P-trap performed".
- Do NOT translate proper nouns: customer names, brand names, parts
catalogue names. "Worcester Bosch", "GCash", "Mrs Reyes",
"Mr Cruz" stay verbatim.
- Do NOT translate measurement units. "50 mm", "1/2-inch", "2 metres"
stay verbatim with the unit attached.
- Preserve specificity. "Tightened the basket strainer and replaced
the P-trap under the kitchen sink" stays specific in translation;
do not generalise to "Performed maintenance on the kitchen sink".
- Preserve the tradesperson's level of formality. A laconic
description stays laconic. A friendly description stays friendly.
- If the source memo was code-switched, translate the whole into the
target language but flag in `translation_voice_notes` what the
original switch was ("source switched to Yoruba for the warranty
promise").
- Translate to the target language's natural register for a tradie
invoice — Brazilian Portuguese stays Brazilian, not European;
Mexican Spanish stays Mexican, not Castilian.
Output:
{
"labour_description_translated": string,
"translation_voice_notes": string[] // one sentence per non-obvious choice
}
No commentary outside the JSON.
```
---
### Call: Geocode an ambiguous address from the memo
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: search grounding
```
You resolve an address the tradesperson spoke aloud into a memo to a
usable geocoded location, biased to the tradesperson's operating
country.
Given:
- `address_verbatim`: the spoken phrase
- `country_bias`: ISO-3166-1 alpha-2 of the tradesperson's operating
country (e.g. "PH", "GB", "NG", "KE", "IN", "BR", "MX")
- optionally `prior_customer_addresses`: a list of streets the
tradesperson has worked on before (cheap context anchor)
Return:
- `formatted_address`: a canonical, mailable address in the country's
local conventions
- `lat`, `lng`: decimal degrees
- `confidence`: 0-1
- `alternates[]`: up to 3 plausible alternates if disambiguation is
ambiguous, each with formatted_address, lat, lng, and a one-line
reason
Hard rules:
- Use `google_search` grounding. The output goes in the JSON in the
text body — do NOT specify `responseSchema` on this call, because
grounding and structured-output schemas are mutually exclusive in
one Gemini call.
- If confidence is below 0.7, set the top-level fields to null and
return only the alternates[] for the user to pick from.
- If the address is clearly within the country_bias, do not return a
match in another country, however phonetically plausible.
- Citation URLs come from `response.groundingMetadata.groundingChunks[].web.uri`
server-side; do NOT include URLs in the JSON body — the model will
hallucinate them.
- If the address phrase is a landmark ("the place behind the petrol
station on Gen. Luis"), return the nearest geocoded result on that
street with confidence below 0.7 and flag as `is_landmark_reference`.
Output the response as JSON in the text body. No commentary.
```
---
### Call: Propose a price-book default after N repetitions
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive the tradesperson's history of jobs with a specific
labour-description signature (e.g. "P-trap swap, kitchen sink") and
their stated prices for each. Your task: propose a default price for
the price book IF AND ONLY IF the prices cluster tightly.
Hard rules:
- Minimum three jobs with the same signature before proposing.
- Cluster the prices. If the standard deviation is more than 15% of
the mean, do NOT propose a default — surface a "your pricing for
this job varies; want to set a band?" instead.
- The proposed default is the median, rounded to the nearest 10 in
the operating currency (₱1,400 not ₱1,397.50; £80 not £77).
- Do NOT propose if any of the input jobs were warranty callbacks or
no-charge goodwill jobs — those distort the distribution.
- Surface the rationale: "you've charged ₱1,400-₱1,500 for kitchen
P-trap swaps four times; suggest ₱1,400 as default".
- The user always confirms or overrides. This is a suggestion, not a
set value.
Output:
{
"signature": string, // "kitchen_p_trap_swap"
"proposed_default_price": number | null,
"currency": string,
"rationale": string,
"supporting_job_ids": string[],
"should_propose": boolean
}
No commentary outside the JSON.
```
---
### Call: Friday roll-up — read out the week's pending invoices
Model: `gemini-3.1-flash-tts-preview` · n/a · n/a
```
Voice: warm, brisk, business-friendly. Pick the Gemini 2.5 Flash TTS
voice whose `languageCode` matches the tradesperson's preferred
working language (commonly "en-GB", "en-PH", "en-IN", "en-NG", "tl-PH",
"es-MX", "pt-BR", "yo-NG"). Pronunciation follows the chosen voice's
native locale.
Pre-process the text before sending it to TTS:
- For each pending invoice, build a short spoken line:
", , ."
Example: "Mrs Reyes, peso fourteen hundred, paid. Mr Cruz, peso
two thousand eight hundred, invoiced."
- At end of each invoice line insert a single ellipsis (`…`) so the
TTS produces a natural pause. After every five invoices insert a
blank line plus an em-dash (`—`) for a longer breath. Gemini 2.5
TTS does not support SSML `` — these textual cues are
how you signal pace.
- Currency: render symbol + number, then either "paid", "invoiced",
"callback no charge", "disputed", or "half paid, half pending".
- Skip the line-item materials in the TTS read-out; only customer +
total + status. The tradesperson is driving home.
- Target rate: ~140 words per minute. Slightly faster than the
family-letter pace; this is a business read-out, not a reading.
Style direction: prepend ONE short directive sentence to the text
input: "Read briskly and warmly, like a colleague reading the week's
list back to a tradie who's driving home". There is no separate
`style` API field on Gemini 2.5 TTS; the directive sentence inside
the input is how style is conveyed.
Phoneme overrides (Tagalog "ng", Yoruba tone marks, Punjabi
retroflexes) are NOT exposed by Gemini 2.5 TTS — no SSML ``
tag. Pronunciation comes from the chosen voice's native locale; pick
the locale that matches the tradesperson's working language.
```
---
### Call: Year-end consolidation (long context over all jobs)
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none)
```
You receive every Job object from the tradesperson's last tax year
(typically 1,800-2,500 jobs). Produce a consolidated annual summary
the tradesperson can hand to their accountant.
If the tax year exceeds the 1M-token context (rare, but possible for
very high-volume tradies), chunk by trade-type or by quarter before
running the call. Reassemble server-side.
Output:
- gross_revenue_by_currency[]
- net_revenue_by_currency[] (after warranty no-charges and
disputed/written-off jobs)
- count_of_jobs
- count_of_customers
- top_10_customers_by_revenue[]
- materials_total_by_canonical_item[] (the parts the tradesperson
bought through across the year)
- count_of_warranty_callbacks
- count_of_disputes
- outstanding_invoices_as_of_year_end[]
- count_of_no_charge_goodwill (the freebies — useful for the tradie
to see, not for the accountant)
- average_jobs_per_working_day (weekend work counted as separate)
- median_job_value_by_trade
- one paragraph of plain-English commentary surfacing patterns the
tradesperson should know: months where outstanding-invoice volume
spiked, customer segments with high dispute rates, materials whose
price the tradesperson never updated.
Hard rules:
- Do NOT compute tax owed. The tradesperson's accountant does that.
- Do NOT extrapolate next year's revenue. Past data only.
- Do NOT include warranty callbacks in gross revenue.
- Goodwill no-charge jobs are tracked separately, NOT subtracted from
gross revenue — they were free, not deducted.
Output as a single JSON object. No commentary outside the JSON.
```
## 5. Use cases & content to include
Build dedicated UI sections or flows for each of these — they tell you what content the app must support.
- **Nine jobs in a Manila Saturday.** Marivic, a self-employed plumber in Quezon City, runs nine jobs from 8 am to 4 pm: a clogged sink in Project 4, a leaking ball-cock in San Juan, a no-water complaint in a duplex on Mabini, three back-to-back P-trap swaps on the same street, a meter-reading dispute in Cubao, a bathtub re-seal, a hot-water heater diagnostic. She records nine 30-second memos in code-switched Tagalog-English. By 4 pm she has nine structured jobs, an itemised list of the seven parts she used, and a Friday roll-up waiting for her. Total paperwork time on Saturday evening: 4 minutes.
- **A Manchester week's invoicing.** James, a gas engineer, runs forty-three jobs Monday to Friday across south Manchester. Three are warranty callbacks (Worcester Bosch service plans he's covered under), eight are part-pays, two are disputes from a landlord, the rest are paid on the spot via card-on-site or bank transfer. On Friday at 16:00 the app produces thirty-two draft invoices grouped by customer. James reviews, presses "send all", and closes the laptop.
- **The warranty callback.** A boiler James serviced in Chorlton three weeks ago has lost pressure overnight. He goes back, refills the system, no charge. He records: "back to the Worcester Bosch in Chorlton, lost pressure, refilled, no charge, on the warranty." The app: `visit_kind: warranty_callback`, `payment_status: no_charge_warranty`, `total_price_quoted: null`, multi-visit-stitched to the original service job from three weeks ago.
- **The split-payment landlord-tenant job.** A blocked drain at a rental flat. The tenant called James out; the landlord pays for parts; the tenant pays for labour. James records: "Mr Khan, 18 Park Road, blocked drain, landlord's paying the parts, that's the £45 for the rods and the camera, tenant's paying the £85 labour, both bank transfer." The app: two `partial_payments[]` entries, two implicit recipients (a note for the tradesperson, not auto-emailed), one job, one invoice with two payee blocks.
- **The Tagalog memo, the English invoice.** Marivic's memo is in code-switched Tagalog-English. Her customer, Mr Cruz, prefers his invoice in English. The transcript is preserved verbatim in Tagalog. The translation call produces "I replaced the P-trap under your kitchen sink and re-sealed the basket strainer with plumber's putty" in English. The transcript and the translation are both kept; the customer only sees the English; the tradesperson can play back the Tagalog audio if there's ever a dispute.
- **The dispute.** Six weeks after the job, a customer claims James said the work would be £180, not £280. James opens the job, presses play on the audio from the right second: "Mrs Whitaker, gave her a quote of two hundred and eighty for the radiator manifold and the bleed valves, she's happy with that, I'll do it Tuesday." Conversation over.
- **The forgotten Start button.** Marivic forgets to press "Arrived" at the third job of the day. She remembers when she records the memo: "got there at about half past nine, left about ten thirty." The model sets `duration_source: spoken_phrase_only`, `duration_uncertain: true`, `duration_minutes: 60`, and surfaces a green-bordered review chip on the home screen so she can confirm before invoicing.
- **The basement with no signal.** James spends an hour in a basement with no mobile signal. He records two memos at the bottom of two ladders. The app queues both. When he gets back upstairs and the signal returns, both memos parse in sequence. He has lost nothing.
- **The Lagos plumber's tooling.** Adebayo runs jobs across Surulere, Yaba, and Lekki. His operating currency is naira; his customers commonly pay through a mix of cash, bank transfer, and the occasional dollar from a returnee. His memos are in Yoruba code-switched with English. The app handles the multi-currency in `partial_payments[]` and produces invoices in English in his customers' preferred format. His price book builds itself out of the prices he names; he never has to find the time to sit down and "build a price list".
- **The first-quarter-out apprentice.** Jamal left a firm three months ago and went self-employed. He doesn't have admin discipline yet. The app is the discipline. Each Friday at 16:00 he gets a roll-up he can't pretend he didn't see. By the end of the third month, his outstanding-invoices total has gone from £4,200 to £350 because the friction of invoicing collapsed.
- **The accountant export.** At year-end, Adebayo's accountant emails: "send me everything for last fiscal year." Adebayo presses one button. A CSV + a PDF arrive in the accountant's inbox: every job, every customer, every payment, every material purchased, in naira. The accountant has never had a cleaner handover from a tradie.
## 6. Page structure
Build the following screens / sections in this order. Adjust copy to fit the voice, but keep the structural intent.
1. **Welcome / sign-in.** A photographed-looking image of a tradie's van back door open at the kerb, a toolbox on the bumper, the phone propped on the toolbox, a sunset behind the row of houses. One paragraph: "Tradie Diary writes your week's invoices between jobs — in your voice, in your language, with the prices you actually quoted." A single primary sign-in: Google. Phone-number sign-in beside it (most tradies don't habitually use email). Apple sign-in as third option. Below: "Try with the sample week" → loads the demo archive in section 8a.
2. **First-run profile.** Pick your trade(s), your operating country (auto-detected from device locale; confirm), your operating currency (auto-pre-filled from country; editable), your working language (the one you'll record memos in — defaults to device language), your invoice language(s) (often the same; sometimes different — Tagalog-speaking tradie invoicing in English). Tap-through, no typing required.
3. **Home — today's jobs.** A vertical list of today's jobs, newest at top. Each row: customer name, address, the time the tradesperson arrived, the price (or a faint "no price stated — tap to enter" chip), payment status as a coloured pill. A persistent **big round red button** at the bottom: "Done with a job → record 30s memo". Above it, a smaller button: "Arriving at a job → start the clock" (this captures `arrived_at_iso` precisely; tradesperson can skip it).
4. **Recording flow.** Press the red button. Big spoken time counter. Live waveform. Tap to stop. The phone immediately uploads (queues if offline). The next screen shows the job appearing into the day's list with a spinner that turns into the structured fields one by one — customer, address, materials, price, payment status. No marketing copy on this screen. The "thinking" indicators are the words the model is reading out of the memo as they parse ("…hearing 'kitchen sink'…", "…hearing '₱1,400'…").
5. **Job detail view.** A two-column layout on desktop, stacked on mobile. Left column: the job summary (customer card, address, arrival/departure, total, payment status, the materials line-itemised). Right column: the verbatim transcript (with a play-from-here button on every paragraph), the translated description (collapsed by default — tap to expand), the photos (tap to view full-screen), and the follow-ups. Sticky header: customer name → time → status pill → "(i) show how the AI read this".
6. **Customer detail view.** Every job for one customer, sorted newest first. The customer card at top: name, address, geocode, phone, email, preferred invoice language, preferred payment method, tradesperson's free-form notes ("landlord pays labour, tenant pays parts"; "always pays by GCash"; "lovely, slow at picking up the phone"). A "merge with another customer" button for over-zealous splits. A "split this customer" button for collisions at shared addresses.
7. **Materials & price book.** A two-tab screen. Tab 1: this week's materials usage (so the tradesperson knows what to restock). Tab 2: the price book — every signature job ("kitchen P-trap swap", "Worcester Bosch boiler service", "exterior tap replacement") with the suggested default price the app has clustered from the tradesperson's own past jobs. The tradesperson edits each entry once; it sticks. Default price is a suggestion, never auto-applied without confirmation.
8. **The Friday roll-up.** A draft worksheet of the week's invoices. Each invoice bundled per customer, with all of that customer's week-jobs as line items in the body. The tradesperson scrolls, taps "edit" on any line to adjust, taps "send" or "send all". The roll-up includes a one-line summary at the top ("32 invoices, ₱41,300 due, 4 follow-ups outstanding").
9. **Invoice editor.** Two-column on desktop. Left: the invoice as the customer will see it. Right: the source jobs the invoice was built from, each with a play-the-audio button. Edit the line items, the description, the discount, the payment terms. The tradesperson's logo, payment details, and bank info sit at the top from the profile.
10. **Send dialog.** Email, WhatsApp link, SMS link, copy as text. A preview of what the customer will see across all three rails — many tradies WhatsApp the invoice rather than email it.
11. **Follow-ups list.** Every follow-up due across all customers. "Ring Mrs Reyes about Friday payment", "Order the manifold for Mr Khan's job", "Quote the bathroom re-tile for the Cruz family". Each item has the source quote and a play-the-audio button.
12. **Search.** A single text field. Natural-language queries: "the one with the cracked manifold in Levenshulme", "all jobs for the Cruz family this year", "every callout under ₱500". Results in two seconds.
13. **Settings & export.** Profile, currency, working language, invoice languages, payment rails configured (Stripe, GCash, M-Pesa, Paystack, Razorpay…), accountant email, Friday roll-up time (default 16:00 local). Year-end export: one button → CSV + PDF + zip of all audio + zip of all photos.
14. **Footer.** "Built for the tradie who doesn't want Friday to be paperwork." Capabilities `(i)` icon in header.
## 6b. First-visit onboarding
Show a **first-visit onboarding** the first time a visitor lands on the app (detect via `localStorage` flag; do not show on return visits). Three slides, dismissible at any time. Persistent re-entry: a `?` icon in the header reopens it.
**Slide 1 — What this is.**
- Headline: "Welcome to Tradie Diary."
- Subhead: "The week's invoices write themselves between jobs — in your voice, in your language, with the prices you actually quoted."
- One paragraph (≤ 60 words) explaining who this is for: self-employed tradies running eight to twelve jobs a day, who hate Friday paperwork. The app never invents an hour or a price — it only records what the tradesperson said. The tradesperson is the source of truth; the app is the notebook.
- Visual: a small annotated illustration of a phone propped on a van toolbox with the "Done with a job" button and a 30-second waveform. Not a generic clipboard icon.
**Slide 2 — Try it now.**
- One short prompt: "Try with the sample week".
- A live demo input pre-loaded with five jobs from the seed content in section 8a — a Manila plumber's Saturday so the demo is non-English-default by intent.
- 1-2 sentences pointing at the specific page elements where the Gemini magic happens (the code-switched Tagalog memo parsing into structured fields; the Friday roll-up; the play-the-audio dispute-mode button).
**Slide 3 — How to remix this.**
- Headline: "Make this yours."
- Three short bullets:
- "Swap the sample week in `/data/seed-week/` for your own trade's example jobs."
- "Adjust the prompts in `/server/prompts/` for your operating language and currency."
- "Wire up your Gemini API key, Firebase project, and (optional) Stripe / GCash / M-Pesa keys via the env-var list in the capabilities panel."
- Primary CTA: "Use this template" → links to AI Studio Build remix entry point.
- Secondary: "Just exploring — close" (sets localStorage flag, never auto-shows again).
**Accessibility:** focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, focus restored to trigger on close. Respect `prefers-reduced-motion`.
**Don't:**
- Don't gate content behind the modal. The page beneath must be fully usable.
- Don't auto-reshow on return visits. Use `localStorage['onboarding-seen-v1']`.
- Don't include unrelated CTAs (newsletter signup, social follow). Keep it about the template only.
## 6c. Capabilities info button (persistent in header)
Add a persistent `(i)` icon in the top-right of the header (next to the primary nav). Click → opens a modal/panel titled **"What powers this app"**.
**Panel contents (in this order):**
**Gemini capabilities used (the hero list):**
- **Gemini 3.5 Flash (audio input + multilingual)** — listens to your 30-second voice memo, transcribes it verbatim in the language(s) you spoke, and produces a structured job record. Handles code-switching mid-sentence: Tagalog-English, Cantonese-English, Yoruba-English, Punjabi-English, Brazilian Portuguese-English.
- **Gemini 3.5 Flash (structured output)** — every job is a typed Job record. Materials, customer, address, payment status, follow-ups all come out as fields, not free text.
- **Gemini 3.5 Flash + grounded search** — resolves the address you spoke aloud ("the place behind the petrol station on Gen. Luis") into a usable geocode, biased to the country you operate in.
- **Gemini 3.5 Flash (long context)** — the year-end consolidation reads every job from the past tax year at once and produces a clean handover for your accountant.
- **Gemini TTS** — reads the Friday roll-up of pending invoices back to you while you drive home, so you can press "send all" before you walk in the door.
- **Gemini 3.5 Flash (multilingual translation)** — translates your verbatim labour description into your customer's preferred invoice language, preserving your voice — never "Performed P-trap replacement services" when you said "I replaced the P-trap".
- **Firebase Auth** — Google sign-in by default; phone-number sign-in if you don't habitually use email; Apple sign-in if you've configured an Apple Developer account.
- **Firestore** — stores your jobs, customers, invoices, and follow-ups; syncs across devices in real time.
- **Firebase Storage** — keeps your raw audio memos, job-site photos, and rendered invoice PDFs at the original resolution, forever.
- **Cost note** — see the detailed breakdown in 6d. A typical week of 45 jobs costs about $0.85 of Gemini API spend, total.
- **Privacy note** — your voice memos, customer details, and invoices are private to you. This app uses the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. Your audio is your working notebook; the app treats it accordingly.
**Backend services this app depends on:**
- Auth: see section 4b
- Database: see section 4b
- Storage: see section 4b
- Email: see section 4b
- WhatsApp / SMS: see section 4b (optional, user-configured per country)
- Payments: see section 4b (optional, user-configured per region — Stripe, GCash, M-Pesa, Paystack, Razorpay)
- External APIs: see section 4b
**Environment variables you'll need to configure:**
- `GEMINI_API_KEY` — your Google AI Studio API key
- `FIREBASE_PROJECT_ID` — your Firebase project id
- `FIREBASE_SERVICE_ACCOUNT` — service-account JSON (server-side only)
- `STRIPE_SECRET_KEY` — optional; only if you accept card payments via Stripe link
- `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` / `TWILIO_NUMBER` — optional; for SMS invoice links
- `WHATSAPP_BUSINESS_TOKEN` — optional; for WhatsApp Business API integration (most users use the "copy as message" fallback)
- `MAPS_API_KEY` — optional; for higher-fidelity geocoding than the grounded-search default
**Cost + privacy notes:**
- One short paragraph per cost-sensitive capability: the year-end consolidation is long-context and is billed per token of input. For a 2,000-job tax year (chunked by quarter), the consolidation costs about $0.60 once a year.
- One short paragraph on privacy: where the data lives (your Firebase project), how to delete it (Settings → "Delete my account forever" — every memo, photo, invoice gone in 60 seconds), what is never sent for training.
**Documentation links:**
- AI Studio Build docs
- Gemini API audio, multilingual, long-context, TTS docs
- Firebase Auth, Firestore, Firebase Storage docs
- Country-specific payment rails — Stripe, GCash, M-Pesa, Paystack, Razorpay — for your region
**Accessibility:** same standards as the onboarding modal — focus trap, `Esc`, ARIA, restored focus.
**Behaviour:**
- Always available — single click from anywhere in the app.
- Tooltip on the `(i)` icon: "How this app is built".
- Mobile: opens as a full-screen sheet that slides up.
- Should be the most honest part of the app — never hand-wave service requirements; never say "AI" without naming the specific Gemini model and capability.
## 6d. Detailed cost breakdown (deployer reads this BEFORE shipping)
- **Parse voice memo (Gemini 3.5 Flash, medium thinking)** — a typical 30-second audio memo is ~700 input tokens (audio at ~24 tokens/sec) + 600 output tokens. ~$0.004/memo. A heavy day of 12 memos: ~$0.05.
- **Resolve customer (Gemini 3.5 Flash, low thinking)** — small input (proposed customer + up to 500 existing as a compact list), ~200 input + 80 output. ~$0.00003/job. Negligible.
- **Translate labour description (Gemini 3.5 Flash, low thinking)** — ~200 input + 150 output. ~$0.001/invoice. A week of 30 invoices ≈ $0.03.
- **Geocode (Gemini 3.5 Flash + grounded search)** — only runs on ambiguous addresses (~1 in 5 jobs). ~$0.001/call. A week of 9 ambiguous addresses ≈ $0.009.
- **Price-book proposal (Gemini 3.5 Flash, low thinking)** — runs once per signature when a third job of that signature lands. ~$0.0005/run. Tiny ongoing cost.
- **Friday roll-up TTS (Gemini 2.5 Flash TTS)** — billed per output token (~$10/M output tokens), effectively ~$0.000003/character. A 30-invoice read-out is ~1,500 characters ≈ $0.005/week.
- **Year-end consolidation (Gemini 3.5 Flash, medium thinking, long-context)** — a 2,000-job year chunked into four quarters: ~200k input tokens per chunk × 4 + ~2k output × 4. ~$0.60/year.
- **Expected per-week Gemini cost for an active tradie (45 jobs/week):** ~$0.85. **Expected per-year Gemini cost** (52 weeks + one consolidation): ~$45.
- **Audio + photo storage:** Firebase Storage standard tier, ~$0.026/GB/month. A typical 30-second m4a is ~250 KB. A heavy year of 2,500 memos = ~625 MB ≈ ~$0.02/month. Add ~5,000 job-site photos at ~1 MB each = ~5 GB ≈ ~$0.13/month.
## 7. Design language
- **Mood:** A working tool, not a clipboard, not a fintech dashboard. The phone sits on the toolbox between two jobs; the screen needs to read in sunlight and in dim van light; the buttons are big enough for a thumb in a work glove. Not Stripe. Not Xero. Not a SaaS. The visual reference is the hand-written A4 carbon-paper invoice book that tradies still carry — but legible, fast, and never out of paper.
- **Typography:** A confident grotesque (Inter, Söhne, Geist) for everything — UI chrome, body, numbers. Tabular numerals for prices and durations so columns line up. The transcript view uses a slightly warmer serif (Source Serif Pro) to distinguish the spoken record from the chrome. The customer's name on a job card is set noticeably larger than the address — that's the field the tradie scans first.
- **Palette:** Slate-blue working background `#1F2937` for the home screen at night (the screen the tradesperson stares at in dim van light) and a clean off-white `#F8F7F4` for the day-shift screen. Bright safety-amber `#F59E0B` for the "Done with a job" button — the single visual loudest element, because tradies need to find it without thinking. A muted forest `#0F766E` for paid status, a muted clay `#B45309` for invoiced-unpaid, a faded brick `#9B2C2C` for disputed. A confident black `#0A0A0A` for prices. Borrowed from work-site safety colours, not from finance UIs.
- **Imagery:** A photographed-looking welcome image of a van back door open at the kerb, a toolbox on the bumper, the phone on the toolbox. The empty states show a hand on the steering wheel of a van. Generate via Nano Banana 2 with prompts emphasising "real working hands, dusty van interior, late afternoon, the phone screen catching the sun". No stock-photo "happy tradesperson" smiles.
- **Hand-feel touches:** Pressing "Done with a job" gives a soft single haptic — the phone in the breast pocket buzzes once when the upload starts. The Friday roll-up screen has a thin top-of-screen confetti only on the first weekly send-all the tradie ever does, never thereafter (an honest one-time celebration, not a repeating gimmick). The customer card has a subtle paper-edge shadow so it reads like a card the tradie pulled from a wallet.
- **Spacing:** consistent 4-px base. Job-card rows are tall enough for a glove (56 px minimum hit area).
- **Radius:** consistent token set (e.g. 6 / 12 / 20 px). Job cards use 12; the "Done with a job" button uses 999 (full circle); the invoice preview uses 6.
- **Shadows:** subtle, layered. The "Done with a job" button has a soft elevated shadow because it's the one persistent loud thing.
- **Motion:** purposeful — the parse "thinking" indicator shows the actual words the model is hearing emerging as it processes ("hearing 'kitchen sink'…"), not a generic spinner. Respect `prefers-reduced-motion`. The Friday roll-up's bundling animation (jobs collapsing into customer cards) is the one place where motion carries meaning; respect reduced-motion by jumping rather than animating.
- **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons that match the eventual layout, not spinners. Offline state has a visible amber chip ("offline — memos will upload when you're back on signal") that the tradesperson can ignore confidently.
## 8. Content generation rules
- Write **realistic, specific copy**. NO Lorem Ipsum. NO generic placeholders like 'Your tagline here'.
- Invent plausible names, addresses, prices, parts. When inventing, draw on the specific cities the seed content names: Quezon City for Manila plumbing, Surulere for Lagos plumbing, Chorlton for Manchester gas-engineering. Use believable parts catalogues: Worcester Bosch boilers, Vaillant boilers, Geberit cistern parts, PHCC-standard P-traps, Schneider Electric breakers.
- Tone: direct, working, free of corporate language. This template is for a person standing at a van, not for a corporate fleet manager.
- Headlines: punchy and concrete. No 'Empower your X'. No 'Revolutionize'. No 'Seamless'.
- Body copy: short paragraphs (2-4 sentences). Use lists where appropriate. Use units the tradesperson actually uses (millimetres for plumbing parts, amperage for breakers, BTU/kW for heating).
- Plain language. Avoid jargon — except trade jargon, which is part of the voice. "P-trap", "bleed valve", "consumer unit", "manifold", "bib tap" stay.
- Where the app outputs AI-generated content (the translated invoice description, the proposed customer match), never label it as "AI says". Show small "(i) show how this was built" buttons next to any inferred field. The tradesperson confirms.
## 8a. Seed content (use these specific examples)
Anchor every generated copy + sample data point in the concrete content below. Use these names, numbers, dates, and snippets verbatim where helpful, or generate close variants that sit in the same world.
**Sample weeks (sidebar):**
- "Marivic's Saturday — Quezon City" (9 jobs, plumbing, code-switched Tagalog-English memos, PHP, default invoice language English) — covers a one-day demo from 8 am to 4 pm with two P-trap swaps, a ball-cock replacement, a bathtub re-seal, three meter-reading disputes, and one warranty callback.
- "James — Manchester gas-engineering week" (43 jobs Monday-Friday, primarily English memos, GBP, default invoice language English) — covers a one-week demo with three Worcester Bosch service callbacks, two split-payment landlord-tenant jobs, one disputed invoice, and eight bank-transfer payments.
- "Adebayo's week — Lagos" (28 jobs across Surulere, Yaba, Lekki, primarily Yoruba code-switched with English, NGN, default invoice language English) — covers a one-week demo with a multi-currency partial-payment job (naira + dollars), three jobs paid through Paystack, and a year-end-prep export at the end.
- "Lakshmi's week — Bengaluru" (35 jobs in HSR Layout and Indiranagar, primarily Tamil-English code-switched memos, INR, default invoice language English) — covers a one-week demo with seven jobs paid via UPI / Razorpay, two GST-applicable jobs, and one apprentice-hours-included job.
**Sample job in detail view (this is what the demo should show):**
- **Job ID:** `job_2026-05-23_marivic_004`
- **Recorded at:** 2026-05-23T11:42:18+08:00
- **Arrived at:** 2026-05-23T10:41:55+08:00 (Marivic pressed Start)
- **Departed at:** 2026-05-23T11:42:18+08:00 (end of memo)
- **Duration:** 60 minutes (from phone clock)
- **Duration source:** `phone_clock`
- **Duration uncertain:** false
- **Trade:** plumbing
- **Customer (verbatim):** "Mrs Reyes"
- **Customer canonical:** "Maria Reyes"
- **Address (verbatim):** "24 Mabini Street, Project 4, Quezon City"
- **Geocoded:** 14.6235°N, 121.0587°E, confidence 0.91
- **Phone:** "+63 917 555 0142"
- **Preferred invoice language:** "en-PH"
- **Preferred payment method:** "cash"
- **Visit kind:** `first_visit`
- **Labour description (verbatim):** "Pinalitan ko ang P-trap sa ilalim ng kitchen sink, tightened the basket strainer, sinilyuhan ng plumber's putty."
- **Labour description (translated to English):** "I replaced the P-trap under your kitchen sink, tightened the basket strainer, and sealed it with plumber's putty."
- **Translation voice notes:** "preserved the first person 'I replaced' rather than 'P-trap replacement performed'; kept 'plumber's putty' as the brand-generic the tradesperson named."
- **Materials (2):**
- "50 mm chrome P-trap", quantity 1, unit `each`, line_total_charged `null` (Marivic charged a flat job price; the per-line price book applies later)
- "plumber's putty roll", quantity 2, unit `roll`, line_total_charged `null`
- **Total price quoted:** ₱1,400
- **Currency:** PHP
- **Payment status:** `paid_in_full`
- **Payment method:** `cash`
- **Partial payments:** []
- **Follow-ups:** []
- **Photos (2):**
- before — under-sink showing leak around P-trap
- after — under-sink with new P-trap, dry
- **Source language:** `tl-PH` (code-switched with `en`)
- **Transcript verbatim:** "Okay, fourth job today — Mrs Reyes, dito sa 24 Mabini Street, Project 4. Pinalitan ko ang P-trap sa ilalim ng kitchen sink, tightened the basket strainer, sinilyuhan ng plumber's putty. Ginamit ko isang chrome P-trap, dalawang roll ng plumber's putty. In for an hour, charged her ₱1,400 cash, paid agad. Wala nang follow-up."
- **Parse confidence:** 0.94
- **Flagged for user review:** []
**Sample input artefacts (for the build to demonstrate):**
- A code-switched Tagalog-English 30-second voice memo (Marivic at job 4 of her Saturday).
- An English-only Manchester voice memo (James at the Worcester Bosch warranty callback).
- A Yoruba-English code-switched voice memo (Adebayo at a Lekki job with a partial dollar payment).
- A Tamil-English code-switched voice memo (Lakshmi at a Bengaluru job paid via UPI).
- A Manchester voice memo with no signal recorded in a basement boiler room (offline-queue demonstration).
- A Manchester voice memo where James forgot to press Start ("got there at about half nine") so the duration enters via `spoken_phrase_only`.
**Sample voice copy:**
- Onboarding: "Record one job. We'll write the invoice."
- Recording prompt: "Walk back to the van. Press here. Tell me about the job — 30 seconds."
- Processing: "Hearing 'kitchen sink'…" / "Hearing '₱1,400'…" / "Matching to Mrs Reyes from last month…" / "Saved to today's list."
- Empty home: "Your day starts here. Press Arriving when you pull up to the first job."
- Error (couldn't parse): "I didn't catch the price. Want to play that part back, or tap to enter it?"
- Save confirmation: "Added — Mrs Reyes, ₱1,400, paid cash."
- No-signal banner: "Offline — your memo is safe. It'll upload when you're back on signal."
- Friday roll-up nudge: "It's 16:00. You have 32 invoices ready to send. Want to look?"
- Send all confirmation: "30 invoices sent. 2 saved as drafts (missing customer email)."
- Dispute mode: "Play back what you said about the price." [▶ 00:18]
- Warranty callback detected: "This sounds like a callback. I've marked it no-charge — tap to override."
- Partial-payment detected: "You said she paid half. I've marked the rest as due Friday — tap to adjust."
**Sample customer-resolution surface:**
- "This sounds like Mrs Reyes from last month (24 Mabini Street). Same person? [Yes — same] [No — different person at the same address] [Wait, show me last month's job]"
**Sample Friday roll-up summary:**
- "Week of 19-23 May. 32 invoices ready. ₱41,300 due. 4 follow-ups still outstanding. 3 jobs need a price before invoicing (tap to enter). Ready to review?"
**Sample invoice email subject + body (auto-drafted):**
- Subject: "Invoice from Marivic — 23 May — ₱1,400 — kitchen sink P-trap"
- Body: "Hi Mrs Reyes — thanks again for today. Quick note for your records — I replaced the P-trap under your kitchen sink, tightened the basket strainer, and sealed it with plumber's putty. Total: ₱1,400, paid in cash today. Receipt attached. Let me know if you need anything else. — Marivic"
## 9. Media & assets
- **Hero image (landing screen):** A photographed-looking shot of a van back door open at a kerb, a toolbox on the bumper, the phone propped on the toolbox catching the last of the sun. Generate via Nano Banana 2 with a prompt emphasising "working hands, dusty van interior, late afternoon, the phone screen visible, no faces in frame, slight motion blur of the street behind the van".
- **App icon / wordmark:** Set in the grotesque. Slightly worn-toolbox texture behind it. The wordmark works at 32 px on a phone home screen — verify.
- **Empty-state illustration:** A simple line drawing of a hand on a steering wheel, the phone on the dashboard with a single big circle button on the screen. Hand-drawn, not a flat icon.
- **Demo memo waveforms:** Generate plausible waveforms for the sample memos in section 8a — the variation between a 12-second "warranty callback no charge" memo and a 90-second "complex multi-room job" memo should be visually obvious.
- **Trade icons:** Small line icons for plumbing, gas, electrical, locksmith, HVAC, mobile mechanic, roofing, tiling, glazing — used on customer cards only, not in the chrome. Lucide-react where good matches exist; custom for the trade-specific ones.
- **Stock fallbacks:** If Nano Banana 2 fails, fall back to a photographed sample image at `/public/samples/van-toolbox.jpg`. Never to a "🔧" emoji.
- **Generated imagery:** prefer Nano Banana 2 over stock photography. Prompt for honesty — real work clothes, real dust, late-afternoon light. Avoid the glossy 'happy tradesperson' stock-photo look.
- **Optimisation:** WebP/AVIF, `loading="lazy"`, explicit `width`/`height` to prevent layout shift.
- **Icons:** `lucide-react` for UI. Use sparingly — never decorative-only.
### Build-time asset manifest (explicit specs)
Every image, illustration, and visual reference mentioned above must resolve to ONE of the three buckets below — runtime-generated, seed-shipped, or user-supplied. Do NOT ship `` tags whose `src` is not listed here. Do NOT depend on bare "section 8a prompts" without binding them to explicit paths and model IDs.
**Bucket 1 — Runtime-generated (Nano Banana Pro `gemini-3-pro-image` for hero/demo photographs; Nano Banana 2 `gemini-3.1-flash-image` for in-app illustrations and reference-conditioned variants).** Cached to Firebase Storage; served via signed URL. Every reference above to "Nano Banana 2" or "Nano Banana Pro" MUST be wired to one of these specific calls with an explicit model id:
- `/public/generated/hero.webp` (2400×1500, WebP) — model `gemini-3-pro-image` — uses the literal prompt described as "Hero image (landing screen)" above. Run once at build; commit a `/public/samples/hero-fallback.webp` (1600×1000) generated from the same prompt with `gemini-3.1-flash-image` so the page renders if quota is exhausted.
- `/public/generated/demo/{demo-slug}-{NN}.webp` (1600×1200, WebP) — model `gemini-3.1-flash-image` (reference-conditioned where the prior frame is passed as input) — one path per "Demo X" image referenced above. The slug derives from the seed example in section 8a; the NN index covers each frame in the demo sequence.
- `/public/generated/illustrations/{name}.webp` (1024×1024, WebP) — model `gemini-3.1-flash-image` — one path per named illustration above ("Empty-state illustration", "Recipe-card hero illustrations", "Curriculum picker imagery", "Period-style frames", etc.). Each illustration's prompt is the literal description above; ship a deterministic seed in the request so re-runs are reproducible.
**Bucket 2 — Seed assets shipped with the deliverable.** Every "Stock fallback" path referenced above (e.g. `/public/samples/sample-X.jpg`) is generated once via Nano Banana 2 (`gemini-3.1-flash-image`) at 1024×1024 WebP using the same prompt as its Bucket-1 counterpart, then committed to the repo so the page renders identically if Gemini quota is exhausted or the user is offline. Replace any `.jpg` extension above with `.webp` to match the optimisation rule. Also commit these empty-state seeds (1024×1024 WebP, single-stroke hand-drawn line, no colour fill):
- `/public/samples/empty-state-primary.webp` — line drawing of the app's primary empty surface (the named "Empty-state illustration" above), generated from that exact prompt.
- `/public/samples/empty-state-archive.webp` — line drawing of an empty saved/archive view, single-stroke outline.
- `/public/samples/empty-state-error.webp` — line drawing of a hand placing a single object aside with care, used when an AI call fails.
**Bucket 3 — User-supplied.** Uploads from the user's camera / file picker land at the Firebase Storage path conventional for this template (named in section 4b). The build ships with Bucket-1 + Bucket-2 only; no user-supplied images at first paint.
**Hard rules**
- Every `` tag MUST have a `src` that resolves to a path listed in Bucket 1, Bucket 2, or a Bucket 3 upload path. Anything else is a build error.
- No bare `image.jpg` / `hero.jpg` / `placeholder.png` references anywhere in the code.
- Model IDs: `gemini-3-pro-image` for hero-quality photographic generation; `gemini-3.1-flash-image` for in-app illustrations, reference-conditioned variants, empty-state seeds, and stock fallbacks. Never use a legacy model id (no `imagen-*`, no `gemini-1.5-*-image`).
- File format: WebP everywhere (AVIF acceptable where the target browsers support it). No `.jpg` / `.jpeg` / `.png` in `/public/samples/`.
## 10. Interactivity & states
- Every interactive element has hover, focus, active, and disabled states.
- The "Done with a job" button is the single largest interactive element — minimum 88 × 88 px on touch, persistently bottom-pinned on mobile.
- Forms validate inline and show specific error messages (not "Invalid input").
- Loading states use skeletons that match the eventual layout, not spinners.
- Empty states explain the next action with a button whose label fits THIS app's domain: "Press Arriving when you pull up to the first job", "Record a 30s memo about your last job", "Press Friday at 16:00 to see your roll-up" — never a generic "Add your first item".
- Smooth scroll for in-page anchors.
- The parse "thinking" indicator streams the words the model is reading out of the audio as it processes ("hearing 'kitchen sink'…"), not a generic spinner.
- If a Gemini call fails (network or model error), show a calm, specific error ("I couldn't parse that memo — want to retry, or open it and fill in the fields by hand?") and offer retry.
- Low-confidence fields are faintly underlined; tapping reveals the alternates the model considered and the audio segment it was reading from.
- Offline-queued memos show a yellow "queued" chip on the home screen; on reconnect they parse in order and the chip turns green one by one.
- Multi-visit job stitching is reversible — the tradesperson can split a stitched job back into separate jobs from the job detail view.
## 11. Tech & responsive requirements
- **Deterministic arithmetic — server-side TypeScript only.** Any money-split / settlement / total / VAT / unit-conversion math runs in TypeScript on the server using integer-cents arithmetic. The Gemini call only extracts entities (line items, payer, currency code, raw quantities) into structured JSON. Never let the model do the sum — Gemini will return plausible-looking arithmetic that does not actually balance.
- **TTS markdown-stripping preprocessor:** before sending any user-authored markdown to `gemini-3.1-flash-tts-preview`, strip non-spoken markdown: `#`/`##`/`###` headings (keep the title text), `**bold**` (keep the inner text), `[label](url)` (keep `label`, drop URL), `` ``` `` fenced code blocks (skip entirely), `>` block-quote markers (keep the text), and `|` table pipes (read row-by-row as sentences). Insert `…` between sentences for a short pause and a blank line plus `—` between paragraphs for a long pause. The model does not understand markdown; raw markdown will be read aloud as literal characters ("asterisk asterisk").
- **File downloads on Safari / Firefox:** when offering local-disk save of any export (PDF, CSV, MP3, ZIP, JSON, image), fall back to `` with a blob URL — the File System Access API (`showSaveFilePicker()`) is Chromium-only. Detect with `'showSaveFilePicker' in window`; otherwise use the anchor-download path.
- **Stack:** React + TypeScript + Tailwind CSS. Functional components + hooks. Use Shadcn UI primitives where appropriate.
- **Build runtime:** AI Studio Build — full-stack with Cloud Run server-side functions. All Gemini API calls happen server-side; API key lives in Secrets Manager, never in client bundle.
- **Model selection:** explicitly pin `gemini-3.5-flash` for memo parse / translation / year-end consolidation, `gemini-3.5-flash` for customer resolution / geocoding / price-book proposal, `gemini-3.1-flash-tts-preview` for the Friday roll-up. Set `thinkingLevel` explicitly per call.
- **Audio capture:** MediaRecorder API → m4a (AAC) at 64 kbps mono. Memos are capped at 3 minutes (a typical job-memo is 20-60 seconds; over 3 minutes is almost certainly an unintentional record-left-on, and the app warns at 2:30).
- **iOS Safari gotchas (graceful degradation):** Safari `MediaRecorder` is already AAC-only — no Opus fallback needed; mic permission does NOT persist across page reloads on iOS — re-request on every memo and surface a one-line "tap to allow mic" hint; an incoming call interrupts the audio session (`MediaStreamTrack.onmute` fires) — auto-pause and let the tradie tap to resume; backgrounded Safari tabs throttle and pause `getUserMedia` — combine `visibilitychange` with a screen Wake Lock during active memos.
- **Offline-first:** memos and photos written to IndexedDB and queued. A Service Worker syncs on reconnect. The home screen shows queued items with an amber chip. Nothing is ever lost in a basement.
- **Database:** Firestore (auto-provisioned by AI Studio Build). Show the seed week on first launch.
- **Auth:** Firebase Auth — Google sign-in by default; phone-number sign-in beside it; Apple sign-in as third option for tradies on iOS.
- **Storage:** Firebase Storage for memos, photos, invoice PDFs. Pre-signed URLs only.
- **Mobile-first.** Verify layouts at 375 px (iPhone SE), 414 px (iPhone Pro), 768 px (iPad), 1024 px, 1440 px+. The phone is the primary device — desktop is for Friday admin.
- Use `clamp()` for fluid typography. Prefer container queries over media queries for component-level responsiveness.
- Use `dvh` / `svh` instead of `vh`. Respect safe-area insets on iOS (the "Done with a job" button sits above the home indicator bar).
- Zero horizontal overflow at any width. Zero layout shift on load.
- Persist user data in Firestore. Use real-time listeners on the home screen so when the parse finishes on the server, the job appears without a refresh.
- Optimistic UI on writes; reconcile on response.
- Memo upload uses a chunked-resumable pattern so a brief signal drop mid-upload doesn't kill the memo.
## 12. Accessibility (WCAG 2.2 AA)
- Semantic HTML — `header`, `nav`, `main`, `section`, `article`, `footer`.
- All interactive controls reachable by keyboard with a visible focus ring.
- Color contrast ≥ 4.5:1 for body, 3:1 for large text and UI components. The safety-amber "Done with a job" button meets contrast against both the dark and light themes.
- All images have meaningful `alt` text. The job-site photographs have `alt` describing the artefact ("before-state under-sink showing leak around P-trap").
- Form fields have associated `