From 163d71d50512c1118d823303b56b9d7534193e24 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Wed, 25 Mar 2026 07:56:35 -0400 Subject: [PATCH] v0.12.27 schedule list view update --- CLAUDE.md | 99 +++++++++++++++++++++++- backend/package.json | 2 +- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/SchedulePage.jsx | 17 +++- 5 files changed, 113 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a63b189..da94c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -300,10 +300,105 @@ Single-user add/remove via `groups.js` (GroupInfoModal) always uses the named me --- +## FCM Push Notifications + +**Status:** Working on Android (v0.12.26+). iOS in progress. + +### Overview + +Push notifications use Firebase Cloud Messaging (FCM) — not the older web-push/VAPID approach. VAPID env vars are still present (auto-generated on first start) but are no longer used for push delivery. + +### Firebase Project Setup + +1. Create a Firebase project at console.firebase.google.com +2. Add a **Web app** to the project → copy the web app config values into `.env` +3. In Project Settings → Cloud Messaging → **Web Push certificates** → generate a key pair → copy the public key as `FIREBASE_VAPID_KEY` +4. In Project Settings → Service accounts → Generate new private key → download JSON → stringify it (remove all newlines) → set as `FIREBASE_SERVICE_ACCOUNT` in `.env` + +Required `.env` vars: +``` +FIREBASE_API_KEY= +FIREBASE_PROJECT_ID= +FIREBASE_APP_ID= +FIREBASE_MESSAGING_SENDER_ID= +FIREBASE_VAPID_KEY= # Web Push certificate public key (from Cloud Messaging tab) +FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only) +``` + +### Architecture + +``` +Frontend (browser/PWA) + └─ usePushNotifications hook (Chat.jsx or dedicated hook) + ├─ GET /api/push/firebase-config → fetches SDK config from backend + ├─ Initialises Firebase JS SDK + getMessaging() + ├─ getToken(messaging, { vapidKey }) → obtains FCM token + └─ POST /api/push/subscribe → registers token in push_subscriptions table + +Backend (push.js) + ├─ sendPushToUser(schema, userId, payload) — shared helper, called from: + │ ├─ messages.js (REST POST route — PRIMARY message path) + │ └─ index.js (socket message:send handler — secondary/fallback) + └─ Firebase Admin SDK sends the FCM message to Google's servers → device +``` + +### Database + +Table `push_subscriptions` (migration 007): +```sql +id, user_id, device ('mobile'|'desktop'), fcm_token, created_at +``` +PK is `(user_id, device)` — one token per device type per user. `/api/push/subscribe` deletes the old row then inserts, so tokens stay fresh. + +### Message Payload Structure + +All real messages use `notification + data`: +```js +{ + token: sub.fcm_token, + notification: { title, body }, // FCM shows this even if SW fails + data: { url: '/', groupId: '42' }, // SW uses for click routing + android: { priority: 'high', notification: { sound: 'default' } }, + webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } }, +} +``` + +### Service Worker (sw.js) + +`onBackgroundMessage` fires when the PWA is backgrounded/closed. Shows the notification and stores `groupId` for click routing. When the user taps the notification, the SW's `notificationclick` handler navigates to the app. + +### Push Trigger Logic (messages.js) + +**Critical:** The frontend sends messages via `POST /api/messages/group/:groupId` (REST), not via the socket `message:send` event. Push notifications **must** be fired from `messages.js`, not just from the socket handler in `index.js`. + +- **Private group:** query `group_members`, skip sender, call `sendPushToUser` for each member +- **Public group:** query `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`, call `sendPushToUser` for each +- Image messages use body `'📷 Image'` +- The socket handler in `index.js` has identical logic for any future socket-path senders + +### Debug & Test Endpoints + +``` +GET /api/push/debug # admin only — lists all FCM tokens for this schema + firebase status +POST /api/push/test # sends test push to own device +POST /api/push/test?mode=browser # webpush-only test (Chrome handles directly, no SW involved) +``` + +Use `/debug` to confirm tokens are registered. Use `/test` to verify end-to-end delivery independently of real message flow. + +### Stale Token Cleanup + +`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for codes: +- `messaging/registration-token-not-registered` +- `messaging/invalid-registration-token` +- `messaging/invalid-argument` + +--- + ## Outstanding / Deferred Work -### Android Background Push (KNOWN_LIMITATIONS.md) -**Status:** Implemented (v0.11.26+). Replaced web-push/VAPID with Firebase Cloud Messaging (FCM). Requires Firebase project setup — see .env.example for required env vars and sw.js for the SW config block. +### iOS Push Notifications +**Status:** In progress. Android working (v0.12.26+). iOS PWA push requires additional handling — investigation ongoing. ### WebSocket Reconnect on Focus **Status:** Deferred. Socket drops when Android PWA is backgrounded. diff --git a/backend/package.json b/backend/package.json index 0467438..2063154 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.26", + "version": "0.12.27", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 771a673..5557a67 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.26}" +VERSION="${1:-0.12.27}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index d64261a..f25f72c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.26", + "version": "0.12.27", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 1e38115..02ca346 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -952,11 +952,15 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) { const endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null; const endCount = rule.ends === 'after' ? (rule.endCount || 13) : null; + // totalOcc counts ALL occurrences from origStart regardless of range, + // so endCount is respected even when rangeStart is after the event's start. + let totalOcc = 0; + // Start from original and step forward while (count < maxOccurrences) { // Check end conditions if (endDate && cur > endDate) break; - if (endCount && occurrences.length >= endCount) break; + if (endCount && totalOcc >= endCount) break; if (cur > rangeEnd) break; if (byDay && (rule.freq === 'weekly' || freq === 'week')) { @@ -964,12 +968,14 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) { const weekStart = new Date(cur); weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week for (const dayKey of byDay) { + if (endCount && totalOcc >= endCount) break; const dayNum = DAY_MAP[dayKey]; const occ = new Date(weekStart); occ.setDate(weekStart.getDate() + dayNum); occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds()); - if (occ >= rangeStart && occ <= rangeEnd) { - if (!endDate || occ <= endDate) { + if (!endDate || occ <= endDate) { + totalOcc++; + if (occ >= rangeStart && occ <= rangeEnd) { const occEnd = new Date(occ.getTime() + durMs); occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true}); } @@ -977,6 +983,7 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) { } cur = step(cur); } else { + totalOcc++; if (cur >= rangeStart && cur <= rangeEnd) { const occEnd = new Date(cur.getTime() + durMs); occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at}); @@ -1110,7 +1117,8 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter {/* Date column */}
{s.getDate()}
-
{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}
+
{SHORT_MONTHS[s.getMonth()]}
+
{DAYS[s.getDay()]}
{/* Time + dot column */}
@@ -1417,6 +1425,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel if (view==='day') d.setDate(d.getDate()+dir); else if (view==='week') d.setDate(d.getDate()+dir*7); else { + d.setDate(1); // prevent overflow (e.g. Jan 31 + 1 month = Mar 3 without this) d.setMonth(d.getMonth()+dir); // Month nav: clear mini-calendar filter and show full month setFilterFromDate(null);