v0.12.27 schedule list view update
This commit is contained in:
99
CLAUDE.md
99
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.26",
|
||||
"version": "0.12.27",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
2
build.sh
2
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.26",
|
||||
"version": "0.12.27",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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) {
|
||||
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 */}
|
||||
<div style={{width:datW,textAlign:'center',flexShrink:0}}>
|
||||
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
|
||||
<div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase'}}>{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}</div>
|
||||
<div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase',lineHeight:1.5}}>{SHORT_MONTHS[s.getMonth()]}</div>
|
||||
<div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase',lineHeight:1.5}}>{DAYS[s.getDay()]}</div>
|
||||
</div>
|
||||
{/* Time + dot column */}
|
||||
<div style={{width:timeW,flexShrink:0,display:'flex',alignItems:'flex-start',gap:timeGap,fontSize:timeFs,color:subColor}}>
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user