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:
1 parent
2c29fc28d6
commit
67c5ff1b35
13 files changed
+419
-138
No files matched your search
@@ -4,6 +4,21 @@ service cloud.firestore {
|
||||
// Allow authenticated users to read/write their own data
|
||||
match /users/{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
|
||||
|
||||
+185
-40
@@ -13,7 +13,7 @@ import AboutPage from '@/components/AboutPage';
|
||||
import HomePage from '@/components/HomePage';
|
||||
import AnsweredPrayers from '@/components/AnsweredPrayers';
|
||||
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 toast from 'react-hot-toast';
|
||||
|
||||
@@ -26,13 +26,16 @@ interface Prayer {
|
||||
answered?: boolean;
|
||||
answerDetails?: string;
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState<'home' | 'compose' | 'journal' | 'answered' | 'praying' | 'reflections' | 'about'>('home');
|
||||
const [prayers, setPrayers] = useState<Prayer[]>([]);
|
||||
const [editingPrayer, setEditingPrayer] = useState<Prayer | null>(null);
|
||||
const [prayersLoading, setPrayersLoading] = useState(false);
|
||||
const [mostPrayedFor, setMostPrayedFor] = useState<string>('');
|
||||
|
||||
// Form state
|
||||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
@@ -45,8 +48,13 @@ export default function Home() {
|
||||
|
||||
// Load prayers from Firestore
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!user) {
|
||||
setPrayers([]);
|
||||
setPrayersLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPrayersLoading(true);
|
||||
const loadPrayers = async () => {
|
||||
try {
|
||||
const prayersRef = collection(db, 'prayers');
|
||||
@@ -62,31 +70,61 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error('Error loading prayers:', error);
|
||||
toast.error('Failed to load prayers');
|
||||
} finally {
|
||||
setPrayersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPrayers();
|
||||
fetchPrayerStatistics();
|
||||
}, [user]);
|
||||
|
||||
// Load draft from localStorage
|
||||
// Load draft from Firebase
|
||||
useEffect(() => {
|
||||
const draft = localStorage.getItem('prayerDraft');
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
if (!user) return;
|
||||
|
||||
// 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(() => {
|
||||
localStorage.setItem('prayerDraft', JSON.stringify({ date, text, prayFor }));
|
||||
}, [date, text, prayFor]);
|
||||
if (!user) return;
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -137,7 +175,12 @@ export default function Home() {
|
||||
setDate(new Date().toISOString().split('T')[0]);
|
||||
setText('');
|
||||
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) {
|
||||
console.error('Error saving prayer:', error);
|
||||
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) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
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), {
|
||||
lastPrayedFor: today,
|
||||
prayedDates: updatedDates,
|
||||
});
|
||||
|
||||
// Update statistics if this prayer has a prayFor value
|
||||
if (prayer.prayFor) {
|
||||
await updatePrayerStatistics(prayer.prayFor);
|
||||
}
|
||||
|
||||
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!');
|
||||
} 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 (
|
||||
<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="text-center">
|
||||
<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">
|
||||
{/* 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="w-24 h-24 mx-auto relative">
|
||||
<div className="absolute inset-0 border-4 border-blue-500/30 rounded-full animate-spin"></div>
|
||||
<div
|
||||
className="absolute inset-2 border-4 border-transparent border-t-blue-400 rounded-full animate-spin"
|
||||
style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}
|
||||
<div className="w-32 h-32 mx-auto relative">
|
||||
{/* 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
|
||||
className="absolute inset-3 border-4 border-transparent border-b-purple-400 border-l-purple-400 rounded-full animate-spin"
|
||||
style={{ animationDirection: 'reverse', animationDuration: '2s' }}
|
||||
></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">
|
||||
<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>
|
||||
|
||||
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
|
||||
<h1 className="text-2xl font-bold text-white mb-2 animate-pulse">Haver</h1>
|
||||
<p className="text-gray-400 text-lg mb-4">Preparing your sacred space...</p>
|
||||
{/* App Title */}
|
||||
<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">
|
||||
Haver
|
||||
</h1>
|
||||
|
||||
{/* Loading Message */}
|
||||
<p className="text-gray-300 text-lg mb-6 animate-pulse">
|
||||
{loadingMessage}
|
||||
</p>
|
||||
|
||||
<div className="max-w-xs mx-auto">
|
||||
<blockquote className="text-gray-300 italic text-sm leading-relaxed">
|
||||
{/* 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."
|
||||
</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 className="flex justify-center space-x-2 mt-8">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0s' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
|
||||
{/* Animated Dots */}
|
||||
<div className="flex justify-center space-x-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"></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>
|
||||
@@ -237,6 +381,7 @@ export default function Home() {
|
||||
onStartPraying={() => setActiveTab('compose')}
|
||||
onSelectPrayer={handleSelectPrayer}
|
||||
userName={user.displayName || user.email || undefined}
|
||||
mostPrayedFor={mostPrayedFor}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { Info, Smartphone, BellRing } from 'lucide-react';
|
||||
import { APP_VERSION, WHATS_NEW_BRIEF } from '@/lib/version';
|
||||
import { db } from '@/lib/firebase';
|
||||
import { doc, getDoc, setDoc } from 'firebase/firestore';
|
||||
|
||||
interface AboutPageProps {
|
||||
userEmail?: string;
|
||||
@@ -55,49 +57,86 @@ export default function AboutPage({ userEmail }: AboutPageProps) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
|
||||
// Load saved settings
|
||||
const savedEnabled = localStorage.getItem('prayerRemindersEnabled') === 'true';
|
||||
const savedTime = localStorage.getItem('prayerReminderTime') || '09:00';
|
||||
setNotificationsEnabled(savedEnabled);
|
||||
setReminderTime(savedTime);
|
||||
|
||||
// Schedule notification for supported platforms
|
||||
if (savedEnabled && webPushSupport) {
|
||||
scheduleNotification();
|
||||
}
|
||||
}, []);
|
||||
// Load saved settings from Firebase
|
||||
const loadSettings = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
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();
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'unavailable') {
|
||||
console.error('Error loading settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [user]);
|
||||
|
||||
const requestNotificationPermission = async () => {
|
||||
if (!supportsWebPush) return;
|
||||
if (!supportsWebPush || !user) return;
|
||||
|
||||
if ('Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
if (permission === 'granted') {
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotifications = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (supportsWebPush) {
|
||||
if (!notificationsEnabled && notificationPermission !== 'granted') {
|
||||
await requestNotificationPermission();
|
||||
} else {
|
||||
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) {
|
||||
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString());
|
||||
scheduleNotification();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For iOS and unsupported platforms, just enable in-app reminders
|
||||
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) => {
|
||||
if (!user) return;
|
||||
|
||||
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) {
|
||||
scheduleNotification();
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNotification = () => {
|
||||
if (!supportsWebPush || !notificationsEnabled) return;
|
||||
if (!supportsWebPush || !notificationsEnabled || !user) return;
|
||||
|
||||
// Calculate time until next reminder
|
||||
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
|
||||
if (timeUntilNotification > 0) {
|
||||
setTimeout(() => {
|
||||
if ('Notification' in window && Notification.permission === 'granted' && notificationsEnabled) {
|
||||
if ('Notification' in window && Notification.permission === 'granted' && notificationsEnabled && user) {
|
||||
new Notification('Time to Pray', {
|
||||
body: 'Take a moment to connect with God and pray.',
|
||||
icon: '/icon-192x192.png',
|
||||
@@ -148,8 +196,11 @@ export default function AboutPage({ userEmail }: AboutPageProps) {
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
// Save reminder shown timestamp
|
||||
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString());
|
||||
// Save reminder shown timestamp to Firebase
|
||||
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
|
||||
setDoc(settingsRef, {
|
||||
lastReminderDate: new Date().toISOString()
|
||||
}, { merge: true });
|
||||
|
||||
// Reschedule for next day
|
||||
setTimeout(scheduleNotification, 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Prayer {
|
||||
answerDetails?: string;
|
||||
prayFrequency?: number; // 1-7 times per week
|
||||
lastPrayedFor?: string; // ISO date string
|
||||
prayedDates?: string[]; // Array of dates when prayed for
|
||||
}
|
||||
|
||||
interface AnsweredPrayersProps {
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Prayer {
|
||||
text: string;
|
||||
prayFor?: string;
|
||||
lastPrayedFor?: string;
|
||||
prayedDates?: string[];
|
||||
}
|
||||
|
||||
interface CalendarViewProps {
|
||||
@@ -24,13 +25,19 @@ export default function CalendarView({ prayers, onDayClick }: CalendarViewProps)
|
||||
}, [prayers]);
|
||||
|
||||
const daysWithPrayedFor = useMemo(() => {
|
||||
const dates = new Set<string>();
|
||||
const dateCounts = new Map<string, number>();
|
||||
prayers.forEach(p => {
|
||||
if (p.lastPrayedFor) {
|
||||
dates.add(p.lastPrayedFor);
|
||||
// Check both old lastPrayedFor and new prayedDates array
|
||||
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]);
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
@@ -120,7 +127,7 @@ export default function CalendarView({ prayers, onDayClick }: CalendarViewProps)
|
||||
? prayers.filter(p => p.date === dateString).length
|
||||
: 0;
|
||||
const prayedForCount = dateString
|
||||
? prayers.filter(p => p.lastPrayedFor === dateString).length
|
||||
? daysWithPrayedFor.get(dateString) || 0
|
||||
: 0;
|
||||
|
||||
// Determine color based on activity type
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function Clock() {
|
||||
useEffect(() => {
|
||||
// Set initial time
|
||||
setTime(new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
@@ -17,7 +17,7 @@ export default function Clock() {
|
||||
// Update time every second
|
||||
const interval = setInterval(() => {
|
||||
setTime(new Date().toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
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 {
|
||||
date: string;
|
||||
@@ -85,14 +85,19 @@ export default function DailyEntry({
|
||||
{/* Pray For */}
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={prayFor}
|
||||
onChange={(e) => onPrayForChange(e.target.value)}
|
||||
placeholder="Person, situation, or concern..."
|
||||
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"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={prayFor}
|
||||
onChange={(e) => onPrayForChange(e.target.value)}
|
||||
placeholder="Person, situation, or concern..."
|
||||
list="prayfor-options"
|
||||
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 && (
|
||||
<datalist id="prayfor-options">
|
||||
{prayForOptions.map((opt) => (
|
||||
|
||||
+24
-22
@@ -14,6 +14,7 @@ interface Prayer {
|
||||
answerDetails?: string;
|
||||
prayFrequency?: number; // 1-7 times per week
|
||||
lastPrayedFor?: string; // ISO date string
|
||||
prayedDates?: string[]; // Array of dates when prayed for
|
||||
}
|
||||
|
||||
interface HomePageProps {
|
||||
@@ -22,9 +23,10 @@ interface HomePageProps {
|
||||
onStartPraying: () => void;
|
||||
onSelectPrayer: (prayer: Prayer) => void;
|
||||
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 todayPrayers = prayers.filter(p => p.date === today);
|
||||
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;
|
||||
|
||||
// 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
|
||||
const calculateStreak = () => {
|
||||
@@ -65,8 +72,13 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
|
||||
const activityDates = new Set<string>();
|
||||
prayers.forEach(p => {
|
||||
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)
|
||||
: 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
|
||||
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>
|
||||
|
||||
{/* Clock */}
|
||||
<div className="max-w-md mx-auto mb-12">
|
||||
<div className="text-center">
|
||||
<Clock />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
<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()}!
|
||||
@@ -151,9 +147,15 @@ export default function HomePage({ prayers, todayCount, onStartPraying, onSelect
|
||||
All caught up! Start a new prayer to keep your journey going.
|
||||
</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' })}
|
||||
</p>
|
||||
{/* Clock - Below Date */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<div className="scale-75">
|
||||
<Clock />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Prayer {
|
||||
answered?: boolean;
|
||||
prayFrequency?: number; // 1-7 times per week
|
||||
lastPrayedFor?: string; // ISO date string
|
||||
prayedDates?: string[]; // Array of dates when prayed for
|
||||
}
|
||||
|
||||
interface PrayerListProps {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import React, { useState, useEffect } from '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 {
|
||||
userEmail?: string;
|
||||
@@ -11,47 +14,70 @@ export default function ReminderBanner({ userEmail }: ReminderBannerProps) {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
const [reminderTime, setReminderTime] = useState('09:00');
|
||||
const [isTime, setIsTime] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if reminders are enabled and if it's reminder time
|
||||
const remindersEnabled = localStorage.getItem('prayerRemindersEnabled') === 'true';
|
||||
const savedTime = localStorage.getItem('prayerReminderTime') || '09:00';
|
||||
const lastReminderDate = localStorage.getItem('lastPrayerReminderDate');
|
||||
|
||||
setReminderTime(savedTime);
|
||||
if (!user) return;
|
||||
|
||||
if (!remindersEnabled) return;
|
||||
// Load settings from Firebase
|
||||
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);
|
||||
|
||||
// Check if we should show the banner
|
||||
const checkIfTimeToRemind = () => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = savedTime.split(':').map(Number);
|
||||
const reminderTodayTime = new Date();
|
||||
reminderTodayTime.setHours(hours, minutes, 0, 0);
|
||||
if (!remindersEnabled) return;
|
||||
|
||||
// Check if current time is within 5 minutes of reminder time
|
||||
const timeDiff = Math.abs(now.getTime() - reminderTodayTime.getTime());
|
||||
const withinWindow = timeDiff < 5 * 60 * 1000; // 5 minute window
|
||||
// Check if we should show the banner
|
||||
const checkIfTimeToRemind = () => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = savedTime.split(':').map(Number);
|
||||
const reminderTodayTime = new Date();
|
||||
reminderTodayTime.setHours(hours, minutes, 0, 0);
|
||||
|
||||
if (withinWindow) {
|
||||
// Check if we already showed reminder today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const lastDate = lastReminderDate?.split('T')[0];
|
||||
// Check if current time is within 5 minutes of reminder time
|
||||
const timeDiff = Math.abs(now.getTime() - reminderTodayTime.getTime());
|
||||
const withinWindow = timeDiff < 5 * 60 * 1000; // 5 minute window
|
||||
|
||||
if (lastDate !== today) {
|
||||
setIsTime(true);
|
||||
setShowBanner(true);
|
||||
localStorage.setItem('lastPrayerReminderDate', new Date().toISOString());
|
||||
if (withinWindow) {
|
||||
// Check if we already showed reminder today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const lastDate = lastReminderDate?.split('T')[0];
|
||||
|
||||
if (lastDate !== today) {
|
||||
setIsTime(true);
|
||||
setShowBanner(true);
|
||||
|
||||
// Update last reminder date in Firebase
|
||||
setDoc(settingsRef, {
|
||||
lastReminderDate: new Date().toISOString()
|
||||
}, { merge: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkIfTimeToRemind();
|
||||
|
||||
// Check every minute
|
||||
const interval = setInterval(checkIfTimeToRemind, 60000);
|
||||
return () => clearInterval(interval);
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'unavailable') {
|
||||
console.error('Error loading reminder settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkIfTimeToRemind();
|
||||
|
||||
// Check every minute
|
||||
const interval = setInterval(checkIfTimeToRemind, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
loadSettingsAndCheck();
|
||||
}, [user]);
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Prayer {
|
||||
answerDetails?: string;
|
||||
prayFrequency?: number; // 1-7 times per week
|
||||
lastPrayedFor?: string; // ISO date string
|
||||
prayedDates?: string[]; // Array of dates when prayed for
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const prayedToday = prayer.lastPrayedFor === today;
|
||||
const prayedToday = prayer.prayedDates?.includes(today) || prayer.lastPrayedFor === today;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -25,8 +25,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simple auth state listener - no messaging complexity
|
||||
let mounted = true;
|
||||
let authResolved = false;
|
||||
|
||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
if (mounted) {
|
||||
@@ -44,22 +44,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
signOut(auth);
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
authResolved = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user);
|
||||
setLoading(false);
|
||||
// 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);
|
||||
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(() => {
|
||||
if (mounted) {
|
||||
if (mounted && !authResolved) {
|
||||
setLoading(false);
|
||||
authResolved = true;
|
||||
}
|
||||
}, 2000);
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
|
||||
+14
-1
@@ -1,7 +1,7 @@
|
||||
// Import the functions you need from the SDKs you need
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import { getFirestore, enableIndexedDbPersistence, enableMultiTabIndexedDbPersistence } from 'firebase/firestore';
|
||||
import { getStorage } from 'firebase/storage';
|
||||
import { getAnalytics } from 'firebase/analytics';
|
||||
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
|
||||
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
|
||||
export const storage = getStorage(app);
|
||||
|
||||
|
||||
Reference in new issue
Block a user