# Operator App — Developer Integration Guide

**Application:** Tick Control, LLC — Operator Mobile App
**Module:** Operator PWA (`/operatorresponsive/` → deployed to `/op/`)
**Last Updated:** 2026-06-07

---

## Table of Contents

1. [Overview](#overview)
2. [File Manifest](#file-manifest)
3. [Architecture](#architecture)
4. [Page Flow](#page-flow)
5. [Feature Inventory](#feature-inventory)
6. [API Endpoints Required](#api-endpoints-required)
7. [Database Schema](#database-schema)
8. [localStorage Keys (Replace with API)](#localstorage-keys)
9. [External API Dependencies](#external-api-dependencies)
10. [Monthly Spray Progress System](#monthly-spray-progress-system)
11. [Job State Machine](#job-state-machine)
12. [Django Template Integration](#django-template-integration)
13. [Security Notes](#security-notes)
14. [How to Connect — Step by Step](#how-to-connect)
15. [Messaging System Architecture](#messaging-system-architecture)
16. [PWA Configuration](#pwa-configuration)
17. [Testing Notes](#testing-notes)

---

## Overview

The Operator App is an iPhone PWA used by field technicians (spray operators) during their daily routes. It handles the full shift lifecycle: login, clock-in, truck assignment, job-by-job execution (travel → start → stop → flowmeter → photo → complete), dispatch messaging, timeline tracking, gas logging, lunch breaks, inventory, and calendar history.

**Target device:** iPhone (standalone PWA via `apple-mobile-web-app-capable`). Also supports iPad and desktop for demo/testing.

**All data is currently in hardcoded demo arrays and `localStorage`.** Every data access point is marked with `// API CONNECTION POINT` or `// DATABASE CONNECTION POINT` comments. The async-ready architecture means swapping to `fetch()` calls requires changing the data source, not the rendering logic.

---

## File Manifest

| File | Purpose |
|------|---------|
| `login.html` / `js/login.js` | Login screen — user select + password, weather prefetch |
| `ci.html` / `js/ci.js` | Clock-in dashboard — shift clock, recognition engine, navigation tiles |
| `truck.html` / `js/truck.js` | Truck selection — pick truck, role, sprayer, tank level |
| `opschedule.html` / `js/opschedule.js` | Main schedule — route list, hero/Street View, all 13 modals, job state machine, monthly progress |
| `calendar.html` / `js/calendar.js` | Calendar — monthly schedule history, day detail view |
| `hours.html` / `js/hours.js` | Hours log — pay period breakdown, supervisor adjustments |
| `messages.html` / `js/messages.js` | Messaging — operator ↔ dispatch/office chat threads |
| `inventory.html` / `js/inventory.js` | Inventory — log supplies taken, request restocks |
| `maintenance.html` / `js/maintenance.js` | Truck maintenance — per-category logs (oil, tires, lights, etc.) |
| `js/shared-state.js` | Cross-page state — shift, truck, login, schedule-visited flag |
| `js/shared-ui.js` | Shared UI — footer, modal system, timeline, lunch state |
| `js/theme.js` | Theme toggle — light/dark mode persistence |
| `js/tailwind-config.js` | Tailwind theme customization — colors, fonts |
| `js/quotes.js` | Motivational quotes pool for recognition engine |
| `css/styles.css` | Custom CSS — job cards, timeline serpentine, modals, animations |
| `sw.js` | Service worker — offline caching for PWA |
| `manifest.json` | PWA manifest — app name, icons, display mode |

---

## Architecture

```
┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│  login.html  │──▶│   ci.html    │──▶│  truck.html  │
│  (auth)      │   │  (clock-in)  │   │  (truck pick)│
└──────────────┘   └──────────────┘   └──────┬───────┘
                                              │
                         ┌────────────────────▼────────────────────┐
                         │          opschedule.html                │
                         │  (main schedule — where operators live) │
                         └────────┬───────┬───────┬───────────────┘
                                  │       │       │
                   ┌──────────────▼┐  ┌───▼───┐  ┌▼──────────────┐
                   │ calendar.html │  │hours  │  │messages.html  │
                   │ inventory.html│  │.html  │  │maintenance    │
                   └───────────────┘  └───────┘  └───────────────┘

Shared across all pages:
  js/shared-state.js  — localStorage-backed state (→ session/API in production)
  js/shared-ui.js     — footer, modal overlay, timeline sync
  js/theme.js         — dark/light mode
```

**Key design principle:** Each page is a standalone HTML file. Cross-page state uses `localStorage` in the prototype. In Django, replace with session-backed API calls. Every `localStorage.getItem` / `setItem` is a candidate for a REST endpoint.

---

## Page Flow

```
Login → Clock In → Truck Selection → Schedule (main loop)
                                          │
                          ┌───────────────┼───────────────┐
                          ▼               ▼               ▼
                      Calendar        Messages        Inventory
                       Hours         Maintenance
```

**Guards:**
- `ci.html` requires `opLoginState` (redirects to `login.html`)
- `opschedule.html` requires shift status `ACTIVE` (redirects to `ci.html`)
- `truck.html` visited once per shift; subsequent Schedule presses go direct to `opschedule.html`

---

## Geofence System (Clock-In/Out Location Restriction)

Operators can only clock in and out from a designated address (e.g., the truck yard). The geofence center and radius are configured by an admin in a company settings page.

### How It Works
1. On page load and every 30 seconds, `ci.js` requests the device's GPS position
2. The haversine formula calculates distance from the operator to the geofence center
3. If the operator is within the radius, clock-in/out buttons are enabled
4. If outside, buttons are disabled and a red banner shows: "You must be at [address] to clock in/out. You are Xm away."
5. If GPS is denied or unavailable, the banner shows: "Location unavailable — enable GPS to clock in."

### Configuration API
```
GET /api/company/settings/
```
Returns:
```json
{
  "geofence": {
    "enabled": true,
    "lat": 41.1792,
    "lng": -73.2451,
    "radiusMeters": 150,
    "address": "275 Fleming Ln, Fairfield, CT"
  }
}
```

### Admin Settings Page (must be built)
The admin/owner needs a settings page where they can:
- **Enable/disable** geofencing
- **Set the address** — type an address, geocode it, then show on a Google Map with a draggable pin
- **Drag the pin to confirm** — geocoders can be off by miles on rural roads. The saved lat/lng must come from the pin's final dragged position, NOT the raw geocode result. This is critical for accuracy.
- **Set the radius** with a visual slider (50m–500m, default 150m) that draws a circle on the map so the admin can see the coverage area
- Save the final pin lat/lng + radius to the `company_settings` table

**Why drag-to-confirm is required:** Nominatim placed "1721 Huntington Tpke, Trumbull CT" 2 miles from the actual location during prototype testing. Even Google Geocoding can miss on long rural roads. The only reliable method is visual pin placement on a map.

### Database
```sql
CREATE TABLE company_settings (
    id SERIAL PRIMARY KEY,
    key VARCHAR(100) UNIQUE NOT NULL,
    value JSONB NOT NULL,
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Example row:
INSERT INTO company_settings (key, value) VALUES (
    'geofence',
    '{"enabled": true, "lat": 41.1792, "lng": -73.2451, "radiusMeters": 150, "address": "275 Fleming Ln, Fairfield, CT"}'
);
```

### Production Integration
Replace the hardcoded `GEOFENCE_CONFIG` in `ci.js` with an API call:
```javascript
// On page load:
const settings = await fetch('/api/company/settings/').then(r => r.json());
GEOFENCE_CONFIG = settings.geofence;
_checkGeofence();
```

### Testing
Add `?geo=off` to the URL to bypass geofencing during development:
```
ci.html?geo=off
```

---

## Feature Inventory

### Login (`login.js`)
- User select dropdown with avatar + DOB data attributes
- Password validation (demo: "tick")
- Weather + location prefetch on login (cached for ci.html)
- Success animation → redirect to ci.html

### Clock-In Dashboard (`ci.js`)
- Live shift timer (hours:minutes, updates every second)
- Clock-in / clock-out buttons with state transitions
- Pay period cumulative hours
- Recognition engine — 12+ kudos categories (early arrival, perfect completion, customer reviews, milestones, etc.)
- Birthday detection with confetti animation
- Weather widget with Open-Meteo API
- Navigation tiles: Schedule, Calendar, Trucks, Messages, Inventory, On Shift
- Day boundary auto-reset at 6:00 AM
- Dev Reset + Logout buttons
- **On clock-in, clears all previous shift data** (opJobState, opTimeline, opTodayRoute, etc.)

### Truck Selection (`truck.js`)
- Truck grid with occupancy indicators
- Role selection (Driver / Passenger)
- Mode selection (Solo / 2-Man)
- Sprayer assignment with swap capability
- Tank level selection (Full → 1/4)

### Operator Schedule (`opschedule.js`) — **Main page, most complex**
- **Monthly spray progress** — Spray Day X of Y, jobs left, spray days left, rain count, pace indicator
- **Route list view** — sorted: rush jobs → active → upcoming → completed at bottom
- **Hero/Street View** — Google Maps panorama with info card overlay
- **Job state machine** — planned → en_route → started → stopped → flowmeter_done → photo_done → completed/unable
- **Return to Job** — tap unable jobs to queue return after current job
- **Priority/Rush Job** — new jobs appear yellow, "Go Now" pauses current schedule
- **13 modals**: completion, unable, flowmeter, camera, text customer, dispatch messages, send office msg, gas log, timeline, operator profile (3D license flip), tick identifier, product label, help, search (today + all dates with inline detail view)
- **Search** — Today tab (activate jobs) + All Dates tab (view service history with detail cards, back to results)
- **Dispatch messages** — real-time demo with notification sound + bell pulse
- **Timeline** — serpentine SVG visualization of day's activity
- **Lunch break** — start/end with graphic overlay
- **Swipe navigation** — left/right with iframe preview panels

### Calendar (`calendar.js`)
- Monthly grid with job counts per day
- Color-coded: completed (green), scheduled (amber), today (ring)
- Day detail view with job list, operator assignments
- Past jobs show completion data; future jobs show schedule only

### Hours (`hours.js`)
- Current and historical pay period views
- Daily breakdown: clock-in, clock-out, lunch, total hours
- Supervisor adjustment notifications
- Weekly total calculation

### Messages (`messages.js`)
- Contact list with online/offline status
- Chat thread UI (iMessage-style bubbles)
- Web Speech API voice dictation
- Hour adjustment auto-messages

### Inventory (`inventory.js`)
- Log supplies taken (gloves, masks with quantity)
- Request restock items
- Recent log and pending requests views

### Maintenance (`maintenance.js`)
- Per-truck maintenance categories (oil, tires, lights, brakes, etc.)
- Log entries per category with date, mileage, notes
- Employee dropdown for who performed work

---

## API Endpoints Required

### Authentication

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| POST | `/api/auth/login/` | Authenticate operator | `{ username, password }` | Session cookie + `{ userId, name, avatar, dob }` |

### Shift Management

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/operator/me/shift/` | Current shift status | — | `{ status, clockInTs?, clockOutTs? }` |
| POST | `/api/operator/me/clock-in/` | Start shift | — | `{ status: 'ACTIVE', clockInTs }` |
| POST | `/api/operator/me/clock-out/` | End shift | — | `{ status: 'COMPLETED', clockOutTs, durationMs }` |
| GET | `/api/operator/me/pay-period/` | Cumulative hours | `?period=current` or `?period=2026-04-07..2026-04-13` | `[{ date, clockIn, clockOut, hours, lunch }]` |
| GET | `/api/operator/me/adjustments/` | Supervisor hour adjustments | — | `[{ date, amount, note, from, timestamp }]` |

### Recognition Engine

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/operator/me/recognition/` | Today's recognition message | — | `{ icon, message }` or `null` |

### Truck Assignment

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/operator/trucks/` | List trucks with occupants + sprayer claims | — | `[{ id, name, capacity, occupants, sprayers }]` |
| GET | `/api/operator/me/truck-assignment/` | Current truck assignment | — | `{ truckId, truckName, role, sprayerId, tankLevel }` |
| POST | `/api/operator/me/truck-assignment/` | Save truck selection | `{ truck_id, role, mode, sprayer_id, tank_level }` | Confirmed assignment |
| POST | `/api/operator/sprayer-swap/` | Swap sprayers between operators | `{ truck_id, swapped_with_id }` | — |

### Monthly Spray Progress

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/operator/me/month-progress/` | Monthly spray day stats | — | `{ sprayDay, sprayDaysTotal, sprayDaysPassed, sprayDaysLeft, rainDays, monthJobsTotal, jobsDone, jobsLeft }` |

**Business logic (must be server-side):**
- Spray days = weekdays Mon–Sat in current month, minus holidays, minus rain days logged by dispatch
- Rain days are logged by dispatch/admin when a day is called off for weather
- `monthJobsTotal` = total customer services scheduled for the current month
- `jobsDone` = services marked completed this month
- Season runs April through October
- Operators must complete all monthly jobs — each missed service = lost monthly revenue
- Pace calculation: `jobsLeft / sprayDaysLeft` vs average rate so far → Ahead / On Pace / Behind

### Route / Jobs

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/operator/me/route/today/` | Today's job list | — | `[{ id, sequence, town, address, name, status, notes, supervisor, phone }]` |
| GET | `/api/operator/me/route/today/count/` | Job count for tiles | — | `{ total, completed, remaining }` |
| POST | `/api/operator/me/jobs/{id}/activate/` | Mark job en_route | — | — |
| POST | `/api/operator/me/jobs/{id}/start/` | Mark job started + capture weather | — | `{ startTime, weather: { temp, windSpeed, windDir, precipitation } }` |
| POST | `/api/operator/me/jobs/{id}/stop/` | Mark job stopped | — | — |
| POST | `/api/operator/me/jobs/{id}/flowmeter/` | Save flowmeter readings | `{ tank1_reading, tank1_operator, tank2_reading?, tank2_operator? }` | — |
| POST | `/api/operator/me/jobs/{id}/photo/` | Upload service photo | Multipart file | `{ photoUrl }` |
| POST | `/api/operator/me/jobs/{id}/complete/` | Complete with tags + note | `{ tags: [...], note }` | — |
| POST | `/api/operator/me/jobs/{id}/unable/` | Unable to complete | `{ reasons: [...], note }` | — |
| POST | `/api/operator/me/jobs/{id}/return/` | Queue return to unable job | — | — |
| POST | `/api/operator/me/route/today/add/` | Add rush/priority job | (dispatch-initiated via WebSocket) | `{ job }` |
| GET | `/api/jobs/?search=` | Search all jobs across dates | `?search=<query>` | `[{ name, address, town, phone, date, status, operator?, duration?, notes? }]` |

### Dispatch / Messaging

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| POST | `/api/operator/me/text-customer/` | Send text to customer | `{ job_id, message }` | — |
| POST | `/api/operator/me/office-message/` | Send message to dispatch | `{ text, truck }` | — |
| GET | `/api/operator/me/contacts/` | Contact list | — | `[{ id, name, role, avatar, online }]` |
| GET | `/api/operator/me/messages/` | All message threads | `?contact=<id>` | `[{ from, text, ts }]` |
| POST | `/api/operator/me/messages/` | Send a message | `{ to, text }` | — |
| WS | `/ws/operator/dispatch/` | Real-time dispatch messages | — | Push: `{ from, text, ts }` |

### Lunch / Gas

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| POST | `/api/operator/me/lunch/start/` | Begin lunch | — | — |
| POST | `/api/operator/me/lunch/end/` | End lunch | — | — |
| POST | `/api/operator/me/gas-log/` | Log fuel expense | `{ mileage, diesel_gallons, diesel_price, regular_gallons, regular_price }` | — |

### Calendar / Schedule History

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/operator/me/schedule/` | Monthly schedule | `?month=2026-06` | `{ 'YYYY-MM-DD': [{ seq, name, address, phone, truck, status, operator, license }] }` |

### Inventory

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| POST | `/api/inventory/log/` | Log supplies taken | `{ item, qty, employee, date, time }` | — |
| POST | `/api/inventory/request/` | Request restock | `{ itemId, item, employee, date, time }` | — |
| GET | `/api/inventory/log/recent/` | Recent log entries | — | `[{ item, qty, employee, date, time }]` |
| GET | `/api/inventory/requests/` | Pending requests | — | `[{ item, employee, date, status }]` |

### Truck Maintenance

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/trucks/` | Full fleet with maintenance data | — | `{ truckId: { id, name, year, make, mileage, categories... } }` |
| GET | `/api/trucks/{id}/maintenance/{category}/` | Maintenance records | — | Records array |
| POST | `/api/trucks/{id}/maintenance/{category}/` | Log maintenance | Varies by category | — |
| GET | `/api/employees/` | Employee list for dropdowns | — | `['Name1', 'Name2']` |

### Weather

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| GET | `/api/weather/` | Server-cached weather proxy | `?lat=<lat>&lon=<lon>` | `{ temp, code, city }` |

### Theme

| Method | Endpoint | Purpose | Request | Response |
|--------|----------|---------|---------|----------|
| POST | `/api/operator/me/theme/` | Save preference | `{ theme: 'dark'\|'light' }` | — |

---

## Database Schema

### Suggested Tables

```sql
-- Operators (extends Django User)
CREATE TABLE operator_profiles (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES auth_user(id) UNIQUE,
    avatar VARCHAR(255),
    dob DATE,
    license_number VARCHAR(50),
    license_front_image VARCHAR(255),
    license_back_image VARCHAR(255),
    hire_date DATE,
    theme VARCHAR(10) DEFAULT 'light',
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Shifts
CREATE TABLE shifts (
    id SERIAL PRIMARY KEY,
    operator_id INTEGER REFERENCES operator_profiles(id),
    clock_in_ts TIMESTAMP NOT NULL,
    clock_out_ts TIMESTAMP,
    duration_ms BIGINT,
    status VARCHAR(20) DEFAULT 'ACTIVE',  -- ACTIVE, COMPLETED
    created_at TIMESTAMP DEFAULT NOW()
);

-- Shift Adjustments (supervisor edits)
CREATE TABLE shift_adjustments (
    id SERIAL PRIMARY KEY,
    shift_id INTEGER REFERENCES shifts(id),
    adjusted_by INTEGER REFERENCES auth_user(id),
    amount_minutes INTEGER NOT NULL,
    note TEXT,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Trucks
CREATE TABLE trucks (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,            -- 'Truck 1', 'Truck 2'
    year INTEGER,
    make VARCHAR(100),
    capacity INTEGER DEFAULT 2,
    current_mileage INTEGER,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Truck Assignments (per shift)
CREATE TABLE truck_assignments (
    id SERIAL PRIMARY KEY,
    shift_id INTEGER REFERENCES shifts(id),
    truck_id INTEGER REFERENCES trucks(id),
    operator_id INTEGER REFERENCES operator_profiles(id),
    role VARCHAR(20),                     -- 'driver', 'passenger'
    mode VARCHAR(20),                     -- 'solo', '2man'
    sprayer_id VARCHAR(50),
    tank_level VARCHAR(20),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Monthly Schedules (jobs assigned to operators)
CREATE TABLE scheduled_jobs (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id),
    scheduled_date DATE NOT NULL,
    sequence INTEGER,                     -- route order for the day
    truck_id INTEGER REFERENCES trucks(id),
    operator_id INTEGER REFERENCES operator_profiles(id),
    status VARCHAR(20) DEFAULT 'planned', -- planned, en_route, started, stopped, flowmeter_done, photo_done, completed, unable
    is_rush BOOLEAN DEFAULT FALSE,
    notes TEXT,
    supervisor_note TEXT,
    start_time TIMESTAMP,
    end_time TIMESTAMP,
    flowmeter1_reading DECIMAL(10,2),
    flowmeter1_operator VARCHAR(100),
    flowmeter2_reading DECIMAL(10,2),
    flowmeter2_operator VARCHAR(100),
    photo_url VARCHAR(255),
    completion_tags TEXT[],               -- ARRAY of tag strings
    completion_note TEXT,
    unable_reasons TEXT[],
    unable_note TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Rain Days (logged by dispatch/admin)
CREATE TABLE rain_days (
    id SERIAL PRIMARY KEY,
    date DATE NOT NULL UNIQUE,
    logged_by INTEGER REFERENCES auth_user(id),
    note TEXT,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Holidays
CREATE TABLE holidays (
    id SERIAL PRIMARY KEY,
    date DATE NOT NULL UNIQUE,
    name VARCHAR(100),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Timeline Events
CREATE TABLE timeline_events (
    id SERIAL PRIMARY KEY,
    shift_id INTEGER REFERENCES shifts(id),
    operator_id INTEGER REFERENCES operator_profiles(id),
    event_type VARCHAR(30),               -- clock-in, job, job-completed, job-unable, lunch, gas, text
    event_title VARCHAR(255),
    event_details TEXT,
    job_id INTEGER REFERENCES scheduled_jobs(id),
    start_time TIMESTAMP,
    end_time TIMESTAMP,
    content TEXT,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Dispatch Messages
CREATE TABLE dispatch_messages (
    id SERIAL PRIMARY KEY,
    from_user_id INTEGER REFERENCES auth_user(id),
    to_truck_id INTEGER REFERENCES trucks(id),
    text TEXT NOT NULL,
    read BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Customer Text Messages
CREATE TABLE customer_texts (
    id SERIAL PRIMARY KEY,
    job_id INTEGER REFERENCES scheduled_jobs(id),
    operator_id INTEGER REFERENCES operator_profiles(id),
    message TEXT NOT NULL,
    sent_at TIMESTAMP DEFAULT NOW()
);

-- Gas Logs
CREATE TABLE gas_logs (
    id SERIAL PRIMARY KEY,
    shift_id INTEGER REFERENCES shifts(id),
    operator_id INTEGER REFERENCES operator_profiles(id),
    truck_id INTEGER REFERENCES trucks(id),
    mileage INTEGER,
    diesel_gallons DECIMAL(6,2),
    diesel_price DECIMAL(6,2),
    regular_gallons DECIMAL(6,2),
    regular_price DECIMAL(6,2),
    total DECIMAL(8,2),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Lunch Breaks
CREATE TABLE lunch_breaks (
    id SERIAL PRIMARY KEY,
    shift_id INTEGER REFERENCES shifts(id),
    operator_id INTEGER REFERENCES operator_profiles(id),
    start_time TIMESTAMP NOT NULL,
    end_time TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Inventory Log
CREATE TABLE inventory_log (
    id SERIAL PRIMARY KEY,
    item VARCHAR(100) NOT NULL,
    quantity INTEGER DEFAULT 1,
    employee VARCHAR(100),
    logged_at TIMESTAMP DEFAULT NOW()
);

-- Inventory Requests
CREATE TABLE inventory_requests (
    id SERIAL PRIMARY KEY,
    item_id VARCHAR(50),
    item_name VARCHAR(100) NOT NULL,
    employee VARCHAR(100),
    status VARCHAR(20) DEFAULT 'pending',  -- pending, fulfilled, cancelled
    requested_at TIMESTAMP DEFAULT NOW(),
    fulfilled_at TIMESTAMP
);

-- Truck Maintenance
CREATE TABLE truck_maintenance (
    id SERIAL PRIMARY KEY,
    truck_id INTEGER REFERENCES trucks(id),
    category VARCHAR(50) NOT NULL,         -- oil, tires, lights, brakes, wipers, battery, inspection, registration
    performed_by VARCHAR(100),
    mileage INTEGER,
    date DATE,
    notes TEXT,
    next_due_date DATE,
    next_due_mileage INTEGER,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Recognition / Kudos
CREATE TABLE operator_kudos (
    id SERIAL PRIMARY KEY,
    operator_id INTEGER REFERENCES operator_profiles(id),
    from_user_id INTEGER REFERENCES auth_user(id),
    message TEXT NOT NULL,
    delivered BOOLEAN DEFAULT FALSE,
    delivered_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW()
);
```

### Key Relationships
- `shifts` → `operator_profiles` (one operator, many shifts)
- `truck_assignments` → `shifts` + `trucks` (one per shift)
- `scheduled_jobs` → `customers` + `trucks` + `operator_profiles` (many jobs per day)
- `timeline_events` → `shifts` + `scheduled_jobs` (log of all activity)
- `dispatch_messages` → trucks (messages go to a truck, not individual)
- `rain_days` → affects `month-progress` API calculation

---

## localStorage Keys (Replace with API)

Every key below is a prototype stand-in for a server-side data source. In Django, eliminate all `localStorage` usage.

| Key | Replace With | What It Stores |
|-----|-------------|----------------|
| `opLoginState` | Django session | `{ userId, name, fullName, avatar, dob, loggedInAt }` |
| `opShiftState` | `GET /api/operator/me/shift/` | `{ status, clockInTs?, clockOutTs? }` |
| `opSelectedTruck` | `GET /api/operator/me/truck-assignment/` | `{ truckId, truckName, role, sprayerId, tankLevel, partnerName }` |
| `opSchedVisited` | Session flag | Boolean — has user visited schedule this shift |
| `opShiftHistory` | `GET /api/operator/me/pay-period/` | `[{ clockInTs, clockOutTs, durationMs }]` |
| `opJobState` | `GET /api/operator/me/route/today/` | `{ completedIds, activeJobId, viewMode, items, returnJobId, pausedJobId }` |
| `opTodayRoute` | Same as above | `[{ sequence, town, address, name, status }]` |
| `opTimeline` | `GET /api/operator/me/timeline/` | `[{ time, event, details, type, ts }]` |
| `opDispatchMessages` | `GET /api/operator/me/messages/` | `[{ from, text, ts, truck }]` |
| `opLunchState` | Server-tracked via lunch API | `{ isOnLunch, startTime }` |
| `opWeather` | `GET /api/weather/` | `{ temp, code, city, ts }` |
| `opWeatherCoords` | Server-side geocoding | `{ lat, lon, city }` |
| `opTheme` | `GET /api/operator/me/theme/` | `'light'` or `'dark'` |
| `opRecognitionCache` | `GET /api/operator/me/recognition/` | `{ date, icon, message }` |
| `opDeliveredKudos_<userId>` | DB `delivered` flag on kudos table | Array of delivered kudos IDs |
| `opDeliveredQuotes_<userId>` | DB `delivered` flag on quotes table | Array of delivered quote IDs |
| `opInventoryLog` | `GET /api/inventory/log/recent/` | `[{ item, qty, employee, date }]` |
| `opInventoryRequests` | `GET /api/inventory/requests/` | `[{ itemId, item, employee, date, status }]` |
| `opMessages` | `GET /api/operator/me/messages/` | `{ chatId: [{ from, text, ts }] }` |
| `opMntNavState` | Session/URL state | `{ view, catId, truckId }` |

---

## External API Dependencies

| API | Used In | Purpose | Production Notes |
|-----|---------|---------|-----------------|
| **Google Maps JavaScript API** | `opschedule.js` | Street View panoramas, geocoding, heading calculation | API key must be server-managed; restrict to domain |
| **Google Maps Geometry Library** | `opschedule.js` | `computeHeading()` for Street View camera angle | Loaded with Maps API |
| **Open-Meteo API** | `login.js`, `ci.js` | Weather data (temp, conditions) | Proxy through Django, cache server-side (rate limits) |
| **Nominatim (OpenStreetMap)** | `login.js`, `ci.js` | Reverse geocoding for city name from GPS | Rate-limited; must proxy through backend |
| **Web Speech API** | `messages.js` | Voice dictation for message input | Browser-native, no backend needed |
| **Lucide Icons** | All pages | Icon system (CDN) | Bundle in production build |
| **Google Fonts (Inter)** | All pages | Typography (CDN) | Self-host in production |

---

## Monthly Spray Progress System

This is a **critical business metric**, not a vanity number. It drives daily operator behavior.

### Business Rules
- Tick Control sprays every customer property once per month (April–October season)
- Customers pay monthly — a missed month = lost revenue (cannot charge)
- Operators need to know at a glance: am I ahead or behind?

### Calculation (server-side in production)
```
sprayDaysTotal = count(Mon–Sat in current month) - holidays - rain_days
sprayDaysPassed = count(spray days from 1st to today)
sprayDaysLeft = sprayDaysTotal - sprayDaysPassed
jobsLeft = monthJobsTotal - jobsDone
pace = (jobsLeft / sprayDaysLeft) vs (jobsDone / sprayDaysPassed)
```

### What reduces spray days
- **Sundays** — always off
- **Holidays** — federal + CT state holidays (stored in `holidays` table)
- **Rain days** — logged by dispatch/admin when a day is called off (stored in `rain_days` table)

### Display
- **"Spray Day X of Y"** — where X = days used, Y = total available
- **"N jobs left"** — remaining services this month
- **"N spray days left"** — remaining work days
- **"N rain"** — rain days lost (amber, signals squeeze)
- **Pace indicator** — "Ahead" (green), "On pace" (green), "Behind" (amber)

### Typical reality
- ~20 usable spray days per month
- End-of-month squeeze is common: rain eats days, same jobs remain
- Operators use this to decide: push hard today or knock off early

---

## Job State Machine

```
planned → en_route → started → stopped → flowmeter_done → photo_done → completed
                                                                      ↘ unable
                                                                         ↓
                                                                    (return to job)
                                                                         ↓
                                                              en_route → ... → completed
```

### Special flows
- **Return to Job**: An `unable` job can be tapped → "Return to Job" modal → queued as `returnJobId`. After current job completes, system auto-activates the return job (resets to `en_route`, clears flowmeter/photo).
- **Rush/Priority Job**: New job arrives with `isRush: true` (via dispatch WebSocket). Shows yellow in list. "Go Now" pauses current job (`pausedJobId` + `pausedJobStatus` saved), activates rush job. On rush completion, resumes paused job.

---

## Django Template Integration

Every HTML file contains `<!-- DJANGO: ... -->` comments marking integration points. Key patterns:

### Static Files
```html
<!-- DJANGO: Replace with {% static 'operator/js/opschedule.js' %} -->
<script src="js/opschedule.js"></script>
```

### URL Tags
```html
<!-- DJANGO: Replace with {% url 'operator_schedule' %} -->
<a href="opschedule.html">Schedule</a>
```

### CSRF Tokens
All POST requests need Django's CSRF token:
```javascript
// DJANGO: Add X-CSRFToken header to fetch() calls
fetch('/api/operator/me/jobs/' + id + '/complete/', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': getCookie('csrftoken'),
    },
    body: JSON.stringify({ tags, note }),
});
```

### Auth Guard
```javascript
// DJANGO: Django @login_required + check shift.status == 'ACTIVE' in the view
if (!SharedState.requireShiftActive('ci.html')) return;
```
Replace with Django's `@login_required` decorator and a server-side shift status check.

---

## Security Notes

### API Keys
- **Google Maps API key** is hardcoded in `opschedule.html` line ~200 for the prototype demo. In production:
  - Move the key to a Django settings/environment variable
  - Restrict the key by HTTP referrer in Google Cloud Console
  - Consider server-side geocoding for address lookups
  - Street View requests should be proxied if possible

### Authentication
- The prototype uses a hardcoded demo password (`'tick'`) in `login.js`. In production, **remove this entirely** — Django handles authentication via session cookies and `@login_required` decorators.
- All `SharedState.requireLogin()` / `requireShiftActive()` guards must be replaced with Django view-level auth checks.

### CSRF Protection
- Every `POST`, `PUT`, `DELETE` request must include Django's CSRF token:
```javascript
fetch('/api/endpoint/', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '',
    },
    body: JSON.stringify(data),
});
```

### Data Sanitization
- All user-generated content (message text, completion notes, unable reasons, custom notes) must be sanitized server-side before storage.
- The prototype uses `textContent` for display (safe from XSS) but some `innerHTML` calls exist for structured content — audit these when integrating.

---

## How to Connect — Step by Step

This section tells you exactly how to replace the prototype's demo data with real API calls.

### Step 1: Authentication (`login.js`)
**Current:** Checks password against hardcoded `'tick'` string, stores user data in localStorage.
**Replace with:**
```javascript
// In doLogin():
const res = await fetch('/api/auth/login/', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrf() },
    body: JSON.stringify({ username: user, password: pass }),
});
if (!res.ok) { showError(); return; }
const profile = await res.json();
// Django session cookie handles auth from here
SharedState.setLoginState(profile);
```

### Step 2: Shift Management (`ci.js`)
**Current:** `SharedState.getShiftState()` reads from localStorage.
**Replace with:**
```javascript
// On page load:
const shift = await fetch('/api/operator/me/shift/').then(r => r.json());

// Clock in:
await fetch('/api/operator/me/clock-in/', { method: 'POST', headers: {...} });

// Clock out:
await fetch('/api/operator/me/clock-out/', { method: 'POST', headers: {...} });
```

### Step 3: Truck Assignment (`truck.js`)
**Current:** Hardcoded `TRUCKS` array with 3 trucks.
**Replace with:**
```javascript
const trucks = await fetch('/api/operator/trucks/').then(r => r.json());
// On selection:
await fetch('/api/operator/me/truck-assignment/', {
    method: 'POST',
    body: JSON.stringify({ truck_id, role, mode, sprayer_id, tank_level }),
});
```

### Step 4: Today's Route (`opschedule.js`)
**Current:** Hardcoded `JOBS` array with 3 demo jobs.
**Replace with:**
```javascript
const jobs = await fetch('/api/operator/me/route/today/').then(r => r.json());
// Each job action:
await fetch(`/api/operator/me/jobs/${id}/activate/`, { method: 'POST' });
await fetch(`/api/operator/me/jobs/${id}/start/`, { method: 'POST' });
await fetch(`/api/operator/me/jobs/${id}/stop/`, { method: 'POST' });
await fetch(`/api/operator/me/jobs/${id}/flowmeter/`, { method: 'POST', body: JSON.stringify({...}) });
await fetch(`/api/operator/me/jobs/${id}/photo/`, { method: 'POST', body: formData }); // multipart
await fetch(`/api/operator/me/jobs/${id}/complete/`, { method: 'POST', body: JSON.stringify({ tags, note }) });
await fetch(`/api/operator/me/jobs/${id}/unable/`, { method: 'POST', body: JSON.stringify({ reasons, note }) });
```

### Step 5: Monthly Spray Progress (`opschedule.js`)
**Current:** `_calcMonthProgress()` calculates from hardcoded rain days and job counts.
**Replace with:**
```javascript
const progress = await fetch('/api/operator/me/month-progress/').then(r => r.json());
// Returns: { sprayDay, sprayDaysTotal, sprayDaysLeft, rainDays, jobsDone, jobsLeft, monthJobsTotal }
```
**Server-side logic required:**
- Count Mon–Sat work days in current month, minus holidays (`holidays` table), minus rain days (`rain_days` table)
- `jobsDone` = `SELECT COUNT(*) FROM scheduled_jobs WHERE status='completed' AND scheduled_date BETWEEN month_start AND month_end`
- `jobsLeft` = total monthly jobs minus completed
- Rain days are logged by dispatch/admin via `POST /api/admin/rain-day/`

### Step 6: Search (`opschedule.js` + `shared-ui.js`)
**Current:** `ALL_JOBS_DATA` hardcoded array (duplicated in both files).
**Replace with:**
```javascript
const results = await fetch(`/api/jobs/?search=${encodeURIComponent(query)}`).then(r => r.json());
// Returns: [{ name, address, town, phone, date, status, operator?, duration?, notes? }]
```
**Note:** The search data is duplicated in `opschedule.js` (line ~57) and `shared-ui.js` (line ~405). In production, both should call the same API endpoint. Consider extracting to a shared data module.

### Step 7: Messaging (`messages.js`)
**Current:** Hardcoded message arrays, auto-reply system for demo.
**Replace with:**
```javascript
// Contacts:
const contacts = await fetch('/api/operator/me/contacts/').then(r => r.json());
// Messages per chat:
const msgs = await fetch(`/api/operator/me/messages/?contact=${chatId}`).then(r => r.json());
// Send:
await fetch('/api/operator/me/messages/', { method: 'POST', body: JSON.stringify({ to, text }) });
// Real-time: WebSocket at /ws/operator/dispatch/ for dispatch messages
```

### Step 8: Recognition Engine (`ci.js`)
**Current:** `recognitionData` object with hardcoded per-user data, `QUOTE_POOL` from `quotes.js`.
**Replace with:**
```javascript
const recognition = await fetch('/api/operator/me/recognition/').then(r => r.json());
// Returns: { icon: 'heart', message: '...' } or null
// Server computes the highest-priority signal from: kudos, reviews, streaks, anniversaries, quotes
```

### Step 9: Weather (`ci.js`, `login.js`)
**Current:** Direct calls to `api.open-meteo.com` and `nominatim.openstreetmap.org`.
**Replace with:**
```javascript
const weather = await fetch(`/api/weather/?lat=${lat}&lon=${lon}`).then(r => r.json());
// Server proxies and caches Open-Meteo and Nominatim (both are rate-limited)
```

### Step 10: Maintenance (`maintenance.js`)
**Current:** `TRUCKS` object with hardcoded maintenance records per truck.
**Replace with:**
```javascript
const fleet = await fetch('/api/trucks/').then(r => r.json());
const records = await fetch(`/api/trucks/${truckId}/maintenance/${category}/`).then(r => r.json());
// Log new entry:
await fetch(`/api/trucks/${truckId}/maintenance/${category}/`, { method: 'POST', body: JSON.stringify({...}) });
```

### Step 11: Inventory (`inventory.js`)
**Current:** localStorage-backed arrays.
**Replace with:**
```javascript
const log = await fetch('/api/inventory/log/recent/').then(r => r.json());
const requests = await fetch('/api/inventory/requests/').then(r => r.json());
await fetch('/api/inventory/log/', { method: 'POST', body: JSON.stringify({ item, qty, employee }) });
await fetch('/api/inventory/request/', { method: 'POST', body: JSON.stringify({ item, employee }) });
```

### Step 12: Hours (`hours.js`)
**Current:** Reads from `opShiftHistory` localStorage key.
**Replace with:**
```javascript
const hours = await fetch('/api/operator/me/hours/?period=current').then(r => r.json());
// Returns: [{ date, clockIn, clockOut, hours, lunch }]
const adjustments = await fetch('/api/operator/me/adjustments/').then(r => r.json());
// Returns: [{ date, amount, note, from, timestamp }]
```

### Step 13: Calendar (`calendar.js`)
**Current:** `scheduleData` hardcoded object keyed by date.
**Replace with:**
```javascript
const schedule = await fetch(`/api/operator/me/schedule/?month=2026-06`).then(r => r.json());
// Returns: { 'YYYY-MM-DD': [{ seq, name, address, phone, truck, status, operator, license }] }
```

### Finding All Connection Points
Every place in the code where demo data needs to be replaced is marked with one of these comments:
```
// API CONNECTION POINT
// DATABASE CONNECTION POINT
// DJANGO:
```
Run this command to find them all:
```bash
grep -rn "API CONNECTION POINT\|DATABASE CONNECTION POINT\|DJANGO:" js/ --include="*.js"
```

---

## Messaging System Architecture

The messaging system has three distinct channel types. The backend must enforce these access rules.

### 1:1 Private Chat
- Only two people can see the conversation
- API: `GET /api/operator/me/messages/?contact=<userId>`
- Each user has their own thread with every other user

### Dispatch Chat (Per-Truck Private)
- Private conversation between a specific truck's crew and dispatch staff
- **Truck 1's dispatch thread is invisible to Truck 2**
- Dispatch staff can see ALL truck threads (one per truck)
- Each truck only sees their own thread
- API: `GET /api/messages/dispatch/<truckId>/`
- Real-time: WebSocket push from dispatch → truck

### Headquarters (HQ) Group Chat
- Company-wide group chat — everyone logged in can read and write
- All messages visible to all users
- API: `GET /api/messages/hq/`
- Used for announcements, company-wide updates

### Database Model
```sql
CREATE TABLE messages (
    id SERIAL PRIMARY KEY,
    channel_type VARCHAR(20) NOT NULL,  -- 'direct', 'dispatch', 'hq'
    channel_id VARCHAR(50),             -- NULL for hq, truckId for dispatch, sorted-user-pair for direct
    from_user_id INTEGER REFERENCES auth_user(id),
    text TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Index for fast channel lookups
CREATE INDEX idx_messages_channel ON messages(channel_type, channel_id, created_at);
```

---

## PWA Configuration

- **Manifest:** `manifest.json` — standalone display, portrait orientation
- **Service Worker:** `sw.js` — cache-first strategy for offline support
- **iOS Meta Tags:** `apple-mobile-web-app-capable`, `black-translucent` status bar
- **Known iOS limitation:** Swipe-back shows stale screenshot during animation (platform bug, no workaround without SPA)
- **Cache busting:** Script tags use `?v=N` parameters; increment on deploy

---

## Testing Notes

### Critical User Flows
1. Login → Clock In → Select Truck → Schedule loads with fresh jobs
2. Activate job → Start → Stop → Flowmeter → Photo → Complete → next job auto-activates or return job activates
3. Mark job Unable → tap it → Return to Job → complete after current job
4. Rush job arrives → Go Now → complete → resumes paused job
5. Search All Dates → view detail → Back to Results → view another
6. Clock Out → Logout → Login as different user → all state reset
7. Close app mid-shift → reopen → shift still running → 6am boundary auto-resets

### Edge Cases
- Clock in, close app, reopen next day after 6am → shift should auto-reset
- Logout clears ALL localStorage keys (opJobState, opTimeline, etc.)
- Rush job arrives while in hero view (mid-job) → visible on return to list
- Return job + rush job both queued → rush takes priority, then return activates
- All jobs completed → "All Done" card shown
- Rain days reduce spray days remaining → pace indicator shifts to "Behind"

### Browser Compatibility
- Primary: Safari iOS (iPhone PWA)
- Secondary: Safari iPad, Chrome desktop
- Google Maps Street View requires API key with proper billing + referrer restrictions
