From 2c49c3d012bde6e9596c41a933d171a64236059c Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 10 Feb 2026 15:19:32 -0800 Subject: [PATCH 01/23] Add PWA badge notifications, CHANGELOG, and AGENTS docs - Enable PWA routes (manifest + service worker) - Fix manifest: add short_name, proper theme/background colors - Activate minimal service worker for PWA qualification - Add badge Stimulus controller: fetches dashboard JSON, calls navigator.setAppBadge() with overdue + due_soon count, polls every 5 min, updates on visibilitychange - Add notification permission banner for iOS badge support - Create CHANGELOG.md with project history - Create AGENTS.md with notes for future Claude Code sessions Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 116 ++++++++++++++++++ CHANGELOG.md | 54 ++++++++ .../controllers/badge_controller.js | 105 ++++++++++++++++ app/views/layouts/application.html.erb | 19 ++- app/views/pwa/manifest.json.erb | 7 +- app/views/pwa/service-worker.js | 36 ++---- config/routes.rb | 4 + 7 files changed, 311 insertions(+), 30 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 app/javascript/controllers/badge_controller.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53f8f6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,116 @@ +# Upkeep — Agent Notes + +Notes for Claude Code sessions working on this project. + +## Project Overview + +**Upkeep** is a personal home maintenance management app. It tracks areas of the house, equipment, recurring maintenance tasks, completion logs, and supplies. + +- **Architecture**: Areas → Equipment → MaintenanceTasks → MaintenanceLogs + Supplies +- **Web app is read-only** — a status dashboard only, no forms or admin UI +- **All data management happens through Claude Code conversations** using rake tasks, Rails runner scripts, and Rails console +- **No authentication** — personal use only (user + partner) + +## Tech Stack + +- Ruby 3.3.7 (via rbenv), Rails 8.1.2, PostgreSQL 17 +- Hotwire (Turbo + Stimulus), Tailwind CSS (tailwindcss-rails) +- Propshaft asset pipeline, importmap-rails +- Solid Queue + Solid Cache (sharing the primary database) +- Minitest for testing +- PWA with service worker and app badge support + +## Key Conventions + +### Data Management +- Use **rake tasks** for common operations: `rake upkeep:status`, `rake upkeep:complete_task[id]`, etc. (see `lib/tasks/upkeep.rake`) +- For complex data entry, write Ruby scripts to `tmp/` files and run with `bin/rails runner tmp/scriptname.rb` — this avoids shell quoting issues with single quotes in names like "Nida's Office" +- Never use inline `bin/rails runner '...'` with code containing single quotes + +### Shell Environment +- The user's shell is zsh. Always prefix commands with `source ~/.zshrc &&` to ensure rbenv and PostgreSQL are on the PATH +- Without this, commands fall back to macOS system Ruby 2.6 which is incompatible + +### Dashboard Behavior +- Shows tasks due within **2 weeks** (14 days) or overdue +- Low stock supplies only appear when their associated task is due within the window +- When nothing is due, shows "Nothing to do. Relax." with a random cat gif from `https://cataas.com/cat/gif` +- "Not Yet Scheduled" tasks are intentionally hidden from the dashboard + +### Deploy Workflow +All changes should be **committed, pushed to GitHub, and deployed to Railway** before telling the user "done". The process: + +```bash +# 1. Commit +git add && git commit -m "message" + +# 2. Push +git push origin main + +# 3. Deploy (make sure upkeep-web service is linked) +cd ~/Code/upkeep +railway service upkeep-web +railway up --detach + +# 4. Wait ~2.5 minutes for build, then verify +railway service status # should show SUCCESS +curl -s -o /dev/null -w "%{http_code}" https://upkeep-web-production.up.railway.app/up # should be 200 +``` + +### Production Data +To update production data directly (e.g., marking tasks complete, adding equipment): +```bash +# Use the public DATABASE_URL for psql access +PROD_DB_URL="postgresql://postgres:dNVJUrdIWOCWHsVCkgmvehjCtXGiuFTf@ballast.proxy.rlwy.net:49051/railway" +psql "$PROD_DB_URL" -c "SQL HERE" +``` + +`railway run` doesn't work well locally because it uses the system Ruby 2.6 instead of rbenv. + +## File Structure (Key Files) + +``` +app/ + controllers/ + dashboard_controller.rb # Main dashboard with JSON API + areas_controller.rb + equipment_controller.rb + maintenance_tasks_controller.rb + maintenance_logs_controller.rb # Log page + supplies_controller.rb + models/ + area.rb # has_many :equipment + equipment.rb # belongs_to :area, has_many :maintenance_tasks + maintenance_task.rb # Core model — scopes, complete!, due_status + maintenance_log.rb # Completion records + supply.rb # Inventory tracking with low_stock? + views/ + layouts/application.html.erb # Nav, badge controller, permission banner + dashboard/index.html.erb # Main dashboard view + pwa/ + manifest.json.erb # PWA manifest + service-worker.js # Minimal service worker + javascript/ + controllers/ + badge_controller.js # PWA badge + notification permission +lib/tasks/ + upkeep.rake # Rake tasks for data management +config/ + routes.rb # Includes PWA routes + database.yml # Production uses DATABASE_URL for all databases +db/ + seeds.rb # Default areas (10, but 3 were removed from DB) +``` + +## Railway Setup + +- **Project**: upkeep +- **Services**: upkeep-web (Rails app) + Postgres +- **URL**: https://upkeep-web-production.up.railway.app +- **Environment variables**: RAILS_MASTER_KEY, RAILS_ENV=production, SOLID_QUEUE_IN_PUMA=1, DATABASE_URL (auto-set) +- **Docker entrypoint** loads Solid Queue/Cache schemas into the shared database on first boot +- **Dockerfile** uses Puma directly (not Thruster) to work with Railway's dynamic PORT + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for a log of all major changes. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ab954fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +All notable changes to Upkeep are documented here. + +## 2026-02-10 — PWA Badge Notifications + +- Added PWA support: web app manifest, service worker, `navigator.setAppBadge()` integration +- App icon on iOS home screen now shows a badge count when tasks are overdue or due soon +- One-time permission banner prompts user to enable notifications (required for iOS badges) +- Badge updates on page load, every 5 minutes, and when app returns to foreground + +## 2026-02-10 — Railway Deployment + +- Created private GitHub repo (`sansari/upkeep`) +- Deployed to Railway with PostgreSQL: https://upkeep-web-production.up.railway.app +- Configured single-database setup for primary + Solid Cache + Solid Queue +- Docker entrypoint loads Solid Queue/Cache schemas on first boot +- Switched from Thruster to Puma for Railway's dynamic PORT assignment +- Migrated all development data (9 areas, 9 equipment, 9 tasks, 3 supplies, 9 logs) to production via pg_dump/pg_restore + +## 2026-02-10 — Dashboard Polish + +- Changed dashboard window from 7 days → 30 days → **2 weeks** (14 days) +- Removed "Not Yet Scheduled" section from dashboard +- Low stock supplies only appear when their associated task is due within 2 weeks +- "All clear" state shows a calming message with a random cat gif from cataas.com +- Added **Log** page: reverse-chronological list of all completed maintenance +- Removed redundant "Dashboard" nav link (🏠 Upkeep logo links to dashboard) + +## 2026-02-10 — Data Entry + +- Added areas: Nida's Office, Guest Room +- Removed areas: Basement, Attic, Garage +- Renamed: Bathroom → Guest Bathroom +- Added equipment: Drinking Water Filter, Shower Head Filter, 5 Mini-Split ACs, 2 Compressors +- Configured maintenance schedules: + - Living Room & Bedroom mini-splits: every 6 weeks + - Studio, Nida's Office, Guest Room mini-splits: every 3 months + - Compressors: every 6 months + - Water filter: yearly + - Shower head filters: every 6 months +- Added supplies with Amazon/Multipure purchase links +- Logged initial maintenance completions + +## 2026-02-10 — Initial Build + +- Rails 8.1.2, Ruby 3.3.7, PostgreSQL 17 +- Data model: Area → Equipment → MaintenanceTask → MaintenanceLog + Supply +- Read-only web dashboard (all data management via Claude Code conversations) +- Rake tasks for common operations (`upkeep:status`, `upkeep:complete_task`, etc.) +- JSON API on all controllers via `respond_to` blocks +- Tailwind CSS styling, Hotwire (Turbo + Stimulus) +- 29 tests, 71 assertions — all passing +- Seeded 10 default home areas diff --git a/app/javascript/controllers/badge_controller.js b/app/javascript/controllers/badge_controller.js new file mode 100644 index 0000000..3a5dfa4 --- /dev/null +++ b/app/javascript/controllers/badge_controller.js @@ -0,0 +1,105 @@ +import { Controller } from "@hotwired/stimulus" + +// Manages PWA app icon badge and notification permission. +// Attach to so it runs on every page. +export default class extends Controller { + static targets = ["permissionBanner"] + static values = { + pollInterval: { type: Number, default: 300000 } // 5 minutes + } + + connect() { + this.registerServiceWorker() + this.showPermissionBannerIfNeeded() + this.updateBadge() + this.startPolling() + document.addEventListener("visibilitychange", this.handleVisibilityChange) + } + + disconnect() { + this.stopPolling() + document.removeEventListener("visibilitychange", this.handleVisibilityChange) + } + + // --- Service Worker Registration --- + + async registerServiceWorker() { + if (!("serviceWorker" in navigator)) return + + try { + await navigator.serviceWorker.register("/service-worker", { scope: "/" }) + } catch (error) { + console.warn("Service worker registration failed:", error) + } + } + + // --- Badge Logic --- + + async updateBadge() { + if (!("setAppBadge" in navigator)) return + + try { + const response = await fetch("/", { + headers: { "Accept": "application/json" } + }) + if (!response.ok) return + + const data = await response.json() + const count = (data.overdue?.length || 0) + (data.due_soon?.length || 0) + + if (count > 0) { + await navigator.setAppBadge(count) + } else { + await navigator.clearAppBadge() + } + } catch (error) { + console.warn("Badge update failed:", error) + } + } + + // --- Permission Banner --- + + showPermissionBannerIfNeeded() { + if (!this.hasPermissionBannerTarget) return + if (!("Notification" in window)) return + if (!("setAppBadge" in navigator)) return + if (Notification.permission !== "default") return + if (localStorage.getItem("upkeep-badge-dismissed")) return + + this.permissionBannerTarget.style.display = "" + } + + async requestPermission() { + if (typeof Notification === "undefined") return + + await Notification.requestPermission() + this.dismissBanner() + this.updateBadge() + } + + dismissBanner() { + localStorage.setItem("upkeep-badge-dismissed", "true") + if (this.hasPermissionBannerTarget) { + this.permissionBannerTarget.style.display = "none" + } + } + + // --- Polling --- + + startPolling() { + this.pollTimer = setInterval(() => this.updateBadge(), this.pollIntervalValue) + } + + stopPolling() { + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + } + + handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + this.updateBadge() + } + } +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 89e8b68..b933cef 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,6 +11,7 @@ <%= yield :head %> + <%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> @@ -19,7 +20,7 @@ <%= javascript_importmap_tags %> - +
+ + <% if notice.present? %>
<%= notice %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 8936247..1ff6df1 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -1,5 +1,6 @@ { "name": "Upkeep", + "short_name": "Upkeep", "icons": [ { "src": "/icon.png", @@ -16,7 +17,7 @@ "start_url": "/", "display": "standalone", "scope": "/", - "description": "Upkeep.", - "theme_color": "red", - "background_color": "red" + "description": "Home maintenance task tracker.", + "theme_color": "#dc2626", + "background_color": "#f9fafb" } diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js index b3a13fb..d2c42c9 100644 --- a/app/views/pwa/service-worker.js +++ b/app/views/pwa/service-worker.js @@ -1,26 +1,10 @@ -// Add a service worker for processing Web Push notifications: -// -// self.addEventListener("push", async (event) => { -// const { title, options } = await event.data.json() -// event.waitUntil(self.registration.showNotification(title, options)) -// }) -// -// self.addEventListener("notificationclick", function(event) { -// event.notification.close() -// event.waitUntil( -// clients.matchAll({ type: "window" }).then((clientList) => { -// for (let i = 0; i < clientList.length; i++) { -// let client = clientList[i] -// let clientPath = (new URL(client.url)).pathname -// -// if (clientPath == event.notification.data.path && "focus" in client) { -// return client.focus() -// } -// } -// -// if (clients.openWindow) { -// return clients.openWindow(event.notification.data.path) -// } -// }) -// ) -// }) +// Minimal service worker for PWA qualification and badge support. +// No complex caching — this is a simple status dashboard. + +self.addEventListener("install", (event) => { + self.skipWaiting() +}) + +self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()) +}) diff --git a/config/routes.rb b/config/routes.rb index a602669..7e561a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,10 @@ Rails.application.routes.draw do root "dashboard#index" + # PWA + get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + resources :areas, only: [ :index, :show ] resources :equipment, only: [ :show ] resources :maintenance_tasks, path: "tasks", only: [ :show ] do From 16a2b9b13163b600d47902448fbe0ef43478fa34 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 10 Feb 2026 15:27:21 -0800 Subject: [PATCH 02/23] Migrate AGENTS.md to CLAUDE.md for Claude Code support Co-Authored-By: Claude Opus 4.6 --- AGENTS.md => CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) rename AGENTS.md => CLAUDE.md (94%) diff --git a/AGENTS.md b/CLAUDE.md similarity index 94% rename from AGENTS.md rename to CLAUDE.md index 53f8f6a..a71fe2b 100644 --- a/AGENTS.md +++ b/CLAUDE.md @@ -114,3 +114,6 @@ db/ ## Changelog See [CHANGELOG.md](CHANGELOG.md) for a log of all major changes. + +- **Always update CHANGELOG.md** when making major changes (new features, significant bug fixes, architectural changes, dependency updates, etc.) +- If a plan was created before implementation, reference it in the changelog entry (e.g., "per plan in conversation" or link to relevant context) From 9e007a51754f945eea18643bf1dd68206bd32cf8 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 10 Feb 2026 16:06:13 -0800 Subject: [PATCH 03/23] Establish documentation workflow with plans, changelog, and spec - Create plans/ directory with reconstructed plans for all 6 major phases of the project (001-initial-build through 006-documentation-workflow) - Update CHANGELOG.md to reference plan files in each entry - Update SPEC.md to reflect current app behavior (2-week window, log page, PWA support, Railway deployment details) - Add Documentation Workflow section to CLAUDE.md establishing conventions: plans committed to repo, changelog references plans, spec stays in sync, CLAUDE.md documents workflow Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 19 ++++++ CLAUDE.md | 32 ++++++++-- SPEC.md | 95 ++++++++++++++++++---------- plans/001-initial-build.md | 43 +++++++++++++ plans/002-data-entry.md | 38 +++++++++++ plans/003-dashboard-polish.md | 46 ++++++++++++++ plans/004-railway-deployment.md | 52 +++++++++++++++ plans/005-pwa-badge-notifications.md | 43 +++++++++++++ plans/006-documentation-workflow.md | 23 +++++++ 9 files changed, 355 insertions(+), 36 deletions(-) create mode 100644 plans/001-initial-build.md create mode 100644 plans/002-data-entry.md create mode 100644 plans/003-dashboard-polish.md create mode 100644 plans/004-railway-deployment.md create mode 100644 plans/005-pwa-badge-notifications.md create mode 100644 plans/006-documentation-workflow.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ab954fa..88de461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,19 @@ All notable changes to Upkeep are documented here. +## 2026-02-10 — Documentation Workflow + +Plan: [plans/006-documentation-workflow.md](plans/006-documentation-workflow.md) + +- Established convention: plans committed to `plans/` directory, numbered sequentially +- Changelog entries now reference their corresponding plan +- SPEC.md updated to reflect current app behavior +- CLAUDE.md documents the plan/changelog/spec workflow for future sessions + ## 2026-02-10 — PWA Badge Notifications +Plan: [plans/005-pwa-badge-notifications.md](plans/005-pwa-badge-notifications.md) + - Added PWA support: web app manifest, service worker, `navigator.setAppBadge()` integration - App icon on iOS home screen now shows a badge count when tasks are overdue or due soon - One-time permission banner prompts user to enable notifications (required for iOS badges) @@ -11,6 +22,8 @@ All notable changes to Upkeep are documented here. ## 2026-02-10 — Railway Deployment +Plan: [plans/004-railway-deployment.md](plans/004-railway-deployment.md) + - Created private GitHub repo (`sansari/upkeep`) - Deployed to Railway with PostgreSQL: https://upkeep-web-production.up.railway.app - Configured single-database setup for primary + Solid Cache + Solid Queue @@ -20,6 +33,8 @@ All notable changes to Upkeep are documented here. ## 2026-02-10 — Dashboard Polish +Plan: [plans/003-dashboard-polish.md](plans/003-dashboard-polish.md) + - Changed dashboard window from 7 days → 30 days → **2 weeks** (14 days) - Removed "Not Yet Scheduled" section from dashboard - Low stock supplies only appear when their associated task is due within 2 weeks @@ -29,6 +44,8 @@ All notable changes to Upkeep are documented here. ## 2026-02-10 — Data Entry +Plan: [plans/002-data-entry.md](plans/002-data-entry.md) + - Added areas: Nida's Office, Guest Room - Removed areas: Basement, Attic, Garage - Renamed: Bathroom → Guest Bathroom @@ -44,6 +61,8 @@ All notable changes to Upkeep are documented here. ## 2026-02-10 — Initial Build +Plan: [plans/001-initial-build.md](plans/001-initial-build.md) + - Rails 8.1.2, Ruby 3.3.7, PostgreSQL 17 - Data model: Area → Equipment → MaintenanceTask → MaintenanceLog + Supply - Read-only web dashboard (all data management via Claude Code conversations) diff --git a/CLAUDE.md b/CLAUDE.md index a71fe2b..d28cf45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,13 @@ config/ database.yml # Production uses DATABASE_URL for all databases db/ seeds.rb # Default areas (10, but 3 were removed from DB) +plans/ + 001-initial-build.md # Plan for each major feature/change + 002-data-entry.md + ... +SPEC.md # Full app specification (keep in sync!) +CHANGELOG.md # All notable changes with plan references +CLAUDE.md # This file — agent instructions ``` ## Railway Setup @@ -111,9 +118,26 @@ db/ - **Docker entrypoint** loads Solid Queue/Cache schemas into the shared database on first boot - **Dockerfile** uses Puma directly (not Thruster) to work with Railway's dynamic PORT -## Changelog +## Documentation Workflow -See [CHANGELOG.md](CHANGELOG.md) for a log of all major changes. +Every functional change must update **all three** of these: -- **Always update CHANGELOG.md** when making major changes (new features, significant bug fixes, architectural changes, dependency updates, etc.) -- If a plan was created before implementation, reference it in the changelog entry (e.g., "per plan in conversation" or link to relevant context) +### 1. Plans → `plans/NNN-short-name.md` +- Before implementing non-trivial features, write a plan file +- Plans are numbered sequentially (001, 002, ...) +- Include: context/motivation, approach, files modified, verification steps +- **Commit the plan with the feature** — plans are part of the repo + +### 2. Changelog → `CHANGELOG.md` +- Update after every functional change +- Each entry references its plan: `Plan: [plans/NNN-name.md](plans/NNN-name.md)` +- Reverse chronological order + +### 3. Spec → `SPEC.md` +- Update after every functional change to reflect current app behavior +- The spec should always describe the **current** state of the app, not the original design +- Key sections to keep in sync: dashboard behavior, routes, scopes, deployment config + +### 4. This file → `CLAUDE.md` +- Update with new conventions, file structure changes, or workflow notes +- This is what future Claude Code sessions read first diff --git a/SPEC.md b/SPEC.md index 56f8fbf..71ee9f6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -116,8 +116,8 @@ A recurring (or one-time) maintenance action for a piece of equipment. **Scopes:** - `overdue` — `where("next_due_at < ?", Time.current)` -- `due_soon` — `where(next_due_at: Time.current..7.days.from_now)` -- `upcoming` — `where("next_due_at > ?", 7.days.from_now)` +- `due_soon` — `where(next_due_at: Time.current..14.days.from_now)` +- `upcoming` — `where("next_due_at > ?", 14.days.from_now)` **Methods:** - `complete!(notes: nil)` — Creates a MaintenanceLog entry, sets `last_completed_at` to now, recalculates `next_due_at`, decrements associated supplies. @@ -170,23 +170,24 @@ The web app is **read-only** and **mobile-friendly**. No forms, no login. ### 4.1 Home Dashboard — `GET /` -The landing page shows an at-a-glance summary: +The landing page shows an at-a-glance summary of what needs attention within the next **2 weeks**: **Overdue Tasks** (red/urgent section) -- Lists all tasks where `next_due_at < now`, grouped by area +- Lists all tasks where `next_due_at < now` - Each entry shows: task name, equipment name, area name, how overdue it is (e.g., "3 days overdue") -**Due Soon** (yellow/attention section) -- Tasks due within the next 7 days, grouped by area +**Due Within 2 Weeks** (yellow/attention section) +- Tasks due within the next 14 days - Shows: task name, equipment, due date -**Low Stock Supplies** (info section) -- Supplies where `quantity_on_hand < quantity_per_use` +**Low Stock Supplies** (orange section) +- Only shown when the associated task is overdue or due within 2 weeks - Shows: supply name, equipment/task it's for, quantity on hand, purchase link -**Recently Completed** (optional, collapsed by default) -- Last 10 completed maintenance logs -- Shows: task name, completed date, notes +**All Clear State** +- When no tasks are overdue, due soon, or have low stock supplies: shows "Nothing to do. Relax." with a random cat gif from `https://cataas.com/cat/gif` + +**Note:** "Not Yet Scheduled" tasks (where `next_due_at` is null) are intentionally hidden from the dashboard. ### 4.2 Areas Index — `GET /areas` @@ -232,7 +233,14 @@ All supplies across all equipment, sortable/filterable: - Low stock indicator - Purchase link (opens in new tab) -### 4.7 JSON API +### 4.7 Maintenance Log — `GET /log` + +Reverse-chronological list of all completed maintenance tasks: +- Task name and location (equipment → area) +- Completion date and time ago +- Notes (if any) + +### 4.8 JSON API Every route above also responds to `.json` format, returning the same data as JSON. This is for: - Future iOS app @@ -355,12 +363,12 @@ When a task is completed (via `complete!` method): Tasks are categorized by their `next_due_at` relative to now: -| Status | Condition | Display | -|--------------|----------------------------------------|------------| -| `overdue` | `next_due_at` is in the past | Red badge | -| `due_soon` | `next_due_at` is within 7 days | Yellow badge| -| `upcoming` | `next_due_at` is more than 7 days away | Green badge| -| `not_scheduled` | `next_due_at` is null (never completed, no initial date set) | Gray badge | +| Status | Condition | Display | +|--------------|-----------------------------------------|------------| +| `overdue` | `next_due_at` is in the past | Red badge | +| `due_soon` | `next_due_at` is within 14 days | Yellow badge| +| `upcoming` | `next_due_at` is more than 14 days away | Green badge| +| `not_scheduled` | `next_due_at` is null (never completed, no initial date set) | Gray badge (hidden from dashboard) | --- @@ -368,31 +376,54 @@ Tasks are categorized by their `next_due_at` relative to now: ### Platform: Railway -- **Source:** GitHub repository, auto-deploy on push to `main` -- **Database:** Railway PostgreSQL addon -- **URL:** Railway-provided subdomain (e.g., `upkeep-production.up.railway.app`) +- **Source:** GitHub repository (`sansari/upkeep`), deploy via `railway up` +- **Database:** Railway PostgreSQL addon (single database shared by primary, Solid Cache, Solid Queue) +- **URL:** https://upkeep-web-production.up.railway.app - **No custom domain needed** - **No authentication needed** ### Environment Variables -| Variable | Purpose | -|------------------|----------------------------| -| `DATABASE_URL` | PostgreSQL connection (set by Railway) | -| `RAILS_ENV` | `production` | -| `SECRET_KEY_BASE`| Rails secret (generate and set in Railway) | +| Variable | Purpose | +|-----------------------|----------------------------------------------| +| `DATABASE_URL` | PostgreSQL connection (auto-set by Railway) | +| `RAILS_ENV` | `production` | +| `RAILS_MASTER_KEY` | Decrypts `config/credentials.yml.enc` | +| `SOLID_QUEUE_IN_PUMA` | `1` — runs Solid Queue inside Puma process | ### Deployment Files -- `Procfile` — defines web process (`web: bin/rails server`) -- `config/database.yml` — uses `DATABASE_URL` in production -- `bin/docker-entrypoint` or `Dockerfile` if Railway needs it (Railway supports both buildpacks and Docker) +- `Dockerfile` — multi-stage build, Ruby 3.3.7-slim, Puma on dynamic PORT +- `bin/docker-entrypoint` — runs `db:prepare` + loads Solid Queue/Cache schemas +- `config/database.yml` — production uses `DATABASE_URL` for all three database roles + +--- + +## 8. PWA Support + +The app is a Progressive Web App that can be added to the iOS/Android home screen. + +### Manifest +- Served at `/manifest.json` via `Rails::PwaController` +- `display: standalone` for full-screen app experience +- App name: "Upkeep", theme color: `#dc2626` (red) + +### Service Worker +- Minimal service worker at `/service-worker` — install + activate only +- No complex caching (this is a simple status dashboard) + +### App Icon Badge +- Uses `navigator.setAppBadge()` API (iOS 16.4+) +- Badge count = number of overdue + due_soon tasks +- Updates on page load, every 5 minutes, and on `visibilitychange` +- Requires notification permission on iOS (one-time permission banner shown) +- Badge only updates while the app is open (no server-side push notifications) --- -## 8. Future Enhancements +## 9. Future Enhancements -These are documented for future reference but are **not part of the MVP**: +These are documented for future reference but are **not yet built**: - **User authentication** — Devise or custom auth, multi-user households with task assignment - **Native iOS app** — Separate repository, Swift/SwiftUI, consumes the JSON API @@ -404,7 +435,7 @@ These are documented for future reference but are **not part of the MVP**: --- -## 9. Development Setup +## 10. Development Setup ### Prerequisites diff --git a/plans/001-initial-build.md b/plans/001-initial-build.md new file mode 100644 index 0000000..9e66b3e --- /dev/null +++ b/plans/001-initial-build.md @@ -0,0 +1,43 @@ +# Plan 001: Initial Build + +## Context + +Build Upkeep, a personal home maintenance management app. The user wants to track areas of the house, equipment, recurring maintenance tasks, completion history, and supplies — all managed through Claude Code conversations with a read-only web dashboard. + +## Decisions + +- **App name**: Upkeep +- **Architecture**: Areas → Equipment → MaintenanceTasks → MaintenanceLogs + Supplies +- **Management model**: All data entry via Claude Code (rake tasks, Rails console). No admin UI. +- **No authentication**: Personal use only (user + partner) +- **Deployment target**: Railway (from GitHub) +- **Tech**: Rails 8, Ruby 3.3+, PostgreSQL, Hotwire/Turbo, Tailwind CSS, Minitest + +## What Was Done + +1. Installed Ruby 3.3.7 (via rbenv), PostgreSQL 17 (via Homebrew) +2. Created Rails app: `rails new upkeep --database=postgresql --css=tailwind --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-storage --skip-action-cable --skip-hotwire --skip-jbuilder --skip-test --force` +3. Added turbo-rails and stimulus-rails manually (since --skip-hotwire was used) +4. Created 5 models with migrations: Area, Equipment, MaintenanceTask, MaintenanceLog, Supply +5. Created seed data for 10 default areas +6. Created rake tasks for conversational management (upkeep:status, upkeep:complete_task, etc.) +7. Created controllers: Dashboard, Areas, Equipment, MaintenanceTasks, Supplies +8. Created views with Tailwind CSS styling +9. Created test fixtures and tests (29 tests, 71 assertions) +10. Wrote SPEC.md with full specification + +## Files Created/Modified + +- `SPEC.md` — full specification document +- `app/models/` — all 5 models with associations, validations, scopes +- `app/controllers/` — 5 controllers with HTML + JSON responses +- `app/views/` — all views with Tailwind styling +- `db/migrate/` — 5 migration files +- `db/seeds.rb` — 10 default areas +- `lib/tasks/upkeep.rake` — rake tasks for data management +- `test/` — model and controller tests + +## Verification + +- `bin/rails test` — 29 tests, 71 assertions, all passing +- `bin/dev` — app runs locally at localhost:3000 diff --git a/plans/002-data-entry.md b/plans/002-data-entry.md new file mode 100644 index 0000000..8e9a398 --- /dev/null +++ b/plans/002-data-entry.md @@ -0,0 +1,38 @@ +# Plan 002: Data Entry + +## Context + +With the app built, populate it with the user's actual home maintenance data. All data entry happens through Claude Code conversations using Rails runner scripts (written to tmp/ files to avoid shell quoting issues). + +## What Was Done + +### Areas +- Added: Nida's Office, Guest Room +- Removed: Basement, Attic, Garage +- Renamed: Bathroom → Guest Bathroom + +### Equipment (9 items) +- Kitchen: Drinking Water Filter +- Bedroom: Shower Head Filter +- Studio, Bedroom, Nida's Office, Guest Room, Living Room: Mini-Split AC (5 units) +- Outdoor: Main Compressor (Whole House), Studio Compressor + +### Maintenance Tasks (9 tasks) +- Replace filter (Kitchen water filter) — yearly +- Replace filters (Shower head) — every 6 months +- Wash mini-split filters — every 6 weeks (Living Room, Bedroom), every 3 months (Studio, Nida's Office, Guest Room) +- Hose down compressor — every 6 months (both compressors) + +### Supplies (3 items) +- Multipure Aquaversa Filter CB6 (0 on hand, purchase URL) +- AquaHomeGroup 20-Stage Replacement Cartridge (1 on hand, Amazon link) +- AquaHomeGroup Vitamin C+E+A Replacement Cartridge (1 on hand, Amazon link) + +### Maintenance Logs +- Logged filter washes from ~3 weeks ago for all mini-splits and compressors +- Logged water filter replacement (December 2025) +- Logged shower head filter replacement (today) + +## Key Learning + +- Use `tmp/` script files for Rails runner instead of inline commands — avoids shell quoting issues with names containing apostrophes (e.g., "Nida's Office") diff --git a/plans/003-dashboard-polish.md b/plans/003-dashboard-polish.md new file mode 100644 index 0000000..063247e --- /dev/null +++ b/plans/003-dashboard-polish.md @@ -0,0 +1,46 @@ +# Plan 003: Dashboard Polish + +## Context + +After initial data entry, the user tested the dashboard and requested several iterations to get the notification behavior right — not too noisy, not too quiet. + +## Changes Made + +### Dashboard Window +- Changed from 7 days → 30 days → **2 weeks** (14 days) +- Updated `MaintenanceTask` scopes: `due_soon` uses `14.days.from_now`, `upcoming` uses `> 14.days.from_now` +- Updated `due_status` method to match + +### Removed "Not Yet Scheduled" Section +- Tasks with `next_due_at: nil` are intentionally hidden from the dashboard + +### Low Stock Supply Filtering +- Low stock supplies only show when their associated task is overdue or due within 2 weeks +- `DashboardController` filters: collects actionable task IDs from overdue + due_soon, then queries `Supply.low_stock.where(maintenance_task_id: actionable_task_ids)` + +### "All Clear" State +- When no overdue tasks, no due_soon tasks, and no low stock supplies: show "Nothing to do. Relax." with a random cat gif from `https://cataas.com/cat/gif?t=` + +### Log Page +- New route: `GET /log` → `MaintenanceLogsController#index` +- Reverse-chronological list of all maintenance completions with task name, location, date, time ago, and notes +- Added "Log" link to nav bar + +### Nav Changes +- Removed redundant "Dashboard" link (🏠 Upkeep logo links to dashboard) +- Final nav: 🏠 Upkeep | Areas | Supplies | Log + +## Files Modified + +- `app/models/maintenance_task.rb` — updated scopes and due_status to 14 days +- `app/controllers/dashboard_controller.rb` — low stock filtering by actionable task IDs +- `app/views/dashboard/index.html.erb` — 2-week label, cat gif, removed "not scheduled" +- `app/controllers/maintenance_logs_controller.rb` — new controller +- `app/views/maintenance_logs/index.html.erb` — new view +- `config/routes.rb` — added log route +- `app/views/layouts/application.html.erb` — updated nav links + +## Mini-Split Frequency Iterations +- Initially set Living Room & Bedroom to 6 weeks, others to 3 months +- Changed to 4 weeks for Living Room & Bedroom +- Changed back to 6 weeks for Living Room & Bedroom (final) diff --git a/plans/004-railway-deployment.md b/plans/004-railway-deployment.md new file mode 100644 index 0000000..10e4e62 --- /dev/null +++ b/plans/004-railway-deployment.md @@ -0,0 +1,52 @@ +# Plan 004: Railway Deployment + +## Context + +The app is fully built and working locally with all data entered. Deploy to Railway so the user can access it on their phone. The development database data must be replicated in production. + +## Steps Taken + +### 1. GitHub +- Created private repo: `gh repo create upkeep --private --source=. --push` +- Repo: https://github.com/sansari/upkeep + +### 2. Production Config Changes +- **`config/database.yml`**: Replaced production section to use `DATABASE_URL` for all three database roles (primary, cache, queue) — Railway provides a single PostgreSQL instance +- **`config/environments/production.rb`**: Added `config.hosts.clear` (personal app, no auth needed) +- **`Dockerfile`**: Changed CMD from Thruster (`./bin/thrust ./bin/rails server`) to Puma directly (`./bin/rails server -b 0.0.0.0`) — Railway assigns a dynamic PORT that conflicts with Thruster's port 80 +- **`bin/docker-entrypoint`**: Added Ruby script to load Solid Queue and Solid Cache schemas into the shared database if their tables don't exist + +### 3. Railway Setup +- Installed Railway CLI: `brew install railway` +- Created project: `railway init --name upkeep` +- Added PostgreSQL: `railway add --database postgres` +- Created web service: `railway add --service upkeep-web` +- Set DATABASE_URL reference: `railway variable set 'DATABASE_URL=${{Postgres.DATABASE_URL}}'` +- Set env vars: RAILS_MASTER_KEY, RAILS_ENV=production, SOLID_QUEUE_IN_PUMA=1 + +### 4. Data Migration +- Dumped local database: `pg_dump -Fc --no-owner --no-privileges upkeep_development > /tmp/upkeep_dev.dump` +- Used public DATABASE_URL to connect: `postgresql://postgres:...@ballast.proxy.rlwy.net:49051/railway` +- Cleared seeded data via psql +- Restored with: `pg_restore --data-only --disable-triggers --no-owner --no-privileges -t areas -t equipment -t maintenance_tasks -t maintenance_logs -t supplies` +- Reset sequences via psql + +### 5. Issues Encountered and Fixed +- **Solid Queue crash**: `db:prepare` only loaded primary schema, not queue/cache schemas → fixed with Ruby script in docker-entrypoint +- **Host authorization 403**: `.railway.app` string and regex patterns didn't match `upkeep-web-production.up.railway.app` → fixed with `config.hosts.clear` +- **Thruster port conflict**: Thruster binds to port 80, Railway assigns dynamic PORT → fixed by using Puma directly + +## Production Details +- **URL**: https://upkeep-web-production.up.railway.app +- **Database**: Single PostgreSQL shared by primary, Solid Cache, Solid Queue +- **Public DB URL**: `postgresql://postgres:dNVJUrdIWOCWHsVCkgmvehjCtXGiuFTf@ballast.proxy.rlwy.net:49051/railway` + +## Files Modified +- `config/database.yml` +- `config/environments/production.rb` +- `Dockerfile` +- `bin/docker-entrypoint` + +## Verification +- Health check: `curl https://upkeep-web-production.up.railway.app/up` → 200 +- Data verified: 9 areas, 9 equipment, 9 tasks, 9 logs, 3 supplies diff --git a/plans/005-pwa-badge-notifications.md b/plans/005-pwa-badge-notifications.md new file mode 100644 index 0000000..5049f39 --- /dev/null +++ b/plans/005-pwa-badge-notifications.md @@ -0,0 +1,43 @@ +# Plan 005: PWA Badge Notifications + +## Context + +The user added the web app to their iOS home screen. They want a notification badge on the app icon when there are overdue or due-soon tasks on the dashboard. iOS 16.4+ supports `navigator.setAppBadge()` for installed PWAs. + +## Approach + +Rails 8 already scaffolded PWA files (manifest + service worker) — they just needed to be enabled and wired up. The badge logic runs as a Stimulus controller attached to ``. + +## What Was Done + +### PWA Infrastructure +1. **Routes**: Added `get "manifest"` and `get "service-worker"` routes pointing to `Rails::PwaController` +2. **Manifest** (`app/views/pwa/manifest.json.erb`): Added `short_name`, fixed `theme_color` → `#dc2626`, `background_color` → `#f9fafb` +3. **Service Worker** (`app/views/pwa/service-worker.js`): Replaced commented-out code with minimal install/activate handlers + +### Badge Controller (`app/javascript/controllers/badge_controller.js`) +- Registers the service worker on connect +- Fetches dashboard JSON (`/` with Accept: application/json header) +- Counts overdue + due_soon tasks +- Calls `navigator.setAppBadge(count)` or `navigator.clearAppBadge()` +- Polls every 5 minutes while app is open +- Updates on `visibilitychange` (when app returns to foreground) +- Shows one-time permission banner (iOS requires Notification permission for badges) +- Stores dismissed state in localStorage + +### Layout Changes +- Added `` tag +- Added `data-controller="badge"` to `` +- Added permission banner (hidden by default, shown via JS when conditions met) + +## Limitations +- Badge only updates when the app is open (no server-side push notifications) +- Requires notification permission to be granted on iOS +- User must re-add app to home screen after PWA changes to pick up new manifest + +## Files Modified/Created +- `config/routes.rb` — 2 PWA routes +- `app/views/pwa/manifest.json.erb` — colors, short_name +- `app/views/pwa/service-worker.js` — minimal SW +- `app/views/layouts/application.html.erb` — manifest link, badge controller, permission banner +- `app/javascript/controllers/badge_controller.js` — **new file** diff --git a/plans/006-documentation-workflow.md b/plans/006-documentation-workflow.md new file mode 100644 index 0000000..cff4981 --- /dev/null +++ b/plans/006-documentation-workflow.md @@ -0,0 +1,23 @@ +# Plan 006: Documentation Workflow + +## Context + +Plans were living in Claude Code's internal `~/.claude/plans/` directory and not committed to the repo. The user wants plans in the repo for reference, changelogs to reference plans, SPEC.md to stay in sync with the app, and CLAUDE.md to document all of this. + +## What Was Done + +1. Created `plans/` directory in the repo root +2. Reconstructed and committed plans for all major phases (001–006) +3. Updated `CHANGELOG.md` to reference plan files in each entry +4. Updated `SPEC.md` to reflect current app behavior (2-week window, log page, PWA, etc.) +5. Added "Documentation Workflow" section to `CLAUDE.md` establishing conventions: + - Plans go in `plans/NNN-short-name.md`, committed with the feature + - Changelog updated after every functional change, referencing the plan + - SPEC.md updated after every functional change to reflect current behavior + - CLAUDE.md updated with new conventions or workflow changes + +## Files Modified/Created +- `plans/001-initial-build.md` through `plans/006-documentation-workflow.md` — new files +- `CHANGELOG.md` — plan references added +- `SPEC.md` — updated to match current app +- `CLAUDE.md` — documentation workflow section added From e9d0e7ccfc9243468e966a3a5997ff0807fd327d Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Tue, 10 Feb 2026 16:20:46 -0800 Subject: [PATCH 04/23] Add Web Push badge notifications for background badge updates Server-side push via web-push gem with VAPID auth. BadgeNotificationJob runs every 12 hours, sends push when task count changes. Service worker updates badge even when app is closed. Job self-reports failures via push. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 +++ CLAUDE.md | 13 +++- Gemfile | 3 + Gemfile.lock | 7 ++ SPEC.md | 10 ++- .../push_subscriptions_controller.rb | 21 +++++ .../controllers/badge_controller.js | 61 ++++++++++++++- app/jobs/badge_notification_job.rb | 78 +++++++++++++++++++ app/models/push_subscription.rb | 5 ++ app/views/layouts/application.html.erb | 2 +- app/views/pwa/service-worker.js | 40 +++++++++- config/credentials.yml.enc | 2 +- config/recurring.yml | 3 + config/routes.rb | 2 + ...0260211001244_create_push_subscriptions.rb | 13 ++++ db/schema.rb | 11 ++- plans/007-web-push-badge-notifications.md | 41 ++++++++++ .../push_subscriptions_controller_test.rb | 50 ++++++++++++ test/fixtures/push_subscriptions.yml | 9 +++ test/jobs/badge_notification_job_test.rb | 14 ++++ test/models/push_subscription_test.rb | 41 ++++++++++ 21 files changed, 426 insertions(+), 12 deletions(-) create mode 100644 app/controllers/push_subscriptions_controller.rb create mode 100644 app/jobs/badge_notification_job.rb create mode 100644 app/models/push_subscription.rb create mode 100644 db/migrate/20260211001244_create_push_subscriptions.rb create mode 100644 plans/007-web-push-badge-notifications.md create mode 100644 test/controllers/push_subscriptions_controller_test.rb create mode 100644 test/fixtures/push_subscriptions.yml create mode 100644 test/jobs/badge_notification_job_test.rb create mode 100644 test/models/push_subscription_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 88de461..b07de9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Upkeep are documented here. +## 2026-02-10 — Web Push Badge Notifications + +Plan: [plans/007-web-push-badge-notifications.md](plans/007-web-push-badge-notifications.md) + +- Added server-side Web Push notifications using VAPID authentication (`web-push` gem) +- New `PushSubscription` model stores browser push subscriptions +- Badge controller now subscribes to push after notification permission is granted +- Service worker handles `push` events to show notifications and update the app badge +- `BadgeNotificationJob` runs every 12 hours, sends push when overdue/due_soon count changes +- Job retries 3 times on failure, then self-reports via push notification +- Existing client-side polling kept as complementary mechanism + ## 2026-02-10 — Documentation Workflow Plan: [plans/006-documentation-workflow.md](plans/006-documentation-workflow.md) diff --git a/CLAUDE.md b/CLAUDE.md index d28cf45..249b635 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,8 @@ Notes for Claude Code sessions working on this project. - Propshaft asset pipeline, importmap-rails - Solid Queue + Solid Cache (sharing the primary database) - Minitest for testing -- PWA with service worker and app badge support +- PWA with service worker, app badge, and Web Push notifications +- `web-push` gem for VAPID-based push notifications ## Key Conventions @@ -78,25 +79,30 @@ app/ maintenance_tasks_controller.rb maintenance_logs_controller.rb # Log page supplies_controller.rb + push_subscriptions_controller.rb # Web Push subscription management models/ area.rb # has_many :equipment equipment.rb # belongs_to :area, has_many :maintenance_tasks maintenance_task.rb # Core model — scopes, complete!, due_status maintenance_log.rb # Completion records supply.rb # Inventory tracking with low_stock? + push_subscription.rb # Web Push subscription (endpoint, keys) views/ layouts/application.html.erb # Nav, badge controller, permission banner dashboard/index.html.erb # Main dashboard view pwa/ manifest.json.erb # PWA manifest - service-worker.js # Minimal service worker + service-worker.js # Service worker with push handlers javascript/ controllers/ - badge_controller.js # PWA badge + notification permission + badge_controller.js # PWA badge + push subscription + jobs/ + badge_notification_job.rb # Recurring job: push badge count changes lib/tasks/ upkeep.rake # Rake tasks for data management config/ routes.rb # Includes PWA routes + recurring.yml # Solid Queue recurring jobs database.yml # Production uses DATABASE_URL for all databases db/ seeds.rb # Default areas (10, but 3 were removed from DB) @@ -115,6 +121,7 @@ CLAUDE.md # This file — agent instructions - **Services**: upkeep-web (Rails app) + Postgres - **URL**: https://upkeep-web-production.up.railway.app - **Environment variables**: RAILS_MASTER_KEY, RAILS_ENV=production, SOLID_QUEUE_IN_PUMA=1, DATABASE_URL (auto-set) +- **VAPID keys** for Web Push are stored in Rails credentials (encrypted), not env vars - **Docker entrypoint** loads Solid Queue/Cache schemas into the shared database on first boot - **Dockerfile** uses Puma directly (not Thruster) to work with Railway's dynamic PORT diff --git a/Gemfile b/Gemfile index c63d087..3eb510c 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,9 @@ gem "stimulus-rails" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" +# Web Push notifications for PWA badge updates +gem "web-push" + # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/Gemfile.lock b/Gemfile.lock index b993298..6ab1f9d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,6 +122,8 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.18.1) + jwt (3.1.2) + base64 kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) @@ -181,6 +183,7 @@ GEM racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) + openssl (4.0.0) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.1) @@ -339,6 +342,9 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + web-push (3.1.0) + jwt (~> 3.0) + openssl (>= 3.0) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -377,6 +383,7 @@ DEPENDENCIES turbo-rails tzinfo-data web-console + web-push BUNDLED WITH 2.5.22 diff --git a/SPEC.md b/SPEC.md index 71ee9f6..44ec206 100644 --- a/SPEC.md +++ b/SPEC.md @@ -417,7 +417,15 @@ The app is a Progressive Web App that can be added to the iOS/Android home scree - Badge count = number of overdue + due_soon tasks - Updates on page load, every 5 minutes, and on `visibilitychange` - Requires notification permission on iOS (one-time permission banner shown) -- Badge only updates while the app is open (no server-side push notifications) + +### Web Push Notifications +- Server-side push via `web-push` gem with VAPID authentication +- After granting notification permission, the browser subscribes to push and sends the subscription to `POST /push_subscriptions` +- `BadgeNotificationJob` runs every 12 hours via Solid Queue recurring schedule +- Sends push notification when overdue + due_soon task count changes +- Service worker receives push, shows notification, and updates app badge — even when app is closed +- Job retries 3 times on failure, then sends a failure notification via push +- Expired/invalid subscriptions are automatically cleaned up --- diff --git a/app/controllers/push_subscriptions_controller.rb b/app/controllers/push_subscriptions_controller.rb new file mode 100644 index 0000000..d842307 --- /dev/null +++ b/app/controllers/push_subscriptions_controller.rb @@ -0,0 +1,21 @@ +class PushSubscriptionsController < ApplicationController + skip_forgery_protection only: [ :create, :destroy ] + + def create + subscription = PushSubscription.find_or_initialize_by(endpoint: params[:endpoint]) + subscription.p256dh = params[:keys][:p256dh] + subscription.auth = params[:keys][:auth] + + if subscription.save + head :created + else + head :unprocessable_entity + end + end + + def destroy + subscription = PushSubscription.find_by(endpoint: params[:endpoint]) + subscription&.destroy + head :ok + end +end diff --git a/app/javascript/controllers/badge_controller.js b/app/javascript/controllers/badge_controller.js index 3a5dfa4..460e990 100644 --- a/app/javascript/controllers/badge_controller.js +++ b/app/javascript/controllers/badge_controller.js @@ -5,7 +5,8 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["permissionBanner"] static values = { - pollInterval: { type: Number, default: 300000 } // 5 minutes + pollInterval: { type: Number, default: 300000 }, // 5 minutes + vapidPublicKey: String } connect() { @@ -27,7 +28,7 @@ export default class extends Controller { if (!("serviceWorker" in navigator)) return try { - await navigator.serviceWorker.register("/service-worker", { scope: "/" }) + this.swRegistration = await navigator.serviceWorker.register("/service-worker", { scope: "/" }) } catch (error) { console.warn("Service worker registration failed:", error) } @@ -72,8 +73,13 @@ export default class extends Controller { async requestPermission() { if (typeof Notification === "undefined") return - await Notification.requestPermission() + const result = await Notification.requestPermission() this.dismissBanner() + + if (result === "granted") { + await this.subscribeToPush() + } + this.updateBadge() } @@ -84,6 +90,55 @@ export default class extends Controller { } } + // --- Push Subscription --- + + async subscribeToPush() { + if (!this.swRegistration) return + if (!this.hasVapidPublicKeyValue) return + + try { + const existingSub = await this.swRegistration.pushManager.getSubscription() + if (existingSub) return // already subscribed + + const applicationServerKey = this.urlBase64ToUint8Array(this.vapidPublicKeyValue) + const subscription = await this.swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey + }) + + await this.sendSubscriptionToServer(subscription) + } catch (error) { + console.warn("Push subscription failed:", error) + } + } + + async sendSubscriptionToServer(subscription) { + const data = subscription.toJSON() + + await fetch("/push_subscriptions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: data.endpoint, + keys: { + p256dh: data.keys.p256dh, + auth: data.keys.auth + } + }) + }) + } + + urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") + const rawData = atob(base64) + const outputArray = new Uint8Array(rawData.length) + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + return outputArray + } + // --- Polling --- startPolling() { diff --git a/app/jobs/badge_notification_job.rb b/app/jobs/badge_notification_job.rb new file mode 100644 index 0000000..935d6a8 --- /dev/null +++ b/app/jobs/badge_notification_job.rb @@ -0,0 +1,78 @@ +class BadgeNotificationJob < ApplicationJob + queue_as :default + + retry_on StandardError, wait: 5.minutes, attempts: 3 do |_job, error| + Rails.logger.error("BadgeNotificationJob failed after 3 attempts: #{error.message}") + notify_failure(error) + end + + def perform + count = MaintenanceTask.overdue.count + MaintenanceTask.due_soon.count + previous_count = Rails.cache.read("badge_notification_count") + + return if count == previous_count + + send_push_to_all(count: count, message: badge_message(count)) + Rails.cache.write("badge_notification_count", count) + end + + private + + def badge_message(count) + count > 0 ? "#{count} #{"task".pluralize(count)} need attention" : "All clear — nothing due!" + end + + def self.notify_failure(error) + vapid = Rails.application.credentials.vapid + return unless vapid + + payload = { count: 1, message: "Badge job failed: #{error.message.truncate(100)}" }.to_json + + PushSubscription.find_each do |sub| + WebPush.payload_send( + message: payload, + endpoint: sub.endpoint, + p256dh: sub.p256dh, + auth: sub.auth, + vapid: { + public_key: vapid[:public_key], + private_key: vapid[:private_key], + subject: vapid[:subject] + } + ) + rescue WebPush::Error + # Can't do much if the failure notification also fails + end + rescue => e + Rails.logger.error("Failed to send failure notification: #{e.message}") + end + + def send_push_to_all(count:, message:) + vapid = Rails.application.credentials.vapid + return unless vapid + + payload = { count: count, message: message }.to_json + + PushSubscription.find_each do |sub| + send_push(sub, payload, vapid) + end + end + + def send_push(subscription, payload, vapid) + WebPush.payload_send( + message: payload, + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + vapid: { + public_key: vapid[:public_key], + private_key: vapid[:private_key], + subject: vapid[:subject] + } + ) + rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription + subscription.destroy + rescue WebPush::ResponseError => e + Rails.logger.warn("Push failed for subscription #{subscription.id}: #{e.message}") + end +end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000..e7d2b8d --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,5 @@ +class PushSubscription < ApplicationRecord + validates :endpoint, presence: true, uniqueness: true + validates :p256dh, presence: true + validates :auth, presence: true +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b933cef..045ba4b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -20,7 +20,7 @@ <%= javascript_importmap_tags %> - +