Remove notification system and fix data refresh issues

- Removed NotificationPanel component and related files
- Removed notifications API endpoint and utils
- Fixed data refresh functionality with cache-busting
- Added refreshing state with better UI feedback
- Added no-cache headers to API endpoint
- Improved refresh button with loading state
This commit is contained in:
Aram Chia Sarafian
2025-07-13 19:12:00 -07:00
parent ba831bf15a
commit 5206aceded
7 changed files with 128 additions and 502 deletions

View File

@ -1,5 +1,9 @@
import { NextResponse } from 'next/server';
// Force dynamic rendering to prevent caching
export const dynamic = 'force-dynamic';
export const revalidate = 0;
// Library branch locations with approximate coordinates
const LIBRARY_LOCATIONS: Record<string, { lat: number; lng: number }> = {
'Beckwourth': { lat: 32.7157, lng: -117.1611 },
@ -127,10 +131,17 @@ export async function GET() {
const locations = Array.from(locationMap.values());
return NextResponse.json({
const response = NextResponse.json({
locations,
lastUpdated: new Date().toISOString(),
});
// Prevent caching to ensure fresh data
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
response.headers.set('Pragma', 'no-cache');
response.headers.set('Expires', '0');
return response;
} catch (error) {
console.error('Error in API route:', error);
return NextResponse.json(

View File

@ -1,32 +0,0 @@
import { NextResponse } from 'next/server';
import { NotificationManager } from '../../utils/notifications';
// This API endpoint can be used for periodic checking
export async function GET() {
try {
const notifications = NotificationManager.getNotifications();
// In a real implementation, you might want to:
// 1. Fetch current availability data
// 2. Check against notification preferences
// 3. Send email notifications
// 4. Return results
return NextResponse.json({
notificationCount: notifications.length,
notifications: notifications.map(n => ({
id: n.id,
libraryName: n.libraryName,
passTypes: n.passTypes,
createdAt: n.createdAt,
lastChecked: n.lastChecked,
})),
});
} catch (error) {
console.error('Error checking notifications:', error);
return NextResponse.json(
{ error: 'Failed to check notifications' },
{ status: 500 }
);
}
}

View File

@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/notifications';
import { calculateDistance, getCurrentLocation, getCoordinatesFromZip, isValidSanDiegoZip } from '../utils/location';
interface LocationData {
name: string;

View File

@ -1,275 +0,0 @@
'use client';
import { useState } from 'react';
import { NotificationManager, type NotificationRequest, WebPushManager } from '../utils/notifications';
interface LocationData {
name: string;
lat: number;
lng: number;
available: boolean;
passType: string;
}
interface NotificationPanelProps {
locations: LocationData[];
}
export default function NotificationPanel({ locations }: { readonly locations: LocationData[] }) {
const [notifications, setNotifications] = useState<NotificationRequest[]>(() =>
NotificationManager.getNotifications()
);
const [showAddForm, setShowAddForm] = useState(false);
const [selectedLibrary, setSelectedLibrary] = useState('');
const [selectedPassTypes, setSelectedPassTypes] = useState<string[]>([]);
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>(
WebPushManager.isSupported() ? WebPushManager.getPermissionStatus() : 'denied'
);
const handlePassTypeChange = (type: string, checked: boolean) => {
if (checked) {
setSelectedPassTypes(prev => [...prev, type]);
} else {
setSelectedPassTypes(prev => prev.filter(t => t !== type));
}
};
const requestNotificationPermission = async () => {
const granted = await WebPushManager.requestPermission();
setNotificationPermission(granted ? 'granted' : 'denied');
};
const handleAddNotification = () => {
if (!selectedLibrary || selectedPassTypes.length === 0) return;
const newNotification = NotificationManager.addNotification({
libraryName: selectedLibrary,
passTypes: selectedPassTypes as ('Pass Only' | 'Backpack Only' | 'Both')[],
});
setNotifications(prev => [...prev, newNotification]);
setShowAddForm(false);
setSelectedLibrary('');
setSelectedPassTypes([]);
};
const handleRemoveNotification = (id: string) => {
NotificationManager.removeNotification(id);
setNotifications(prev => prev.filter(n => n.id !== id));
};
const checkNotifications = async () => {
const availableNow = notifications.filter(notification => {
const library = locations.find(loc => loc.name === notification.libraryName);
return library && library.available && (
notification.passTypes.includes('Both') ||
notification.passTypes.some(type => library.passType.includes(type.replace(' Only', '')))
);
});
if (availableNow.length > 0) {
const message = availableNow.map(n => `${n.libraryName} has passes available!`).join('\n');
// Try web notification first
if (notificationPermission === 'granted') {
for (const notification of availableNow) {
await WebPushManager.showNotification(
'🎉 Beach Pass Available!',
`${notification.libraryName} has ${notification.passTypes.join(' and ')} available!`,
{
tag: `park-pass-${notification.id}`,
requireInteraction: true,
}
);
}
}
// Also show alert for immediate feedback
alert(`🎉 Great news!\n\n${message}`);
// Update last checked for all notifications
notifications.forEach(n => NotificationManager.updateLastChecked(n.id));
} else {
alert('No passes currently available for your requested libraries.');
}
};
return (
<div style={{ marginTop: '15px', borderTop: '1px solid #eee', paddingTop: '15px' }}>
<h4 style={{ marginBottom: '10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>🔔 Notifications ({notifications.length})</span>
<button
onClick={() => setShowAddForm(!showAddForm)}
style={{
fontSize: '12px',
padding: '4px 8px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{showAddForm ? 'Cancel' : '+ Add'}
</button>
</h4>
{/* Notification Permission Status */}
{WebPushManager.isSupported() && (
<div style={{
marginBottom: '10px',
padding: '8px',
backgroundColor: notificationPermission === 'granted' ? '#e8f5e8' : '#fff3cd',
borderRadius: '4px',
fontSize: '12px',
border: `1px solid ${notificationPermission === 'granted' ? '#4CAF50' : '#ffc107'}`
}}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
Push Notifications: {notificationPermission === 'granted' ? '✅ Enabled' : '❌ Disabled'}
</div>
{notificationPermission !== 'granted' && (
<div>
<div style={{ marginBottom: '6px', color: '#666' }}>
Enable push notifications to get alerts when passes become available.
</div>
<button
onClick={requestNotificationPermission}
style={{
fontSize: '11px',
padding: '4px 8px',
backgroundColor: '#007cba',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
}}
>
Enable Notifications
</button>
</div>
)}
</div>
)}
{showAddForm && (
<div style={{
backgroundColor: '#f9f9f9',
padding: '10px',
borderRadius: '4px',
marginBottom: '10px',
fontSize: '13px'
}}>
<div style={{ marginBottom: '8px' }}>
<div style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
Library:
</div>
<select
value={selectedLibrary}
onChange={(e) => setSelectedLibrary(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
aria-label="Select library"
>
<option value="">Select a library...</option>
{locations.map(loc => (
<option key={loc.name} value={loc.name}>{loc.name}</option>
))}
</select>
</div>
<div style={{ marginBottom: '8px' }}>
<div style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
Pass Types:
</div>
{['Pass Only', 'Backpack Only', 'Both'].map(type => (
<div key={type} style={{ display: 'block', fontSize: '12px' }}>
<input
type="checkbox"
checked={selectedPassTypes.includes(type)}
onChange={(e) => handlePassTypeChange(type, e.target.checked)}
style={{ marginRight: '6px' }}
id={`passtype-${type}`}
/>
<label htmlFor={`passtype-${type}`}>
{type}
</label>
</div>
))}
</div>
<button
onClick={handleAddNotification}
disabled={!selectedLibrary || selectedPassTypes.length === 0}
style={{
padding: '6px 12px',
backgroundColor: selectedLibrary && selectedPassTypes.length > 0 ? '#007cba' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: selectedLibrary && selectedPassTypes.length > 0 ? 'pointer' : 'not-allowed',
fontSize: '12px',
}}
>
Add Notification
</button>
</div>
)}
{notifications.length > 0 && (
<>
<div style={{ maxHeight: '120px', overflowY: 'auto', marginBottom: '8px' }}>
{notifications.map(notification => (
<div key={notification.id} style={{
fontSize: '11px',
marginBottom: '6px',
padding: '6px',
backgroundColor: '#f0f0f0',
borderRadius: '3px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<div style={{ fontWeight: 'bold' }}>{notification.libraryName}</div>
<div style={{ color: '#666' }}>
{notification.passTypes.join(', ')}
</div>
</div>
<button
onClick={() => handleRemoveNotification(notification.id)}
style={{
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '2px',
padding: '2px 6px',
fontSize: '10px',
cursor: 'pointer',
}}
>
</button>
</div>
))}
</div>
<button
onClick={checkNotifications}
style={{
width: '100%',
padding: '8px',
backgroundColor: '#ff9800',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 'bold',
}}
>
Check Now
</button>
</>
)}
</div>
);
}

View File

@ -3,7 +3,6 @@
import dynamic from 'next/dynamic';
import { useState, useEffect } from 'react';
import LibraryList from './components/LibraryList';
import NotificationPanel from './components/NotificationPanel';
// Dynamically import the map component to avoid SSR issues
const MapComponent = dynamic(() => import('./components/MapComponent'), {
@ -27,6 +26,7 @@ interface ApiResponse {
export default function Home() {
const [locations, setLocations] = useState<LocationData[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string>('');
// Start with menu collapsed only on mobile
@ -42,10 +42,22 @@ export default function Home() {
fetchAvailability();
}, []);
const fetchAvailability = async () => {
const fetchAvailability = async (isRefresh = false) => {
try {
setLoading(true);
const response = await fetch('/api/availability');
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
// Add cache-busting parameter to ensure fresh data
const timestamp = new Date().getTime();
const response = await fetch(`/api/availability?t=${timestamp}`, {
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache',
},
});
if (!response.ok) {
throw new Error('Failed to fetch data');
}
@ -56,7 +68,11 @@ export default function Home() {
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
};
@ -111,8 +127,6 @@ export default function Home() {
</div>
</div>
<NotificationPanel locations={locations} />
{lastUpdated && (
<p style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
Last updated: {new Date(lastUpdated).toLocaleString()}
@ -120,19 +134,20 @@ export default function Home() {
)}
<button
onClick={fetchAvailability}
onClick={() => fetchAvailability(true)}
disabled={refreshing}
style={{
marginTop: '10px',
padding: '8px 16px',
backgroundColor: '#007cba',
backgroundColor: refreshing ? '#ccc' : '#007cba',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
cursor: refreshing ? 'not-allowed' : 'pointer',
width: '100%',
}}
>
Refresh Data
{refreshing ? 'Refreshing...' : 'Refresh Data'}
</button>
</>
)}

89
app/utils/location.ts Normal file
View File

@ -0,0 +1,89 @@
// Geolocation utilities
export function calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3959; // Earth's radius in miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
export async function getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
},
() => {
resolve(null);
},
{ timeout: 10000 }
);
});
}
// San Diego ZIP code validation and coordinates
const SAN_DIEGO_ZIP_COORDINATES: Record<string, { lat: number; lng: number }> = {
'92101': { lat: 32.7157, lng: -117.1611 }, // Downtown
'92102': { lat: 32.7030, lng: -117.1289 }, // Logan Heights
'92103': { lat: 32.7328, lng: -117.1461 }, // Core-Columbia
'92104': { lat: 32.7482, lng: -117.0704 }, // College-Rolando
'92105': { lat: 32.7089, lng: -117.1242 }, // Southeastern San Diego
'92106': { lat: 32.7330, lng: -117.1430 }, // Point Loma
'92107': { lat: 32.7469, lng: -117.1978 }, // Ocean Beach
'92108': { lat: 32.7469, lng: -117.1978 }, // Mission Valley
'92109': { lat: 32.7714, lng: -117.1789 }, // Pacific Beach
'92110': { lat: 32.7714, lng: -117.1789 }, // Mission Bay
'92111': { lat: 32.8328, lng: -117.2050 }, // Clairemont
'92113': { lat: 32.6747, lng: -117.0742 }, // Southeastern San Diego
'92114': { lat: 32.7030, lng: -117.1289 }, // Southeastern San Diego
'92115': { lat: 32.7411, lng: -117.1045 }, // City Heights
'92116': { lat: 32.7644, lng: -117.1164 }, // Kensington
'92117': { lat: 32.8328, lng: -117.2050 }, // Clairemont
'92118': { lat: 32.6144, lng: -117.0845 }, // Coronado
'92119': { lat: 32.7482, lng: -117.0704 }, // College Area
'92120': { lat: 32.7482, lng: -117.0704 }, // College Area
'92121': { lat: 32.9340, lng: -117.2340 }, // Sorrento Valley
'92122': { lat: 32.8344, lng: -117.2544 }, // La Jolla
'92123': { lat: 32.7714, lng: -117.1789 }, // Serra Mesa
'92124': { lat: 32.7714, lng: -117.1789 }, // Serra Mesa
'92126': { lat: 32.9286, lng: -117.1311 }, // Carmel Mountain
'92127': { lat: 33.0200, lng: -117.1156 }, // Rancho Bernardo
'92128': { lat: 32.958034, lng: -117.121975 }, // Rancho Penasquitos
'92129': { lat: 32.958034, lng: -117.121975 }, // Rancho Penasquitos
'92130': { lat: 32.9340, lng: -117.2340 }, // Carmel Valley
'92131': { lat: 32.9286, lng: -117.1311 }, // Carmel Mountain
'92132': { lat: 32.8344, lng: -117.2544 }, // La Jolla
'92133': { lat: 32.7157, lng: -117.1611 }, // Naval Base
'92134': { lat: 32.7157, lng: -117.1611 }, // Naval Base
'92135': { lat: 32.7157, lng: -117.1611 }, // Naval Base
'92136': { lat: 32.7330, lng: -117.1430 }, // Point Loma
'92137': { lat: 32.7330, lng: -117.1430 }, // Point Loma
'92138': { lat: 32.6144, lng: -117.0845 }, // Naval Air Station
'92139': { lat: 32.6747, lng: -117.0742 }, // Paradise Hills
'92140': { lat: 32.6144, lng: -117.0845 }, // Coronado
'92145': { lat: 32.7157, lng: -117.1611 }, // Naval Medical Center
'92147': { lat: 32.7330, lng: -117.1430 }, // Point Loma
'92154': { lat: 32.5592, lng: -117.0431 }, // San Ysidro
'92155': { lat: 32.5592, lng: -117.0431 }, // San Ysidro
'92173': { lat: 32.6781, lng: -117.0200 }, // Skyline Hills
};
export function isValidSanDiegoZip(zip: string): boolean {
return zip in SAN_DIEGO_ZIP_COORDINATES;
}
export function getCoordinatesFromZip(zip: string): { lat: number; lng: number } | null {
return SAN_DIEGO_ZIP_COORDINATES[zip] || null;
}

View File

@ -1,182 +0,0 @@
// Simple notification storage using localStorage
export interface NotificationRequest {
id: string;
libraryName: string;
passTypes: ('Pass Only' | 'Backpack Only' | 'Both')[];
email?: string; // Optional for future email notifications
createdAt: string;
lastChecked?: string;
}
export class NotificationManager {
private static STORAGE_KEY = 'park-pass-notifications';
static getNotifications(): NotificationRequest[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
static addNotification(request: Omit<NotificationRequest, 'id' | 'createdAt'>): NotificationRequest {
const newRequest: NotificationRequest = {
...request,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
createdAt: new Date().toISOString(),
};
const notifications = this.getNotifications();
notifications.push(newRequest);
if (typeof window !== 'undefined') {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
}
return newRequest;
}
static removeNotification(id: string): void {
const notifications = this.getNotifications().filter(n => n.id !== id);
if (typeof window !== 'undefined') {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
}
}
static updateLastChecked(id: string): void {
const notifications = this.getNotifications();
const notification = notifications.find(n => n.id === id);
if (notification) {
notification.lastChecked = new Date().toISOString();
if (typeof window !== 'undefined') {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(notifications));
}
}
}
}
// Geolocation utilities
export function calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 3959; // Earth's radius in miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
export async function getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
},
() => {
resolve(null);
},
{ timeout: 10000 }
);
});
}
// Web Push Notification utilities
export class WebPushManager {
static async requestPermission(): Promise<boolean> {
if (!('Notification' in window)) {
console.log('This browser does not support notifications');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission === 'denied') {
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
static async showNotification(title: string, body: string, options?: NotificationOptions): Promise<void> {
if (!await this.requestPermission()) {
console.log('Notification permission denied');
return;
}
new Notification(title, {
body,
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: 'park-pass-notification',
requireInteraction: true,
...options
});
}
static isSupported(): boolean {
return 'Notification' in window;
}
static getPermissionStatus(): NotificationPermission {
return Notification.permission;
}
}
// Zip code to coordinates lookup (San Diego area zip codes)
const ZIP_COORDINATES: Record<string, { lat: number; lng: number; area: string }> = {
// Central San Diego
'92101': { lat: 32.7157, lng: -117.1611, area: 'Downtown' },
'92102': { lat: 32.7081, lng: -117.1367, area: 'Golden Hill' },
'92103': { lat: 32.7441, lng: -117.1739, area: 'Hillcrest' },
'92104': { lat: 32.7489, lng: -117.1364, area: 'Normal Heights' },
'92105': { lat: 32.7089, lng: -117.1242, area: 'City Heights' },
'92106': { lat: 32.7330, lng: -117.1430, area: 'Point Loma' },
'92107': { lat: 32.7572, lng: -117.2528, area: 'Ocean Beach' },
'92108': { lat: 32.7678, lng: -117.1189, area: 'Mission Valley' },
'92109': { lat: 32.7889, lng: -117.2306, area: 'Pacific Beach' },
'92110': { lat: 32.7742, lng: -117.1936, area: 'Mission Bay' },
'92111': { lat: 32.7714, lng: -117.1789, area: 'Linda Vista' },
'92113': { lat: 32.6781, lng: -117.0200, area: 'Skyline' },
'92114': { lat: 32.7030, lng: -117.1289, area: 'Logan Heights' },
'92115': { lat: 32.7328, lng: -117.1461, area: 'Oak Park' },
'92116': { lat: 32.7644, lng: -117.1164, area: 'Kensington' },
'92117': { lat: 32.8328, lng: -117.2050, area: 'Clairemont' },
'92118': { lat: 32.6100, lng: -117.0842, area: 'Coronado' },
'92119': { lat: 32.7482, lng: -117.0704, area: 'College Area' },
'92120': { lat: 32.7678, lng: -117.0731, area: 'Del Cerro' },
'92121': { lat: 32.8797, lng: -117.2072, area: 'Sorrento Valley' },
'92122': { lat: 32.8544, lng: -117.2144, area: 'University City' },
'92123': { lat: 32.8100, lng: -117.1350, area: 'Serra Mesa' },
'92124': { lat: 32.8086, lng: -117.0547, area: 'Tierrasanta' },
'92126': { lat: 32.8831, lng: -117.1558, area: 'Mira Mesa' },
'92127': { lat: 32.9581, lng: -117.1089, area: 'Rancho Penasquitos' },
'92128': { lat: 33.0200, lng: -117.1156, area: 'Rancho Bernardo' },
'92129': { lat: 32.9584, lng: -117.1253, area: 'Rancho Penasquitos' },
'92130': { lat: 32.9340, lng: -117.2340, area: 'Carmel Valley' },
'92131': { lat: 32.9286, lng: -117.1311, area: 'Carmel Mountain' },
'92132': { lat: 32.6747, lng: -117.0742, area: 'Paradise Hills' },
'92154': { lat: 32.5592, lng: -117.0431, area: 'San Ysidro' },
'92173': { lat: 32.6400, lng: -117.0200, area: 'San Diego East' },
};
export function getCoordinatesFromZip(zipCode: string): { lat: number; lng: number; area: string } | null {
return ZIP_COORDINATES[zipCode] || null;
}
export function isValidSanDiegoZip(zipCode: string): boolean {
return zipCode in ZIP_COORDINATES;
}