Compare commits
4 Commits
85477e5499
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05fcb0a0d5 | |||
| d6da8177c1 | |||
| 237ba6b3c1 | |||
| 93dbf2023c |
@@ -8,6 +8,7 @@ ARG VITE_FIREBASE_PROJECT_ID
|
|||||||
ARG VITE_FIREBASE_STORAGE_BUCKET
|
ARG VITE_FIREBASE_STORAGE_BUCKET
|
||||||
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
|
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
|
||||||
ARG VITE_FIREBASE_APP_ID
|
ARG VITE_FIREBASE_APP_ID
|
||||||
|
ARG VITE_FIREBASE_VAPID_KEY
|
||||||
ARG VITE_API_URL=/api
|
ARG VITE_API_URL=/api
|
||||||
|
|
||||||
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
|
ENV VITE_FIREBASE_API_KEY=${VITE_FIREBASE_API_KEY}
|
||||||
@@ -16,6 +17,7 @@ ENV VITE_FIREBASE_PROJECT_ID=${VITE_FIREBASE_PROJECT_ID}
|
|||||||
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
|
ENV VITE_FIREBASE_STORAGE_BUCKET=${VITE_FIREBASE_STORAGE_BUCKET}
|
||||||
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
ENV VITE_FIREBASE_MESSAGING_SENDER_ID=${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
||||||
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
|
ENV VITE_FIREBASE_APP_ID=${VITE_FIREBASE_APP_ID}
|
||||||
|
ENV VITE_FIREBASE_VAPID_KEY=${VITE_FIREBASE_VAPID_KEY}
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|||||||
328
REMINDER_FEATURE_SETUP.md
Normal file
328
REMINDER_FEATURE_SETUP.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Daily Reminder Feature - Complete Setup & Context
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** ✅ Enabled & Ready for Testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Daily Reminder feature is a **fully implemented Firebase Cloud Messaging (FCM)** system that sends push notifications to remind users to journal. It works even when the browser is closed (on mobile PWA).
|
||||||
|
|
||||||
|
**Key Point:** All code was already in place but disabled in the UI. This document captures the setup and what was changed to enable it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend Flow
|
||||||
|
|
||||||
|
**Files:** `src/hooks/useReminder.ts`, `src/hooks/reminderApi.ts`, `src/pages/SettingsPage.tsx`
|
||||||
|
|
||||||
|
1. User opens Settings → clicks "Daily Reminder" button
|
||||||
|
2. Modal opens with time picker (`ClockTimePicker` component)
|
||||||
|
3. User selects time (e.g., 08:00) → clicks "Save"
|
||||||
|
4. `enableReminder()` is called:
|
||||||
|
- Requests browser notification permission (`Notification.requestPermission()`)
|
||||||
|
- Gets FCM token from service worker
|
||||||
|
- Sends token to backend: `POST /api/notifications/fcm-token`
|
||||||
|
- Sends settings to backend: `PUT /api/notifications/reminder/{userId}`
|
||||||
|
- Stores time + enabled state in localStorage
|
||||||
|
|
||||||
|
**Message Handling:**
|
||||||
|
|
||||||
|
- `listenForegroundMessages()` called on app mount (in `src/main.tsx`)
|
||||||
|
- When app is **focused**: Firebase SDK triggers `onMessage()` → shows notification manually
|
||||||
|
- When app is **closed**: Service worker (`public/sw.js`) handles it via `onBackgroundMessage()` → shows notification
|
||||||
|
|
||||||
|
### Backend Flow
|
||||||
|
|
||||||
|
**Files:** `backend/scheduler.py`, `backend/routers/notifications.py`, `backend/main.py`
|
||||||
|
|
||||||
|
**Initialization:**
|
||||||
|
|
||||||
|
- `start_scheduler()` called in FastAPI app lifespan
|
||||||
|
- Initializes Firebase Admin SDK (requires `FIREBASE_SERVICE_ACCOUNT_JSON`)
|
||||||
|
- Starts APScheduler cron job
|
||||||
|
|
||||||
|
**Every Minute:**
|
||||||
|
|
||||||
|
1. Find all users with `reminder.enabled=true` and FCM tokens
|
||||||
|
2. For each user:
|
||||||
|
- Convert UTC time → user's timezone (stored in DB)
|
||||||
|
- Check if current HH:MM matches `reminder.time` (e.g., "08:00")
|
||||||
|
- Check if already notified today (via `reminder.lastNotifiedDate`)
|
||||||
|
- Check if user has written a journal entry today
|
||||||
|
- **If NOT written yet:** Send FCM push via `firebase_admin.messaging.send_each_for_multicast()`
|
||||||
|
- Auto-prune stale tokens on failure
|
||||||
|
- Mark as notified today
|
||||||
|
|
||||||
|
**Database Structure (MongoDB):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
users collection {
|
||||||
|
_id: ObjectId,
|
||||||
|
fcmTokens: [token1, token2, ...], // per device
|
||||||
|
reminder: {
|
||||||
|
enabled: boolean,
|
||||||
|
time: "HH:MM", // 24-hour format
|
||||||
|
timezone: "Asia/Kolkata", // IANA timezone
|
||||||
|
lastNotifiedDate: "2026-04-16" // prevents duplicates today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes Made (2026-04-20)
|
||||||
|
|
||||||
|
### 1. Updated Frontend Environment (`.env.local`)
|
||||||
|
|
||||||
|
**Changed:** Firebase credentials from mentor's project → personal test project
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_FIREBASE_API_KEY=AIzaSyAjGq7EFrp1mE_8Ni2iZz8LNk7ySVz-lX8
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN=react-test-8cb04.firebaseapp.com
|
||||||
|
VITE_FIREBASE_PROJECT_ID=react-test-8cb04
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID=1036594341832
|
||||||
|
VITE_FIREBASE_APP_ID=1:1036594341832:web:9db6fa337e9cd2e953c2fd
|
||||||
|
VITE_FIREBASE_VAPID_KEY=BLXhAWY-ms-ACW4PFpqnPak3VZobBIruylVE8Jt-Gm4x53g4aAzEhQzjTvGW8O7dX76-ZoUjlBV15b-EODr1IaY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Updated Backend Environment (`backend/.env`)
|
||||||
|
|
||||||
|
**Changed:** Added Firebase service account JSON (from personal test project)
|
||||||
|
|
||||||
|
```env
|
||||||
|
FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"react-test-8cb04",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deleted Service Account JSON File
|
||||||
|
|
||||||
|
- Removed: `service account.json` (no longer needed — credentials now in env var)
|
||||||
|
|
||||||
|
### 4. Enabled Reminder UI (`src/pages/SettingsPage.tsx`)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="settings-item" style={{ opacity: 0.5 }}>
|
||||||
|
<label className="settings-toggle">
|
||||||
|
<input type="checkbox" checked={false} disabled readOnly />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-item settings-item-button"
|
||||||
|
onClick={handleOpenReminderModal}
|
||||||
|
>
|
||||||
|
<div className="settings-item-content">
|
||||||
|
<h4 className="settings-item-title">Daily Reminder</h4>
|
||||||
|
<p className="settings-item-subtitle">
|
||||||
|
{reminderEnabled && reminderTime
|
||||||
|
? `Set for ${reminderTime}`
|
||||||
|
: "Set a daily reminder"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Changed from disabled toggle → interactive button
|
||||||
|
- Shows current reminder time or "Set a daily reminder"
|
||||||
|
- Clicking opens time picker modal
|
||||||
|
|
||||||
|
### 5. Removed Type Ignore Comment
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// @ts-ignore — intentionally unused, reminder is disabled (coming soon)
|
||||||
|
const handleReminderToggle = async () => {
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleReminderToggle = async () => {
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Code Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `src/hooks/useReminder.ts` | `enableReminder()`, `disableReminder()`, `reenableReminder()`, `getFcmToken()`, `listenForegroundMessages()` |
|
||||||
|
| `src/hooks/reminderApi.ts` | `saveFcmToken()`, `saveReminderSettings()` |
|
||||||
|
| `backend/scheduler.py` | `send_reminder_notifications()`, `_process_user()`, `_send_push()`, `init_firebase()` |
|
||||||
|
| `backend/routers/notifications.py` | `POST /fcm-token`, `PUT /reminder/{user_id}` endpoints |
|
||||||
|
| `public/sw.js` | Service worker background message handler |
|
||||||
|
| `src/pages/SettingsPage.tsx` | UI: time picker modal, reminder state mgmt |
|
||||||
|
| `src/main.tsx` | Calls `listenForegroundMessages()` on mount |
|
||||||
|
| `backend/main.py` | Scheduler initialization in app lifespan |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Test
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- ✅ Backend `.env` has Firebase service account JSON
|
||||||
|
- ✅ Frontend `.env.local` has Firebase web config + VAPID key
|
||||||
|
- ✅ UI is enabled (button visible in Settings)
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Restart the backend** (so it picks up new `FIREBASE_SERVICE_ACCOUNT_JSON`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Open the app** and go to **Settings**
|
||||||
|
|
||||||
|
3. **Click "Daily Reminder"** → time picker modal opens
|
||||||
|
|
||||||
|
4. **Pick a time** (e.g., 14:30 for testing: pick a time 1-2 minutes in the future)
|
||||||
|
|
||||||
|
5. **Click "Save"**
|
||||||
|
- Browser asks for notification permission → Accept
|
||||||
|
- Time is saved locally + sent to backend
|
||||||
|
|
||||||
|
6. **Monitor backend logs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs grateful-journal-backend-1 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for: `Reminder sent to user {user_id}: X ok, 0 failed`
|
||||||
|
|
||||||
|
7. **At the reminder time:**
|
||||||
|
- If browser is open: notification appears in-app
|
||||||
|
- If browser is closed: PWA/OS notification appears (mobile)
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
| --------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
|
| Browser asks for notification permission repeatedly | Check `Notification.permission === 'default'` in browser console |
|
||||||
|
| FCM token is null | Check `VITE_FIREBASE_VAPID_KEY` is correct; browser may not support FCM |
|
||||||
|
| Scheduler doesn't run | Restart backend; check `FIREBASE_SERVICE_ACCOUNT_JSON` is valid JSON |
|
||||||
|
| Notification doesn't appear | Check `reminder.lastNotifiedDate` in MongoDB; trigger time must match exactly |
|
||||||
|
| Token registration fails | Check backend logs; 400 error means invalid userId format (must be valid ObjectId) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
### Frontend (`.env.local`)
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_FIREBASE_API_KEY # Firebase API key
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN # Firebase auth domain
|
||||||
|
VITE_FIREBASE_PROJECT_ID # Firebase project ID
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID # Firebase sender ID
|
||||||
|
VITE_FIREBASE_APP_ID # Firebase app ID
|
||||||
|
VITE_FIREBASE_VAPID_KEY # FCM Web Push VAPID key (from Firebase Console → Messaging)
|
||||||
|
VITE_API_URL # Backend API URL (e.g., http://localhost:8001/api)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (`backend/.env`)
|
||||||
|
|
||||||
|
```
|
||||||
|
FIREBASE_SERVICE_ACCOUNT_JSON # Entire Firebase service account JSON (minified single line)
|
||||||
|
MONGODB_URI # MongoDB connection string
|
||||||
|
MONGODB_DB_NAME # Database name
|
||||||
|
API_PORT # Backend port
|
||||||
|
ENVIRONMENT # production/development
|
||||||
|
FRONTEND_URL # Frontend URL for CORS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### For Production
|
||||||
|
|
||||||
|
- Switch back to mentor's Firebase credentials (remove personal test project)
|
||||||
|
- Update `.env.local` and `backend/.env` with production Firebase values
|
||||||
|
|
||||||
|
### Future Improvements
|
||||||
|
|
||||||
|
- Add UI toggle to enable/disable without removing settings
|
||||||
|
- Show timezone in Settings (currently auto-detected)
|
||||||
|
- Show last notification date in UI
|
||||||
|
- Add snooze button to notifications
|
||||||
|
- Let users set multiple reminder times
|
||||||
|
|
||||||
|
### Resetting to Disabled State
|
||||||
|
|
||||||
|
If you need to disable reminders again:
|
||||||
|
|
||||||
|
1. Revert `.env.local` and `backend/.env` to mentor's credentials
|
||||||
|
2. Revert `src/pages/SettingsPage.tsx` to show "Coming soon" UI
|
||||||
|
3. Add back `@ts-ignore` comment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
### Why This Approach?
|
||||||
|
|
||||||
|
- **FCM:** Works on web, mobile, PWA; no polling needed
|
||||||
|
- **Service Worker:** Handles background notifications even when browser closed
|
||||||
|
- **Timezone:** Stores user's IANA timezone to support global users
|
||||||
|
- **Duplicate Prevention:** Tracks `lastNotifiedDate` per user
|
||||||
|
- **Smart Timing:** Only notifies if user hasn't written today (no spam)
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- Firebase service account JSON should never be in git (only in env vars)
|
||||||
|
- FCM tokens are device-specific; backend stores them securely
|
||||||
|
- All reminder data is encrypted end-to-end (matches app's crypto design)
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- Reminder check runs every minute (not more frequent)
|
||||||
|
- FCM token refresh is handled by Firebase SDK automatically
|
||||||
|
- Stale tokens are auto-pruned on failed sends
|
||||||
|
- Timezone must be valid IANA format (not GMT±X)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Commands
|
||||||
|
|
||||||
|
**Check backend scheduler logs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs grateful-journal-backend-1 -f | grep -i "reminder\|firebase"
|
||||||
|
```
|
||||||
|
|
||||||
|
**View user reminders in MongoDB:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec grateful-journal-mongo-1 mongosh grateful_journal --eval "db.users.findOne({_id: ObjectId('...')})" --username admin --password internvps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clear FCM tokens for a user (testing):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec grateful-journal-mongo-1 mongosh grateful_journal --eval "db.users.updateOne({_id: ObjectId('...')}, {\$set: {fcmTokens: []}})" --username admin --password internvps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions about:
|
||||||
|
|
||||||
|
- **Reminders:** Check daily_reminder_feature.md in memory
|
||||||
|
- **FCM:** Firebase Cloud Messaging docs
|
||||||
|
- **APScheduler:** APScheduler documentation
|
||||||
|
- **Firebase Admin SDK:** Firebase Admin SDK for Python docs
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,8 @@
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
|
from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ENV_FILE = str(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -12,7 +15,7 @@ class Settings(BaseSettings):
|
|||||||
firebase_service_account_json: str = ""
|
firebase_service_account_json: str = ""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=_ENV_FILE,
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
extra="ignore", # ignore unknown env vars (e.g. VITE_* from root .env)
|
extra="ignore", # ignore unknown env vars (e.g. VITE_* from root .env)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from db import MongoDB
|
from db import MongoDB
|
||||||
@@ -7,6 +8,13 @@ from routers import notifications
|
|||||||
from scheduler import start_scheduler
|
from scheduler import start_scheduler
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
logging.getLogger("scheduler").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
_scheduler = None
|
_scheduler = None
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async def register_user(user_data: UserCreate):
|
|||||||
"theme": user.get("theme", "light"),
|
"theme": user.get("theme", "light"),
|
||||||
"backgroundImage": user.get("backgroundImage"),
|
"backgroundImage": user.get("backgroundImage"),
|
||||||
"backgroundImages": user.get("backgroundImages", []),
|
"backgroundImages": user.get("backgroundImages", []),
|
||||||
|
"reminder": user.get("reminder"),
|
||||||
"createdAt": user["createdAt"].isoformat(),
|
"createdAt": user["createdAt"].isoformat(),
|
||||||
"updatedAt": user["updatedAt"].isoformat(),
|
"updatedAt": user["updatedAt"].isoformat(),
|
||||||
"message": "User registered successfully" if result.upserted_id else "User already exists"
|
"message": "User registered successfully" if result.upserted_id else "User already exists"
|
||||||
@@ -83,6 +84,7 @@ async def get_user_by_email(email: str):
|
|||||||
"theme": user.get("theme", "light"),
|
"theme": user.get("theme", "light"),
|
||||||
"backgroundImage": user.get("backgroundImage"),
|
"backgroundImage": user.get("backgroundImage"),
|
||||||
"backgroundImages": user.get("backgroundImages", []),
|
"backgroundImages": user.get("backgroundImages", []),
|
||||||
|
"reminder": user.get("reminder"),
|
||||||
"tutorial": user.get("tutorial"),
|
"tutorial": user.get("tutorial"),
|
||||||
"createdAt": user["createdAt"].isoformat(),
|
"createdAt": user["createdAt"].isoformat(),
|
||||||
"updatedAt": user["updatedAt"].isoformat()
|
"updatedAt": user["updatedAt"].isoformat()
|
||||||
|
|||||||
@@ -49,79 +49,109 @@ def init_firebase():
|
|||||||
def send_reminder_notifications():
|
def send_reminder_notifications():
|
||||||
"""Check all users and send reminders where due."""
|
"""Check all users and send reminders where due."""
|
||||||
if not _firebase_initialized:
|
if not _firebase_initialized:
|
||||||
|
log.warning("Reminder check skipped — Firebase not initialized")
|
||||||
return
|
return
|
||||||
|
|
||||||
db = get_database()
|
db = get_database()
|
||||||
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
|
now_utc = datetime.utcnow().replace(second=0, microsecond=0)
|
||||||
|
|
||||||
# Find all users with reminder enabled and at least one FCM token
|
candidates = list(db.users.find({
|
||||||
users = db.users.find({
|
|
||||||
"reminder.enabled": True,
|
"reminder.enabled": True,
|
||||||
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
|
"fcmTokens": {"$exists": True, "$not": {"$size": 0}},
|
||||||
"reminder.time": {"$exists": True},
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
for user in users:
|
log.debug(f"Reminder check at {now_utc.strftime('%H:%M')} UTC — {len(candidates)} candidate(s)")
|
||||||
|
|
||||||
|
for user in candidates:
|
||||||
try:
|
try:
|
||||||
_process_user(db, user, now_utc)
|
if user.get("reminder", {}).get("time"):
|
||||||
|
_process_user(db, user, now_utc)
|
||||||
|
_process_universal(db, user, now_utc)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
|
log.error(f"Error processing reminder for user {user.get('_id')}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_local_time(now_utc: datetime, timezone_str: str):
|
||||||
|
"""Returns (now_local, today_str, user_tz)."""
|
||||||
|
try:
|
||||||
|
user_tz = pytz.timezone(timezone_str)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
user_tz = pytz.utc
|
||||||
|
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
|
||||||
|
today_str = now_local.strftime("%Y-%m-%d")
|
||||||
|
return now_local, today_str, user_tz
|
||||||
|
|
||||||
|
|
||||||
|
def _wrote_today(db, user_id, now_local, user_tz) -> bool:
|
||||||
|
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||||
|
today_end_utc = today_start_utc + timedelta(days=1)
|
||||||
|
return db.entries.count_documents({
|
||||||
|
"userId": user_id,
|
||||||
|
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
|
||||||
|
}) > 0
|
||||||
|
|
||||||
|
|
||||||
def _process_user(db, user: dict, now_utc: datetime):
|
def _process_user(db, user: dict, now_utc: datetime):
|
||||||
|
uid = user.get("_id")
|
||||||
reminder = user.get("reminder", {})
|
reminder = user.get("reminder", {})
|
||||||
reminder_time_str = reminder.get("time") # "HH:MM"
|
reminder_time_str = reminder.get("time")
|
||||||
timezone_str = reminder.get("timezone", "UTC")
|
timezone_str = reminder.get("timezone", "UTC")
|
||||||
fcm_tokens: list = user.get("fcmTokens", [])
|
fcm_tokens: list = user.get("fcmTokens", [])
|
||||||
|
|
||||||
if not reminder_time_str or not fcm_tokens:
|
if not reminder_time_str or not fcm_tokens:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
|
||||||
user_tz = pytz.timezone(timezone_str)
|
|
||||||
except pytz.UnknownTimeZoneError:
|
|
||||||
user_tz = pytz.utc
|
|
||||||
|
|
||||||
# Current time in user's timezone
|
|
||||||
now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz)
|
|
||||||
current_hm = now_local.strftime("%H:%M")
|
current_hm = now_local.strftime("%H:%M")
|
||||||
|
|
||||||
if current_hm != reminder_time_str:
|
if current_hm != reminder_time_str:
|
||||||
return # Not the right minute
|
log.debug(f"User {uid}: skipped — current time {current_hm} != reminder time {reminder_time_str} ({timezone_str})")
|
||||||
|
|
||||||
# Check if already notified today (in user's local date)
|
|
||||||
today_local_str = now_local.strftime("%Y-%m-%d")
|
|
||||||
last_notified = reminder.get("lastNotifiedDate", "")
|
|
||||||
if last_notified == today_local_str:
|
|
||||||
return # Already sent today
|
|
||||||
|
|
||||||
# Check if user has already written today (using createdAt in their timezone)
|
|
||||||
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
|
||||||
today_end_utc = today_start_utc + timedelta(days=1)
|
|
||||||
|
|
||||||
entry_count = db.entries.count_documents({
|
|
||||||
"userId": user["_id"],
|
|
||||||
"createdAt": {"$gte": today_start_utc, "$lt": today_end_utc},
|
|
||||||
})
|
|
||||||
|
|
||||||
if entry_count > 0:
|
|
||||||
# Already wrote today — mark notified to avoid repeated checks
|
|
||||||
db.users.update_one(
|
|
||||||
{"_id": user["_id"]},
|
|
||||||
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send FCM notification
|
if _wrote_today(db, uid, now_local, user_tz):
|
||||||
_send_push(user["_id"], fcm_tokens, db, today_local_str)
|
log.debug(f"User {uid}: skipped — already wrote today")
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info(f"User {uid}: sending reminder (time={reminder_time_str}, tz={timezone_str})")
|
||||||
|
_send_push(uid, fcm_tokens, db)
|
||||||
|
|
||||||
|
|
||||||
def _send_push(user_id, tokens: list, db, today_local_str: str):
|
def _process_universal(db, user: dict, now_utc: datetime):
|
||||||
|
"""Universal 11pm reminder — fires if enabled and no entry written today."""
|
||||||
|
uid = user.get("_id")
|
||||||
|
reminder = user.get("reminder", {})
|
||||||
|
timezone_str = reminder.get("timezone", "UTC")
|
||||||
|
fcm_tokens: list = user.get("fcmTokens", [])
|
||||||
|
|
||||||
|
if not fcm_tokens:
|
||||||
|
return
|
||||||
|
|
||||||
|
now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str)
|
||||||
|
|
||||||
|
if now_local.strftime("%H:%M") != "23:00":
|
||||||
|
return
|
||||||
|
|
||||||
|
if reminder.get("lastUniversalDate") == today_str:
|
||||||
|
log.debug(f"User {uid}: universal reminder skipped — already sent today")
|
||||||
|
return
|
||||||
|
|
||||||
|
if _wrote_today(db, uid, now_local, user_tz):
|
||||||
|
log.debug(f"User {uid}: universal reminder skipped — already wrote today")
|
||||||
|
db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}})
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info(f"User {uid}: sending universal 11pm reminder (tz={timezone_str})")
|
||||||
|
_send_push(uid, fcm_tokens, db, universal=True)
|
||||||
|
db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}})
|
||||||
|
|
||||||
|
|
||||||
|
def _send_push(user_id, tokens: list, db, universal: bool = False):
|
||||||
"""Send FCM multicast and prune stale tokens."""
|
"""Send FCM multicast and prune stale tokens."""
|
||||||
|
title = "Last chance to journal today 🌙" if universal else "Time to journal 🌱"
|
||||||
message = messaging.MulticastMessage(
|
message = messaging.MulticastMessage(
|
||||||
notification=messaging.Notification(
|
notification=messaging.Notification(
|
||||||
title="Time to journal 🌱",
|
title=title,
|
||||||
body="You haven't written today yet. Take a moment to reflect.",
|
body="You haven't written today yet. Take a moment to reflect.",
|
||||||
),
|
),
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
@@ -143,7 +173,6 @@ def _send_push(user_id, tokens: list, db, today_local_str: str):
|
|||||||
response = messaging.send_each_for_multicast(message)
|
response = messaging.send_each_for_multicast(message)
|
||||||
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
|
log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed")
|
||||||
|
|
||||||
# Remove tokens that are no longer valid
|
|
||||||
stale_tokens = [
|
stale_tokens = [
|
||||||
tokens[i] for i, r in enumerate(response.responses)
|
tokens[i] for i, r in enumerate(response.responses)
|
||||||
if not r.success and r.exception and "not-registered" in str(r.exception).lower()
|
if not r.success and r.exception and "not-registered" in str(r.exception).lower()
|
||||||
@@ -155,12 +184,6 @@ def _send_push(user_id, tokens: list, db, today_local_str: str):
|
|||||||
)
|
)
|
||||||
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
|
log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}")
|
||||||
|
|
||||||
# Mark today as notified
|
|
||||||
db.users.update_one(
|
|
||||||
{"_id": user_id},
|
|
||||||
{"$set": {"reminder.lastNotifiedDate": today_local_str}}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> BackgroundScheduler:
|
def start_scheduler() -> BackgroundScheduler:
|
||||||
"""Initialize Firebase and start the minute-by-minute scheduler."""
|
"""Initialize Firebase and start the minute-by-minute scheduler."""
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET}
|
VITE_FIREBASE_STORAGE_BUCKET: ${VITE_FIREBASE_STORAGE_BUCKET}
|
||||||
VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
VITE_FIREBASE_MESSAGING_SENDER_ID: ${VITE_FIREBASE_MESSAGING_SENDER_ID}
|
||||||
VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID}
|
VITE_FIREBASE_APP_ID: ${VITE_FIREBASE_APP_ID}
|
||||||
|
VITE_FIREBASE_VAPID_KEY: ${VITE_FIREBASE_VAPID_KEY}
|
||||||
VITE_API_URL: ${VITE_API_URL:-/api}
|
VITE_API_URL: ${VITE_API_URL:-/api}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
19
src/App.css
19
src/App.css
@@ -26,7 +26,9 @@
|
|||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-loader--transparent {
|
.page-loader--transparent,
|
||||||
|
[data-theme="dark"] .page-loader--transparent,
|
||||||
|
[data-theme="liquid-glass"] .page-loader--transparent {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2110,7 +2112,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-modal {
|
.confirm-modal {
|
||||||
background: var(--color-surface);
|
background: #ffffff;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
@@ -2895,7 +2897,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .confirm-modal {
|
[data-theme="dark"] .confirm-modal {
|
||||||
background: var(--color-surface);
|
background: #1e1e1e;
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3187,7 +3189,6 @@
|
|||||||
[data-theme="liquid-glass"] .calendar-card,
|
[data-theme="liquid-glass"] .calendar-card,
|
||||||
[data-theme="liquid-glass"] .entry-card,
|
[data-theme="liquid-glass"] .entry-card,
|
||||||
[data-theme="liquid-glass"] .entry-modal,
|
[data-theme="liquid-glass"] .entry-modal,
|
||||||
[data-theme="liquid-glass"] .confirm-modal,
|
|
||||||
[data-theme="liquid-glass"] .settings-profile,
|
[data-theme="liquid-glass"] .settings-profile,
|
||||||
[data-theme="liquid-glass"] .settings-card,
|
[data-theme="liquid-glass"] .settings-card,
|
||||||
[data-theme="liquid-glass"] .settings-tutorial-btn,
|
[data-theme="liquid-glass"] .settings-tutorial-btn,
|
||||||
@@ -3410,6 +3411,14 @@
|
|||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Modal boxes always opaque -- */
|
||||||
|
[data-theme="liquid-glass"] .confirm-modal,
|
||||||
|
[data-theme="liquid-glass"] .bg-modal {
|
||||||
|
background: #ffffff;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Bottom nav -- */
|
/* -- Bottom nav -- */
|
||||||
[data-theme="liquid-glass"] .bottom-nav-btn {
|
[data-theme="liquid-glass"] .bottom-nav-btn {
|
||||||
color: #475569;
|
color: #475569;
|
||||||
@@ -3818,7 +3827,7 @@ body.gj-has-bg .settings-page {
|
|||||||
============================ */
|
============================ */
|
||||||
|
|
||||||
.bg-modal {
|
.bg-modal {
|
||||||
background: var(--color-surface, #fff);
|
background: #ffffff;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
width: min(440px, calc(100vw - 2rem));
|
width: min(440px, calc(100vw - 2rem));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
saveEncryptedSecretKey,
|
saveEncryptedSecretKey,
|
||||||
getEncryptedSecretKey,
|
getEncryptedSecretKey,
|
||||||
} from '../lib/crypto'
|
} from '../lib/crypto'
|
||||||
|
import { REMINDER_TIME_KEY, REMINDER_ENABLED_KEY } from '../hooks/useReminder'
|
||||||
|
|
||||||
type MongoUser = {
|
type MongoUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -40,6 +41,11 @@ type MongoUser = {
|
|||||||
tutorial?: boolean
|
tutorial?: boolean
|
||||||
backgroundImage?: string | null
|
backgroundImage?: string | null
|
||||||
backgroundImages?: string[]
|
backgroundImages?: string[]
|
||||||
|
reminder?: {
|
||||||
|
enabled: boolean
|
||||||
|
time?: string
|
||||||
|
timezone?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
@@ -135,6 +141,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncReminderFromDb(mongoUser: MongoUser) {
|
||||||
|
const r = mongoUser.reminder
|
||||||
|
if (r) {
|
||||||
|
localStorage.setItem(REMINDER_ENABLED_KEY, r.enabled ? 'true' : 'false')
|
||||||
|
if (r.time) localStorage.setItem(REMINDER_TIME_KEY, r.time)
|
||||||
|
else localStorage.removeItem(REMINDER_TIME_KEY)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(REMINDER_ENABLED_KEY, 'false')
|
||||||
|
localStorage.removeItem(REMINDER_TIME_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Register or fetch user from MongoDB
|
// Register or fetch user from MongoDB
|
||||||
async function syncUserWithDatabase(authUser: User) {
|
async function syncUserWithDatabase(authUser: User) {
|
||||||
try {
|
try {
|
||||||
@@ -148,12 +166,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
console.log('[Auth] Fetching user by email:', email)
|
console.log('[Auth] Fetching user by email:', email)
|
||||||
const existingUser = await getUserByEmail(email, token) as MongoUser
|
const existingUser = await getUserByEmail(email, token) as MongoUser
|
||||||
// console.log('[Auth] Found existing user:', existingUser.id)
|
|
||||||
setUserId(existingUser.id)
|
setUserId(existingUser.id)
|
||||||
setMongoUser(existingUser)
|
setMongoUser(existingUser)
|
||||||
|
syncReminderFromDb(existingUser)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Auth] User not found, registering...', error)
|
console.warn('[Auth] User not found, registering...', error)
|
||||||
// User doesn't exist, register them
|
|
||||||
const newUser = await registerUser(
|
const newUser = await registerUser(
|
||||||
{
|
{
|
||||||
email,
|
email,
|
||||||
@@ -165,6 +182,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
console.log('[Auth] Registered new user:', newUser.id)
|
console.log('[Auth] Registered new user:', newUser.id)
|
||||||
setUserId(newUser.id)
|
setUserId(newUser.id)
|
||||||
setMongoUser(newUser)
|
setMongoUser(newUser)
|
||||||
|
syncReminderFromDb(newUser)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth] Error syncing user with database:', error)
|
console.error('[Auth] Error syncing user with database:', error)
|
||||||
@@ -226,13 +244,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// Clear secret key from memory
|
|
||||||
setSecretKey(null)
|
setSecretKey(null)
|
||||||
setMongoUser(null)
|
setMongoUser(null)
|
||||||
// Clear pending tour step (session state)
|
|
||||||
localStorage.removeItem('gj-tour-pending-step')
|
localStorage.removeItem('gj-tour-pending-step')
|
||||||
// Keep device key and encrypted key for next login
|
localStorage.removeItem(REMINDER_TIME_KEY)
|
||||||
// Do NOT clear localStorage or IndexedDB
|
localStorage.removeItem(REMINDER_ENABLED_KEY)
|
||||||
await firebaseSignOut(auth)
|
await firebaseSignOut(auth)
|
||||||
setUserId(null)
|
setUserId(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,21 @@ export function isReminderEnabled(): boolean {
|
|||||||
/** Get FCM token using the existing sw.js (which includes Firebase messaging). */
|
/** Get FCM token using the existing sw.js (which includes Firebase messaging). */
|
||||||
async function getFcmToken(): Promise<string | null> {
|
async function getFcmToken(): Promise<string | null> {
|
||||||
const messaging = await messagingPromise
|
const messaging = await messagingPromise
|
||||||
if (!messaging) return null
|
if (!messaging) {
|
||||||
|
console.warn('[FCM] Firebase Messaging not supported in this browser')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Use the already-registered sw.js — no second SW needed
|
|
||||||
const swReg = await navigator.serviceWorker.ready
|
const swReg = await navigator.serviceWorker.ready
|
||||||
return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
|
console.log('[FCM] Service worker ready:', swReg.active?.scriptURL)
|
||||||
|
|
||||||
|
const token = await getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg })
|
||||||
|
if (token) {
|
||||||
|
console.log('[FCM] Token obtained:', token.slice(0, 20) + '…')
|
||||||
|
} else {
|
||||||
|
console.warn('[FCM] getToken returned empty — VAPID key wrong or SW not registered?')
|
||||||
|
}
|
||||||
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,16 +74,21 @@ export async function enableReminder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
console.log('[FCM] Saving token and reminder settings:', { timeStr, timezone })
|
||||||
|
|
||||||
await saveFcmToken(userId, fcmToken, authToken)
|
await saveFcmToken(userId, fcmToken, authToken)
|
||||||
|
console.log('[FCM] Token saved to backend')
|
||||||
|
|
||||||
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
|
await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken)
|
||||||
|
console.log('[FCM] Reminder settings saved to backend')
|
||||||
|
|
||||||
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
|
localStorage.setItem(REMINDER_TIME_KEY, timeStr)
|
||||||
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
|
localStorage.setItem(REMINDER_ENABLED_KEY, 'true')
|
||||||
return null
|
return null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('FCM reminder setup failed', err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
return 'Failed to set up push notification. Please try again.'
|
console.error('[FCM] Reminder setup failed:', msg)
|
||||||
|
return `Failed to set up reminder: ${msg}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,16 +113,21 @@ export async function listenForegroundMessages(): Promise<() => void> {
|
|||||||
const messaging = await messagingPromise
|
const messaging = await messagingPromise
|
||||||
if (!messaging) return () => {}
|
if (!messaging) return () => {}
|
||||||
|
|
||||||
|
console.log('[FCM] Foreground message listener registered')
|
||||||
|
|
||||||
const unsubscribe = onMessage(messaging, (payload) => {
|
const unsubscribe = onMessage(messaging, (payload) => {
|
||||||
|
console.log('[FCM] Foreground message received:', payload)
|
||||||
const title = payload.notification?.title || 'Grateful Journal 🌱'
|
const title = payload.notification?.title || 'Grateful Journal 🌱'
|
||||||
const body = payload.notification?.body || "You haven't written today yet."
|
const body = payload.notification?.body || "You haven't written today yet."
|
||||||
if (Notification.permission === 'granted') {
|
if (Notification.permission !== 'granted') {
|
||||||
new Notification(title, {
|
console.warn('[FCM] Notification permission not granted — cannot show notification')
|
||||||
body,
|
return
|
||||||
icon: '/web-app-manifest-192x192.png',
|
|
||||||
tag: 'gj-daily-reminder',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
new Notification(title, {
|
||||||
|
body,
|
||||||
|
icon: '/web-app-manifest-192x192.png',
|
||||||
|
tag: 'gj-daily-reminder',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ if ('serviceWorker' in navigator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show FCM notifications when app is open in foreground
|
// Show FCM notifications when app is open in foreground
|
||||||
listenForegroundMessages()
|
listenForegroundMessages().catch((err) => {
|
||||||
|
console.error('[FCM] Failed to set up foreground message listener:', err)
|
||||||
|
})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { PageLoader } from '../components/PageLoader'
|
|||||||
import { usePWAInstall } from '../hooks/usePWAInstall'
|
import { usePWAInstall } from '../hooks/usePWAInstall'
|
||||||
import {
|
import {
|
||||||
getSavedReminderTime, isReminderEnabled,
|
getSavedReminderTime, isReminderEnabled,
|
||||||
enableReminder, disableReminder, reenableReminder,
|
enableReminder, disableReminder,
|
||||||
} from '../hooks/useReminder'
|
} from '../hooks/useReminder'
|
||||||
import ClockTimePicker from '../components/ClockTimePicker'
|
import ClockTimePicker from '../components/ClockTimePicker'
|
||||||
|
|
||||||
@@ -311,33 +311,6 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore — intentionally unused, reminder is disabled (coming soon)
|
|
||||||
const handleReminderToggle = async () => {
|
|
||||||
if (!user || !userId) return
|
|
||||||
if (!reminderTime) {
|
|
||||||
handleOpenReminderModal()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (reminderEnabled) {
|
|
||||||
const authToken = await user.getIdToken()
|
|
||||||
await disableReminder(userId, authToken)
|
|
||||||
setReminderEnabled(false)
|
|
||||||
} else {
|
|
||||||
setReminderSaving(true)
|
|
||||||
const authToken = await user.getIdToken()
|
|
||||||
const error = await reenableReminder(userId, authToken)
|
|
||||||
setReminderSaving(false)
|
|
||||||
if (error) {
|
|
||||||
setReminderError(error)
|
|
||||||
setShowReminderModal(true)
|
|
||||||
} else {
|
|
||||||
setReminderEnabled(true)
|
|
||||||
setMessage({ type: 'success', text: 'Reminder enabled!' })
|
|
||||||
setTimeout(() => setMessage(null), 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
try {
|
try {
|
||||||
await signOut()
|
await signOut()
|
||||||
@@ -477,8 +450,12 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<div className="settings-divider"></div>
|
<div className="settings-divider"></div>
|
||||||
|
|
||||||
{/* Daily Reminder — disabled for now, logic preserved */}
|
{/* Daily Reminder */}
|
||||||
<div className="settings-item" style={{ opacity: 0.5 }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-item settings-item-button"
|
||||||
|
onClick={handleOpenReminderModal}
|
||||||
|
>
|
||||||
<div className="settings-item-icon settings-item-icon-orange">
|
<div className="settings-item-icon settings-item-icon-orange">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
@@ -487,18 +464,14 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="settings-item-content">
|
<div className="settings-item-content">
|
||||||
<h4 className="settings-item-title">Daily Reminder</h4>
|
<h4 className="settings-item-title">Daily Reminder</h4>
|
||||||
<p className="settings-item-subtitle">Coming soon</p>
|
<p className="settings-item-subtitle">
|
||||||
|
{reminderEnabled && reminderTime ? `Set for ${reminderTime}` : 'Set a daily reminder' }
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="settings-toggle">
|
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<input
|
<polyline points="9 18 15 12 9 6" />
|
||||||
type="checkbox"
|
</svg>
|
||||||
checked={false}
|
</button>
|
||||||
disabled
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<span className="settings-toggle-slider" style={{ cursor: 'not-allowed' }}></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -999,6 +972,35 @@ export default function SettingsPage() {
|
|||||||
{reminderSaving ? 'Saving…' : 'Save'}
|
{reminderSaving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{reminderEnabled && reminderTime && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user || !userId) return
|
||||||
|
setReminderSaving(true)
|
||||||
|
const authToken = await user.getIdToken()
|
||||||
|
await disableReminder(userId, authToken)
|
||||||
|
setReminderEnabled(false)
|
||||||
|
setReminderSaving(false)
|
||||||
|
setShowReminderModal(false)
|
||||||
|
setMessage({ type: 'success', text: 'Reminder disabled' })
|
||||||
|
setTimeout(() => setMessage(null), 2000)
|
||||||
|
}}
|
||||||
|
disabled={reminderSaving}
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--color-error, #ef4444)',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disable Reminder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user