chore: modular monolith folder split (no behavior change)

This commit is contained in:
serversdwn
2026-01-08 20:54:30 +00:00
parent 893cb96e8d
commit 991aaca34b
90 changed files with 16129 additions and 28 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,4 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="128" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="152" height="152" xmlns="http://www.w3.org/2000/svg">
<rect width="152" height="152" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="152" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,4 @@
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="192" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,4 @@
<svg width="384" height="384" xmlns="http://www.w3.org/2000/svg">
<rect width="384" height="384" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="38" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -0,0 +1,4 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="512" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
<rect width="72" height="72" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,4 @@
<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" fill="#142a66"/>
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="96" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -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" }]
}
]
}

612
app/ui/static/mobile.css Normal file
View File

@@ -0,0 +1,612 @@
/* 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 */
}
}
/* ===== MAP OVERLAP FIX ===== */
/* Prevent map and controls from overlapping UI elements on mobile */
@media (max-width: 767px) {
/* Constrain leaflet container to prevent overflow */
.leaflet-container {
max-width: 100%;
overflow: hidden;
}
/* Override Leaflet's default high z-index values */
/* Bottom nav is z-20, sidebar is z-40, so map must be below */
.leaflet-pane,
.leaflet-tile-pane,
.leaflet-overlay-pane,
.leaflet-shadow-pane,
.leaflet-marker-pane,
.leaflet-tooltip-pane,
.leaflet-popup-pane {
z-index: 1 !important;
}
/* Map controls should also be below navigation elements */
.leaflet-control-container,
.leaflet-top,
.leaflet-bottom,
.leaflet-left,
.leaflet-right {
z-index: 1 !important;
}
.leaflet-control {
z-index: 1 !important;
}
/* When sidebar is open, hide all Leaflet controls (zoom, attribution, etc) */
body.menu-open .leaflet-control-container {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
}
/* Ensure map tiles are non-interactive when sidebar is open */
body.menu-open #fleet-map,
body.menu-open #unit-map {
pointer-events: none;
}
}
/* ===== 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;
}
}
}

597
app/ui/static/mobile.js Normal file
View File

@@ -0,0 +1,597 @@
/* 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 = '';
document.body.classList.remove('menu-open');
} else {
// Open menu
sidebar.classList.add('open');
backdrop.classList.add('show');
hamburgerBtn?.classList.add('menu-open');
document.body.style.overflow = 'hidden';
document.body.classList.add('menu-open');
}
}
}
// 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 = '';
document.body.classList.remove('menu-open');
}
}
// 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 = '';
document.body.classList.remove('menu-open');
}
}
}
// ===== 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 = `
<!-- Status Section -->
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded-full bg-${statusColor}-500"></span>
<span class="font-semibold ${statusTextColor}">${statusLabel}</span>
</div>
<span class="text-sm text-gray-500">${statusInfo.age || '--'}</span>
</div>
<!-- Device Info -->
<div class="space-y-3">
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Device Type</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.device_type || '--'}</p>
</div>
${unit.unit_type ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.unit_type}</p>
</div>
` : ''}
${unit.project_id ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Project ID</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.project_id}</p>
</div>
` : ''}
${unit.address ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Address</label>
${navUrl ? `
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="underline">${unit.address}</span>
</a>
` : `
<p class="mt-1 text-gray-900 dark:text-white">${unit.address}</p>
`}
</div>
` : ''}
${unit.coordinates && !unit.address ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
${navUrl ? `
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-mono text-sm underline">${unit.coordinates}</span>
</a>
` : `
<p class="mt-1 text-gray-900 dark:text-white font-mono text-sm">${unit.coordinates}</p>
`}
</div>
` : ''}
<!-- Seismograph-specific fields -->
${unit.device_type === 'seismograph' ? `
${unit.last_calibrated ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.last_calibrated}</p>
</div>
` : ''}
${unit.next_calibration_due ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.next_calibration_due}</p>
</div>
` : ''}
${unit.deployed_with_modem_id ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed_with_modem_id}</p>
</div>
` : ''}
` : ''}
<!-- Modem-specific fields -->
${unit.device_type === 'modem' ? `
${unit.ip_address ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.ip_address}</p>
</div>
` : ''}
${unit.phone_number ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.phone_number}</p>
</div>
` : ''}
${unit.hardware_model ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.hardware_model}</p>
</div>
` : ''}
` : ''}
${unit.note ? `
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Notes</label>
<p class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">${unit.note}</p>
</div>
` : ''}
<div class="grid grid-cols-2 gap-3 pt-2">
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed ? 'Yes' : 'No'}</p>
</div>
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Retired</label>
<p class="mt-1 text-gray-900 dark:text-white">${unit.retired ? 'Yes' : 'No'}</p>
</div>
</div>
</div>
`;
// 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) {
// Prefer roster table data if it was rendered with the current view
if (window.rosterStatusMap && window.rosterStatusMap[unitId]) {
return window.rosterStatusMap[unitId];
}
// 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;

349
app/ui/static/offline-db.js Normal file
View File

@@ -0,0 +1,349 @@
/* 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);
}
});

12
app/ui/static/style.css Normal file
View File

@@ -0,0 +1,12 @@
/* Custom styles for Seismo Fleet Manager */
/* Additional custom styles can go here */
.card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

347
app/ui/static/sw.js Normal file
View File

@@ -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(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - SFM</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f3f4f6;
color: #1f2937;
}
.container {
text-align: center;
padding: 2rem;
}
h1 { color: #f48b1c; margin-bottom: 1rem; }
p { margin-bottom: 1.5rem; color: #6b7280; }
button {
background: #f48b1c;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
}
button:hover { background: #d97706; }
</style>
</head>
<body>
<div class="container">
<h1>📡 You're Offline</h1>
<p>SFM requires an internet connection for this page.</p>
<p>Please check your connection and try again.</p>
<button onclick="location.reload()">Retry</button>
</div>
</body>
</html>`,
{
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');