diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a633f70..2c2a900 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,8 @@ "Bash(npx --yes lighthouse --version)", "Bash(curl:*)", "Bash(npx lighthouse:*)", - "Bash(echo \"exit:$?\")" + "Bash(echo \"exit:$?\")", + "Bash(python -c \"from config import get_settings; s = get_settings\\(\\); print\\('SA JSON set:', bool\\(s.firebase_service_account_json\\)\\)\")" ] } } diff --git a/index.html b/index.html index a6437a1..76dfeaf 100644 --- a/index.html +++ b/index.html @@ -156,7 +156,7 @@

Get started โ€” it's free

About ยท - Privacy Policy + Privacy Policy

diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js deleted file mode 100644 index 3e85f9a..0000000 --- a/public/firebase-messaging-sw.js +++ /dev/null @@ -1,40 +0,0 @@ -// 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/sw.js b/public/sw.js index 385b4a6..7f78d91 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,3 +1,29 @@ +// Firebase Messaging โ€” handles background push notifications +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() + +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', + }) +}) + +// Cache management const CACHE = 'gj-__BUILD_TIME__' self.addEventListener('install', (e) => { @@ -23,9 +49,8 @@ self.addEventListener('notificationclick', (e) => { e.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { if (clients.length > 0) { - const client = clients[0] - client.focus() - client.navigate('/') + clients[0].focus() + clients[0].navigate('/') } else { self.clients.openWindow('/') } @@ -34,12 +59,7 @@ self.addEventListener('notificationclick', (e) => { }) self.addEventListener('fetch', (e) => { - // Only cache GET requests for same-origin non-API resources - if ( - e.request.method !== 'GET' || - e.request.url.includes('/api/') - ) return - + if (e.request.method !== 'GET' || e.request.url.includes('/api/')) return e.respondWith( caches.match(e.request).then((cached) => cached || fetch(e.request)) ) diff --git a/src/App.css b/src/App.css index 90d8176..f2a3756 100644 --- a/src/App.css +++ b/src/App.css @@ -2750,6 +2750,254 @@ background: #333; } +/* โ”€โ”€ Reminder Modal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.reminder-modal-overlay { + background: rgba(0, 0, 0, 0.85); + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.reminder-modal { + background: #ffffff; + max-width: 340px; + width: 100%; + border-radius: 24px; + max-height: none; + overflow: visible; + padding: 1.25rem 1.25rem 1.5rem; +} + +[data-theme="dark"] .reminder-modal-overlay { + background: rgba(0, 0, 0, 0.88); +} + +[data-theme="dark"] .reminder-modal { + background: #1c1c1e; +} + +/* โ”€โ”€ Clock Time Picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.clock-picker { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + padding: 0.25rem 0; +} + +.clock-picker__display { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.clock-picker__seg { + background: var(--color-surface-alt, #f3f4f6); + border: none; + border-radius: 10px; + font-size: 2.25rem; + font-weight: 700; + font-family: inherit; + color: var(--color-text, #111827); + width: 3rem; + height: 3.5rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s, color 0.15s; + line-height: 1; + padding: 0; +} + +.clock-picker__seg--active { + background: var(--color-primary, #22c55e); + color: #fff; +} + +.clock-picker__colon { + font-size: 2rem; + font-weight: 700; + color: var(--color-text, #111827); + line-height: 1; + padding: 0 0.1rem; + user-select: none; +} + +.clock-picker__ampm { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-left: 0.5rem; +} + +.clock-picker__ampm-btn { + background: var(--color-surface-alt, #f3f4f6); + border: 1.5px solid transparent; + border-radius: 8px; + font-size: 0.7rem; + font-weight: 700; + font-family: inherit; + color: var(--color-text-muted, #6b7280); + padding: 0.25rem 0.5rem; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + letter-spacing: 0.03em; +} + +.clock-picker__ampm-btn--active { + background: var(--color-primary, #22c55e); + color: #fff; + border-color: var(--color-primary, #22c55e); +} + +.clock-picker__face { + width: 180px; + height: 180px; + display: block; + border-radius: 50%; + overflow: visible; +} + +.clock-picker__bg { + fill: var(--color-surface-alt, #f3f4f6); +} + +.clock-picker__sector { + fill: rgba(34, 197, 94, 0.12); +} + +.clock-picker__hand { + stroke: var(--color-primary, #22c55e); + stroke-width: 2.5; + stroke-linecap: round; +} + +.clock-picker__center-dot { + fill: var(--color-primary, #22c55e); +} + +.clock-picker__hand-tip { + fill: var(--color-primary, #22c55e); +} + +.clock-picker__num { + font-family: inherit; + font-size: 13px; + font-weight: 500; + fill: var(--color-text, #111827); + user-select: none; + pointer-events: none; +} + +.clock-picker__num--selected { + fill: #fff; + font-weight: 700; +} + +.clock-picker__tick { + stroke: var(--color-text-muted, #9ca3af); + stroke-width: 1.5; + opacity: 0.4; +} + +.clock-picker__modes { + display: flex; + gap: 0.5rem; +} + +.clock-picker__mode-btn { + background: none; + border: 1.5px solid var(--color-border, #e5e7eb); + border-radius: 20px; + font-size: 0.7rem; + font-weight: 600; + font-family: inherit; + color: var(--color-text-muted, #6b7280); + padding: 0.25rem 0.75rem; + cursor: pointer; + transition: all 0.15s; +} + +.clock-picker__mode-btn--active { + background: var(--color-primary, #22c55e); + border-color: var(--color-primary, #22c55e); + color: #fff; +} + +/* Dark mode overrides */ +[data-theme="dark"] .clock-picker__seg { + background: #2a2a2a; + color: #f9fafb; +} + +[data-theme="dark"] .clock-picker__seg--active { + background: var(--color-primary, #4ade80); + color: #111; +} + +[data-theme="dark"] .clock-picker__colon { + color: #f9fafb; +} + +[data-theme="dark"] .clock-picker__ampm-btn { + background: #2a2a2a; + color: #9ca3af; +} + +[data-theme="dark"] .clock-picker__ampm-btn--active { + background: var(--color-primary, #4ade80); + color: #111; + border-color: var(--color-primary, #4ade80); +} + +[data-theme="dark"] .clock-picker__bg { + fill: #2a2a2a; +} + +[data-theme="dark"] .clock-picker__sector { + fill: rgba(74, 222, 128, 0.12); +} + +[data-theme="dark"] .clock-picker__hand { + stroke: #4ade80; +} + +[data-theme="dark"] .clock-picker__center-dot { + fill: #4ade80; +} + +[data-theme="dark"] .clock-picker__hand-tip { + fill: #4ade80; +} + +[data-theme="dark"] .clock-picker__num { + fill: #d1d5db; +} + +[data-theme="dark"] .clock-picker__num--selected { + fill: #111; +} + +[data-theme="dark"] .clock-picker__tick { + stroke: #4b5563; +} + +[data-theme="dark"] .clock-picker__mode-btn { + border-color: #333; + color: #9ca3af; +} + +[data-theme="dark"] .clock-picker__mode-btn--active { + background: #4ade80; + border-color: #4ade80; + color: #111; +} + +/* โ”€โ”€ End Clock Time Picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + /* -- Login page โ€” light mode only, no dark theme overrides -- */ /* -- Google sign-in btn -- */ diff --git a/src/App.tsx b/src/App.tsx index 641f330..704eed9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage')) const LoginPage = lazy(() => import('./pages/LoginPage')) const PrivacyPage = lazy(() => import('./pages/PrivacyPage')) const AboutPage = lazy(() => import('./pages/AboutPage')) +const TermsOfServicePage = lazy(() => import('./pages/TermsOfServicePage')) function App() { return ( @@ -44,6 +45,7 @@ function App() { /> } /> } /> + } /> } /> diff --git a/src/components/ClockTimePicker.tsx b/src/components/ClockTimePicker.tsx new file mode 100644 index 0000000..f0aea4a --- /dev/null +++ b/src/components/ClockTimePicker.tsx @@ -0,0 +1,274 @@ +import { useState, useRef, useCallback, useEffect } from 'react' + +interface Props { + value: string // "HH:MM" 24-hour format + onChange: (value: string) => void + disabled?: boolean +} + +const SIZE = 240 +const CENTER = SIZE / 2 +const CLOCK_RADIUS = 108 +const NUM_RADIUS = 82 +const HAND_RADIUS = 74 +const TIP_RADIUS = 16 + +function polarToXY(angleDeg: number, radius: number) { + const rad = ((angleDeg - 90) * Math.PI) / 180 + return { + x: CENTER + radius * Math.cos(rad), + y: CENTER + radius * Math.sin(rad), + } +} + +function parseValue(v: string): { h: number; m: number } { + const [h, m] = v.split(':').map(Number) + return { h: isNaN(h) ? 8 : h, m: isNaN(m) ? 0 : m } +} + +export default function ClockTimePicker({ value, onChange, disabled }: Props) { + const { h: initH, m: initM } = parseValue(value) + + const [mode, setMode] = useState<'hours' | 'minutes'>('hours') + const [hour24, setHour24] = useState(initH) + const [minute, setMinute] = useState(initM) + const svgRef = useRef(null) + const isDragging = useRef(false) + // Keep mutable refs for use inside native event listeners + const modeRef = useRef(mode) + const isPMRef = useRef(initH >= 12) + const hour24Ref = useRef(initH) + const minuteRef = useRef(initM) + + // Keep refs in sync with state + useEffect(() => { modeRef.current = mode }, [mode]) + useEffect(() => { isPMRef.current = hour24 >= 12 }, [hour24]) + useEffect(() => { hour24Ref.current = hour24 }, [hour24]) + useEffect(() => { minuteRef.current = minute }, [minute]) + + // Sync when value prop changes externally + useEffect(() => { + const { h, m } = parseValue(value) + setHour24(h) + setMinute(m) + }, [value]) + + const isPM = hour24 >= 12 + const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24 + + const emit = useCallback( + (h24: number, m: number) => { + onChange(`${h24.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`) + }, + [onChange] + ) + + const handleAmPm = (pm: boolean) => { + if (disabled) return + let newH = hour24 + if (pm && hour24 < 12) newH = hour24 + 12 + else if (!pm && hour24 >= 12) newH = hour24 - 12 + setHour24(newH) + emit(newH, minute) + } + + const applyAngle = useCallback( + (angle: number, currentMode: 'hours' | 'minutes') => { + if (currentMode === 'hours') { + const h12 = Math.round(angle / 30) % 12 || 12 + const pm = isPMRef.current + const newH24 = pm ? (h12 === 12 ? 12 : h12 + 12) : (h12 === 12 ? 0 : h12) + setHour24(newH24) + emit(newH24, minuteRef.current) + } else { + const m = Math.round(angle / 6) % 60 + setMinute(m) + emit(hour24Ref.current, m) + } + }, + [emit] + ) + + const getSVGAngle = (clientX: number, clientY: number): number => { + if (!svgRef.current) return 0 + const rect = svgRef.current.getBoundingClientRect() + const scale = rect.width / SIZE + const x = clientX - rect.left - CENTER * scale + const y = clientY - rect.top - CENTER * scale + return ((Math.atan2(y, x) * 180) / Math.PI + 90 + 360) % 360 + } + + // Mouse handlers (mouse events don't need passive:false) + const handleMouseDown = (e: React.MouseEvent) => { + if (disabled) return + isDragging.current = true + applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current) + } + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging.current || disabled) return + applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current) + } + const handleMouseUp = (e: React.MouseEvent) => { + if (!isDragging.current) return + isDragging.current = false + applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current) + if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120) + } + const handleMouseLeave = () => { isDragging.current = false } + + // Attach non-passive touch listeners imperatively to avoid the passive warning + useEffect(() => { + const svg = svgRef.current + if (!svg) return + + const onTouchStart = (e: TouchEvent) => { + if (disabled) return + e.preventDefault() + isDragging.current = true + const t = e.touches[0] + applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current) + } + + const onTouchMove = (e: TouchEvent) => { + if (!isDragging.current || disabled) return + e.preventDefault() + const t = e.touches[0] + applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current) + } + + const onTouchEnd = (e: TouchEvent) => { + if (!isDragging.current) return + e.preventDefault() + isDragging.current = false + const t = e.changedTouches[0] + applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current) + if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120) + } + + svg.addEventListener('touchstart', onTouchStart, { passive: false }) + svg.addEventListener('touchmove', onTouchMove, { passive: false }) + svg.addEventListener('touchend', onTouchEnd, { passive: false }) + + return () => { + svg.removeEventListener('touchstart', onTouchStart) + svg.removeEventListener('touchmove', onTouchMove) + svg.removeEventListener('touchend', onTouchEnd) + } + }, [applyAngle, disabled]) + + const handAngle = mode === 'hours' ? (hour12 / 12) * 360 : (minute / 60) * 360 + const handTip = polarToXY(handAngle, HAND_RADIUS) + const displayH = hour12.toString() + const displayM = minute.toString().padStart(2, '0') + const selectedNum = mode === 'hours' ? hour12 : minute + + const hourPositions = Array.from({ length: 12 }, (_, i) => { + const h = i + 1 + return { h, ...polarToXY((h / 12) * 360, NUM_RADIUS) } + }) + + const minutePositions = Array.from({ length: 12 }, (_, i) => { + const m = i * 5 + return { m, ...polarToXY((m / 60) * 360, NUM_RADIUS) } + }) + + return ( +
+ {/* Time display */} +
+ + : + +
+ + +
+
+ + {/* Clock face */} + + + + {/* Shaded sector */} + {(() => { + const start = polarToXY(0, HAND_RADIUS) + const end = polarToXY(handAngle, HAND_RADIUS) + const large = handAngle > 180 ? 1 : 0 + return ( + + ) + })()} + + + + + + {mode === 'hours' && hourPositions.map(({ h, x, y }) => ( + {h} + ))} + + {mode === 'minutes' && minutePositions.map(({ m, x, y }) => ( + {m.toString().padStart(2, '0')} + ))} + + {mode === 'minutes' && Array.from({ length: 60 }, (_, i) => { + if (i % 5 === 0) return null + const angle = (i / 60) * 360 + const inner = polarToXY(angle, CLOCK_RADIUS - 10) + const outer = polarToXY(angle, CLOCK_RADIUS - 4) + return + })} + + + {/* Mode pills */} +
+ + +
+
+ ) +} diff --git a/src/hooks/useReminder.ts b/src/hooks/useReminder.ts index 4bc8f99..ea3abc1 100644 --- a/src/hooks/useReminder.ts +++ b/src/hooks/useReminder.ts @@ -26,14 +26,13 @@ export function isReminderEnabled(): boolean { return localStorage.getItem(REMINDER_ENABLED_KEY) === 'true' } -/** Get FCM token using the dedicated firebase-messaging SW. */ +/** Get FCM token using the existing sw.js (which includes Firebase messaging). */ 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 - + // Use the already-registered sw.js โ€” no second SW needed + const swReg = await navigator.serviceWorker.ready return getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: swReg }) } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 88f36b8..990d1e0 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -12,6 +12,7 @@ import { getSavedReminderTime, isReminderEnabled, enableReminder, disableReminder, reenableReminder, } from '../hooks/useReminder' +import ClockTimePicker from '../components/ClockTimePicker' const MAX_PHOTO_SIZE = 200 // px โ€” resize to 200x200 const MAX_BG_HISTORY = 3 @@ -933,25 +934,16 @@ export default function SettingsPage() { {/* Daily Reminder Modal */} {showReminderModal && ( -
!reminderSaving && setShowReminderModal(false)}> -
e.stopPropagation()}> -
๐Ÿ””
-

+
!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)} + onChange={setReminderPickedTime} disabled={reminderSaving} - autoFocus /> {reminderError && ( @@ -966,17 +958,7 @@ export default function SettingsPage() {

)} -

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

- -
+
+ ) +} diff --git a/vite.config.ts b/vite.config.ts index e74700a..a3217de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,29 +20,28 @@ function swPlugin() { config(_: unknown, { mode }: { mode: string }) { env = loadEnv(mode, process.cwd(), '') }, - // Dev server: serve firebase-messaging-sw.js with injected config + // Dev server: serve sw.js with injected Firebase 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') + server.middlewares.use('/sw.js', (_req, res) => { + const swPath = path.resolve(__dirname, 'public/sw.js') if (fs.existsSync(swPath)) { - const content = injectFirebaseConfig(fs.readFileSync(swPath, 'utf-8'), env) + const content = injectFirebaseConfig( + fs.readFileSync(swPath, 'utf-8').replace('__BUILD_TIME__', 'dev'), + env + ) res.setHeader('Content-Type', 'application/javascript') res.end(content) } }) }, closeBundle() { - // Cache-bust the main service worker + // Cache-bust sw.js and inject Firebase config const swPath = path.resolve(__dirname, 'dist/sw.js') if (fs.existsSync(swPath)) { - const content = fs.readFileSync(swPath, 'utf-8') - 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) + let content = fs.readFileSync(swPath, 'utf-8') + content = content.replace('__BUILD_TIME__', Date.now().toString()) + content = injectFirebaseConfig(content, env) + fs.writeFileSync(swPath, content) } }, }