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
|
## Outstanding / Deferred Work
|
||||||
|
|
||||||
### Android Background Push (KNOWN_LIMITATIONS.md)
|
### iOS Push Notifications
|
||||||
**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.
|
**Status:** In progress. Android working (v0.12.26+). iOS PWA push requires additional handling — investigation ongoing.
|
||||||
|
|
||||||
### WebSocket Reconnect on Focus
|
### WebSocket Reconnect on Focus
|
||||||
**Status:** Deferred. Socket drops when Android PWA is backgrounded.
|
**Status:** Deferred. Socket drops when Android PWA is backgrounded.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.26",
|
"version": "0.12.27",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.26}"
|
VERSION="${1:-0.12.27}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.26",
|
"version": "0.12.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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 endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null;
|
||||||
const endCount = rule.ends === 'after' ? (rule.endCount || 13) : 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
|
// Start from original and step forward
|
||||||
while (count < maxOccurrences) {
|
while (count < maxOccurrences) {
|
||||||
// Check end conditions
|
// Check end conditions
|
||||||
if (endDate && cur > endDate) break;
|
if (endDate && cur > endDate) break;
|
||||||
if (endCount && occurrences.length >= endCount) break;
|
if (endCount && totalOcc >= endCount) break;
|
||||||
if (cur > rangeEnd) break;
|
if (cur > rangeEnd) break;
|
||||||
|
|
||||||
if (byDay && (rule.freq === 'weekly' || freq === 'week')) {
|
if (byDay && (rule.freq === 'weekly' || freq === 'week')) {
|
||||||
@@ -964,12 +968,14 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) {
|
|||||||
const weekStart = new Date(cur);
|
const weekStart = new Date(cur);
|
||||||
weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week
|
weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week
|
||||||
for (const dayKey of byDay) {
|
for (const dayKey of byDay) {
|
||||||
|
if (endCount && totalOcc >= endCount) break;
|
||||||
const dayNum = DAY_MAP[dayKey];
|
const dayNum = DAY_MAP[dayKey];
|
||||||
const occ = new Date(weekStart);
|
const occ = new Date(weekStart);
|
||||||
occ.setDate(weekStart.getDate() + dayNum);
|
occ.setDate(weekStart.getDate() + dayNum);
|
||||||
occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds());
|
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);
|
const occEnd = new Date(occ.getTime() + durMs);
|
||||||
occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true});
|
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);
|
cur = step(cur);
|
||||||
} else {
|
} else {
|
||||||
|
totalOcc++;
|
||||||
if (cur >= rangeStart && cur <= rangeEnd) {
|
if (cur >= rangeStart && cur <= rangeEnd) {
|
||||||
const occEnd = new Date(cur.getTime() + durMs);
|
const occEnd = new Date(cur.getTime() + durMs);
|
||||||
occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at});
|
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 */}
|
{/* Date column */}
|
||||||
<div style={{width:datW,textAlign:'center',flexShrink:0}}>
|
<div style={{width:datW,textAlign:'center',flexShrink:0}}>
|
||||||
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
|
<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>
|
</div>
|
||||||
{/* Time + dot column */}
|
{/* Time + dot column */}
|
||||||
<div style={{width:timeW,flexShrink:0,display:'flex',alignItems:'flex-start',gap:timeGap,fontSize:timeFs,color:subColor}}>
|
<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);
|
if (view==='day') d.setDate(d.getDate()+dir);
|
||||||
else if (view==='week') d.setDate(d.getDate()+dir*7);
|
else if (view==='week') d.setDate(d.getDate()+dir*7);
|
||||||
else {
|
else {
|
||||||
|
d.setDate(1); // prevent overflow (e.g. Jan 31 + 1 month = Mar 3 without this)
|
||||||
d.setMonth(d.getMonth()+dir);
|
d.setMonth(d.getMonth()+dir);
|
||||||
// Month nav: clear mini-calendar filter and show full month
|
// Month nav: clear mini-calendar filter and show full month
|
||||||
setFilterFromDate(null);
|
setFilterFromDate(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user