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 to localStorage (debounced, 24-hour expiry) so a form survives a tab close or app backgrounding.
  • useOfflineSubmit — a real offline queue (IndexedDB via idb-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: /field home 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:

  1. 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).
  2. Resolves the organization: uses organizationId if given, else looks up company by exact case-insensitive name match, else creates a new organization with orgType: "private_sector".
  3. Creates the contact (createContact — dedupes; if a duplicate is detected, the existing contact is used instead of creating a new one).
  4. Stores the card image key on the contact's metadata.cardImageKey via a direct jsonb_set update.
  5. Creates a consultation of type bilateral, immediately marked status: "completed" with endedAt set to the meeting date — i.e., intake logs a past meeting, not a scheduled future one.
  6. Adds the new contact as an attendee on that consultation.
  7. Saves meetingNotes and/or minutesText each as a separate minutes-type consultation note (both approved immediately — this path does not go through the AI-draft pending gate; see Minutes and AI approval for when that gate does apply).
  8. If isQualified is true, creates a pipeline opportunity titled "{company or name} — Investment Interest" with source: "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 (extractMinutesFromFilestructureMinutes) 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 categoryEngagementType
phone_callcall
site_visitsite_visit
email_summaryemail
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):

  1. Requests microphone permission (getUserMedia({ audio: true })) — on denial, shows "Microphone permission denied."
  2. Records via MediaRecorder, picking the first supported MIME type from ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"].
  3. On stop, encodes the recorded blob as base64 and calls transcribeAudioAction (src/lib/platform/stt/actions).
  4. 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:

  1. Reads the file into a base64 string client-side.
  2. Calls extractMinutesFromFile(base64, mimeType) — server-side OCR via src/lib/platform/ocr.
  3. Passes the extracted raw text into structureMinutes(rawText), which truncates to 8,000 characters and calls extractStructured for 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.
  4. 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:

ScreenOffline mechanismWhat happens offline
Meeting LoguseOfflineSubmit (real queue)Enqueues to IndexedDB, shows "Saved offline. Will sync when online.", navigates back to /field
Quick NoteuseOfflineSubmit (real queue)Same as above
Investor IntakeRegistered 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
OpportunityuseConnectivity hard blockShows "You are offline. Please reconnect before saving." and refuses to submit — no queueing
Scan Business CarduseConnectivity hard blockSame hard-block toast — no queueing
Event Check-InNot 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 === false at submit time, enqueues immediately and returns { queued: true } without ever calling submit.
  • Otherwise calls submit(payload) directly; if that throws a network-shaped error (isNetworkErrornavigator.onLine === false or a TypeError matching /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 notelogNoteAction, meetinglogMeetingAction, investor-intakesubmitInvestorIntake) 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 minutes notes, 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 /pipeline like any other.
  • Scan Business Card creates a standard contact record (visible under /stakeholders), with the card image key stored in metadata.cardImageKey if uploaded successfully.

Roles and permissions

ActionMinimum 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

  1. 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.
  2. Don't rely on drafts as a submission guarantee. useLocalDraft only 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).
  3. 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