diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 276eb6b..a633f70 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,14 @@ "Bash(find /Users/jeet/Desktop/Jio/grateful-journal/src -type f -name *.ts -o -name *.tsx)", "Bash(ls -la /Users/jeet/Desktop/Jio/grateful-journal/*.config.*)", "mcp__ide__getDiagnostics", - "Bash(npx skills:*)" + "Bash(npx skills:*)", + "Bash(ls /Users/jeet/Desktop/Jio/grateful-journal/.env*)", + "Bash(ls /Users/jeet/Desktop/Jio/grateful-journal/backend/.env*)", + "Bash(lsof -ti:8000,4173)", + "Bash(npx --yes lighthouse --version)", + "Bash(curl:*)", + "Bash(npx lighthouse:*)", + "Bash(echo \"exit:$?\")" ] } } diff --git a/backend/.env.example b/backend/.env.example index 76a7b70..a7a6a7e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,3 +8,8 @@ FRONTEND_URL=http://localhost:8000 # MONGODB_URI=mongodb://mongo:27017 # ENVIRONMENT=production +# Firebase Admin SDK service account (for sending push notifications) +# Firebase Console → Project Settings → Service Accounts → Generate new private key +# Paste the entire JSON on a single line (escape double quotes if needed): +FIREBASE_SERVICE_ACCOUNT_JSON= + diff --git a/backend/__pycache__/config.cpython-312.pyc b/backend/__pycache__/config.cpython-312.pyc index 249281c..be19f9a 100644 Binary files a/backend/__pycache__/config.cpython-312.pyc and b/backend/__pycache__/config.cpython-312.pyc differ diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index bbd86dd..84248ae 100644 Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/config.py b/backend/config.py index 162a936..78160a4 100644 --- a/backend/config.py +++ b/backend/config.py @@ -8,6 +8,8 @@ class Settings(BaseSettings): api_port: int = 8001 environment: str = "development" frontend_url: str = "http://localhost:8000" + # Firebase Admin SDK service account JSON (paste the full JSON as a single-line string) + firebase_service_account_json: str = "" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/main.py b/backend/main.py index 438c0ce..102b70c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,19 +1,26 @@ -from fastapi import FastAPI, HTTPException, Depends +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from db import MongoDB, get_database +from db import MongoDB from config import get_settings from routers import entries, users +from routers import notifications +from scheduler import start_scheduler from contextlib import asynccontextmanager settings = get_settings() +_scheduler = None @asynccontextmanager async def lifespan(app: FastAPI): # Startup MongoDB.connect_db() + global _scheduler + _scheduler = start_scheduler() yield # Shutdown + if _scheduler: + _scheduler.shutdown(wait=False) MongoDB.close_db() app = FastAPI( @@ -43,6 +50,7 @@ app.add_middleware( # Include routers app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(entries.router, prefix="/api/entries", tags=["entries"]) +app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"]) @app.get("/health") diff --git a/backend/requirements.txt b/backend/requirements.txt index a5bf7aa..9f43e69 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,9 @@ pydantic>=2.5.0 python-dotenv==1.0.0 pydantic-settings>=2.1.0 python-multipart==0.0.6 +firebase-admin>=6.5.0 +apscheduler>=3.10.4 +pytz>=2024.1 # Testing pytest>=7.4.0 diff --git a/backend/routers/notifications.py b/backend/routers/notifications.py new file mode 100644 index 0000000..9b2a2c3 --- /dev/null +++ b/backend/routers/notifications.py @@ -0,0 +1,78 @@ +"""Notification routes — FCM token registration and reminder settings.""" +from fastapi import APIRouter, HTTPException +from db import get_database +from pydantic import BaseModel +from typing import Optional +from bson import ObjectId +from bson.errors import InvalidId +from datetime import datetime + +router = APIRouter() + + +class FcmTokenRequest(BaseModel): + userId: str + fcmToken: str + + +class ReminderSettingsRequest(BaseModel): + time: Optional[str] = None # "HH:MM" in 24-hour format + enabled: bool + timezone: Optional[str] = None # IANA timezone, e.g. "Asia/Kolkata" + + +@router.post("/fcm-token", response_model=dict) +async def register_fcm_token(body: FcmTokenRequest): + """ + Register (or refresh) an FCM device token for a user. + Stores unique tokens per user — duplicate tokens are ignored. + """ + db = get_database() + + try: + user_oid = ObjectId(body.userId) + except InvalidId: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.users.find_one({"_id": user_oid}) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Add token to set (avoid duplicates) + db.users.update_one( + {"_id": user_oid}, + { + "$addToSet": {"fcmTokens": body.fcmToken}, + "$set": {"updatedAt": datetime.utcnow()}, + } + ) + return {"message": "FCM token registered"} + + +@router.put("/reminder/{user_id}", response_model=dict) +async def update_reminder(user_id: str, settings: ReminderSettingsRequest): + """ + Save or update daily reminder settings for a user. + """ + db = get_database() + + try: + user_oid = ObjectId(user_id) + except InvalidId: + raise HTTPException(status_code=400, detail="Invalid user ID") + + user = db.users.find_one({"_id": user_oid}) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + reminder_update: dict = {"reminder.enabled": settings.enabled} + if settings.time is not None: + reminder_update["reminder.time"] = settings.time + if settings.timezone is not None: + reminder_update["reminder.timezone"] = settings.timezone + + db.users.update_one( + {"_id": user_oid}, + {"$set": {**reminder_update, "updatedAt": datetime.utcnow()}} + ) + return {"message": "Reminder settings updated"} diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..c858704 --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,179 @@ +""" +Daily reminder scheduler. + +Runs every minute. For each user with an enabled reminder: + - Converts current UTC time to the user's local timezone + - Checks if the current HH:MM matches their reminder time + - Checks if they already got a notification today (avoids duplicates) + - Checks if they have already written a journal entry today + - If not, sends an FCM push notification to all their registered devices +""" +import json +import logging +from datetime import datetime, timedelta + +import pytz +import firebase_admin +from firebase_admin import credentials, messaging +from apscheduler.schedulers.background import BackgroundScheduler + +from config import get_settings +from db import get_database + +log = logging.getLogger(__name__) + +_firebase_initialized = False + + +def init_firebase(): + """Initialize Firebase Admin SDK once using the service account JSON from env.""" + global _firebase_initialized + if _firebase_initialized: + return + + settings = get_settings() + if not settings.firebase_service_account_json: + log.warning("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled") + return + + try: + sa_dict = json.loads(settings.firebase_service_account_json) + cred = credentials.Certificate(sa_dict) + firebase_admin.initialize_app(cred) + _firebase_initialized = True + log.info("Firebase Admin SDK initialized") + except Exception as e: + log.error(f"Failed to initialize Firebase Admin SDK: {e}") + + +def send_reminder_notifications(): + """Check all users and send reminders where due.""" + if not _firebase_initialized: + return + + db = get_database() + now_utc = datetime.utcnow().replace(second=0, microsecond=0) + + # Find all users with reminder enabled and at least one FCM token + users = db.users.find({ + "reminder.enabled": True, + "fcmTokens": {"$exists": True, "$not": {"$size": 0}}, + "reminder.time": {"$exists": True}, + }) + + for user in users: + try: + _process_user(db, user, now_utc) + except Exception as e: + log.error(f"Error processing reminder for user {user.get('_id')}: {e}") + + +def _process_user(db, user: dict, now_utc: datetime): + reminder = user.get("reminder", {}) + reminder_time_str = reminder.get("time") # "HH:MM" + timezone_str = reminder.get("timezone", "UTC") + fcm_tokens: list = user.get("fcmTokens", []) + + if not reminder_time_str or not fcm_tokens: + return + + try: + 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") + + if current_hm != reminder_time_str: + return # Not the right minute + + # 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 + + # Send FCM notification + _send_push(user["_id"], fcm_tokens, db, today_local_str) + + +def _send_push(user_id, tokens: list, db, today_local_str: str): + """Send FCM multicast and prune stale tokens.""" + message = messaging.MulticastMessage( + notification=messaging.Notification( + title="Time to journal 🌱", + body="You haven't written today yet. Take a moment to reflect.", + ), + tokens=tokens, + android=messaging.AndroidConfig(priority="high"), + apns=messaging.APNSConfig( + payload=messaging.APNSPayload( + aps=messaging.Aps(sound="default") + ) + ), + webpush=messaging.WebpushConfig( + notification=messaging.WebpushNotification( + icon="/web-app-manifest-192x192.png", + badge="/favicon-96x96.png", + tag="gj-daily-reminder", + ) + ), + ) + + response = messaging.send_each_for_multicast(message) + 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 = [ + 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 stale_tokens: + db.users.update_one( + {"_id": user_id}, + {"$pullAll": {"fcmTokens": stale_tokens}} + ) + 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: + """Initialize Firebase and start the minute-by-minute scheduler.""" + init_firebase() + + scheduler = BackgroundScheduler(timezone="UTC") + scheduler.add_job( + send_reminder_notifications, + trigger="cron", + minute="*", # every minute + id="daily_reminders", + replace_existing=True, + ) + scheduler.start() + log.info("Reminder scheduler started") + return scheduler diff --git a/index.html b/index.html index b1994cb..a6437a1 100644 --- a/index.html +++ b/index.html @@ -30,52 +30,111 @@ + + + + + - + + + + + + + + + + diff --git a/nginx/default.conf b/nginx/default.conf index 70f6bcc..6823bd8 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -1,3 +1,24 @@ +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_min_length 256; +gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/x-javascript + application/json + application/xml + application/rss+xml + application/atom+xml + image/svg+xml + font/truetype + font/opentype + application/vnd.ms-fontobject; + server { listen 80; server_name _; @@ -5,6 +26,20 @@ server { root /usr/share/nginx/html; index index.html; + # Cache hashed static assets (JS/CSS/fonts) for 1 year — Vite adds content hashes + location ~* \.(js|css|woff|woff2|ttf|eot|otf)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Cache images for 30 days + location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif)$ { + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + try_files $uri =404; + } + location /api/ { proxy_pass http://backend:8001/api/; proxy_http_version 1.1; diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js new file mode 100644 index 0000000..3e85f9a --- /dev/null +++ b/public/firebase-messaging-sw.js @@ -0,0 +1,40 @@ +// Firebase Cloud Messaging service worker +// Config values are injected at build time by the Vite plugin (see vite.config.ts) +importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js') +importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js') + +firebase.initializeApp({ + apiKey: '__VITE_FIREBASE_API_KEY__', + authDomain: '__VITE_FIREBASE_AUTH_DOMAIN__', + projectId: '__VITE_FIREBASE_PROJECT_ID__', + messagingSenderId: '__VITE_FIREBASE_MESSAGING_SENDER_ID__', + appId: '__VITE_FIREBASE_APP_ID__', +}) + +const messaging = firebase.messaging() + +// Handle background push messages (browser/PWA is closed or in background) +messaging.onBackgroundMessage((payload) => { + const title = payload.notification?.title || 'Grateful Journal 🌱' + const body = payload.notification?.body || "You haven't written today yet. Take a moment to reflect." + + self.registration.showNotification(title, { + body, + icon: '/web-app-manifest-192x192.png', + badge: '/favicon-96x96.png', + tag: 'gj-daily-reminder', + }) +}) + +self.addEventListener('notificationclick', (e) => { + e.notification.close() + e.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + if (clients.length > 0) { + clients[0].focus() + return clients[0].navigate('/') + } + return self.clients.openWindow('/') + }) + ) +}) diff --git a/public/robots.txt b/public/robots.txt index 2a36987..1264822 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,6 +1,4 @@ User-agent: * -Disallow: /write -Disallow: /history -Disallow: /settings +Disallow: Sitemap: https://gratefuljournal.online/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml index 967078d..f5e1a64 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,19 +2,19 @@ https://gratefuljournal.online/ - 2026-04-08 + 2026-04-13 monthly 1.0 https://gratefuljournal.online/about - 2026-04-08 + 2026-04-13 monthly 0.7 https://gratefuljournal.online/privacy - 2026-04-08 + 2026-04-13 yearly 0.4 diff --git a/public/sw.js b/public/sw.js index 6aa9cb5..385b4a6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -18,6 +18,21 @@ self.addEventListener('activate', (e) => { self.clients.claim() }) +self.addEventListener('notificationclick', (e) => { + e.notification.close() + e.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + if (clients.length > 0) { + const client = clients[0] + client.focus() + client.navigate('/') + } else { + self.clients.openWindow('/') + } + }) + ) +}) + self.addEventListener('fetch', (e) => { // Only cache GET requests for same-origin non-API resources if ( diff --git a/src/App.tsx b/src/App.tsx index 2120b4e..641f330 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,21 @@ +import { lazy, Suspense } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider } from './contexts/AuthContext' import { ProtectedRoute } from './components/ProtectedRoute' -import HomePage from './pages/HomePage' -import HistoryPage from './pages/HistoryPage' -import SettingsPage from './pages/SettingsPage' -import LoginPage from './pages/LoginPage' -import PrivacyPage from './pages/PrivacyPage' -import AboutPage from './pages/AboutPage' import './App.css' +const HomePage = lazy(() => import('./pages/HomePage')) +const HistoryPage = lazy(() => import('./pages/HistoryPage')) +const SettingsPage = lazy(() => import('./pages/SettingsPage')) +const LoginPage = lazy(() => import('./pages/LoginPage')) +const PrivacyPage = lazy(() => import('./pages/PrivacyPage')) +const AboutPage = lazy(() => import('./pages/AboutPage')) + function App() { return ( + } /> } /> } /> + ) diff --git a/src/hooks/reminderApi.ts b/src/hooks/reminderApi.ts new file mode 100644 index 0000000..bb6b674 --- /dev/null +++ b/src/hooks/reminderApi.ts @@ -0,0 +1,43 @@ +/** API calls specific to FCM token registration and reminder settings. */ + +const BASE = import.meta.env.VITE_API_URL || 'http://localhost:8001/api' + +async function post(url: string, body: unknown, token: string) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + credentials: 'include', + body: JSON.stringify(body), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail || res.statusText) + } + return res.json() +} + +async function put(url: string, body: unknown, token: string) { + const res = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + credentials: 'include', + body: JSON.stringify(body), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail || res.statusText) + } + return res.json() +} + +export function saveFcmToken(userId: string, fcmToken: string, authToken: string) { + return post(`${BASE}/notifications/fcm-token`, { userId, fcmToken }, authToken) +} + +export function saveReminderSettings( + userId: string, + settings: { time?: string; enabled: boolean; timezone?: string }, + authToken: string +) { + return put(`${BASE}/notifications/reminder/${userId}`, settings, authToken) +} diff --git a/src/hooks/usePageMeta.ts b/src/hooks/usePageMeta.ts new file mode 100644 index 0000000..5b813c9 --- /dev/null +++ b/src/hooks/usePageMeta.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react' + +interface PageMeta { + title: string + description: string + canonical: string + ogTitle?: string + ogDescription?: string +} + +export function usePageMeta({ title, description, canonical, ogTitle, ogDescription }: PageMeta) { + useEffect(() => { + document.title = title + + setMeta('name', 'description', description) + setMeta('property', 'og:title', ogTitle ?? title) + setMeta('property', 'og:description', ogDescription ?? description) + setMeta('property', 'og:url', canonical) + setMeta('name', 'twitter:title', ogTitle ?? title) + setMeta('name', 'twitter:description', ogDescription ?? description) + setLink('canonical', canonical) + }, [title, description, canonical, ogTitle, ogDescription]) +} + +function setMeta(attr: 'name' | 'property', key: string, value: string) { + let el = document.querySelector(`meta[${attr}="${key}"]`) + if (!el) { + el = document.createElement('meta') + el.setAttribute(attr, key) + document.head.appendChild(el) + } + el.setAttribute('content', value) +} + +function setLink(rel: string, href: string) { + let el = document.querySelector(`link[rel="${rel}"]`) + if (!el) { + el = document.createElement('link') + el.setAttribute('rel', rel) + document.head.appendChild(el) + } + el.setAttribute('href', href) +} diff --git a/src/hooks/useReminder.ts b/src/hooks/useReminder.ts new file mode 100644 index 0000000..4bc8f99 --- /dev/null +++ b/src/hooks/useReminder.ts @@ -0,0 +1,115 @@ +/** + * Daily reminder — uses Firebase Cloud Messaging (FCM) for true push notifications. + * Works even when the browser is fully closed (on mobile PWA). + * + * Flow: + * 1. User picks a time in Settings → enableReminder() is called + * 2. Browser notification permission is requested + * 3. FCM token is fetched via the firebase-messaging-sw.js service worker + * 4. Token + reminder settings are saved to the backend + * 5. Backend scheduler sends a push at the right time each day + */ +import { getToken, onMessage } from 'firebase/messaging' +import { messagingPromise } from '../lib/firebase' +import { saveFcmToken, saveReminderSettings } from './reminderApi' + +const VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY + +export const REMINDER_TIME_KEY = 'gj-reminder-time' +export const REMINDER_ENABLED_KEY = 'gj-reminder-enabled' + +export function getSavedReminderTime(): string | null { + return localStorage.getItem(REMINDER_TIME_KEY) +} + +export function isReminderEnabled(): boolean { + return localStorage.getItem(REMINDER_ENABLED_KEY) === 'true' +} + +/** Get FCM token using the dedicated firebase-messaging SW. */ +async function getFcmToken(): Promise { + const messaging = await messagingPromise + if (!messaging) return null + + const swReg = await navigator.serviceWorker.register('/firebase-messaging-sw.js', { scope: '/' }) + await navigator.serviceWorker.ready + + return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg }) +} + +/** + * Request permission, get FCM token, and save reminder settings to backend. + * Returns an error string on failure, or null on success. + */ +export async function enableReminder( + timeStr: string, + userId: string, + authToken: string +): Promise { + if (!('Notification' in window)) { + return 'Notifications are not supported in this browser.' + } + + let perm = Notification.permission + if (perm === 'default') { + perm = await Notification.requestPermission() + } + if (perm !== 'granted') { + return 'Permission denied. To enable reminders, allow notifications for this site in your browser settings.' + } + + try { + const fcmToken = await getFcmToken() + if (!fcmToken) { + return 'Push notifications are not supported in this browser. Try Chrome or Edge.' + } + + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + + await saveFcmToken(userId, fcmToken, authToken) + await saveReminderSettings(userId, { time: timeStr, enabled: true, timezone }, authToken) + + localStorage.setItem(REMINDER_TIME_KEY, timeStr) + localStorage.setItem(REMINDER_ENABLED_KEY, 'true') + return null + } catch (err) { + console.error('FCM reminder setup failed', err) + return 'Failed to set up push notification. Please try again.' + } +} + +/** Pause the reminder (keeps the saved time). */ +export async function disableReminder(userId: string, authToken: string): Promise { + await saveReminderSettings(userId, { enabled: false }, authToken) + localStorage.setItem(REMINDER_ENABLED_KEY, 'false') +} + +/** Re-enable using the previously saved time. Returns error string or null. */ +export async function reenableReminder(userId: string, authToken: string): Promise { + const time = localStorage.getItem(REMINDER_TIME_KEY) + if (!time) return 'No reminder time saved.' + return enableReminder(time, userId, authToken) +} + +/** + * Listen for foreground FCM messages and show a manual notification. + * Call once after the app mounts. Returns an unsubscribe function. + */ +export async function listenForegroundMessages(): Promise<() => void> { + const messaging = await messagingPromise + if (!messaging) return () => {} + + const unsubscribe = onMessage(messaging, (payload) => { + const title = payload.notification?.title || 'Grateful Journal 🌱' + const body = payload.notification?.body || "You haven't written today yet." + if (Notification.permission === 'granted') { + new Notification(title, { + body, + icon: '/web-app-manifest-192x192.png', + tag: 'gj-daily-reminder', + }) + } + }) + + return unsubscribe +} diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index ed7eacb..bdcad13 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -1,5 +1,6 @@ import { initializeApp } from 'firebase/app' import { getAuth, GoogleAuthProvider } from 'firebase/auth' +import { getMessaging, isSupported } from 'firebase/messaging' const firebaseConfig = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY, @@ -15,3 +16,6 @@ const app = initializeApp(firebaseConfig) // Google Auth initialization export const auth = getAuth(app) export const googleProvider = new GoogleAuthProvider() + +// FCM Messaging — resolves to null in unsupported browsers (e.g. Firefox, older Safari) +export const messagingPromise = isSupported().then((yes) => (yes ? getMessaging(app) : null)) diff --git a/src/main.tsx b/src/main.tsx index 6ff8793..63bd4ba 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { listenForegroundMessages } from './hooks/useReminder' if ('serviceWorker' in navigator) { window.addEventListener('load', () => { @@ -9,6 +10,9 @@ if ('serviceWorker' in navigator) { }) } +// Show FCM notifications when app is open in foreground +listenForegroundMessages() + createRoot(document.getElementById('root')!).render( diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index cbe4de3..3eb3bf7 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,6 +1,14 @@ import { Link } from 'react-router-dom' +import { usePageMeta } from '../hooks/usePageMeta' export default function AboutPage() { + usePageMeta({ + title: 'About — Grateful Journal', + description: 'Learn about Grateful Journal — a free, end-to-end encrypted daily gratitude journal. No ads, no tracking, no social feed. Just you and your thoughts.', + canonical: 'https://gratefuljournal.online/about', + ogTitle: 'About Grateful Journal', + ogDescription: 'A free, private gratitude journal with end-to-end encryption. Learn how we built a distraction-free space for your daily reflection practice.', + }) return (
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 703f1b4..c9dbf6e 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -4,8 +4,14 @@ import { useEffect, useState } from 'react' import { GoogleSignInButton } from '../components/GoogleSignInButton' import { TreeAnimation } from '../components/TreeAnimation' import { PageLoader } from '../components/PageLoader' +import { usePageMeta } from '../hooks/usePageMeta' export default function LoginPage() { + usePageMeta({ + title: 'Grateful Journal — Your Private Gratitude Journal', + description: 'A private, end-to-end encrypted gratitude journal. No feeds, no noise — just you and your thoughts. Grow your gratitude one moment at a time.', + canonical: 'https://gratefuljournal.online/', + }) const { user, loading, signInWithGoogle, authError } = useAuth() const navigate = useNavigate() const [signingIn, setSigningIn] = useState(false) diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx index 4b6e21c..6539c67 100644 --- a/src/pages/PrivacyPage.tsx +++ b/src/pages/PrivacyPage.tsx @@ -1,6 +1,14 @@ import { Link } from 'react-router-dom' +import { usePageMeta } from '../hooks/usePageMeta' export default function PrivacyPage() { + usePageMeta({ + title: 'Privacy Policy — Grateful Journal', + description: 'Grateful Journal\'s privacy policy. Your journal entries are end-to-end encrypted — we cannot read them. No ads, no tracking, no data selling.', + canonical: 'https://gratefuljournal.online/privacy', + ogTitle: 'Privacy Policy — Grateful Journal', + ogDescription: 'Your journal entries are end-to-end encrypted and private. We cannot read them, we don\'t sell your data, and we use no advertising cookies.', + }) return (
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 9854342..5c513c2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -7,6 +7,10 @@ import BottomNav from '../components/BottomNav' import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour' import { PageLoader } from '../components/PageLoader' import { usePWAInstall } from '../hooks/usePWAInstall' +import { + getSavedReminderTime, isReminderEnabled, + enableReminder, disableReminder, reenableReminder, +} from '../hooks/useReminder' const MAX_PHOTO_SIZE = 200 // px — resize to 200x200 @@ -55,6 +59,14 @@ export default function SettingsPage() { const { canInstall, isIOS, triggerInstall } = usePWAInstall() const [installModal, setInstallModal] = useState<'ios' | 'chrome' | null>(null) + // Reminder state + const [reminderTime, setReminderTime] = useState(() => getSavedReminderTime()) + const [reminderEnabled, setReminderEnabled] = useState(() => isReminderEnabled()) + const [showReminderModal, setShowReminderModal] = useState(false) + const [reminderPickedTime, setReminderPickedTime] = useState('08:00') + const [reminderError, setReminderError] = useState(null) + const [reminderSaving, setReminderSaving] = useState(false) + // Edit profile modal state const [showEditModal, setShowEditModal] = useState(false) const [editName, setEditName] = useState('') @@ -182,6 +194,56 @@ export default function SettingsPage() { } } + const handleOpenReminderModal = () => { + setReminderPickedTime(reminderTime || '08:00') + setReminderError(null) + setShowReminderModal(true) + } + + const handleSaveReminder = async () => { + if (!user || !userId) return + setReminderSaving(true) + setReminderError(null) + const authToken = await user.getIdToken() + const error = await enableReminder(reminderPickedTime, userId, authToken) + setReminderSaving(false) + if (error) { + setReminderError(error) + } else { + setReminderTime(reminderPickedTime) + setReminderEnabled(true) + setShowReminderModal(false) + setMessage({ type: 'success', text: 'Reminder set!' }) + setTimeout(() => setMessage(null), 2000) + } + } + + 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 () => { try { await signOut() @@ -318,6 +380,40 @@ export default function SettingsPage() { + +
+ + {/* Daily Reminder */} +
+
+ + + + +
+ + +
@@ -622,6 +718,73 @@ export default function SettingsPage() {
)} + {/* Daily Reminder Modal */} + {showReminderModal && ( +
!reminderSaving && setShowReminderModal(false)}> +
e.stopPropagation()}> +
🔔
+

+ {reminderTime ? 'Edit Reminder' : 'Set Daily Reminder'} +

+

+ You'll get a notification at this time each day if you haven't written yet. + {' '}The reminder stays even if you log out and back in. +

+ + + setReminderPickedTime(e.target.value)} + disabled={reminderSaving} + autoFocus + /> + + {reminderError && ( +

+ {reminderError} +

+ )} + +

+ Works best when the app is installed on your home screen. +

+ +
+ + +
+
+
+ )} + ) diff --git a/vite.config.ts b/vite.config.ts index 50b65ec..e74700a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,17 +1,48 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import fs from 'fs' import path from 'path' -function swBuildTimePlugin() { +function injectFirebaseConfig(content: string, env: Record): string { + return content + .replace('__VITE_FIREBASE_API_KEY__', env.VITE_FIREBASE_API_KEY || '') + .replace('__VITE_FIREBASE_AUTH_DOMAIN__', env.VITE_FIREBASE_AUTH_DOMAIN || '') + .replace('__VITE_FIREBASE_PROJECT_ID__', env.VITE_FIREBASE_PROJECT_ID || '') + .replace('__VITE_FIREBASE_MESSAGING_SENDER_ID__', env.VITE_FIREBASE_MESSAGING_SENDER_ID || '') + .replace('__VITE_FIREBASE_APP_ID__', env.VITE_FIREBASE_APP_ID || '') +} + +function swPlugin() { + let env: Record = {} + return { - name: 'sw-build-time', + name: 'sw-plugin', + config(_: unknown, { mode }: { mode: string }) { + env = loadEnv(mode, process.cwd(), '') + }, + // Dev server: serve firebase-messaging-sw.js with injected config + configureServer(server: { middlewares: { use: (path: string, handler: (req: unknown, res: { setHeader: (k: string, v: string) => void; end: (s: string) => void }, next: () => void) => void) => void } }) { + server.middlewares.use('/firebase-messaging-sw.js', (_req, res) => { + const swPath = path.resolve(__dirname, 'public/firebase-messaging-sw.js') + if (fs.existsSync(swPath)) { + const content = injectFirebaseConfig(fs.readFileSync(swPath, 'utf-8'), env) + res.setHeader('Content-Type', 'application/javascript') + res.end(content) + } + }) + }, closeBundle() { + // Cache-bust the main service worker const swPath = path.resolve(__dirname, 'dist/sw.js') if (fs.existsSync(swPath)) { const content = fs.readFileSync(swPath, 'utf-8') - const timestamp = Date.now().toString() - fs.writeFileSync(swPath, content.replace('__BUILD_TIME__', timestamp)) + fs.writeFileSync(swPath, content.replace('__BUILD_TIME__', Date.now().toString())) + } + // Inject Firebase config into the FCM service worker + const fswPath = path.resolve(__dirname, 'dist/firebase-messaging-sw.js') + if (fs.existsSync(fswPath)) { + const content = injectFirebaseConfig(fs.readFileSync(fswPath, 'utf-8'), env) + fs.writeFileSync(fswPath, content) } }, } @@ -19,7 +50,7 @@ function swBuildTimePlugin() { // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), swBuildTimePlugin()], + plugins: [react(), swPlugin()], server: { port: 8000, strictPort: false, @@ -27,5 +58,30 @@ export default defineConfig({ optimizeDeps: { include: ['libsodium-wrappers'], }, + build: { + chunkSizeWarningLimit: 1000, + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules/firebase')) { + return 'firebase' + } + if ( + id.includes('node_modules/react/') || + id.includes('node_modules/react-dom/') || + id.includes('node_modules/react-router-dom/') + ) { + return 'react-vendor' + } + if (id.includes('node_modules/libsodium')) { + return 'crypto' + } + if (id.includes('node_modules/driver.js')) { + return 'driver' + } + }, + }, + }, + }, })