Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,8 @@ __pycache__
.coverage
htmlcov

# Frontend build artifacts and deps — rebuilt inside image
frontend/node_modules
frontend/dist
frontend/.vite
frontend/src.bak

# Archived React frontend — never built
archive
node_modules

# Editors / OS
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,6 @@ analytics/.Rhistory
# Backups
backup/

# Archived old React frontend (Path A) — kept on disk for reference, never built
archive/

18 changes: 2 additions & 16 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
# Multi-stage build: frontend (node) + backend (python) in one image.
# FastAPI serves the built SPA via StaticFiles. No separate nginx needed.
# Single-stage python runtime. FastAPI serves the HTMX UI via Jinja2
# templates + StaticFiles (no node, no npm, no SPA bundle).

# ── Stage 1: build frontend ──────────────────────────────────────────
FROM node:20-alpine AS frontend
WORKDIR /app/frontend

COPY frontend/package*.json ./
RUN npm ci

COPY frontend/ ./
RUN npm run build

# ── Stage 2: python runtime ──────────────────────────────────────────
FROM python:3.11-slim

WORKDIR /app
Expand All @@ -31,9 +20,6 @@ COPY backend/ ./backend/
COPY alembic/ ./alembic/
COPY alembic.ini ./

# Built SPA from stage 1
COPY --from=frontend /app/frontend/dist ./frontend/dist

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

Expand Down
76 changes: 31 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
> ⚠️ **WIP — full-Postgres rewrite branch (Path C).** This branch re-implements FieldOpt as *Postgres-as-platform*: PL/pgSQL functions serve the HTMX fragments and JSON via PostgREST, replacing the FastAPI backend. **Ported & verified:** the render surface (all fragments + map JSON), the assign/unassign write path, and the sim clock + controls (19 endpoints, matched against the FastAPI reference). **In progress:** the sim engine (auto-route + dispatch loop), the `/pg/` browser shell, and packaging (Docker + raw flavors). The docs below still describe the current FastAPI (Path B) app; they'll be rewritten when the rewrite lands.

## Overview
FieldOpt - v0.0.8<br>
FieldOpt - v0.0.9<br>
An open-source field service management system. FieldOpt is an enterprise-grade dispatch console designed for dispatchers and field service companies to efficiently assign, route, and manage service jobs across a workforce of field technicians.

<table align="center">
Expand Down Expand Up @@ -45,61 +47,30 @@ An open-source field service management system. FieldOpt is an enterprise-grade

### Requirements

- Python 3.11+
- pip and npm
- Docker or PostgreSQL 15+
- Docker + Docker Compose

### Run

#### Backend

```bash
cd fieldopt
pip install -r requirements.txt

# Start PostgreSQL
docker compose up -d postgres

# Start the API
python -m uvicorn backend.api.main:app --reload
make up # regular (no demo, sim endpoints 404)
make demo # IS_DEMO=true, simulation engine active
make down # stop containers (DB volume kept)
make clean # stop + wipe DB volume (fresh seed next run)
make logs # tail app logs
```

#### Frontend

```bash
cd fieldopt/frontend
npm install
npm run dev
```
Single-container build (multi-stage `Dockerfile`: node → python). Compose runs `postgres` + `app` on port 8080.

#### Access

| Service | URL |
|---------|-----|
| Frontend | http://localhost:5173 |
| API | http://localhost:8000 |
| Swagger Docs | http://localhost:8000/docs |
| ReDoc | http://localhost:8000/redoc |

#### Seed & Reset
| Dispatch Console | http://localhost:8080/ |
| API | http://localhost:8080/api/v1 |
| Swagger Docs | http://localhost:8080/docs |
| ReDoc | http://localhost:8080/redoc |

```bash
# Seed the database with sample data
python -m backend.database.seeds.seed_data

# Reset database (drop all tables + reseed)
python -m backend.database.reset_db

# Reset database (empty, no seed data)
python -m backend.database.reset_db --empty
```

#### Environment

```bash
cp .env.example .env
# Edit .env — defaults work for development
```
`IS_DEMO=true` (set by `make demo`) enables the simulation engine and a daily 04:00 UTC reseed. Without it, `/simulation/*` returns 404 and the SimBar stays hidden.

## Routing
### Routing Modes
Expand Down Expand Up @@ -172,7 +143,22 @@ If any check fails, the dispatcher receives a warning with details but can overr

## Change Log

### 0.0.8 (Latest)
### 0.0.9 (Latest)
HTMX is now the only frontend. React archived. Tighter chrome + slot-aware sim.
- **HTMX at root** — server-rendered Jinja2 dashboard mounted at `/`. All `/htmx/*` paths gone; fragments live under root. Vendored htmx / idiomorph / leaflet (no npm).
- **React archived** — old SPA moved to `archive/frontend/` (gitignored, never built, never served). Dockerfile collapsed to single-stage python. Smaller image, no Vite/npm in build, no t3.micro OOM risk.
- **Pre-route on demo start** — `Start Demo` reseeds and immediately auto-routes every today's job before the dispatch loop kicks off. Demo plays a fully-routed day.
- **Sequential, slot-aware dispatch** — loop dispatches one job per tech per tick, ordered by `time_slot_start`. Tech idles at base (AVAILABLE) until `depart = slot_start − travel`, then EN_ROUTE → ON_JOB → AVAILABLE. Arrivals land inside the customer window; days fill the full shift instead of finishing by 10am.
- **Override-modal flash fixed** — manual fetch handler parses the response and moves `#modal-host` + toasts into place properly (htmx OOB swaps don't fire on raw fetches).
- **Column resize** — vanilla JS injects `<colgroup>` per `.grid`, adds 6px resize handles on each `<th>`, persists widths to localStorage by table key. Re-attaches on every `htmx:afterSwap`.
- **CSS port** — `app.css` is a full lift of the React design tokens + components, adapted to HTMX class names. Mobile `@media (max-width: 768px)` makes floating windows + map fullscreen.
- **SVG icons** — header buttons use the React-source SVGs (filter, personnel, search, settings, timeline, map, refresh, auto-route bolt). Bigger touch targets (34×34 icon, 30px min-height regular).
- **Thicker splitters** — 9px with centered grip bar, accent on hover/drag. Jobs↔Timeline splitter hidden when the timeline pane is collapsed.
- **Grid horizontal scroll** — `.grid { min-width: 720px }` so narrow panes get a horizontal scrollbar instead of column-shrunk-to-nothing.
- **Hidden filter selects** — Jobs pane dropped its inline status/type/tech/route bar; dash-cell clicks drive a single hidden `[name='status']` input, Filter window covers multi-select.
- **Cache-bust** — `app.css?v=<mtime>` query param so CSS edits land without manual hard-refresh after rebuild.

### 0.0.8
ML routing + investor-ready demo day
- **Single-container Docker** — multi-stage build (node → python). FastAPI serves the SPA via `StaticFiles`; no separate nginx. One command brings up Postgres + app: `make up` (or `make demo` to enable the simulation). Browse `http://localhost:8080`.
- **MLStrategy** — default dispatch. Scores `(job, tech)` by `travel_time + what_if_duration(job, tech)` using hidden `speed_factor` / `skill_bonuses` modifiers. Two-pass routing: prefers techs who can arrive within the customer window, falls back to best-available so jobs never sit unrouted. `HeuristicStrategy` kept for A/B.
Expand Down
28 changes: 0 additions & 28 deletions backend/.env.example

This file was deleted.

35 changes: 8 additions & 27 deletions backend/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from backend.config import get_settings
from backend.database.connection import init_db
from backend.api.routes import technicians, jobs, assignments, routing, simulation
from backend.api.routes import technicians, jobs, assignments, routing, simulation, htmx

logger = logging.getLogger(__name__)
settings = get_settings()
Expand Down Expand Up @@ -42,8 +41,7 @@ async def _daily_reseed_loop() -> None:
except Exception:
logger.exception("Daily reseed failed")

FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
SERVE_FRONTEND = FRONTEND_DIST.is_dir() and (FRONTEND_DIST / "index.html").is_file()
# React SPA (Path A) is archived. HTMX UI is the only frontend.


@asynccontextmanager
Expand Down Expand Up @@ -84,17 +82,6 @@ async def lifespan(app: FastAPI):
)


if not SERVE_FRONTEND:
@app.get("/")
async def root():
return {
"message": f"Welcome to {settings.APP_NAME} API",
"version": settings.APP_VERSION,
"docs": "/docs",
"status": "operational",
}


@app.get("/health")
async def health_check():
return {
Expand Down Expand Up @@ -134,19 +121,13 @@ async def health_check():
tags=["Simulation"],
)

# HTMX UI — primary frontend, mounted at /. All dashboard fragments + sim
# controls live under root. /api/v1/* routers above win on prefix.
app.include_router(htmx.router, prefix="", tags=["HTMX"], include_in_schema=False)

# SPA fallback — must be registered AFTER all /api/* routers so they win on prefix.
# StaticFiles with html=True serves index.html at "/" and assets by path.
# A catch-all FileResponse handles client-side routes (e.g. /jobs/123 deep-links).
if SERVE_FRONTEND:
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")

@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str):
candidate = FRONTEND_DIST / full_path
if full_path and candidate.is_file():
return FileResponse(candidate)
return FileResponse(FRONTEND_DIST / "index.html")
_STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
if _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")


if __name__ == "__main__":
Expand Down
Loading