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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .specify/specs/028-pwa/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Implementation Plan: Progressive Web App (PWA) Support

> **Spec ID:** 028-pwa
> **Status:** In Progress
> **Last Updated:** 2026-05-27
> **Estimated Effort:** S

## Summary

Add a web app manifest, minimal service worker, and PWA meta tags to make ROTV installable on Android and iOS. Zero new dependencies — leverages existing brand assets and static-serving pipeline.

---

## Architecture

### PWA File Flow

```
┌─────────────────────────────────────────┐
│ frontend/public/ │
│ │
│ manifest.webmanifest ─── Vite ───► dist/manifest.webmanifest
│ sw.js ─── Vite ───► dist/sw.js
│ │
│ Containerfile │
│ mv frontend/dist public ───────► /app/public/
│ │
│ Express (server.js:2652) │
│ express.static(staticPath) ─────► /manifest.webmanifest
│ /sw.js
└─────────────────────────────────────────┘
```

### Service Worker Strategy

Network-first for all GET requests. Cache only the app shell (`/`). Falls back to cached shell on navigation failure. Does not intercept non-GET requests.

---

## Technology Choices

| Component | Technology | Rationale |
|-----------|------------|-----------|
| Manifest | Static `.webmanifest` file | No build plugin needed; Vite copies public/ verbatim |
| Service Worker | Hand-written vanilla JS | Avoids Workbox dependency; installability requires minimal SW |
| Icons | Existing brand PNGs | 192x192 and 512x512 already in `frontend/public/brand/` |

---

## Implementation Steps

### Phase 1: New Files

- [ ] Create `frontend/public/manifest.webmanifest`
- [ ] Create `frontend/public/sw.js`

### Phase 2: Modify Existing Files

- [ ] Add PWA meta tags to `frontend/index.html`
- [ ] Add SW registration to `frontend/src/main.jsx`

### Phase 3: Verify

- [ ] `./run.sh reload-app` and verify manifest/sw.js served correctly
- [ ] Check Chrome DevTools Application tab
- [ ] `./run.sh build` passes
- [ ] User verifies install prompt on mobile

---

## File Changes

### New Files

| File | Purpose |
|------|---------|
| `frontend/public/manifest.webmanifest` | Web app identity, icons, display mode |
| `frontend/public/sw.js` | Minimal service worker for installability |

### Modified Files

| File | Changes |
|------|---------|
| `frontend/index.html` | Add manifest link, theme-color, Apple PWA meta tags |
| `frontend/src/main.jsx` | Add service worker registration |

---

## Testing Strategy

### Manual Testing

1. Visit `/manifest.webmanifest` — returns valid JSON with correct content type
2. Visit `/sw.js` — returns JavaScript
3. Chrome DevTools → Application → Manifest: shows name, icons, colors
4. Chrome DevTools → Application → Service Workers: shows `sw.js` active
5. Lighthouse audit → Installable: passes
6. Android Chrome: "Add to Home Screen" prompt appears
7. iOS Safari: Share → "Add to Home Screen" works

---

## Rollback Plan

If issues are discovered:
1. Remove `<link rel="manifest">` from index.html to disable installability
2. Remove SW registration from main.jsx
3. SW will go dormant (no fetch handler registered on next load)

---

## Risks and Mitigations

| Risk | Impact | Mitigation |
|------|--------|------------|
| Stale cached HTML after deploy | Low | Network-first strategy; cache only used on network failure |
| OG tag middleware conflict | None | OG middlewares only target og:/twitter:/title tags, not manifest/theme-color |
| iOS Safari limitations | Low | apple-mobile-web-app-capable meta tag handles iOS; no push notifications needed |

---

## Changelog

| Date | Changes |
|------|---------|
| 2026-05-27 | Initial plan |
110 changes: 110 additions & 0 deletions .specify/specs/028-pwa/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Specification: Progressive Web App (PWA) Support

> **Spec ID:** 028-pwa
> **Status:** In Progress
> **Version:** 0.1.0
> **Author:** Scott McCarty
> **Date:** 2026-05-27

## Overview

Add Progressive Web App support so users can install Roots of The Valley on their phone (Android or iOS) directly from the browser. The installed app launches in standalone mode with its own icon, splash screen, and no browser chrome — looking and feeling like a native app while remaining the same website under the hood.

---

## User Stories

### Installation

**US-028-1: Install on Android**
> As a mobile user on Android, I want to install ROTV from my browser so that I can launch it from my home screen like a native app.

Acceptance Criteria:
- [ ] Chrome on Android shows "Add to Home Screen" or install prompt when visiting rootsofthevalley.org
- [ ] Installed app appears on home screen with ROTV icon and "ROTV" label
- [ ] App launches in standalone mode (no URL bar or browser chrome)
- [ ] App uses forest green (#2d5016) for the Android status bar

**US-028-2: Install on iOS**
> As a mobile user on iPhone, I want to add ROTV to my home screen so that I can access it like an app.

Acceptance Criteria:
- [ ] Safari on iOS shows "Add to Home Screen" option via Share menu
- [ ] Installed app appears on home screen with ROTV icon and "ROTV" label
- [ ] App launches in standalone mode (no Safari chrome)

### Manifest & Identity

**US-028-3: App Identity**
> As a user installing the app, I want to see correct branding (name, icon, colors) so that the app looks professional and trustworthy.

Acceptance Criteria:
- [ ] Web app manifest declares name "Roots of The Valley" and short_name "ROTV"
- [ ] Manifest references 192x192 and 512x512 PNG icons from existing brand assets
- [ ] Theme color (#2d5016) and background color (#f5f5f5) match the website design
- [ ] Lighthouse PWA audit "Installable" section passes

---

## Data Model

No database changes required.

---

## API Endpoints

No API changes required.

---

## UI/UX Requirements

### Meta Tags

The following meta tags are added to `index.html`:
- `<link rel="manifest">` pointing to the web app manifest
- `<meta name="theme-color">` for browser/OS UI theming
- Apple-specific meta tags for iOS home screen support

### No New Components

No React components are added. PWA support is purely infrastructure (manifest, service worker, meta tags).

---

## Non-Functional Requirements

**NFR-028-1: No New Dependencies**
- No npm packages added (no vite-plugin-pwa, no Workbox)
- Manual, lightweight implementation

**NFR-028-2: No Offline Caching of External Resources**
- Service worker does NOT cache map tiles, API responses, or CDN resources
- App remains online-dependent (map tiles require network)
- Only the app shell (index.html) is cached for installability

**NFR-028-3: No Backend Changes**
- Existing Express static-serving pipeline serves new files without modification

---

## Dependencies

- Depends on: Existing brand icons at 192x192 and 512x512 (already present)
- Depends on: HTTPS in production (already configured)
- Blocks: None

---

## Open Questions

None — all design decisions resolved during planning.

---

## Changelog

| Version | Date | Changes |
|---------|------|---------|
| 0.1.0 | 2026-05-27 | Initial draft |
9 changes: 9 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
<link rel="apple-touch-icon" sizes="180x180" href="/brand/rotv-favicon-180x180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/brand/rotv-favicon-192x192.png" />

<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2d5016" />

<!-- iOS PWA support -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="ROTV" />

<!-- Basic Meta Tags -->
<meta name="description" content="Explore the rich history and natural beauty of Cuyahoga Valley National Park. Discover trails, historic sites, and hidden gems." />

Expand Down
21 changes: 21 additions & 0 deletions frontend/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "Roots of The Valley",
"short_name": "ROTV",
"description": "Explore the rich history and natural beauty of Cuyahoga Valley National Park.",
"start_url": "/",
"display": "standalone",
"theme_color": "#2d5016",
"background_color": "#f5f5f5",
"icons": [
{
"src": "/brand/rotv-favicon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/brand/rotv-favicon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
36 changes: 36 additions & 0 deletions frontend/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
var CACHE_NAME = 'rotv-shell-v1';
var SHELL_URLS = ['/'];

self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(SHELL_URLS);
})
);
self.skipWaiting();
});

self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.filter(function (k) { return k !== CACHE_NAME; })
.map(function (k) { return caches.delete(k); })
);
})
);
self.clients.claim();
});

self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;

event.respondWith(
fetch(event.request).catch(function () {
if (event.request.mode === 'navigate') {
return caches.match('/');
}
return caches.match(event.request);
})
);
});
Comment on lines +25 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Returning the cached / (index.html) when offline or on network failure will result in a White Screen of Death (blank page) for the user.

Why this happens:

  1. The service worker only caches / (index.html) during installation. It does not cache the compiled JS and CSS assets (which are hashed by Vite, e.g., index-XXXX.js).
  2. When the user is offline and navigates, the service worker intercepts the request and returns the cached index.html.
  3. The browser loads this HTML and immediately attempts to fetch the referenced JS and CSS assets from the network.
  4. Since the user is offline and these assets are not cached, the network requests fail, leaving the user with a completely blank, non-functional page.

Solution:

Instead of returning the cached / which depends on non-cached external assets, return a self-contained, branded offline HTML fallback response directly from the service worker. This satisfies the PWA installability requirement (serving a 200 OK when offline) while providing a clean, user-friendly offline message instead of a broken blank screen.

Suggested change
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request).catch(function () {
if (event.request.mode === 'navigate') {
return caches.match('/');
}
return caches.match(event.request);
})
);
});
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request).catch(function () {
if (event.request.mode === 'navigate') {
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 - Roots of The Valley</title><style>body { font-family: system-ui, sans-serif; text-align: center; padding: 2rem; background: #f5f5f5; color: #333; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 80vh; } h1 { color: #2d5016; margin-bottom: 0.5rem; } p { color: #666; margin-top: 0; }</style></head><body><h1>Roots of The Valley</h1><p>You are currently offline. Please check your connection and try again.</p></body></html>",
{
status: 200,
headers: { "Content-Type": "text/html; charset=utf-8" }
}
);
}
return caches.match(event.request);
})
);
});

8 changes: 8 additions & 0 deletions frontend/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ ReactDOM.createRoot(document.getElementById('root')).render(
</BrowserRouter>
</React.StrictMode>
);

if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('SW registration failed:', err);
});
});
}
Comment on lines +15 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the script is loaded asynchronously or deferred (which is common for Vite module scripts), the browser's load event might have already fired by the time this code executes. In such cases, the load event listener will never trigger, and the service worker will not be registered.

To ensure reliable registration, check document.readyState and register immediately if the document is already fully loaded.

Suggested change
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('SW registration failed:', err);
});
});
}
if ('serviceWorker' in navigator) {
const registerSW = () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('SW registration failed:', err);
});
};
if (document.readyState === 'complete') {
registerSW();
} else {
window.addEventListener('load', registerSW);
}
}

Loading