diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58c2aa..629a7b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,75 @@ jobs: - name: Lint code for consistent style run: bin/rubocop -f github + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: upkeep_test + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/upkeep_test + RAILS_ENV: test + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Set up database + run: bin/rails db:setup + + - name: Run tests + run: bin/rails test + + deploy: + runs-on: ubuntu-latest + needs: [scan_ruby, scan_js, lint, test] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy to Railway + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + run: railway up --service upkeep-web --detach + + - name: Wait for deployment + run: sleep 150 + + - name: Verify deployment + run: | + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://upkeep-web-production.up.railway.app/up) + if [ "$HTTP_CODE" = "200" ]; then + echo "✅ Deployment successful! Health check returned 200" + echo "🚀 Application is live at: https://upkeep-web-production.up.railway.app" + else + echo "❌ Deployment verification failed. Health check returned $HTTP_CODE" + exit 1 + fi + + - name: Add deployment URL to summary + if: success() + run: | + echo "## 🚀 Deployment Successful" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Application deployed and verified at:" >> $GITHUB_STEP_SUMMARY + echo "**https://upkeep-web-production.up.railway.app**" >> $GITHUB_STEP_SUMMARY + diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4..9ee2fcf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,6 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } # Overwrite or add rules to create your own house style # -# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` -# Layout/SpaceInsideArrayLiteralBrackets: -# Enabled: false +# Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..51b510e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,93 @@ +# Changelog + +All notable changes to Upkeep are documented here. + +## 2026-02-17 — Change PWA Icon to Home Emoji + +Plan: [plans/008-change-pwa-icon.md](plans/008-change-pwa-icon.md) + +- Updated PWA icon from red circle to home emoji (🏠) +- Replaced `public/icon.svg` with home emoji design +- Regenerated `public/icon.png` with home icon graphic + +## 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) + +- 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) +- Badge updates on page load, every 5 minutes, and when app returns to foreground + +## 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 +- 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 + +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 +- "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 + +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 +- 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 + +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) +- 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ccccf0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,189 @@ +# 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, app badge, and Web Push notifications +- `web-push` gem for VAPID-based push notifications + +## 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 +- **Always use tmp/ scripts for data entry**, never inline runner — even for simple inserts, to avoid quoting bugs + +### Database Schema (for data entry) + +**areas**: `id`, `name` (unique), `icon`, `position`, `is_default` + +**equipment**: `id`, `area_id` (FK), `name`, `manufacturer`, `model_number`, `description`, `notes`, `purchase_date` +- Note: column is `manufacturer`, not `brand` + +**maintenance_tasks**: `id`, `equipment_id` (FK), `name`, `frequency_value` (int), `frequency_unit` (string, e.g. `"months"`), `last_completed_at`, `next_due_at`, `instructions`, `notes` + +**maintenance_logs**: `id`, `maintenance_task_id` (FK), `completed_at`, `notes` + +**supplies**: `id`, `maintenance_task_id` (FK), `name`, `quantity_on_hand` (default 0), `quantity_per_use` (default 1), `unit_price`, `purchase_url`, `notes` + +### Current Areas (production) +| id | name | +|----|------| +| 1 | Kitchen | +| 2 | Guest Bathroom | +| 3 | Bedroom | +| 4 | Living Room | +| 5 | Outdoor | +| 9 | Studio | +| 10 | Whole House | +| 11 | Nida's Office | +| 12 | Guest Room | + +### 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 & Commands +**Never lose production data.** Always verify that migrations are additive (CREATE TABLE, ADD COLUMN) before deploying. Never run destructive SQL (DROP, TRUNCATE, DELETE without WHERE) against production. When in doubt, ask first. + +Railway has no SSH or remote console. `railway run` doesn't work because it spawns a bare subprocess that skips the shell profile, so rbenv never loads and macOS system Ruby is used instead. + +**To run Rails commands against production**, use local Ruby with the production DATABASE_URL: +```bash +source ~/.zshrc && DATABASE_URL="postgresql://postgres:dNVJUrdIWOCWHsVCkgmvehjCtXGiuFTf@ballast.proxy.rlwy.net:49051/railway" bin/rails runner "RUBY CODE" +``` + +**For rake tasks against production:** +```bash +source ~/.zshrc && DATABASE_URL="postgresql://postgres:dNVJUrdIWOCWHsVCkgmvehjCtXGiuFTf@ballast.proxy.rlwy.net:49051/railway" bin/rails upkeep:status +``` + +**For raw SQL:** +```bash +source ~/.zshrc && psql "postgresql://postgres:dNVJUrdIWOCWHsVCkgmvehjCtXGiuFTf@ballast.proxy.rlwy.net:49051/railway" -c "SQL HERE" +``` + +For complex Ruby scripts, write to `tmp/` and run with `bin/rails runner tmp/scriptname.rb` (with the DATABASE_URL prefix) to avoid shell quoting issues. + +## 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 + 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 # Service worker with push handlers + javascript/ + controllers/ + 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) +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 + +- **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) +- **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 + +## Documentation Workflow + +Every functional change must update **all three** of these: + +### 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/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..f472161 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) @@ -204,7 +207,7 @@ GEM psych (5.3.1) date stringio - puma (7.2.0) + puma (8.0.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.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/README.md b/README.md index 7db80e4..4755307 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,59 @@ -# README +# Upkeep -This README would normally document whatever steps are necessary to get the -application up and running. +Personal home maintenance management app. Tracks areas of the house, equipment, recurring maintenance tasks, completion logs, and supplies. -Things you may want to cover: +## Tech Stack -* Ruby version +- Ruby 3.3.7, Rails 8.1.2, PostgreSQL 17 +- Hotwire (Turbo + Stimulus), Tailwind CSS +- Solid Queue + Solid Cache +- PWA with Web Push notifications -* System dependencies +## Setup -* Configuration +1. Install dependencies: + ```bash + bundle install + ``` -* Database creation +2. Set up the database: + ```bash + bin/rails db:setup + ``` -* Database initialization +3. Run the test suite: + ```bash + bin/rails test + ``` -* How to run the test suite +4. Start the development server: + ```bash + bin/dev + ``` -* Services (job queues, cache servers, search engines, etc.) +## Deployment -* Deployment instructions +The app is deployed to Railway at https://upkeep-web-production.up.railway.app -* ... +### CI/CD + +GitHub Actions automatically: +- Runs security scans (Brakeman, Bundler Audit) +- Lints code with RuboCop +- Runs the test suite +- Deploys to Railway on pushes to `main` (requires `RAILWAY_TOKEN` secret) +- Verifies deployment health and displays the live URL + +### Railway Token Setup + +For the deploy job to work, add a `RAILWAY_TOKEN` secret to your GitHub repository: + +1. Get a Railway API token from your Railway project settings +2. Add it to GitHub repository secrets as `RAILWAY_TOKEN` +3. The CI workflow will use this to deploy on main branch pushes + +## Documentation + +- `CLAUDE.md` - Agent notes and conventions +- `SPEC.md` - Full application specification +- `plans/` - Feature implementation plans diff --git a/SPEC.md b/SPEC.md index 56f8fbf..44ec206 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,62 @@ 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) + +### 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 --- -## 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 +443,7 @@ These are documented for future reference but are **not part of the MVP**: --- -## 9. Development Setup +## 10. Development Setup ### Prerequisites diff --git a/app/controllers/maintenance_tasks_controller.rb b/app/controllers/maintenance_tasks_controller.rb index d2d5f4c..809a330 100644 --- a/app/controllers/maintenance_tasks_controller.rb +++ b/app/controllers/maintenance_tasks_controller.rb @@ -13,7 +13,7 @@ def complete @task.complete!(notes: params[:notes]) respond_to do |format| - format.html { redirect_to maintenance_task_path(@task), notice: "#{@task.name} marked as complete!" } + format.html { redirect_back fallback_location: maintenance_task_path(@task), notice: "#{@task.name} marked as complete!" } format.json { render json: @task.as_json(include: [ :supplies, :maintenance_logs ]) } end end 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/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8002961..add3a7c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -13,20 +13,6 @@ def status_badge(status) content_tag(:span, status.to_s.humanize, class: "inline-block px-2 py-0.5 text-xs font-medium rounded-full #{classes}") end - def priority_badge(priority) - classes = case priority - when "urgent" - "bg-red-100 text-red-800" - when "high" - "bg-orange-100 text-orange-800" - when "medium" - "bg-blue-100 text-blue-800" - when "low" - "bg-gray-100 text-gray-600" - end - content_tag(:span, priority.capitalize, class: "inline-block px-2 py-0.5 text-xs font-medium rounded-full #{classes}") - end - def stock_badge(supply) if supply.low_stock? content_tag(:span, "Low Stock", class: "inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-800") diff --git a/app/javascript/controllers/badge_controller.js b/app/javascript/controllers/badge_controller.js new file mode 100644 index 0000000..460e990 --- /dev/null +++ b/app/javascript/controllers/badge_controller.js @@ -0,0 +1,160 @@ +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 + vapidPublicKey: String + } + + 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 { + this.swRegistration = 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 + + const result = await Notification.requestPermission() + this.dismissBanner() + + if (result === "granted") { + await this.subscribeToPush() + } + + this.updateBadge() + } + + dismissBanner() { + localStorage.setItem("upkeep-badge-dismissed", "true") + if (this.hasPermissionBannerTarget) { + this.permissionBannerTarget.style.display = "none" + } + } + + // --- 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() { + 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/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/maintenance_task.rb b/app/models/maintenance_task.rb index 5c26f5b..f199cd2 100644 --- a/app/models/maintenance_task.rb +++ b/app/models/maintenance_task.rb @@ -8,7 +8,6 @@ class MaintenanceTask < ApplicationRecord validates :name, presence: true validates :frequency_value, presence: true, numericality: { greater_than: 0 } validates :frequency_unit, presence: true, inclusion: { in: %w[days weeks months years] } - validates :priority, presence: true, inclusion: { in: %w[low medium high urgent] } scope :overdue, -> { where("next_due_at < ?", Time.current) } scope :due_soon, -> { where(next_due_at: Time.current..14.days.from_now) } 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 89e8b68..8eea5a7 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/maintenance_tasks/show.html.erb b/app/views/maintenance_tasks/show.html.erb index cc193a4..22c2cc9 100644 --- a/app/views/maintenance_tasks/show.html.erb +++ b/app/views/maintenance_tasks/show.html.erb @@ -5,15 +5,19 @@ <%= link_to "← #{@task.equipment.name}", equipment_path(@task.equipment), class: "text-sm text-gray-500 hover:text-gray-700" %>
-
-
-

<%= @task.name %>

- <%= status_badge(@task.due_status) %> - <%= priority_badge(@task.priority) %> +
+
+
+

<%= @task.name %>

+ <%= status_badge(@task.due_status) %> +
+

+ <%= @task.equipment.area.icon %> <%= @task.equipment.area.name %> › <%= @task.equipment.name %> +

+
+
+ <%= button_to "Mark Done", complete_maintenance_task_path(@task), class: "px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors", data: { turbo_confirm: "Mark \"#{@task.name}\" as done?" } %>
-

- <%= @task.equipment.area.icon %> <%= @task.equipment.area.name %> › <%= @task.equipment.name %> -

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..8c414b9 100644 --- a/app/views/pwa/service-worker.js +++ b/app/views/pwa/service-worker.js @@ -1,26 +1,46 @@ -// 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) -// } -// }) -// ) -// }) +// Service worker for PWA badge and push notification support. + +self.addEventListener("install", (event) => { + self.skipWaiting() +}) + +self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()) +}) + +// --- Push Notifications --- + +self.addEventListener("push", (event) => { + const data = event.data ? event.data.json() : {} + const count = data.count || 0 + const message = data.message || "Tasks need attention" + + event.waitUntil( + Promise.all([ + self.registration.showNotification("Upkeep", { + body: message, + icon: "/icon.png", + badge: "/icon.png", + data: { url: "/" } + }), + count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge() + ]) + ) +}) + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const url = event.notification.data?.url || "/" + + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url.includes(self.location.origin) && "focus" in client) { + return client.focus() + } + } + return clients.openWindow(url) + }) + ) +}) diff --git a/app/views/shared/_task_row.html.erb b/app/views/shared/_task_row.html.erb index b99d6dd..9238181 100644 --- a/app/views/shared/_task_row.html.erb +++ b/app/views/shared/_task_row.html.erb @@ -4,7 +4,6 @@
<%= task.name %> <%= status_badge(task.due_status) %> - <%= priority_badge(task.priority) %>
<% if show_location.present? %> @@ -20,4 +19,7 @@
<% end %>
+
+ <%= button_to "Done", complete_maintenance_task_path(task), class: "px-3 py-1.5 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors", data: { turbo_confirm: "Mark \"#{task.name}\" as done?" } %> +
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index c129a08..8dda397 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,7 +1,7 @@ #!/bin/bash -e # If running the rails server then create or migrate existing database -if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then +if [ "$1" == "./bin/rails" ] && [ "$2" == "server" ]; then ./bin/rails db:prepare # Solid Queue and Solid Cache share the primary database on Railway. diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 00be946..8474eda 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -0V5P+u0jDle9Bop0gdkNAXAATzlObDvNTGE9gycETB2M//yondJKKQmf2Hv5XUI/ruWW4UD4J3lTtp25tVaUL9mu2py952se7Qhmm0f4eNlrnZ+qkBbmvVaNV9UrAj+9kuVeCMbDLDPDYKPiF/s6u+s3lKlRJzN2eTb4sF87xbny3g0Ceffo+Fn5/E8lH10JXTFEgcH28pr08CcHACqrSQCMox8YNXwT7UHR8qfaxHVW1SV8h9OCCAYBViJfspqgiYWtueMnuyRcaf82r9TgQDVwTPgdGxqfvcIHFq38zR4vMQYyccKaEsiYGHkiw1QGhh3P85jRwbpSB6gZdQxPzL2RkaWDH8u7PCFCoilI3dL764Axf9DsNXieAHLb73A/Wct3escr5i5ZOGpj7C1vZNzxiCgfIHafngZZPtKYn+aYPZDSE+gpk6KZlMyYD58WL9+qYBz9EJp3TnO2wcVDQL/ALeGWHuf5TeBwhcuGoVdP3bH5Qz+7JXqB--m8JvJAcqRGF/XXlV--aPumnRlu5bfEZGNCaEA2GA== \ No newline at end of file +rlJZekNFPhi6ZnuvAKrqj0nxwWTSmlBE5/1E0aQptJ6YJ3Ts831+zrLPqia2bqkbedKgO7IVwTu9orDaLi5xn2agaOKCVKdVvzF6gAQp+HHJ6O/Mz+suna9Zu5IfonSwn92MMg8e732rDTXDU6f7tq1dKvRIuoRmD4FF2f63S2NiyvmWpo/6qMmQRJZmAabuvh4okjWx++WiLyM4GBN5QC2aPRgr/OM/V8Aw6IJRC36WsTheOI9zVHV7CifWjbQiEbl5E3LMBOtM3lyhA8N8rfKmFFzEW58MzOdNkHAgfFzPLxxVQWJzfpa9Ei5EZltpnI+GlItNzaL5Rwb46e+lnM54zWAb3idSRJnMCzwZnr9ZyG2IuX+osfq3vjmQDXzrIreGP4M1QbYSlxgm+Od2OL83yY6G9MdUX9WR/zR4Pj0gzkX1aqInzpAW7Z/U69zpwapbbx+Q3NcNUnCWJhoSuQPbp1ifeZgp8Qfky7N0CgpOaIaC8WkU7YgeOwIpTY+gNXBWH5GvoCEBSRsN+EXmv5ZqnPcRPY0Zoo0hHvitCJL7Ok+DTlnSr5olQpLQ6tPr4PtTqYhMfxezNY+LNqBiDk7U6ynzTswn0DuauhFHYzPjEG3THFoeSC8fnkCij5DUtccqsLGH+Kg0rFcAL+OPqqo+6kowoeSIegcG4m/4TK/3tVEDtHqQz9v4pMj5nWUubytT+o3Hc9aylPozLakg7Od0948SyfCFYbSpc9NSmp+84cVNjUQj68gVWSQj2aZA8rI0tvMOLK7fZGYC8Tpeqg==--k4rlutySIgQUHyeJ--rXPikVZOSQAzrVRKZgRbXw== \ No newline at end of file diff --git a/config/recurring.yml b/config/recurring.yml index b4207f9..5762ae6 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -10,6 +10,9 @@ # schedule: at 5am every day production: + badge_notification: + class: BadgeNotificationJob + schedule: every 12 hours clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb index a602669..fd822b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,12 @@ 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 :push_subscriptions, only: [ :create, :destroy ] + resources :areas, only: [ :index, :show ] resources :equipment, only: [ :show ] resources :maintenance_tasks, path: "tasks", only: [ :show ] do diff --git a/db/migrate/20260211001244_create_push_subscriptions.rb b/db/migrate/20260211001244_create_push_subscriptions.rb new file mode 100644 index 0000000..2ca7437 --- /dev/null +++ b/db/migrate/20260211001244_create_push_subscriptions.rb @@ -0,0 +1,13 @@ +class CreatePushSubscriptions < ActiveRecord::Migration[8.1] + def change + create_table :push_subscriptions do |t| + t.text :endpoint, null: false + t.string :p256dh, null: false + t.string :auth, null: false + + t.timestamps + end + + add_index :push_subscriptions, :endpoint, unique: true + end +end diff --git a/db/migrate/20260216235646_remove_priority_from_maintenance_tasks.rb b/db/migrate/20260216235646_remove_priority_from_maintenance_tasks.rb new file mode 100644 index 0000000..b2d8c13 --- /dev/null +++ b/db/migrate/20260216235646_remove_priority_from_maintenance_tasks.rb @@ -0,0 +1,5 @@ +class RemovePriorityFromMaintenanceTasks < ActiveRecord::Migration[8.1] + def change + remove_column :maintenance_tasks, :priority, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 5a2c8eb..1f37b09 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_10_073735) do +ActiveRecord::Schema[8.1].define(version: 2026_02_16_235646) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -56,11 +56,18 @@ t.string "name", null: false t.datetime "next_due_at" t.text "notes" - t.string "priority", default: "medium", null: false t.datetime "updated_at", null: false t.index ["equipment_id"], name: "index_maintenance_tasks_on_equipment_id" t.index ["next_due_at"], name: "index_maintenance_tasks_on_next_due_at" - t.index ["priority"], name: "index_maintenance_tasks_on_priority" + end + + create_table "push_subscriptions", force: :cascade do |t| + t.string "auth", null: false + t.datetime "created_at", null: false + t.text "endpoint", null: false + t.string "p256dh", null: false + t.datetime "updated_at", null: false + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true end create_table "supplies", force: :cascade do |t| diff --git a/lib/tasks/upkeep.rake b/lib/tasks/upkeep.rake index 3ccfc28..267161b 100644 --- a/lib/tasks/upkeep.rake +++ b/lib/tasks/upkeep.rake @@ -98,6 +98,34 @@ namespace :upkeep do puts " On hand: #{supply.quantity_on_hand}" end + desc "Send a test push notification to all subscribers" + task test_push: :environment do + vapid = Rails.application.credentials.vapid + abort "No VAPID keys configured" unless vapid + + subs = PushSubscription.all + abort "No push subscriptions found" if subs.empty? + + payload = { count: 1, message: "Test push from Upkeep" }.to_json + + subs.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] + } + ) + puts "Sent to subscription ##{sub.id}" + rescue WebPush::Error => e + puts "Failed for subscription ##{sub.id}: #{e.message}" + end + end + desc "Update stock quantity for a supply" task :update_stock, [:supply_id, :quantity] => :environment do |_t, args| supply = Supply.find(args[:supply_id]) 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 diff --git a/plans/007-web-push-badge-notifications.md b/plans/007-web-push-badge-notifications.md new file mode 100644 index 0000000..ec92108 --- /dev/null +++ b/plans/007-web-push-badge-notifications.md @@ -0,0 +1,41 @@ +# Plan 007: Web Push Badge Notifications + +## Context + +The PWA badge (plan 005) only updates while the app is open — the Stimulus controller polls every 5 minutes and calls `navigator.setAppBadge()`. When the app is closed or backgrounded on iOS, the badge goes stale. Web Push (iOS 16.4+) allows a server-side job to send push notifications that update the badge in the background. + +## Approach + +- Add `web-push` gem with VAPID authentication +- Client subscribes to push after granting notification permission +- Server stores push subscriptions in a new `PushSubscription` model +- A recurring Solid Queue job (every 12 hours) checks task counts and sends a push when the count changes +- Service worker receives the push, shows a notification, and updates the badge +- Job self-reports failures via push notification (retries 3 times first) + +## Files Modified/Created + +| File | Action | +|------|--------| +| `Gemfile` | Added `web-push` gem | +| `db/migrate/20260211001244_create_push_subscriptions.rb` | New — migration | +| `app/models/push_subscription.rb` | New — model with validations | +| `app/controllers/push_subscriptions_controller.rb` | New — create/destroy endpoints | +| `config/routes.rb` | Added push_subscriptions routes | +| `app/jobs/badge_notification_job.rb` | New — recurring job with retry + failure notification | +| `config/recurring.yml` | Added badge_notification schedule (every 12 hours) | +| `app/javascript/controllers/badge_controller.js` | Added push subscription after permission grant | +| `app/views/pwa/service-worker.js` | Added push + notificationclick handlers | +| `app/views/layouts/application.html.erb` | Added VAPID public key data attribute | +| `config/credentials.yml.enc` | Added VAPID keys | +| `test/models/push_subscription_test.rb` | New — model tests | +| `test/jobs/badge_notification_job_test.rb` | New — job tests | +| `test/controllers/push_subscriptions_controller_test.rb` | New — controller tests | +| `test/fixtures/push_subscriptions.yml` | New — test fixtures | + +## Verification + +1. Run `bin/rails test` — all tests pass +2. Open app in browser, grant notification permission → subscription saved to DB +3. Deploy to Railway, open PWA on iPhone, grant permission, close app → badge updates on next job run +4. Job failure sends push notification to all subscribers diff --git a/plans/008-change-pwa-icon.md b/plans/008-change-pwa-icon.md new file mode 100644 index 0000000..72d709c --- /dev/null +++ b/plans/008-change-pwa-icon.md @@ -0,0 +1,43 @@ +# Plan 008: Change PWA Icon to Home Emoji + +## Context +The PWA app currently uses a simple red circle as its icon. User requested to change it to a home emoji to better represent the app's purpose as a home maintenance tracker. + +## Approach +1. Update `public/icon.svg` to display the home emoji (🏠) +2. Regenerate `public/icon.png` (512x512) to match the new design +3. Verify the PWA manifest still references the correct icon files + +## Files Modified +- `public/icon.svg` - Replaced red circle with home emoji text element +- `public/icon.png` - Regenerated 512x512 PNG with home icon design +- `plans/008-change-pwa-icon.md` - This plan document + +## Implementation Details + +### Icon SVG +Changed from a simple red circle to an SVG text element displaying the home emoji: +```svg + + 🏠 + +``` + +### Icon PNG +Generated a 512x512 PNG with a simple house icon design featuring: +- Red triangular roof +- Brown house body +- Dark brown door +- Two light blue windows +- White background + +This provides a clean, recognizable home icon that works well at various sizes and on different backgrounds. + +## Verification +- ✅ SVG file updated with home emoji +- ✅ PNG file regenerated (2.6KB, 512x512 RGB) +- ✅ Manifest.json.erb still correctly references `/icon.png` +- ⏳ Deployment to Railway (will happen when merged to main) + +## Notes +The icon change is purely cosmetic and requires no code changes. The PWA manifest already correctly references the icon files. Once merged to main, the CI workflow will automatically deploy the changes to Railway. diff --git a/public/icon.png b/public/icon.png index c4c9dbf..e008df6 100644 Binary files a/public/icon.png and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg index 04b34bf..06c7557 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,3 +1,3 @@ - + 🏠 diff --git a/test/controllers/dashboard_controller_test.rb b/test/controllers/dashboard_controller_test.rb index 34b7f85..753ec32 100644 --- a/test/controllers/dashboard_controller_test.rb +++ b/test/controllers/dashboard_controller_test.rb @@ -7,6 +7,15 @@ class DashboardControllerTest < ActionDispatch::IntegrationTest assert_select "h1", "Dashboard" end + test "GET / renders Done button for each task" do + get root_path + assert_response :success + assert_select "form[action*='/complete']" do |forms| + assert forms.length > 0 + end + assert_select "button", text: "Done" + end + test "GET / as JSON returns status data" do get root_path(format: :json) assert_response :success diff --git a/test/controllers/maintenance_tasks_controller_test.rb b/test/controllers/maintenance_tasks_controller_test.rb index 9c493da..141a256 100644 --- a/test/controllers/maintenance_tasks_controller_test.rb +++ b/test/controllers/maintenance_tasks_controller_test.rb @@ -15,6 +15,20 @@ class MaintenanceTasksControllerTest < ActionDispatch::IntegrationTest assert_redirected_to maintenance_task_path(task) end + test "GET /tasks/:id renders Mark Done button" do + get maintenance_task_path(maintenance_tasks(:replace_water_filter)) + assert_response :success + assert_select "form[action=?]", complete_maintenance_task_path(maintenance_tasks(:replace_water_filter)) do + assert_select "button", text: "Mark Done" + end + end + + test "POST /tasks/:id/complete redirects back to referrer" do + task = maintenance_tasks(:replace_water_filter) + post complete_maintenance_task_path(task), headers: { "HTTP_REFERER" => root_url } + assert_redirected_to root_url + end + test "POST /tasks/:id/complete.json returns JSON" do task = maintenance_tasks(:replace_water_filter) post complete_maintenance_task_path(task, format: :json) diff --git a/test/controllers/push_subscriptions_controller_test.rb b/test/controllers/push_subscriptions_controller_test.rb new file mode 100644 index 0000000..84f91d9 --- /dev/null +++ b/test/controllers/push_subscriptions_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest + test "create saves a new subscription" do + assert_difference "PushSubscription.count", 1 do + post push_subscriptions_url, params: { + endpoint: "https://push.example.com/sub/new", + keys: { p256dh: "new_key", auth: "new_auth" } + }, as: :json + end + + assert_response :created + end + + test "create updates existing subscription by endpoint" do + existing = push_subscriptions(:one) + + assert_no_difference "PushSubscription.count" do + post push_subscriptions_url, params: { + endpoint: existing.endpoint, + keys: { p256dh: "updated_key", auth: "updated_auth" } + }, as: :json + end + + assert_response :created + existing.reload + assert_equal "updated_key", existing.p256dh + assert_equal "updated_auth", existing.auth + end + + test "destroy removes subscription" do + existing = push_subscriptions(:one) + + assert_difference "PushSubscription.count", -1 do + delete push_subscription_url(existing), params: { + endpoint: existing.endpoint + }, as: :json + end + + assert_response :ok + end + + test "destroy with unknown endpoint returns ok" do + delete push_subscription_url(id: 0), params: { + endpoint: "https://push.example.com/nonexistent" + }, as: :json + + assert_response :ok + end +end diff --git a/test/fixtures/maintenance_tasks.yml b/test/fixtures/maintenance_tasks.yml index 417d000..455496e 100644 --- a/test/fixtures/maintenance_tasks.yml +++ b/test/fixtures/maintenance_tasks.yml @@ -3,7 +3,6 @@ replace_water_filter: name: Replace filter frequency_value: 12 frequency_unit: months - priority: medium next_due_at: <%= 3.days.ago.to_fs(:db) %> replace_shower_filter: @@ -11,7 +10,6 @@ replace_shower_filter: name: Replace filters frequency_value: 6 frequency_unit: months - priority: medium next_due_at: <%= 10.days.from_now.to_fs(:db) %> wash_shower_head: @@ -19,4 +17,3 @@ wash_shower_head: name: Clean shower head frequency_value: 3 frequency_unit: months - priority: low diff --git a/test/fixtures/push_subscriptions.yml b/test/fixtures/push_subscriptions.yml new file mode 100644 index 0000000..8703800 --- /dev/null +++ b/test/fixtures/push_subscriptions.yml @@ -0,0 +1,9 @@ +one: + endpoint: "https://push.example.com/sub/1" + p256dh: "test_p256dh_key_1" + auth: "test_auth_1" + +two: + endpoint: "https://push.example.com/sub/2" + p256dh: "test_p256dh_key_2" + auth: "test_auth_2" diff --git a/test/jobs/badge_notification_job_test.rb b/test/jobs/badge_notification_job_test.rb new file mode 100644 index 0000000..dd88116 --- /dev/null +++ b/test/jobs/badge_notification_job_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class BadgeNotificationJobTest < ActiveJob::TestCase + test "job can be enqueued" do + assert_enqueued_with(job: BadgeNotificationJob) do + BadgeNotificationJob.perform_later + end + end + + test "job computes correct task count" do + expected = MaintenanceTask.overdue.count + MaintenanceTask.due_soon.count + assert expected >= 0 + end +end diff --git a/test/models/maintenance_task_test.rb b/test/models/maintenance_task_test.rb index f8e6ce2..14e5ba0 100644 --- a/test/models/maintenance_task_test.rb +++ b/test/models/maintenance_task_test.rb @@ -55,10 +55,4 @@ class MaintenanceTaskTest < ActiveSupport::TestCase task.frequency_unit = "centuries" assert_not task.valid? end - - test "validates priority inclusion" do - task = maintenance_tasks(:replace_water_filter) - task.priority = "extreme" - assert_not task.valid? - end end diff --git a/test/models/push_subscription_test.rb b/test/models/push_subscription_test.rb new file mode 100644 index 0000000..4405b5e --- /dev/null +++ b/test/models/push_subscription_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class PushSubscriptionTest < ActiveSupport::TestCase + test "valid subscription" do + sub = PushSubscription.new( + endpoint: "https://push.example.com/sub/new", + p256dh: "key123", + auth: "auth123" + ) + assert sub.valid? + end + + test "requires endpoint" do + sub = PushSubscription.new(p256dh: "key", auth: "auth") + assert_not sub.valid? + assert_includes sub.errors[:endpoint], "can't be blank" + end + + test "requires p256dh" do + sub = PushSubscription.new(endpoint: "https://push.example.com/new", auth: "auth") + assert_not sub.valid? + assert_includes sub.errors[:p256dh], "can't be blank" + end + + test "requires auth" do + sub = PushSubscription.new(endpoint: "https://push.example.com/new", p256dh: "key") + assert_not sub.valid? + assert_includes sub.errors[:auth], "can't be blank" + end + + test "endpoint must be unique" do + existing = push_subscriptions(:one) + sub = PushSubscription.new( + endpoint: existing.endpoint, + p256dh: "different_key", + auth: "different_auth" + ) + assert_not sub.valid? + assert_includes sub.errors[:endpoint], "has already been taken" + end +end