Major improvements: Firebase data migration, enhanced loading screens, multi-day prayer tracking

- Migrated all localStorage data to Firebase for cross-device sync
  - Prayer drafts now stored in users/{userId}/drafts
  - Reminder settings in users/{userId}/settings
  - Prayer statistics in users/{userId}/statistics
- Implemented persistent 'most prayed for' tracking in Firebase
- Enhanced loading screen with animated backgrounds and gradients
- Fixed auth session restoration on hard refresh
- Added prayedDates array for multi-day prayer tracking
- Calendar now accurately shows all days a prayer was prayed for
- Enabled Firestore offline persistence for offline-first experience
- Updated Firestore security rules for new data structure
- Improved clock display (removed leading zero from hours)
- Moved clock below date on homepage with appropriate sizing
- Fixed data loading race conditions between auth and Firestore
This commit is contained in:
nmemmert committed 2026-01-19 10:11:57 -05:00
1 parent 2c29fc28d6
commit 67c5ff1b35
13 files changed
+382 -101

No files matched your search

+15
View File
@@ -4,6 +4,21 @@ service cloud.firestore {
// Allow authenticated users to read/write their own data // Allow authenticated users to read/write their own data
match /users/{userId} { match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId; allow read, write: if request.auth != null && request.auth.uid == userId;
// Allow users to read/write their own statistics
match /statistics/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Allow users to read/write their own settings
match /settings/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Allow users to read/write their own drafts
match /drafts/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
} }
// Allow public reading of answered prayers for testimony wall // Allow public reading of answered prayers for testimony wall
+184 -39
View File
@@ -13,7 +13,7 @@ import AboutPage from '@/components/AboutPage';
import HomePage from '@/components/HomePage'; import HomePage from '@/components/HomePage';
import AnsweredPrayers from '@/components/AnsweredPrayers'; import AnsweredPrayers from '@/components/AnsweredPrayers';
import { db } from '@/lib/firebase'; import { db } from '@/lib/firebase';
import { collection, addDoc, getDocs, updateDoc, query, where, doc } from 'firebase/firestore'; import { collection, addDoc, getDocs, updateDoc, query, where, doc, setDoc, getDoc } from 'firebase/firestore';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -26,13 +26,16 @@ interface Prayer {
answered?: boolean; answered?: boolean;
answerDetails?: string; answerDetails?: string;
prayFrequency?: number; // 1-7 times per week prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string lastPrayedFor?: string; // ISO date string - kept for backward compatibility
prayedDates?: string[]; // Array of dates when prayed for
} }
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState<'home' | 'compose' | 'journal' | 'answered' | 'praying' | 'reflections' | 'about'>('home'); const [activeTab, setActiveTab] = useState<'home' | 'compose' | 'journal' | 'answered' | 'praying' | 'reflections' | 'about'>('home');
const [prayers, setPrayers] = useState<Prayer[]>([]); const [prayers, setPrayers] = useState<Prayer[]>([]);
const [editingPrayer, setEditingPrayer] = useState<Prayer | null>(null); const [editingPrayer, setEditingPrayer] = useState<Prayer | null>(null);
const [prayersLoading, setPrayersLoading] = useState(false);
const [mostPrayedFor, setMostPrayedFor] = useState<string>('');
// Form state // Form state
const [date, setDate] = useState(new Date().toISOString().split('T')[0]); const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
@@ -45,8 +48,13 @@ export default function Home() {
// Load prayers from Firestore // Load prayers from Firestore
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) {
setPrayers([]);
setPrayersLoading(false);
return;
}
setPrayersLoading(true);
const loadPrayers = async () => { const loadPrayers = async () => {
try { try {
const prayersRef = collection(db, 'prayers'); const prayersRef = collection(db, 'prayers');
@@ -62,31 +70,61 @@ export default function Home() {
} catch (error) { } catch (error) {
console.error('Error loading prayers:', error); console.error('Error loading prayers:', error);
toast.error('Failed to load prayers'); toast.error('Failed to load prayers');
} finally {
setPrayersLoading(false);
} }
}; };
loadPrayers(); loadPrayers();
fetchPrayerStatistics();
}, [user]); }, [user]);
// Load draft from localStorage // Load draft from Firebase
useEffect(() => { useEffect(() => {
const draft = localStorage.getItem('prayerDraft'); if (!user) return;
if (draft) {
try {
const { date: draftDate, text: draftText, prayFor: draftPrayFor } = JSON.parse(draft);
setDate(draftDate || new Date().toISOString().split('T')[0]);
setText(draftText || '');
setPrayFor(draftPrayFor || '');
} catch (e) {
console.error('Error loading draft:', e);
}
}
}, []);
// Save draft to localStorage const loadDraft = async () => {
try {
const draftRef = doc(db, 'users', user.uid, 'drafts', 'currentPrayer');
const draftSnap = await getDoc(draftRef);
if (draftSnap.exists()) {
const draftData = draftSnap.data();
setDate(draftData.date || new Date().toISOString().split('T')[0]);
setText(draftData.text || '');
setPrayFor(draftData.prayFor || '');
}
} catch (error: any) {
// Silently fail if offline
if (error?.code !== 'unavailable') {
console.error('Error loading draft:', error);
}
}
};
loadDraft();
}, [user]);
// Save draft to Firebase
useEffect(() => { useEffect(() => {
localStorage.setItem('prayerDraft', JSON.stringify({ date, text, prayFor })); if (!user) return;
}, [date, text, prayFor]);
const saveDraft = async () => {
try {
const draftRef = doc(db, 'users', user.uid, 'drafts', 'currentPrayer');
await setDoc(draftRef, { date, text, prayFor, updatedAt: new Date().toISOString() }, { merge: true });
} catch (error: any) {
// Silently fail if offline - will sync when connection is restored
if (error?.code !== 'unavailable') {
console.error('Error saving draft:', error);
}
}
};
// Debounce the save to avoid too many writes
const timeoutId = setTimeout(saveDraft, 1000);
return () => clearTimeout(timeoutId);
}, [date, text, prayFor, user]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -137,7 +175,12 @@ export default function Home() {
setDate(new Date().toISOString().split('T')[0]); setDate(new Date().toISOString().split('T')[0]);
setText(''); setText('');
setPrayFor(''); setPrayFor('');
localStorage.removeItem('prayerDraft');
// Clear draft from Firebase
if (user) {
const draftRef = doc(db, 'users', user.uid, 'drafts', 'currentPrayer');
await setDoc(draftRef, { date: new Date().toISOString().split('T')[0], text: '', prayFor: '' });
}
} catch (error) { } catch (error) {
console.error('Error saving prayer:', error); console.error('Error saving prayer:', error);
toast.error('Failed to save prayer'); toast.error('Failed to save prayer');
@@ -164,17 +207,86 @@ export default function Home() {
} }
}; };
const updatePrayerStatistics = async (prayFor: string) => {
if (!user) return;
try {
const statsDocRef = doc(db, 'users', user.uid, 'statistics', 'prayerStats');
// Get current stats
let stats: Record<string, number> = {};
try {
const statsSnap = await getDoc(statsDocRef);
if (statsSnap.exists()) {
stats = statsSnap.data() as Record<string, number>;
}
} catch (e) {
// Stats don't exist yet
}
// Increment count for this prayFor item
stats[prayFor] = (stats[prayFor] || 0) + 1;
// Update the stats document
await setDoc(statsDocRef, stats, { merge: true });
// Update local state
const sorted = Object.entries(stats).sort((a, b) => b[1] - a[1]);
setMostPrayedFor(sorted.length > 0 ? sorted[0][0] : '');
} catch (error) {
console.error('Error updating statistics:', error);
}
};
const fetchPrayerStatistics = async () => {
if (!user) return;
try {
const statsDocRef = doc(db, 'users', user.uid, 'statistics', 'prayerStats');
const statsSnap = await getDoc(statsDocRef);
if (statsSnap.exists()) {
const stats = statsSnap.data() as Record<string, number>;
const sorted = Object.entries(stats).sort((a, b) => b[1] - a[1]);
setMostPrayedFor(sorted[0][0]);
}
} catch (error: any) {
// Silently fail if offline - will retry when connection is restored
if (error?.code !== 'unavailable') {
console.error('Error fetching statistics:', error);
}
}
};
const handleMarkPrayed = async (prayerId: string) => { const handleMarkPrayed = async (prayerId: string) => {
if (!user) return; if (!user) return;
try { try {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const prayer = prayers.find(p => p.id === prayerId);
if (!prayer) return;
// Get existing prayed dates or initialize array
const existingDates = prayer.prayedDates || [];
// Add today's date if not already in the array
const updatedDates = existingDates.includes(today)
? existingDates
: [...existingDates, today];
await updateDoc(doc(db, 'prayers', prayerId), { await updateDoc(doc(db, 'prayers', prayerId), {
lastPrayedFor: today, lastPrayedFor: today,
prayedDates: updatedDates,
}); });
// Update statistics if this prayer has a prayFor value
if (prayer.prayFor) {
await updatePrayerStatistics(prayer.prayFor);
}
setPrayers(prev => setPrayers(prev =>
prev.map(p => (p.id === prayerId ? { ...p, lastPrayedFor: today } : p)) prev.map(p => (p.id === prayerId ? { ...p, lastPrayedFor: today, prayedDates: updatedDates } : p))
); );
toast.success('Prayed!'); toast.success('Prayed!');
} catch (error) { } catch (error) {
@@ -183,38 +295,70 @@ export default function Home() {
} }
}; };
if (authLoading) { if (authLoading || prayersLoading) {
const loadingMessage = authLoading ? 'Preparing your sacred space...' : 'Loading your prayers...';
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4 overflow-hidden">
<div className="text-center"> {/* Animated Background */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
<div className="absolute top-1/2 left-1/2 w-64 h-64 bg-pink-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '2s' }}></div>
</div>
<div className="text-center relative z-10">
{/* Animated Icon */}
<div className="mb-8 relative"> <div className="mb-8 relative">
<div className="w-24 h-24 mx-auto relative"> <div className="w-32 h-32 mx-auto relative">
<div className="absolute inset-0 border-4 border-blue-500/30 rounded-full animate-spin"></div> {/* Outer rotating ring */}
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
<div className="absolute inset-0 border-4 border-transparent border-t-blue-400 border-r-blue-400 rounded-full animate-spin"></div>
{/* Middle rotating ring - reverse direction */}
<div className="absolute inset-3 border-4 border-purple-500/20 rounded-full"></div>
<div <div
className="absolute inset-2 border-4 border-transparent border-t-blue-400 rounded-full animate-spin" className="absolute inset-3 border-4 border-transparent border-b-purple-400 border-l-purple-400 rounded-full animate-spin"
style={{ animationDirection: 'reverse', animationDuration: '1.5s' }} style={{ animationDirection: 'reverse', animationDuration: '2s' }}
></div> ></div>
{/* Inner pulsing circle */}
<div className="absolute inset-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full animate-pulse"></div>
{/* App Icon */}
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" /> <img
src="/icon-192x192.png"
alt="Haver icon"
className="w-16 h-16 rounded-lg shadow-2xl animate-pulse"
/>
</div> </div>
</div> </div>
</div> </div>
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" /> {/* App Title */}
<h1 className="text-2xl font-bold text-white mb-2 animate-pulse">Haver</h1> <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent mb-3 animate-pulse">
<p className="text-gray-400 text-lg mb-4">Preparing your sacred space...</p> Haver
</h1>
<div className="max-w-xs mx-auto"> {/* Loading Message */}
<blockquote className="text-gray-300 italic text-sm leading-relaxed"> <p className="text-gray-300 text-lg mb-6 animate-pulse">
{loadingMessage}
</p>
{/* Inspirational Quote */}
<div className="max-w-md mx-auto mb-8 px-4">
<blockquote className="text-gray-400 italic text-sm leading-relaxed">
"Be anxious for nothing, but in everything by prayer and supplication, with thanksgiving, let your requests be made known to God." "Be anxious for nothing, but in everything by prayer and supplication, with thanksgiving, let your requests be made known to God."
</blockquote> </blockquote>
<cite className="text-gray-500 text-xs mt-2 block">— Philippians 4:6</cite> <cite className="text-gray-600 text-xs mt-2 block">— Philippians 4:6</cite>
</div> </div>
<div className="flex justify-center space-x-2 mt-8"> {/* Animated Dots */}
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0s' }}></div> <div className="flex justify-center space-x-3">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div> <div className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div> <div className="w-3 h-3 bg-purple-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-3 h-3 bg-pink-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div> </div>
</div> </div>
</div> </div>
@@ -237,6 +381,7 @@ export default function Home() {
onStartPraying={() => setActiveTab('compose')} onStartPraying={() => setActiveTab('compose')}
onSelectPrayer={handleSelectPrayer} onSelectPrayer={handleSelectPrayer}
userName={user.displayName || user.email || undefined} userName={user.displayName || user.email || undefined}
mostPrayedFor={mostPrayedFor}
/> />
)} )}
+70 -19
View File
@@ -4,6 +4,8 @@ import React, { useState } from 'react';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { Info, Smartphone, BellRing } from 'lucide-react'; import { Info, Smartphone, BellRing } from 'lucide-react';
import { APP_VERSION, WHATS_NEW_BRIEF } from '@/lib/version'; import { APP_VERSION, WHATS_NEW_BRIEF } from '@/lib/version';
import { db } from '@/lib/firebase';
import { doc, getDoc, setDoc } from 'firebase/firestore';
interface AboutPageProps { interface AboutPageProps {
userEmail?: string; userEmail?: string;
@@ -55,49 +57,86 @@ export default function AboutPage({ userEmail }: AboutPageProps) {
setNotificationPermission(Notification.permission); setNotificationPermission(Notification.permission);
} }
// Load saved settings // Load saved settings from Firebase
const savedEnabled = localStorage.getItem('prayerRemindersEnabled') === 'true'; const loadSettings = async () => {
const savedTime = localStorage.getItem('prayerReminderTime') || '09:00'; if (!user) return;
setNotificationsEnabled(savedEnabled);
setReminderTime(savedTime);
// Schedule notification for supported platforms try {
if (savedEnabled && webPushSupport) { const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
const settingsSnap = await getDoc(settingsRef);
if (settingsSnap.exists()) {
const settings = settingsSnap.data();
setNotificationsEnabled(settings.enabled || false);
setReminderTime(settings.time || '09:00');
if (settings.enabled && webPushSupport) {
scheduleNotification(); scheduleNotification();
} }
}, []); }
} catch (error: any) {
if (error?.code !== 'unavailable') {
console.error('Error loading settings:', error);
}
}
};
loadSettings();
}, [user]);
const requestNotificationPermission = async () => { const requestNotificationPermission = async () => {
if (!supportsWebPush) return; if (!supportsWebPush || !user) return;
if ('Notification' in window) { if ('Notification' in window) {
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
setNotificationPermission(permission); setNotificationPermission(permission);
if (permission === 'granted') { if (permission === 'granted') {
setNotificationsEnabled(true); setNotificationsEnabled(true);
localStorage.setItem('prayerRemindersEnabled', 'true');
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString()); // Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
await setDoc(settingsRef, {
enabled: true,
time: reminderTime,
lastReminderDate: new Date().toISOString()
}, { merge: true });
scheduleNotification(); scheduleNotification();
} }
} }
}; };
const toggleNotifications = async () => { const toggleNotifications = async () => {
if (!user) return;
if (supportsWebPush) { if (supportsWebPush) {
if (!notificationsEnabled && notificationPermission !== 'granted') { if (!notificationsEnabled && notificationPermission !== 'granted') {
await requestNotificationPermission(); await requestNotificationPermission();
} else { } else {
setNotificationsEnabled(!notificationsEnabled); setNotificationsEnabled(!notificationsEnabled);
localStorage.setItem('prayerRemindersEnabled', String(!notificationsEnabled));
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
await setDoc(settingsRef, {
enabled: !notificationsEnabled,
time: reminderTime,
lastReminderDate: new Date().toISOString()
}, { merge: true });
if (!notificationsEnabled) { if (!notificationsEnabled) {
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString());
scheduleNotification(); scheduleNotification();
} }
} }
} else { } else {
// For iOS and unsupported platforms, just enable in-app reminders // For iOS and unsupported platforms, just enable in-app reminders
setNotificationsEnabled(!notificationsEnabled); setNotificationsEnabled(!notificationsEnabled);
localStorage.setItem('prayerRemindersEnabled', String(!notificationsEnabled));
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
await setDoc(settingsRef, {
enabled: !notificationsEnabled,
time: reminderTime
}, { merge: true });
} }
}; };
@@ -113,15 +152,24 @@ export default function AboutPage({ userEmail }: AboutPageProps) {
}; };
const handleTimeChange = (newTime: string) => { const handleTimeChange = (newTime: string) => {
if (!user) return;
setReminderTime(newTime); setReminderTime(newTime);
localStorage.setItem('prayerReminderTime', newTime);
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
setDoc(settingsRef, {
enabled: notificationsEnabled,
time: newTime
}, { merge: true });
if (notificationsEnabled) { if (notificationsEnabled) {
scheduleNotification(); scheduleNotification();
} }
}; };
const scheduleNotification = () => { const scheduleNotification = () => {
if (!supportsWebPush || !notificationsEnabled) return; if (!supportsWebPush || !notificationsEnabled || !user) return;
// Calculate time until next reminder // Calculate time until next reminder
const [hours, minutes] = reminderTime.split(':').map(Number); const [hours, minutes] = reminderTime.split(':').map(Number);
@@ -139,7 +187,7 @@ export default function AboutPage({ userEmail }: AboutPageProps) {
// Only schedule if we have a valid time difference // Only schedule if we have a valid time difference
if (timeUntilNotification > 0) { if (timeUntilNotification > 0) {
setTimeout(() => { setTimeout(() => {
if ('Notification' in window && Notification.permission === 'granted' && notificationsEnabled) { if ('Notification' in window && Notification.permission === 'granted' && notificationsEnabled && user) {
new Notification('Time to Pray', { new Notification('Time to Pray', {
body: 'Take a moment to connect with God and pray.', body: 'Take a moment to connect with God and pray.',
icon: '/icon-192x192.png', icon: '/icon-192x192.png',
@@ -148,8 +196,11 @@ export default function AboutPage({ userEmail }: AboutPageProps) {
requireInteraction: false, requireInteraction: false,
}); });
// Save reminder shown timestamp // Save reminder shown timestamp to Firebase
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString()); const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
setDoc(settingsRef, {
lastReminderDate: new Date().toISOString()
}, { merge: true });
// Reschedule for next day // Reschedule for next day
setTimeout(scheduleNotification, 24 * 60 * 60 * 1000); setTimeout(scheduleNotification, 24 * 60 * 60 * 1000);
+1
View File
@@ -10,6 +10,7 @@ interface Prayer {
answerDetails?: string; answerDetails?: string;
prayFrequency?: number; // 1-7 times per week prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
} }
interface AnsweredPrayersProps { interface AnsweredPrayersProps {
+12 -5
View File
@@ -9,6 +9,7 @@ interface Prayer {
text: string; text: string;
prayFor?: string; prayFor?: string;
lastPrayedFor?: string; lastPrayedFor?: string;
prayedDates?: string[];
} }
interface CalendarViewProps { interface CalendarViewProps {
@@ -24,13 +25,19 @@ export default function CalendarView({ prayers, onDayClick }: CalendarViewProps)
}, [prayers]); }, [prayers]);
const daysWithPrayedFor = useMemo(() => { const daysWithPrayedFor = useMemo(() => {
const dates = new Set<string>(); const dateCounts = new Map<string, number>();
prayers.forEach(p => { prayers.forEach(p => {
if (p.lastPrayedFor) { // Check both old lastPrayedFor and new prayedDates array
dates.add(p.lastPrayedFor); if (p.prayedDates && p.prayedDates.length > 0) {
p.prayedDates.forEach(date => {
dateCounts.set(date, (dateCounts.get(date) || 0) + 1);
});
} else if (p.lastPrayedFor) {
// Backward compatibility with old format
dateCounts.set(p.lastPrayedFor, (dateCounts.get(p.lastPrayedFor) || 0) + 1);
} }
}); });
return dates; return dateCounts;
}, [prayers]); }, [prayers]);
const getDaysInMonth = (date: Date) => { const getDaysInMonth = (date: Date) => {
@@ -120,7 +127,7 @@ export default function CalendarView({ prayers, onDayClick }: CalendarViewProps)
? prayers.filter(p => p.date === dateString).length ? prayers.filter(p => p.date === dateString).length
: 0; : 0;
const prayedForCount = dateString const prayedForCount = dateString
? prayers.filter(p => p.lastPrayedFor === dateString).length ? daysWithPrayedFor.get(dateString) || 0
: 0; : 0;
// Determine color based on activity type // Determine color based on activity type
+2 -2
View File
@@ -8,7 +8,7 @@ export default function Clock() {
useEffect(() => { useEffect(() => {
// Set initial time // Set initial time
setTime(new Date().toLocaleTimeString('en-US', { setTime(new Date().toLocaleTimeString('en-US', {
hour: '2-digit', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: true hour12: true
@@ -17,7 +17,7 @@ export default function Clock() {
// Update time every second // Update time every second
const interval = setInterval(() => { const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString('en-US', { setTime(new Date().toLocaleTimeString('en-US', {
hour: '2-digit', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: true hour12: true
+7 -2
View File
@@ -2,7 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { PenSquare, Calendar as CalendarIcon, HandHeart, CheckCircle2, Sparkles, Save, Loader2 } from 'lucide-react'; import { PenSquare, Calendar as CalendarIcon, HandHeart, CheckCircle2, Sparkles, Save, Loader2, ChevronDown } from 'lucide-react';
interface DailyEntryProps { interface DailyEntryProps {
date: string; date: string;
@@ -85,14 +85,19 @@ export default function DailyEntry({
{/* Pray For */} {/* Pray For */}
<div> <div>
<label className="block text-sm font-semibold text-slate-300 mb-3 inline-flex items-center gap-1.5"><HandHeart className="w-4 h-4" /> Praying For (Optional)</label> <label className="block text-sm font-semibold text-slate-300 mb-3 inline-flex items-center gap-1.5"><HandHeart className="w-4 h-4" /> Praying For (Optional)</label>
<div className="relative">
<input <input
type="text" type="text"
value={prayFor} value={prayFor}
onChange={(e) => onPrayForChange(e.target.value)} onChange={(e) => onPrayForChange(e.target.value)}
placeholder="Person, situation, or concern..." placeholder="Person, situation, or concern..."
list="prayfor-options" list="prayfor-options"
className="w-full px-4 py-3 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-slate-700/50 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all" className="w-full px-4 py-3.5 pr-12 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-slate-700/50 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
/> />
{prayForOptions.length > 0 && (
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-6 h-6 text-slate-400 pointer-events-none" />
)}
</div>
{prayForOptions.length > 0 && ( {prayForOptions.length > 0 && (
<datalist id="prayfor-options"> <datalist id="prayfor-options">
{prayForOptions.map((opt) => ( {prayForOptions.map((opt) => (
+24 -22
View File
@@ -14,6 +14,7 @@ interface Prayer {
answerDetails?: string; answerDetails?: string;
prayFrequency?: number; // 1-7 times per week prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
} }
interface HomePageProps { interface HomePageProps {
@@ -22,9 +23,10 @@ interface HomePageProps {
onStartPraying: () => void; onStartPraying: () => void;
onSelectPrayer: (prayer: Prayer) => void; onSelectPrayer: (prayer: Prayer) => void;
userName?: string; userName?: string;
mostPrayedFor?: string;
} }
export default function HomePage({ prayers, todayCount, onStartPraying, onSelectPrayer, userName }: HomePageProps) { export default function HomePage({ prayers, todayCount, onStartPraying, onSelectPrayer, userName, mostPrayedFor }: HomePageProps) {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const todayPrayers = prayers.filter(p => p.date === today); const todayPrayers = prayers.filter(p => p.date === today);
const recentPrayers = prayers.slice(0, 5); const recentPrayers = prayers.slice(0, 5);
@@ -55,7 +57,12 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
const activePrayerCount = prayers.filter(p => !p.answered).length; const activePrayerCount = prayers.filter(p => !p.answered).length;
// Count how many active prayers were prayed for today // Count how many active prayers were prayed for today
const prayedTodayCount = prayers.filter(p => !p.answered && p.lastPrayedFor === today).length; const prayedTodayCount = prayers.filter(p =>
!p.answered && (
p.prayedDates?.includes(today) ||
p.lastPrayedFor === today
)
).length;
// Calculate actual streak - consecutive days with prayers // Calculate actual streak - consecutive days with prayers
const calculateStreak = () => { const calculateStreak = () => {
@@ -65,8 +72,13 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
const activityDates = new Set<string>(); const activityDates = new Set<string>();
prayers.forEach(p => { prayers.forEach(p => {
activityDates.add(p.date); // Date prayer was created activityDates.add(p.date); // Date prayer was created
if (p.lastPrayedFor) {
activityDates.add(p.lastPrayedFor); // Date marked as prayed for // Add all prayed dates from the array
if (p.prayedDates && p.prayedDates.length > 0) {
p.prayedDates.forEach((date: string) => activityDates.add(date));
} else if (p.lastPrayedFor) {
// Backward compatibility
activityDates.add(p.lastPrayedFor);
} }
}); });
@@ -101,15 +113,6 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
? Math.round((answeredPrayers / totalPrayers) * 100) ? Math.round((answeredPrayers / totalPrayers) * 100)
: 0; : 0;
// Get most prayed for person/topic based on lastPrayedFor clicks
const prayerForCounts: Record<string, number> = {};
prayers.forEach(p => {
if (p.prayFor && p.lastPrayedFor) {
prayerForCounts[p.prayFor] = (prayerForCounts[p.prayFor] || 0) + 1;
}
});
const mostPrayedFor = Object.entries(prayerForCounts).sort((a, b) => b[1] - a[1])[0];
// Recent answered prayers // Recent answered prayers
const recentAnswered = prayers.filter(p => p.answered).slice(0, 3); const recentAnswered = prayers.filter(p => p.answered).slice(0, 3);
@@ -122,15 +125,8 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
<div className="absolute bottom-20 left-10 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl"></div> <div className="absolute bottom-20 left-10 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl"></div>
</div> </div>
{/* Clock */}
<div className="max-w-md mx-auto mb-12">
<div className="text-center">
<Clock />
</div>
</div>
{/* Header with Personalized Greeting */} {/* Header with Personalized Greeting */}
<div className="max-w-md mx-auto mb-12 text-center"> <div className="max-w-md mx-auto mb-8 text-center">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" /> <img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-3"> <h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-3">
{getGreeting()}, {getFirstName()}! {getGreeting()}, {getFirstName()}!
@@ -151,9 +147,15 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
All caught up! Start a new prayer to keep your journey going. All caught up! Start a new prayer to keep your journey going.
</p> </p>
)} )}
<p className="text-slate-400 mt-2"> <p className="text-slate-400 mt-4 text-sm">
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</p> </p>
{/* Clock - Below Date */}
<div className="mt-3 flex justify-center">
<div className="scale-75">
<Clock />
</div>
</div>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
+1
View File
@@ -12,6 +12,7 @@ interface Prayer {
answered?: boolean; answered?: boolean;
prayFrequency?: number; // 1-7 times per week prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
} }
interface PrayerListProps { interface PrayerListProps {
+32 -6
View File
@@ -2,6 +2,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { BellRing, X } from 'lucide-react'; import { BellRing, X } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { db } from '@/lib/firebase';
import { doc, getDoc, setDoc } from 'firebase/firestore';
interface ReminderBannerProps { interface ReminderBannerProps {
userEmail?: string; userEmail?: string;
@@ -11,12 +14,23 @@ export default function ReminderBanner({ userEmail }: ReminderBannerProps) {
const [showBanner, setShowBanner] = useState(false); const [showBanner, setShowBanner] = useState(false);
const [reminderTime, setReminderTime] = useState('09:00'); const [reminderTime, setReminderTime] = useState('09:00');
const [isTime, setIsTime] = useState(false); const [isTime, setIsTime] = useState(false);
const { user } = useAuth();
useEffect(() => { useEffect(() => {
// Check if reminders are enabled and if it's reminder time if (!user) return;
const remindersEnabled = localStorage.getItem('prayerRemindersEnabled') === 'true';
const savedTime = localStorage.getItem('prayerReminderTime') || '09:00'; // Load settings from Firebase
const lastReminderDate = localStorage.getItem('lastPrayerReminderDate'); const loadSettingsAndCheck = async () => {
try {
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
const settingsSnap = await getDoc(settingsRef);
if (!settingsSnap.exists()) return;
const settings = settingsSnap.data();
const remindersEnabled = settings.enabled || false;
const savedTime = settings.time || '09:00';
const lastReminderDate = settings.lastReminderDate;
setReminderTime(savedTime); setReminderTime(savedTime);
@@ -41,7 +55,11 @@ export default function ReminderBanner({ userEmail }: ReminderBannerProps) {
if (lastDate !== today) { if (lastDate !== today) {
setIsTime(true); setIsTime(true);
setShowBanner(true); setShowBanner(true);
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString());
// Update last reminder date in Firebase
setDoc(settingsRef, {
lastReminderDate: new Date().toISOString()
}, { merge: true });
} }
} }
}; };
@@ -51,7 +69,15 @@ export default function ReminderBanner({ userEmail }: ReminderBannerProps) {
// Check every minute // Check every minute
const interval = setInterval(checkIfTimeToRemind, 60000); const interval = setInterval(checkIfTimeToRemind, 60000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); } catch (error: any) {
if (error?.code !== 'unavailable') {
console.error('Error loading reminder settings:', error);
}
}
};
loadSettingsAndCheck();
}, [user]);
if (!showBanner) return null; if (!showBanner) return null;
+2 -1
View File
@@ -13,6 +13,7 @@ interface Prayer {
answerDetails?: string; answerDetails?: string;
prayFrequency?: number; // 1-7 times per week prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
} }
interface ThingsToPrayForProps { interface ThingsToPrayForProps {
@@ -85,7 +86,7 @@ export default function ThingsToPrayFor({ prayers, onEdit, onMarkPrayed }: Thing
(new Date().getTime() - new Date(prayer.date).getTime()) / (1000 * 60 * 60 * 24) (new Date().getTime() - new Date(prayer.date).getTime()) / (1000 * 60 * 60 * 24)
); );
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const prayedToday = prayer.lastPrayedFor === today; const prayedToday = prayer.prayedDates?.includes(today) || prayer.lastPrayedFor === today;
return ( return (
<div <div
+18 -4
View File
@@ -25,8 +25,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
// Simple auth state listener - no messaging complexity
let mounted = true; let mounted = true;
let authResolved = false;
const unsubscribe = onAuthStateChanged(auth, (user) => { const unsubscribe = onAuthStateChanged(auth, (user) => {
if (mounted) { if (mounted) {
@@ -44,22 +44,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
signOut(auth); signOut(auth);
setUser(null); setUser(null);
setLoading(false); setLoading(false);
authResolved = true;
return; return;
} }
} }
} }
// If we get null on first call, wait a bit for session restoration
if (!user && !authResolved) {
setTimeout(() => {
if (mounted && !authResolved) {
setUser(null);
setLoading(false);
authResolved = true;
}
}, 1000);
} else {
setUser(user); setUser(user);
setLoading(false); setLoading(false);
authResolved = true;
}
} }
}); });
// Timeout safety: force loading to false after 2 seconds // Safety timeout: force loading to false after 4 seconds
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (mounted) { if (mounted && !authResolved) {
setLoading(false); setLoading(false);
authResolved = true;
} }
}, 2000); }, 4000);
return () => { return () => {
mounted = false; mounted = false;
+14 -1
View File
@@ -1,7 +1,7 @@
// Import the functions you need from the SDKs you need // Import the functions you need from the SDKs you need
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth'; import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore'; import { getFirestore, enableIndexedDbPersistence, enableMultiTabIndexedDbPersistence } from 'firebase/firestore';
import { getStorage } from 'firebase/storage'; import { getStorage } from 'firebase/storage';
import { getAnalytics } from 'firebase/analytics'; import { getAnalytics } from 'firebase/analytics';
import { getMessaging, onMessage, getToken } from 'firebase/messaging'; import { getMessaging, onMessage, getToken } from 'firebase/messaging';
@@ -29,6 +29,19 @@ export const auth = getAuth(app);
// Initialize Cloud Firestore and get a reference to the service // Initialize Cloud Firestore and get a reference to the service
export const db = getFirestore(app); export const db = getFirestore(app);
// Enable offline persistence
if (typeof window !== 'undefined') {
enableMultiTabIndexedDbPersistence(db).catch((err) => {
if (err.code === 'failed-precondition') {
// Multiple tabs open, persistence can only be enabled in one tab at a time
console.warn('Firestore: Multiple tabs open, persistence disabled');
} else if (err.code === 'unimplemented') {
// The current browser doesn't support persistence
console.warn('Firestore: Persistence not supported in this browser');
}
});
}
// Initialize Firebase Storage and get a reference to the service // Initialize Firebase Storage and get a reference to the service
export const storage = getStorage(app); export const storage = getStorage(app);