Docs / Admin
Field Mode capture on mobile
Summary: Field Mode is the mobile-optimized capture surface at /field, built for staff working events or site visits on a phone. This article documents the six capture screens that actually exist in the routes, how voice input and AI extraction work under each, which captures genuinely queue offline and which do not, and where the captured data appears once staff are back at a desktop.
Audience: Staff and above — same role gate as the desktop actions each Field Mode screen calls into (requireRole("staff")).
Prerequisites: None beyond a staff account. No separate Field Mode permission — it is the same auth as the desktop app, served through a mobile-first layout.
Overview
Key concepts:
/field— the Field Mode home page (src/app/(authenticated)/field/page.tsx), a grid of capture tiles.- Six routes, matching six tiles/entry points: Investor Intake, Opportunity, Meeting Log, Quick Note, Event Check-In, and Scan Business Card (the last is reached from within Investor Intake, not its own home-page tile).
useLocalDraft— auto-saves in-progress form state tolocalStorage(debounced, 24-hour expiry) so a form survives a tab close or app backgrounding.useOfflineSubmit— a real offline queue (IndexedDB viaidb-keyval) for two of the six capture types; the rest hard-block submission when offline.- Voice input — records audio client-side and transcribes it server-side via Mistral Voxtral (EU-resident), not the browser's built-in Web Speech API.
Screenshot:
/fieldhome page showing the five visible capture tiles (Investor Intake, Opportunity, Meeting Log, Quick Note, Event Check-In) in a 2-column grid with colored icon badges.
The six capture screens
1. Investor Intake (/field/investor-intake)
A multi-step wizard (steps.tsx, state.ts) collecting, in order: Step 1 — Contact (name, email, phone, company, role, optional business-card photo), Step 2 — Meeting (title, date, location, notes), Step 3 — Minutes (paste or upload transcribed text), Step 4 — Qualification (a yes/no flag plus investment timeline and interest).
On submit, submitInvestorIntake (src/app/(authenticated)/field/investor-intake/actions.ts, requires staff) does all of the following in one call:
- Validates and uploads the business-card image if attached — bounds the decoded size to 5MB, checks JPEG/PNG/WebP magic bytes, rejects anything else with
"Card image is not a recognized image type". Upload failures are non-blocking (contact is still created without the image). - Resolves the organization: uses
organizationIdif given, else looks upcompanyby exact case-insensitive name match, else creates a new organization withorgType: "private_sector". - Creates the contact (
createContact— dedupes; if a duplicate is detected, the existing contact is used instead of creating a new one). - Stores the card image key on the contact's
metadata.cardImageKeyvia a directjsonb_setupdate. - Creates a consultation of type
bilateral, immediately markedstatus: "completed"withendedAtset to the meeting date — i.e., intake logs a past meeting, not a scheduled future one. - Adds the new contact as an attendee on that consultation.
- Saves
meetingNotesand/orminutesTexteach as a separateminutes-type consultation note (bothapprovedimmediately — this path does not go through the AI-draft pending gate; see Minutes and AI approval for when that gate does apply). - If
isQualifiedis true, creates a pipeline opportunity titled"{company or name} — Investment Interest"withsource: "investor_intake", and links it to the consultation.
Revalidates /stakeholders, /pipeline, and /consultations on completion.
2. Scan Business Card (/field/scan-card)
Reached as a step inside Investor Intake's card capture, and also exists as its own route. Uses BusinessCardScanner, which is entirely client-side: the photo is taken via a hidden <input type="file" accept="image/*" capture="environment">, then OCR'd in-browser with tesseract.js (Tesseract.recognize(file, "eng", ...), with a live "Reading card… {progress}%" overlay). A deterministic regex then pulls email/phone from the OCR text and splits remaining lines into name/company guesses.
The extracted fields pre-fill a manual review form (name, email, phone, company, role, notes) — nothing is saved until the user submits that form, which calls createContactAction. This screen explicitly blocks submission while offline: if (!isOnline) { toast.error("You are offline. Please reconnect before saving."); return; } — it does not queue.
EU residency note (verified in code comments): the raw card image never leaves the browser. Only extracted text would ever be sent server-side (via parseBusinessCard, used by the Investor Intake wizard's own scan step, not this standalone page), specifically to avoid sending PII-carrying images to a non-EU vision model.
3. Opportunity (/field/opportunity)
A single form: title (required, with a voice-mic button), cluster (cluster_1/cluster_2/cluster_3), priority (low/medium/high), estimated value in USD, organization/contact pickers, notes (with voice input), and a file upload (PDF/DOCX/image) that runs OCR text extraction + AI structuring (extractMinutesFromFile → structureMinutes) and appends the result into the notes field.
Submits via createOpportunityAction directly — no offline queue. Also hard-blocks with the same "You are offline. Please reconnect before saving." toast via useConnectivity.
4. Meeting Log (/field/meeting)
Fields: title, who/contact, location, outcome/notes (required, voice-enabled), a "Follow-up needed" checkbox that conditionally reveals a follow-up notes field, organization/contact pickers, and the same PDF/DOCX/image extraction-to-notes upload as Opportunity.
Submits through logMeetingAction (log-meeting-action.ts, requires staff), which calls logEngagement from the stakeholders module with engagementType: "meeting" and a description built by joining outcome, Contact: {who}, and Location: {location} lines. This screen uses useOfflineSubmit — it genuinely queues when offline (see below).
5. Quick Note (/field/note, via NoteForm)
Fields: category (phone_call / site_visit / email_summary / general), note text (required, voice-enabled), organization/contact/opportunity pickers, and the same file-upload-to-extracted-notes flow.
Submits through logNoteAction (log-note-action.ts), which maps the UI category to an EngagementType via an explicit lookup table:
| UI category | EngagementType |
|---|---|
phone_call | call |
site_visit | site_visit |
email_summary | email |
general (or anything unmapped) | other |
Calls logEngagement with title: "Quick note: {category with underscores replaced by spaces}". Also uses useOfflineSubmit — genuinely queues offline.
6. Event Check-In (/field/checkin)
Renders the shared CheckInForm component (src/components/consultations/CheckInForm) — the same QR-scan/manual-ID check-in flow documented in Consultation attendees and agenda. No Field-Mode-specific logic; this tile is just a mobile-friendly entry point into the existing public check-in flow.
Voice input
VoiceMicButton appears next to most text fields across Investor Intake, Opportunity, Meeting Log, and Quick Note. It only renders if useVoiceInput().isSupported — true when MediaRecorder and navigator.mediaDevices.getUserMedia exist in the browser.
Flow (src/hooks/use-voice-input.ts):
- Requests microphone permission (
getUserMedia({ audio: true })) — on denial, shows"Microphone permission denied." - Records via
MediaRecorder, picking the first supported MIME type from["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"]. - On stop, encodes the recorded blob as base64 and calls
transcribeAudioAction(src/lib/platform/stt/actions). - On success, calls back with the transcribed text (inserted into the field, or appended for the Quick Note textarea specifically). On empty result:
"No speech detected."On failure:"Transcription failed. Please try again."
Per the code comment in use-voice-input.ts, this replaced the old Web Speech API implementation specifically because Web Speech only worked in Chrome/Safari and sent audio to Google — the current MediaRecorder + server-transcription approach uses Mistral Voxtral (EU-resident) and works across Chrome, Firefox, Edge, Android Chrome, and Safari 14.1+.
AI-assisted document extraction
Opportunity, Meeting Log, and Quick Note all offer a file-upload control (accept=".pdf,.docx,image/*,...") that:
- Reads the file into a base64 string client-side.
- Calls
extractMinutesFromFile(base64, mimeType)— server-side OCR viasrc/lib/platform/ocr. - Passes the extracted raw text into
structureMinutes(rawText), which truncates to 8,000 characters and callsextractStructuredfor a JSON shape (summary,actionItems,decisions,keyPoints), then formats it back into a readable text block (Summary: ...,Key Points:,Decisions:,Action Items:) that gets appended into the form's notes/outcome field. - On failure, falls back to the first 2,000 characters of the raw extracted text (or shows
"Could not extract text from file"if extraction itself fails).
This routes through the same shared Mistral → Gemini → Claude Haiku provider chain documented in Minutes and AI approval — Mistral (EU) gets the full prompt, Gemini/Haiku fallbacks get a PII-redacted one.
Offline behavior — verified, not assumed
Field Mode's offline story is not uniform across all six screens. Only two capture types use the real offline queue:
| Screen | Offline mechanism | What happens offline |
|---|---|---|
| Meeting Log | useOfflineSubmit (real queue) | Enqueues to IndexedDB, shows "Saved offline. Will sync when online.", navigates back to /field |
| Quick Note | useOfflineSubmit (real queue) | Same as above |
| Investor Intake | Registered offline handler exists (handlers-init.ts) but the wizard's own submit path was not confirmed to call useOfflineSubmit in the files read for this article — verify whether the wizard's final-step submit button routes through useOfflineSubmit or calls submitInvestorIntake directly | |
| Opportunity | useConnectivity hard block | Shows "You are offline. Please reconnect before saving." and refuses to submit — no queueing |
| Scan Business Card | useConnectivity hard block | Same hard-block toast — no queueing |
| Event Check-In | Not evaluated in this pass (shared CheckInForm) — see check-in idempotency for the underlying action's own offline assumptions |
How the real offline queue works
src/lib/offline/queue.ts stores items in an IndexedDB store (idb-keyval, database "eccp-offline", store "queue") with id, type ("note" | "meeting" | "investor-intake"), payload, createdAt, attempts, and lastError.
useOfflineSubmit({ type, submit }):
- If
navigator.onLine === falseat submit time, enqueues immediately and returns{ queued: true }without ever callingsubmit. - Otherwise calls
submit(payload)directly; if that throws a network-shaped error (isNetworkError—navigator.onLine === falseor aTypeErrormatching/fetch|network|load failed/i), it falls back to enqueueing rather than surfacing the error.
Draining the queue: OfflineBanner (rendered in the Field layout, src/components/mobile/field-header + src/components/offline/offline-banner.tsx) listens for the browser's online event and calls drainQueue(), which replays each queued item through its registered handler (handlers-init.ts registers note → logNoteAction, meeting → logMeetingAction, investor-intake → submitInvestorIntake) in FIFO order, removing each on success and incrementing attempts/lastError on failure. It shows toast summaries: "Synced {n} offline capture(s)" and/or "{n} capture(s) failed to sync". The banner also polls countQueue() every 5 seconds and shows a persistent bar ("Offline · {n} pending" while offline, "{n} capture(s) pending sync" once back online but not yet drained).
Local drafts (distinct from the offline queue)
Independently of network status, every capture form uses useLocalDraft(key, initialValue) to auto-save form field values to localStorage under field-draft-{key}, debounced 500ms, expiring after 24 hours. This survives closing the browser tab or backgrounding the PWA — it is not a submission mechanism, just a draft-recovery mechanism (e.g., Scan Business Card shows "Resume saved draft" / "Draft auto-saved" based on hasDraft).
Where captures surface on desktop
- Meeting Log and Quick Note both call
logEngagement, writing to the stakeholders module's engagement history — visible on the contact/organization detail page (src/app/(authenticated)/stakeholders/[id]/page.tsx) alongside engagements logged from the desktop UI. There is no Field-Mode-specific engagement view; it is the same list. - Investor Intake creates a completed consultation, an attendee record, one or two
minutesnotes, and optionally a pipeline opportunity — all visible on the standard consultation detail page and pipeline board, exactly as if entered from desktop. - Opportunity creates a standard pipeline opportunity record, visible on
/pipelinelike any other. - Scan Business Card creates a standard contact record (visible under
/stakeholders), with the card image key stored inmetadata.cardImageKeyif uploaded successfully.
Roles and permissions
| Action | Minimum role |
|---|---|
| All six Field Mode capture screens (server actions behind them) | staff (requireRole("staff"), or requireRole("staff", { module: ... }) where the underlying module scopes it) |
There is no distinct "field" role or permission — Field Mode is a UI surface over the same staff-gated actions used elsewhere.
Best practices
- Use Meeting Log or Quick Note (not Opportunity) when connectivity is uncertain. Only these two genuinely queue offline; the others will block you with a "reconnect" toast.
- Don't rely on drafts as a submission guarantee.
useLocalDraftonly protects in-progress typing from being lost — it does not submit anything. You still have to hit Save while online (or use one of the two offline-queue-backed screens). - Upload documents before typing manual notes if you have both — the extraction result is appended to whatever's already in the notes field, so uploading first avoids reordering your own typed context awkwardly.
Warnings
Investor Intake's offline behavior is not confirmed end-to-end in this review. A handler is registered for it (registerHandler("investor-intake", ...) in handlers-init.ts), implying the intent is for it to be offline-capable, but the wizard's actual submit call path was not traced far enough to confirm it uses useOfflineSubmit rather than calling submitInvestorIntake directly. Treat this as unverified until confirmed — don't assume a multi-step intake survives a dropped connection at the final step.
Opportunity and Scan Business Card have no offline fallback. If connectivity drops mid-form, your only options are to wait for reconnection or manually copy your notes elsewhere before navigating away (which would also clear the local draft... no, drafts persist independently — navigating away is safe, only Save is blocked).
Troubleshooting
"You are offline. Please reconnect before saving."
Symptom: Toast appears when tapping Save on Opportunity or Scan Business Card.
Cause: These two screens use a hard connectivity check (useConnectivity), not the offline queue.
Fix: Wait for a connection, or switch to Meeting Log / Quick Note if the content fits one of those forms and offline capture is essential right now.
Voice mic button doesn't appear
Symptom: No microphone icon next to a text field.
Cause: useVoiceInput().isSupported is false — the browser lacks MediaRecorder or getUserMedia support.
Fix: Use a supported browser (Chrome, Firefox, Edge, Android Chrome, or Safari 14.1+) or type the field manually.
"Microphone permission denied."
Cause: The browser's mic permission prompt was declined.
Fix: Re-enable microphone access for the site in browser/OS settings and try again.
"No speech detected." / "Transcription failed. Please try again."
Cause: Either the recording captured silence, or the server-side Mistral Voxtral transcription call failed (network issue, provider error).
Fix: Retry recording closer to the microphone; if it persists, type the field manually and continue.
"Could not extract text from file"
Cause: OCR/text extraction (extractMinutesFromFile) failed on the uploaded PDF/DOCX/image — e.g., unreadable scan, corrupt file, or unsupported encoding.
Fix: Try a clearer scan or a different file format, or type the notes manually.
FAQ
Q: Can I capture a business card scan while offline? No — Scan Business Card hard-blocks submission when offline. The camera/OCR step itself works offline (Tesseract runs in-browser), but the final "Save Contact" call requires connectivity.
Q: Does Quick Note require picking a contact or organization?
No — both are optional pickers. Only category (defaults to "general" if unselected) and the note text (required) are needed.
Q: What happens if I submit a Meeting Log and then immediately close the tab before it syncs?
If it queued (offline), the item is already in IndexedDB and will sync on the next online event in any tab that loads OfflineBanner (the Field layout). If it was submitted successfully online, it's already saved server-side.
Q: Is there a desktop equivalent of Field Mode?
No — Field Mode is a set of dedicated mobile-first routes under /field. The same underlying actions (logEngagement, createOpportunity, createConsultation, etc.) are also called from the full desktop staff workbench forms, but there is no single "desktop Field Mode" page.
Related articles
- Investor matching — what happens to a qualified opportunity created from Investor Intake
- Organizations and contacts — the contact/organization records these captures write to
- Consultation attendees and agenda — the check-in flow Event Check-In reuses
- Minutes and AI approval — the shared AI provider chain used for document/voice extraction