v0.12.27 schedule list view update

This commit is contained in:
2026-03-25 07:56:35 -04:00
parent 8c4650d1bc
commit 163d71d505
5 changed files with 113 additions and 9 deletions

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-backend",
"version": "0.12.26",
"version": "0.12.27",
"description": "RosterChirp backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.12.26}"
VERSION="${1:-0.12.27}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.26",
"version": "0.12.27",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -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 */}
<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);