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'
+ }
+ },
+ },
+ },
+ },
})