diff --git a/README.md b/README.md index bb47f1c..543b549 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -# Seismo Fleet Manager v0.3.0 - +# Seismo Fleet Manager v0.3.2 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features @@ -14,6 +13,7 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog - **Photo Management**: Upload and view photos for each unit - **Interactive Maps**: Leaflet-based maps showing unit locations - **SQLite Storage**: Lightweight, file-based database for easy deployment +- **PWA Mobile Version**: Optimized for ease of use during remote deployment operations. ## Roster Manager & Settings diff --git a/backend/main.py b/backend/main.py index d707063..45839e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,8 +3,10 @@ from fastapi import FastAPI, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from sqlalchemy.orm import Session +from typing import List, Dict +from pydantic import BaseModel from backend.database import engine, Base, get_db from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs @@ -18,7 +20,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.3.0" +VERSION = "0.3.1" app = FastAPI( title="Seismo Fleet Manager", description="Backend API for managing seismograph fleet status", @@ -104,6 +106,99 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request}) +# ===== PWA ROUTES ===== + +@app.get("/sw.js") +async def service_worker(): + """Serve service worker with proper headers for PWA""" + return FileResponse( + "backend/static/sw.js", + media_type="application/javascript", + headers={ + "Service-Worker-Allowed": "/", + "Cache-Control": "no-cache" + } + ) + + +@app.get("/offline-db.js") +async def offline_db_script(): + """Serve offline database script""" + return FileResponse( + "backend/static/offline-db.js", + media_type="application/javascript", + headers={"Cache-Control": "no-cache"} + ) + + +# Pydantic model for sync edits +class EditItem(BaseModel): + id: int + unitId: str + changes: Dict + timestamp: int + + +class SyncEditsRequest(BaseModel): + edits: List[EditItem] + + +@app.post("/api/sync-edits") +async def sync_edits(request: SyncEditsRequest, db: Session = Depends(get_db)): + """Process offline edit queue and sync to database""" + from backend.models import RosterUnit + + results = [] + synced_ids = [] + + for edit in request.edits: + try: + # Find the unit + unit = db.query(RosterUnit).filter_by(id=edit.unitId).first() + + if not unit: + results.append({ + "id": edit.id, + "status": "error", + "reason": f"Unit {edit.unitId} not found" + }) + continue + + # Apply changes + for key, value in edit.changes.items(): + if hasattr(unit, key): + # Handle boolean conversions + if key in ['deployed', 'retired']: + setattr(unit, key, value in ['true', True, 'True', '1', 1]) + else: + setattr(unit, key, value if value != '' else None) + + db.commit() + + results.append({ + "id": edit.id, + "status": "success" + }) + synced_ids.append(edit.id) + + except Exception as e: + db.rollback() + results.append({ + "id": edit.id, + "status": "error", + "reason": str(e) + }) + + synced_count = len(synced_ids) + + return JSONResponse({ + "synced": synced_count, + "total": len(request.edits), + "synced_ids": synced_ids, + "results": results + }) + + @app.get("/partials/roster-deployed", response_class=HTMLResponse) async def roster_deployed_partial(request: Request): """Partial template for deployed units tab""" @@ -114,12 +209,15 @@ async def roster_deployed_partial(request: Request): for unit_id, unit_data in snapshot["active"].items(): units_list.append({ "id": unit_id, - "status": unit_data["status"], - "age": unit_data["age"], - "last_seen": unit_data["last"], - "deployed": unit_data["deployed"], + "status": unit_data.get("status", "Unknown"), + "age": unit_data.get("age", "N/A"), + "last_seen": unit_data.get("last", "Never"), + "deployed": unit_data.get("deployed", False), "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), "last_calibrated": unit_data.get("last_calibrated"), "next_calibration_due": unit_data.get("next_calibration_due"), "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), @@ -149,12 +247,15 @@ async def roster_benched_partial(request: Request): for unit_id, unit_data in snapshot["benched"].items(): units_list.append({ "id": unit_id, - "status": unit_data["status"], - "age": unit_data["age"], - "last_seen": unit_data["last"], - "deployed": unit_data["deployed"], + "status": unit_data.get("status", "N/A"), + "age": unit_data.get("age", "N/A"), + "last_seen": unit_data.get("last", "Never"), + "deployed": unit_data.get("deployed", False), "note": unit_data.get("note", ""), "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), "last_calibrated": unit_data.get("last_calibrated"), "next_calibration_due": unit_data.get("next_calibration_due"), "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), diff --git a/backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md b/backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md new file mode 100644 index 0000000..a62823f --- /dev/null +++ b/backend/static/icons/ICON_GENERATION_INSTRUCTIONS.md @@ -0,0 +1,78 @@ +# PWA Icon Generation Instructions + +The PWA manifest requires 8 icon sizes for full compatibility across devices. + +## Required Icon Sizes + +- 72x72px +- 96x96px +- 128x128px +- 144x144px +- 152x152px +- 192x192px +- 384x384px +- 512x512px (maskable) + +## Design Guidelines + +**Background:** Navy blue (#142a66) +**Icon/Logo:** Orange (#f48b1c) +**Style:** Simple, recognizable design that works at small sizes + +## Quick Generation Methods + +### Option 1: Online PWA Icon Generator + +1. Visit: https://www.pwabuilder.com/imageGenerator +2. Upload a 512x512px source image +3. Download the generated icon pack +4. Copy PNG files to this directory + +### Option 2: ImageMagick (Command Line) + +If you have a 512x512px source image called `source-icon.png`: + +```bash +# From the icons directory +for size in 72 96 128 144 152 192 384 512; do + convert source-icon.png -resize ${size}x${size} icon-${size}.png +done +``` + +### Option 3: Photoshop/GIMP + +1. Create a 512x512px canvas +2. Add your design (navy background + orange icon) +3. Save/Export for each required size +4. Name files as: icon-72.png, icon-96.png, etc. + +## Temporary Placeholder + +For testing, you can use a simple colored square: + +```bash +# Generate simple colored placeholder icons +for size in 72 96 128 144 152 192 384 512; do + convert -size ${size}x${size} xc:#142a66 \ + -gravity center \ + -fill '#f48b1c' \ + -pointsize $((size / 2)) \ + -annotate +0+0 'SFM' \ + icon-${size}.png +done +``` + +## Verification + +After generating icons, verify: +- All 8 sizes exist in this directory +- Files are named exactly: icon-72.png, icon-96.png, etc. +- Images have transparent or navy background +- Logo/text is clearly visible at smallest size (72px) + +## Testing PWA Installation + +1. Open SFM in Chrome on Android or Safari on iOS +2. Look for "Install App" or "Add to Home Screen" prompt +3. Check that the correct icon appears in the install dialog +4. After installation, verify icon on home screen diff --git a/backend/static/icons/icon-128.png b/backend/static/icons/icon-128.png new file mode 100644 index 0000000..83af799 Binary files /dev/null and b/backend/static/icons/icon-128.png differ diff --git a/backend/static/icons/icon-128.png.svg b/backend/static/icons/icon-128.png.svg new file mode 100644 index 0000000..e812a63 --- /dev/null +++ b/backend/static/icons/icon-128.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-144.png b/backend/static/icons/icon-144.png new file mode 100644 index 0000000..d8d90b5 Binary files /dev/null and b/backend/static/icons/icon-144.png differ diff --git a/backend/static/icons/icon-144.png.svg b/backend/static/icons/icon-144.png.svg new file mode 100644 index 0000000..5de8b3a --- /dev/null +++ b/backend/static/icons/icon-144.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-152.png b/backend/static/icons/icon-152.png new file mode 100644 index 0000000..9ef75af Binary files /dev/null and b/backend/static/icons/icon-152.png differ diff --git a/backend/static/icons/icon-152.png.svg b/backend/static/icons/icon-152.png.svg new file mode 100644 index 0000000..a3f0850 --- /dev/null +++ b/backend/static/icons/icon-152.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-192.png b/backend/static/icons/icon-192.png new file mode 100644 index 0000000..3290b47 Binary files /dev/null and b/backend/static/icons/icon-192.png differ diff --git a/backend/static/icons/icon-192.png.svg b/backend/static/icons/icon-192.png.svg new file mode 100644 index 0000000..ef79877 --- /dev/null +++ b/backend/static/icons/icon-192.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-384.png b/backend/static/icons/icon-384.png new file mode 100644 index 0000000..2cf0aef Binary files /dev/null and b/backend/static/icons/icon-384.png differ diff --git a/backend/static/icons/icon-384.png.svg b/backend/static/icons/icon-384.png.svg new file mode 100644 index 0000000..f71f324 --- /dev/null +++ b/backend/static/icons/icon-384.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-512.png b/backend/static/icons/icon-512.png new file mode 100644 index 0000000..b2c82dd Binary files /dev/null and b/backend/static/icons/icon-512.png differ diff --git a/backend/static/icons/icon-512.png.svg b/backend/static/icons/icon-512.png.svg new file mode 100644 index 0000000..39b3068 --- /dev/null +++ b/backend/static/icons/icon-512.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-72.png b/backend/static/icons/icon-72.png new file mode 100644 index 0000000..d0d0359 Binary files /dev/null and b/backend/static/icons/icon-72.png differ diff --git a/backend/static/icons/icon-72.png.svg b/backend/static/icons/icon-72.png.svg new file mode 100644 index 0000000..5ebfd03 --- /dev/null +++ b/backend/static/icons/icon-72.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/icons/icon-96.png b/backend/static/icons/icon-96.png new file mode 100644 index 0000000..cbcff51 Binary files /dev/null and b/backend/static/icons/icon-96.png differ diff --git a/backend/static/icons/icon-96.png.svg b/backend/static/icons/icon-96.png.svg new file mode 100644 index 0000000..1217879 --- /dev/null +++ b/backend/static/icons/icon-96.png.svg @@ -0,0 +1,4 @@ + + + SFM + diff --git a/backend/static/manifest.json b/backend/static/manifest.json new file mode 100644 index 0000000..8d0c879 --- /dev/null +++ b/backend/static/manifest.json @@ -0,0 +1,78 @@ +{ + "name": "Seismo Fleet Manager", + "short_name": "SFM", + "description": "Real-time seismograph and modem fleet monitoring and management", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#142a66", + "theme_color": "#f48b1c", + "icons": [ + { + "src": "/static/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "/static/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/static/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "/static/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "/static/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/static/screenshots/dashboard.png", + "type": "image/png", + "sizes": "540x720", + "form_factor": "narrow" + } + ], + "categories": ["utilities", "productivity"], + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "View fleet status dashboard", + "url": "/", + "icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Fleet Roster", + "short_name": "Roster", + "description": "View and manage fleet roster", + "url": "/roster", + "icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/backend/static/mobile.css b/backend/static/mobile.css new file mode 100644 index 0000000..c8db026 --- /dev/null +++ b/backend/static/mobile.css @@ -0,0 +1,564 @@ +/* Mobile-specific styles for Seismo Fleet Manager */ +/* Touch-optimized, portrait-first design */ + +/* ===== MOBILE TOUCH TARGETS ===== */ +@media (max-width: 767px) { + /* Buttons - 44x44px minimum (iOS standard) */ + .btn, button:not(.tab-button), .button, a.button { + min-width: 44px; + min-height: 44px; + padding: 12px 16px; + } + + /* Icon-only buttons */ + .icon-button, .btn-icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + /* Form inputs - 48px height, 16px font prevents iOS zoom */ + input:not([type="checkbox"]):not([type="radio"]), + select, + textarea { + min-height: 48px; + font-size: 16px !important; + padding: 12px 16px; + } + + /* Checkboxes and radio buttons - larger touch targets */ + input[type="checkbox"], + input[type="radio"] { + width: 24px; + height: 24px; + min-height: 24px; + } + + /* Bottom nav buttons - 56px industry standard */ + .bottom-nav button { + min-height: 56px; + padding: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + } + + /* Increase spacing between clickable elements */ + .btn + .btn, + button + button { + margin-left: 8px; + } +} + +/* ===== HAMBURGER MENU ===== */ +.hamburger-btn { + position: fixed; + top: 1rem; + left: 1rem; + z-index: 50; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background-color: white; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + transition: all 0.2s; +} + +.hamburger-btn:active { + transform: scale(0.95); +} + +.dark .hamburger-btn { + background-color: #1e293b; + border-color: #374151; +} + +/* Hamburger icon */ +.hamburger-icon { + width: 24px; + height: 24px; + display: flex; + flex-direction: column; + justify-content: space-around; +} + +.hamburger-line { + width: 100%; + height: 2px; + background-color: #374151; + transition: all 0.3s; +} + +.dark .hamburger-line { + background-color: #e5e7eb; +} + +/* Hamburger animation when menu open */ +.menu-open .hamburger-line:nth-child(1) { + transform: translateY(8px) rotate(45deg); +} + +.menu-open .hamburger-line:nth-child(2) { + opacity: 0; +} + +.menu-open .hamburger-line:nth-child(3) { + transform: translateY(-8px) rotate(-45deg); +} + +/* ===== SIDEBAR (RESPONSIVE) ===== */ +.sidebar { + position: fixed; + left: 0; + top: 0; + width: 16rem; /* 256px */ + height: 100vh; + z-index: 40; + transition: transform 0.3s ease-in-out; +} + +@media (max-width: 767px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } +} + +@media (min-width: 768px) { + .sidebar { + transform: translateX(0) !important; + } +} + +/* ===== BACKDROP ===== */ +.backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 30; + opacity: 0; + transition: opacity 0.3s ease-in-out; + pointer-events: none; +} + +.backdrop.show { + opacity: 1; + pointer-events: auto; +} + +@media (min-width: 768px) { + .backdrop { + display: none; + } +} + +/* ===== BOTTOM NAVIGATION ===== */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 4rem; + background-color: white; + border-top: 1px solid #e5e7eb; + z-index: 20; + box-shadow: 0 -1px 3px 0 rgb(0 0 0 / 0.1); +} + +.dark .bottom-nav { + background-color: #1e293b; + border-top-color: #374151; +} + +.bottom-nav-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: #6b7280; + transition: all 0.2s; + border: none; + background: none; + cursor: pointer; + width: 100%; + height: 100%; +} + +.bottom-nav-btn:active { + transform: scale(0.95); + background-color: #f3f4f6; +} + +.dark .bottom-nav-btn:active { + background-color: #374151; +} + +.bottom-nav-btn.active { + color: #f48b1c; /* seismo-orange */ +} + +.bottom-nav-btn svg { + width: 24px; + height: 24px; +} + +.bottom-nav-btn span { + font-size: 11px; + font-weight: 500; +} + +@media (min-width: 768px) { + .bottom-nav { + display: none; + } +} + +/* ===== MAIN CONTENT ADJUSTMENTS ===== */ +.main-content { + margin-left: 0; + padding-bottom: 5rem; /* 80px for bottom nav */ + min-height: 100vh; +} + +@media (min-width: 768px) { + .main-content { + margin-left: 16rem; /* 256px sidebar width */ + padding-bottom: 0; + } +} + +/* ===== MOBILE ROSTER CARDS ===== */ +.unit-card { + background-color: white; + border-radius: 0.5rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + padding: 1rem; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + -webkit-tap-highlight-color: transparent; +} + +.unit-card:active { + transform: scale(0.98); +} + +.dark .unit-card { + background-color: #1e293b; +} + +.unit-card:hover { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); +} + +/* ===== UNIT DETAIL MODAL (BOTTOM SHEET) ===== */ +.unit-modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: flex-end; + justify-content: center; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease-in-out; +} + +.unit-modal.show { + pointer-events: auto; + opacity: 1; +} + +.unit-modal-backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); +} + +.unit-modal-content { + position: relative; + width: 100%; + max-height: 85vh; + background-color: white; + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1); + overflow-y: auto; + transform: translateY(100%); + transition: transform 0.3s ease-out; +} + +.unit-modal.show .unit-modal-content { + transform: translateY(0); +} + +.dark .unit-modal-content { + background-color: #1e293b; +} + +@media (min-width: 768px) { + .unit-modal { + align-items: center; + } + + .unit-modal-content { + max-width: 42rem; /* 672px */ + border-radius: 0.75rem; + transform: translateY(20px); + opacity: 0; + } + + .unit-modal.show .unit-modal-content { + transform: translateY(0); + opacity: 1; + } +} + +/* Modal handle bar (mobile only) */ +.modal-handle { + height: 4px; + width: 3rem; + background-color: #d1d5db; + border-radius: 9999px; + margin: 0.75rem auto 1rem; +} + +@media (min-width: 768px) { + .modal-handle { + display: none; + } +} + +/* ===== OFFLINE INDICATOR ===== */ +.offline-indicator { + position: fixed; + top: 0; + left: 0; + right: 0; + background-color: #eab308; /* yellow-500 */ + color: white; + text-align: center; + padding: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + z-index: 50; + transform: translateY(-100%); + transition: transform 0.3s ease-in-out; +} + +.offline-indicator.show { + transform: translateY(0); +} + +/* ===== SYNC TOAST ===== */ +.sync-toast { + position: fixed; + bottom: 6rem; /* Above bottom nav */ + left: 1rem; + right: 1rem; + background-color: #22c55e; /* green-500 */ + color: white; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + z-index: 50; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s, transform 0.3s; + pointer-events: none; +} + +.sync-toast.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +@media (min-width: 768px) { + .sync-toast { + bottom: 1rem; + left: auto; + right: 1rem; + max-width: 20rem; + } +} + +/* ===== MOBILE SEARCH BAR (STICKY) ===== */ +@media (max-width: 767px) { + .mobile-search-sticky { + position: sticky; + top: 0; + z-index: 10; + background-color: #f3f4f6; + margin: -1rem -1rem 1rem -1rem; + padding: 0.5rem 1rem; + } + + .dark .mobile-search-sticky { + background-color: #111827; + } +} + +@media (min-width: 768px) { + .mobile-search-sticky { + position: static; + background-color: transparent; + margin: 0; + padding: 0; + } +} + +/* ===== STATUS BADGES ===== */ +.status-dot { + width: 1rem; + height: 1rem; + border-radius: 9999px; + flex-shrink: 0; +} + +.status-badge { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +/* ===== DEVICE TYPE BADGES ===== */ +.device-badge { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + display: inline-block; +} + +/* ===== MOBILE MAP HEIGHT ===== */ +@media (max-width: 767px) { + #fleet-map { + height: 16rem !important; /* 256px on mobile */ + } + + #unit-map { + height: 16rem !important; /* 256px on mobile */ + } +} + +/* ===== PENDING SYNC BADGE ===== */ +.pending-sync-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + background-color: #fef3c7; /* amber-100 */ + color: #92400e; /* amber-800 */ + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.dark .pending-sync-badge { + background-color: #78350f; + color: #fef3c7; +} + +.pending-sync-badge::before { + content: "⏳"; + font-size: 0.875rem; +} + +/* ===== MOBILE-SPECIFIC UTILITY CLASSES ===== */ +@media (max-width: 767px) { + .mobile-text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .mobile-text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + + .mobile-p-4 { + padding: 1rem; + } + + .mobile-mb-4 { + margin-bottom: 1rem; + } +} + +/* ===== ACCESSIBILITY ===== */ +/* Improve focus visibility on mobile */ +@media (max-width: 767px) { + button:focus-visible, + a:focus-visible, + input:focus-visible, + select:focus-visible, + textarea:focus-visible { + outline: 2px solid #f48b1c; + outline-offset: 2px; + } +} + +/* Prevent text selection on buttons (better mobile UX) */ +button, +.btn, +.button { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* ===== SMOOTH SCROLLING ===== */ +html { + scroll-behavior: smooth; +} + +/* Prevent overscroll bounce on iOS */ +body { + overscroll-behavior-y: none; +} + +/* ===== LOADING STATES ===== */ +.loading-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* ===== SAFE AREA SUPPORT (iOS notch) ===== */ +@supports (padding: env(safe-area-inset-bottom)) { + .bottom-nav { + padding-bottom: env(safe-area-inset-bottom); + height: calc(4rem + env(safe-area-inset-bottom)); + } + + .main-content { + padding-bottom: calc(5rem + env(safe-area-inset-bottom)); + } + + @media (min-width: 768px) { + .main-content { + padding-bottom: 0; + } + } +} diff --git a/backend/static/mobile.js b/backend/static/mobile.js new file mode 100644 index 0000000..6575f95 --- /dev/null +++ b/backend/static/mobile.js @@ -0,0 +1,588 @@ +/* Mobile JavaScript for Seismo Fleet Manager */ +/* Handles hamburger menu, modals, offline sync, and mobile interactions */ + +// ===== GLOBAL STATE ===== +let currentUnitData = null; +let isOnline = navigator.onLine; + +// ===== HAMBURGER MENU TOGGLE ===== +function toggleMenu() { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('backdrop'); + const hamburgerBtn = document.getElementById('hamburgerBtn'); + + if (sidebar && backdrop) { + const isOpen = sidebar.classList.contains('open'); + + if (isOpen) { + // Close menu + sidebar.classList.remove('open'); + backdrop.classList.remove('show'); + hamburgerBtn?.classList.remove('menu-open'); + document.body.style.overflow = ''; + } else { + // Open menu + sidebar.classList.add('open'); + backdrop.classList.add('show'); + hamburgerBtn?.classList.add('menu-open'); + document.body.style.overflow = 'hidden'; + } + } +} + +// Close menu when clicking backdrop +function closeMenuFromBackdrop() { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('backdrop'); + const hamburgerBtn = document.getElementById('hamburgerBtn'); + + if (sidebar && backdrop) { + sidebar.classList.remove('open'); + backdrop.classList.remove('show'); + hamburgerBtn?.classList.remove('menu-open'); + document.body.style.overflow = ''; + } +} + +// Close menu when window is resized to desktop +function handleResize() { + if (window.innerWidth >= 768) { + const sidebar = document.getElementById('sidebar'); + const backdrop = document.getElementById('backdrop'); + const hamburgerBtn = document.getElementById('hamburgerBtn'); + + if (sidebar && backdrop) { + sidebar.classList.remove('open'); + backdrop.classList.remove('show'); + hamburgerBtn?.classList.remove('menu-open'); + document.body.style.overflow = ''; + } + } +} + +// ===== UNIT DETAIL MODAL ===== +function openUnitModal(unitId, status = null, age = null) { + const modal = document.getElementById('unitModal'); + if (!modal) return; + + // Store the status info passed from the card + // Accept status if it's a non-empty string, use age if provided or default to '--' + const cardStatusInfo = (status && status !== '') ? { + status: status, + age: age || '--' + } : null; + + console.log('openUnitModal:', { unitId, status, age, cardStatusInfo }); + + // Fetch unit data and populate modal + fetchUnitDetails(unitId).then(unit => { + if (unit) { + currentUnitData = unit; + // Pass the card status info to the populate function + populateUnitModal(unit, cardStatusInfo); + modal.classList.add('show'); + document.body.style.overflow = 'hidden'; + } + }); +} + +function closeUnitModal(event) { + // Only close if clicking backdrop or close button + if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) { + return; + } + + const modal = document.getElementById('unitModal'); + if (modal) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + currentUnitData = null; + } +} + +async function fetchUnitDetails(unitId) { + try { + // Try to fetch from network first + const response = await fetch(`/api/roster/${unitId}`); + if (response.ok) { + const unit = await response.json(); + + // Save to IndexedDB if offline support is available + if (window.offlineDB) { + await window.offlineDB.saveUnit(unit); + } + + return unit; + } + } catch (error) { + console.log('Network fetch failed, trying offline storage:', error); + + // Fall back to offline storage + if (window.offlineDB) { + return await window.offlineDB.getUnit(unitId); + } + } + + return null; +} + +function populateUnitModal(unit, cardStatusInfo = null) { + // Set unit ID in header + const modalUnitId = document.getElementById('modalUnitId'); + if (modalUnitId) { + modalUnitId.textContent = unit.id; + } + + // Populate modal content + const modalContent = document.getElementById('modalContent'); + if (!modalContent) return; + + // Use status from card if provided, otherwise get from snapshot or derive from unit + let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit); + console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo }); + + const statusColor = statusInfo.status === 'OK' ? 'green' : + statusInfo.status === 'Pending' ? 'yellow' : + statusInfo.status === 'Missing' ? 'red' : 'gray'; + + const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' : + statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' : + statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' : + 'text-gray-600 dark:text-gray-400'; + + // Determine status label (show "Benched" instead of "Unknown" for non-deployed units) + let statusLabel = statusInfo.status; + if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) { + statusLabel = 'Benched'; + } + + // Create navigation URL for location + const createNavUrl = (address, coordinates) => { + if (address) { + // Use address for navigation + const encodedAddress = encodeURIComponent(address); + // Universal link that works on iOS and Android + return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`; + } else if (coordinates) { + // Use coordinates for navigation (format: lat,lon) + const encodedCoords = encodeURIComponent(coordinates); + return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`; + } + return null; + }; + + const navUrl = createNavUrl(unit.address, unit.coordinates); + + modalContent.innerHTML = ` + +
+
+ + ${statusLabel} +
+ ${statusInfo.age || '--'} +
+ + +
+
+ +

${unit.device_type || '--'}

+
+ + ${unit.unit_type ? ` +
+ +

${unit.unit_type}

+
+ ` : ''} + + ${unit.project_id ? ` +
+ +

${unit.project_id}

+
+ ` : ''} + + ${unit.address ? ` +
+ + ${navUrl ? ` + + + + + + ${unit.address} + + ` : ` +

${unit.address}

+ `} +
+ ` : ''} + + ${unit.coordinates && !unit.address ? ` +
+ + ${navUrl ? ` + + + + + + ${unit.coordinates} + + ` : ` +

${unit.coordinates}

+ `} +
+ ` : ''} + + + ${unit.device_type === 'seismograph' ? ` + ${unit.last_calibrated ? ` +
+ +

${unit.last_calibrated}

+
+ ` : ''} + + ${unit.next_calibration_due ? ` +
+ +

${unit.next_calibration_due}

+
+ ` : ''} + + ${unit.deployed_with_modem_id ? ` +
+ +

${unit.deployed_with_modem_id}

+
+ ` : ''} + ` : ''} + + + ${unit.device_type === 'modem' ? ` + ${unit.ip_address ? ` +
+ +

${unit.ip_address}

+
+ ` : ''} + + ${unit.phone_number ? ` +
+ +

${unit.phone_number}

+
+ ` : ''} + + ${unit.hardware_model ? ` +
+ +

${unit.hardware_model}

+
+ ` : ''} + ` : ''} + + ${unit.note ? ` +
+ +

${unit.note}

+
+ ` : ''} + +
+
+ +

${unit.deployed ? 'Yes' : 'No'}

+
+
+ +

${unit.retired ? 'Yes' : 'No'}

+
+
+
+ `; + + // Update action buttons + const editBtn = document.getElementById('modalEditBtn'); + const deployBtn = document.getElementById('modalDeployBtn'); + const deleteBtn = document.getElementById('modalDeleteBtn'); + + if (editBtn) { + editBtn.onclick = () => { + window.location.href = `/unit/${unit.id}`; + }; + } + + if (deployBtn) { + deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit'; + deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed); + } + + if (deleteBtn) { + deleteBtn.onclick = () => deleteUnit(unit.id); + } +} + +function getUnitStatus(unitId, unit = null) { + // Try to get status from dashboard snapshot if it exists + if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) { + const unitStatus = window.lastStatusSnapshot.units[unitId]; + return { + status: unitStatus.status, + age: unitStatus.age, + last: unitStatus.last + }; + } + + // Fallback: if unit data is provided, derive status from deployment state + if (unit) { + if (unit.deployed) { + // For deployed units without status data, default to "Unknown" + return { status: 'Unknown', age: '--', last: '--' }; + } else { + // For benched units, use "N/A" which will be displayed as "Benched" + return { status: 'N/A', age: '--', last: '--' }; + } + } + + return { status: 'Unknown', age: '--', last: '--' }; +} + +async function toggleDeployStatus(unitId, deployed) { + try { + const formData = new FormData(); + formData.append('deployed', deployed ? 'true' : 'false'); + + const response = await fetch(`/api/roster/edit/${unitId}`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + showToast('✓ Unit updated successfully'); + closeUnitModal(); + + // Trigger HTMX refresh if on roster page + const rosterTable = document.querySelector('[hx-get*="roster"]'); + if (rosterTable) { + htmx.trigger(rosterTable, 'refresh'); + } + } else { + showToast('❌ Failed to update unit', 'error'); + } + } catch (error) { + console.error('Error toggling deploy status:', error); + showToast('❌ Failed to update unit', 'error'); + } +} + +async function deleteUnit(unitId) { + if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) { + return; + } + + try { + const response = await fetch(`/api/roster/${unitId}`, { + method: 'DELETE' + }); + + if (response.ok) { + showToast('✓ Unit deleted successfully'); + closeUnitModal(); + + // Refresh roster page if present + const rosterTable = document.querySelector('[hx-get*="roster"]'); + if (rosterTable) { + htmx.trigger(rosterTable, 'refresh'); + } + } else { + showToast('❌ Failed to delete unit', 'error'); + } + } catch (error) { + console.error('Error deleting unit:', error); + showToast('❌ Failed to delete unit', 'error'); + } +} + +// ===== ONLINE/OFFLINE STATUS ===== +function updateOnlineStatus() { + isOnline = navigator.onLine; + const offlineIndicator = document.getElementById('offlineIndicator'); + + if (offlineIndicator) { + if (isOnline) { + offlineIndicator.classList.remove('show'); + // Trigger sync when coming back online + if (window.offlineDB) { + syncPendingEdits(); + } + } else { + offlineIndicator.classList.add('show'); + } + } +} + +window.addEventListener('online', updateOnlineStatus); +window.addEventListener('offline', updateOnlineStatus); + +// ===== SYNC FUNCTIONALITY ===== +async function syncPendingEdits() { + if (!window.offlineDB) return; + + try { + const pendingEdits = await window.offlineDB.getPendingEdits(); + + if (pendingEdits.length === 0) return; + + console.log(`Syncing ${pendingEdits.length} pending edits...`); + + for (const edit of pendingEdits) { + try { + const formData = new FormData(); + for (const [key, value] of Object.entries(edit.changes)) { + formData.append(key, value); + } + + const response = await fetch(`/api/roster/edit/${edit.unitId}`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + await window.offlineDB.clearEdit(edit.id); + console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`); + } else { + console.error(`Failed to sync edit ${edit.id}`); + } + } catch (error) { + console.error(`Error syncing edit ${edit.id}:`, error); + // Keep in queue for next sync attempt + } + } + + // Show success toast + showToast('✓ Synced successfully'); + + } catch (error) { + console.error('Error in syncPendingEdits:', error); + } +} + +// Manual sync button +function manualSync() { + if (!isOnline) { + showToast('⚠️ Cannot sync while offline', 'warning'); + return; + } + + syncPendingEdits(); +} + +// ===== TOAST NOTIFICATIONS ===== +function showToast(message, type = 'success') { + const toast = document.getElementById('syncToast'); + if (!toast) return; + + // Update toast appearance based on type + toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500'); + + if (type === 'success') { + toast.classList.add('bg-green-500'); + } else if (type === 'error') { + toast.classList.add('bg-red-500'); + } else if (type === 'warning') { + toast.classList.add('bg-yellow-500'); + } + + toast.textContent = message; + toast.classList.add('show'); + + // Auto-hide after 3 seconds + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); +} + +// ===== BOTTOM NAV ACTIVE STATE ===== +function updateBottomNavActiveState() { + const currentPath = window.location.pathname; + const navButtons = document.querySelectorAll('.bottom-nav-btn'); + + navButtons.forEach(btn => { + const href = btn.getAttribute('data-href'); + if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); +} + +// ===== INITIALIZATION ===== +document.addEventListener('DOMContentLoaded', () => { + // Initialize online/offline status + updateOnlineStatus(); + + // Update bottom nav active state + updateBottomNavActiveState(); + + // Add resize listener + window.addEventListener('resize', handleResize); + + // Close menu on navigation (for mobile) + document.addEventListener('click', (e) => { + const link = e.target.closest('a'); + if (link && link.closest('#sidebar')) { + // Delay to allow navigation to start + setTimeout(() => { + if (window.innerWidth < 768) { + closeMenuFromBackdrop(); + } + }, 100); + } + }); + + // Prevent scroll when modals are open (iOS fix) + document.addEventListener('touchmove', (e) => { + const modal = document.querySelector('.unit-modal.show, #sidebar.open'); + if (modal && !e.target.closest('.unit-modal-content, #sidebar')) { + e.preventDefault(); + } + }, { passive: false }); + + console.log('Mobile.js initialized'); +}); + +// ===== SERVICE WORKER REGISTRATION ===== +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') + .then(registration => { + console.log('Service Worker registered:', registration); + + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60 * 60 * 1000); // Check every hour + }) + .catch(error => { + console.error('Service Worker registration failed:', error); + }); + + // Listen for service worker updates + navigator.serviceWorker.addEventListener('controllerchange', () => { + console.log('Service Worker updated, reloading page...'); + window.location.reload(); + }); +} + +// Export functions for global use +window.toggleMenu = toggleMenu; +window.closeMenuFromBackdrop = closeMenuFromBackdrop; +window.openUnitModal = openUnitModal; +window.closeUnitModal = closeUnitModal; +window.manualSync = manualSync; +window.showToast = showToast; diff --git a/backend/static/offline-db.js b/backend/static/offline-db.js new file mode 100644 index 0000000..15cc8bd --- /dev/null +++ b/backend/static/offline-db.js @@ -0,0 +1,352 @@ +/* IndexedDB wrapper for offline data storage in SFM */ +/* Handles unit data, status snapshots, and pending edit queue */ + +class OfflineDB { + constructor() { + this.dbName = 'sfm-offline-db'; + this.version = 1; + this.db = null; + } + + // Initialize database + async init() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => { + console.error('IndexedDB error:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + this.db = request.result; + console.log('IndexedDB initialized successfully'); + resolve(this.db); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Units store - full unit details + if (!db.objectStoreNames.contains('units')) { + const unitsStore = db.createObjectStore('units', { keyPath: 'id' }); + unitsStore.createIndex('device_type', 'device_type', { unique: false }); + unitsStore.createIndex('deployed', 'deployed', { unique: false }); + console.log('Created units object store'); + } + + // Status snapshot store - latest status data + if (!db.objectStoreNames.contains('status-snapshot')) { + db.createObjectStore('status-snapshot', { keyPath: 'timestamp' }); + console.log('Created status-snapshot object store'); + } + + // Pending edits store - offline edit queue + if (!db.objectStoreNames.contains('pending-edits')) { + const editsStore = db.createObjectStore('pending-edits', { + keyPath: 'id', + autoIncrement: true + }); + editsStore.createIndex('unitId', 'unitId', { unique: false }); + editsStore.createIndex('timestamp', 'timestamp', { unique: false }); + console.log('Created pending-edits object store'); + } + }; + }); + } + + // ===== UNITS OPERATIONS ===== + + // Save or update a unit + async saveUnit(unit) { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['units'], 'readwrite'); + const store = transaction.objectStore('units'); + const request = store.put(unit); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Get a single unit by ID + async getUnit(unitId) { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['units'], 'readonly'); + const store = transaction.objectStore('units'); + const request = store.get(unitId); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Get all units + async getAllUnits() { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['units'], 'readonly'); + const store = transaction.objectStore('units'); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Delete a unit + async deleteUnit(unitId) { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['units'], 'readwrite'); + const store = transaction.objectStore('units'); + const request = store.delete(unitId); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // ===== STATUS SNAPSHOT OPERATIONS ===== + + // Save status snapshot + async saveSnapshot(snapshot) { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['status-snapshot'], 'readwrite'); + const store = transaction.objectStore('status-snapshot'); + + // Add timestamp + const snapshotWithTimestamp = { + ...snapshot, + timestamp: Date.now() + }; + + const request = store.put(snapshotWithTimestamp); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Get latest status snapshot + async getLatestSnapshot() { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['status-snapshot'], 'readonly'); + const store = transaction.objectStore('status-snapshot'); + const request = store.getAll(); + + request.onsuccess = () => { + const snapshots = request.result; + if (snapshots.length > 0) { + // Return the most recent snapshot + const latest = snapshots.reduce((prev, current) => + (prev.timestamp > current.timestamp) ? prev : current + ); + resolve(latest); + } else { + resolve(null); + } + }; + request.onerror = () => reject(request.error); + }); + } + + // Clear old snapshots (keep only latest) + async clearOldSnapshots() { + if (!this.db) await this.init(); + + return new Promise(async (resolve, reject) => { + const transaction = this.db.transaction(['status-snapshot'], 'readwrite'); + const store = transaction.objectStore('status-snapshot'); + const getAllRequest = store.getAll(); + + getAllRequest.onsuccess = () => { + const snapshots = getAllRequest.result; + + if (snapshots.length > 1) { + // Sort by timestamp, keep only the latest + snapshots.sort((a, b) => b.timestamp - a.timestamp); + + // Delete all except the first (latest) + for (let i = 1; i < snapshots.length; i++) { + store.delete(snapshots[i].timestamp); + } + } + + resolve(); + }; + + getAllRequest.onerror = () => reject(getAllRequest.error); + }); + } + + // ===== PENDING EDITS OPERATIONS ===== + + // Queue an edit for offline sync + async queueEdit(unitId, changes) { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['pending-edits'], 'readwrite'); + const store = transaction.objectStore('pending-edits'); + + const edit = { + unitId, + changes, + timestamp: Date.now() + }; + + const request = store.add(edit); + + request.onsuccess = () => { + console.log(`Queued edit for unit ${unitId}`); + resolve(request.result); + }; + request.onerror = () => reject(request.error); + }); + } + + // Get all pending edits + async getPendingEdits() { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['pending-edits'], 'readonly'); + const store = transaction.objectStore('pending-edits'); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Get pending edits count + async getPendingEditsCount() { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['pending-edits'], 'readonly'); + const store = transaction.objectStore('pending-edits'); + const request = store.count(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Clear a synced edit + async clearEdit(editId) { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['pending-edits'], 'readwrite'); + const store = transaction.objectStore('pending-edits'); + const request = store.delete(editId); + + request.onsuccess = () => { + console.log(`Cleared edit ${editId} from queue`); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + } + + // Clear all pending edits + async clearAllEdits() { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const transaction = this.db.transaction(['pending-edits'], 'readwrite'); + const store = transaction.objectStore('pending-edits'); + const request = store.clear(); + + request.onsuccess = () => { + console.log('Cleared all pending edits'); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + } + + // ===== UTILITY OPERATIONS ===== + + // Clear all data (for debugging/reset) + async clearAllData() { + if (!this.db) await this.init(); + + return new Promise((resolve, reject) => { + const storeNames = ['units', 'status-snapshot', 'pending-edits']; + const transaction = this.db.transaction(storeNames, 'readwrite'); + + storeNames.forEach(storeName => { + transaction.objectStore(storeName).clear(); + }); + + transaction.oncomplete = () => { + console.log('Cleared all offline data'); + resolve(); + }; + transaction.onerror = () => reject(transaction.error); + }); + } + + // Get database statistics + async getStats() { + if (!this.db) await this.init(); + + const unitsCount = await new Promise((resolve, reject) => { + const transaction = this.db.transaction(['units'], 'readonly'); + const request = transaction.objectStore('units').count(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + const pendingEditsCount = await this.getPendingEditsCount(); + + const hasSnapshot = await new Promise((resolve, reject) => { + const transaction = this.db.transaction(['status-snapshot'], 'readonly'); + const request = transaction.objectStore('status-snapshot').count(); + request.onsuccess = () => resolve(request.result > 0); + request.onerror = () => reject(request.error); + }); + + return { + unitsCount, + pendingEditsCount, + hasSnapshot + }; + } +} + +// Create global instance +window.offlineDB = new OfflineDB(); + +// Initialize on page load +document.addEventListener('DOMContentLoaded', async () => { + try { + await window.offlineDB.init(); + console.log('Offline database ready'); + + // Display pending edits count if any + const pendingCount = await window.offlineDB.getPendingEditsCount(); + if (pendingCount > 0) { + console.log(`${pendingCount} pending edits in queue`); + // Could show a badge in the UI here + } + } catch (error) { + console.error('Failed to initialize offline database:', error); + } +}); + +// Export for use in other scripts +export default OfflineDB; diff --git a/backend/static/sw.js b/backend/static/sw.js new file mode 100644 index 0000000..2c24788 --- /dev/null +++ b/backend/static/sw.js @@ -0,0 +1,347 @@ +/* Service Worker for Seismo Fleet Manager PWA */ +/* Network-first strategy with cache fallback for real-time data */ + +const CACHE_VERSION = 'v1'; +const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; +const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; +const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; + +// Files to precache (critical app shell) +const STATIC_FILES = [ + '/', + '/static/style.css', + '/static/mobile.css', + '/static/mobile.js', + '/static/offline-db.js', + '/static/manifest.json', + 'https://cdn.tailwindcss.com', + 'https://unpkg.com/htmx.org@1.9.10', + 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', + 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js' +]; + +// Install event - cache static files +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[SW] Precaching static files'); + return cache.addAll(STATIC_FILES); + }) + .then(() => { + console.log('[SW] Static files cached successfully'); + return self.skipWaiting(); // Activate immediately + }) + .catch((error) => { + console.error('[SW] Precaching failed:', error); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + // Delete old caches that don't match current version + if (cacheName !== STATIC_CACHE && + cacheName !== DYNAMIC_CACHE && + cacheName !== DATA_CACHE) { + console.log('[SW] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + .then(() => { + console.log('[SW] Service worker activated'); + return self.clients.claim(); // Take control of all pages + }) + ); +}); + +// Fetch event - network-first strategy +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip chrome-extension and other non-http(s) requests + if (!url.protocol.startsWith('http')) { + return; + } + + // API requests - network first, cache fallback + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirstStrategy(request, DATA_CACHE)); + return; + } + + // Static assets - cache first + if (isStaticAsset(url.pathname)) { + event.respondWith(cacheFirstStrategy(request, STATIC_CACHE)); + return; + } + + // HTML pages - network first with cache fallback + if (request.headers.get('accept')?.includes('text/html')) { + event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE)); + return; + } + + // Everything else - network first + event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE)); +}); + +// Network-first strategy +async function networkFirstStrategy(request, cacheName) { + try { + // Try network first + const networkResponse = await fetch(request); + + // Cache successful responses + if (networkResponse && networkResponse.status === 200) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + // Network failed, try cache + console.log('[SW] Network failed, trying cache:', request.url); + const cachedResponse = await caches.match(request); + + if (cachedResponse) { + console.log('[SW] Serving from cache:', request.url); + return cachedResponse; + } + + // No cache available, return offline page or error + console.error('[SW] No cache available for:', request.url); + + // For HTML requests, return a basic offline page + if (request.headers.get('accept')?.includes('text/html')) { + return new Response( + ` + + + + + Offline - SFM + + + +
+

📡 You're Offline

+

SFM requires an internet connection for this page.

+

Please check your connection and try again.

+ +
+ + `, + { + headers: { 'Content-Type': 'text/html' } + } + ); + } + + // For other requests, return error + return new Response('Network error', { + status: 503, + statusText: 'Service Unavailable' + }); + } +} + +// Cache-first strategy +async function cacheFirstStrategy(request, cacheName) { + const cachedResponse = await caches.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + // Not in cache, fetch from network + try { + const networkResponse = await fetch(request); + + // Cache successful responses + if (networkResponse && networkResponse.status === 200) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + console.error('[SW] Fetch failed:', request.url, error); + return new Response('Network error', { + status: 503, + statusText: 'Service Unavailable' + }); + } +} + +// Check if URL is a static asset +function isStaticAsset(pathname) { + const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2']; + return staticExtensions.some(ext => pathname.endsWith(ext)); +} + +// Background Sync - for offline edits +self.addEventListener('sync', (event) => { + console.log('[SW] Background sync event:', event.tag); + + if (event.tag === 'sync-edits') { + event.waitUntil(syncPendingEdits()); + } +}); + +// Sync pending edits to server +async function syncPendingEdits() { + console.log('[SW] Syncing pending edits...'); + + try { + // Get pending edits from IndexedDB + const db = await openDatabase(); + const edits = await getPendingEdits(db); + + if (edits.length === 0) { + console.log('[SW] No pending edits to sync'); + return; + } + + console.log(`[SW] Syncing ${edits.length} pending edits`); + + // Send edits to server + const response = await fetch('/api/sync-edits', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ edits }) + }); + + if (response.ok) { + const result = await response.json(); + console.log('[SW] Sync successful:', result); + + // Clear synced edits from IndexedDB + await clearSyncedEdits(db, result.synced_ids || []); + + // Notify all clients about successful sync + const clients = await self.clients.matchAll(); + clients.forEach(client => { + client.postMessage({ + type: 'SYNC_COMPLETE', + synced: result.synced + }); + }); + } else { + console.error('[SW] Sync failed:', response.status); + } + } catch (error) { + console.error('[SW] Sync error:', error); + throw error; // Will retry sync later + } +} + +// IndexedDB helpers (simplified versions - full implementations in offline-db.js) +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('sfm-offline-db', 1); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + if (!db.objectStoreNames.contains('pending-edits')) { + db.createObjectStore('pending-edits', { keyPath: 'id', autoIncrement: true }); + } + }; + }); +} + +function getPendingEdits(db) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(['pending-edits'], 'readonly'); + const store = transaction.objectStore('pending-edits'); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function clearSyncedEdits(db, editIds) { + return new Promise((resolve, reject) => { + const transaction = db.transaction(['pending-edits'], 'readwrite'); + const store = transaction.objectStore('pending-edits'); + + editIds.forEach(id => { + store.delete(id); + }); + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); +} + +// Message event - handle messages from clients +self.addEventListener('message', (event) => { + console.log('[SW] Message received:', event.data); + + if (event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +}); + +console.log('[SW] Service Worker loaded'); diff --git a/templates/base.html b/templates/base.html index b16ba60..9370401 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,6 +15,16 @@ + + + + + + + + + + + + + + + + {% block extra_scripts %}{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index 851c1ca..c5ae233 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -42,15 +42,15 @@
Total Units - -- + --
Deployed - -- + --
Benched - -- + --

Deployed Status:

@@ -134,7 +134,7 @@

Fleet Map

Deployed units
-
+
@@ -224,17 +224,16 @@ function updateDashboard(event) { // ===== Alerts ===== const alertsList = document.getElementById('alerts-list'); - // Only show alerts for deployed units (not benched) + // Only show alerts for deployed units that are MISSING (not pending) const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing'); - const pendingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Pending'); - if (!missingUnits.length && !pendingUnits.length) { + if (!missingUnits.length) { alertsList.innerHTML = '

✓ All units reporting normally

'; } else { let alertsHtml = ''; - missingUnits.slice(0, 3).forEach(([id, unit]) => { + missingUnits.forEach(([id, unit]) => { alertsHtml += `
@@ -245,17 +244,6 @@ function updateDashboard(event) {
`; }); - pendingUnits.slice(0, 2).forEach(([id, unit]) => { - alertsHtml += ` -
- -
- ${id} -

Pending for ${unit.age}

-
-
`; - }); - alertsList.innerHTML = alertsHtml; } diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 6a48d34..3ca638a 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -1,4 +1,5 @@ -
+ +