-
Notifications
You must be signed in to change notification settings - Fork 33
feat: add real-time satellite flyby notifications #175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| import React, { useEffect } from 'react'; | ||
| import { motion } from 'framer-motion'; | ||
| import { useNotificationStore } from '@/store/notificationStore'; | ||
| import type { FlybyNotification as FlybyNotificationType } from '@/store/notificationStore'; | ||
| import { useUIStore } from '@/store/uiStore'; | ||
| import { MaterialIcon } from '../MaterialIcon'; | ||
| import { useNavigate } from 'react-router-dom'; | ||
|
|
||
| interface Props { | ||
| notification: FlybyNotificationType; | ||
| } | ||
|
|
||
| const playBeep = () => { | ||
| try { | ||
| const AudioContext = window.AudioContext || (window as any).webkitAudioContext; | ||
| if (!AudioContext) return; | ||
|
|
||
| const ctx = new AudioContext(); | ||
| const osc = ctx.createOscillator(); | ||
| const gainNode = ctx.createGain(); | ||
|
|
||
| osc.type = 'sine'; | ||
| osc.frequency.setValueAtTime(880, ctx.currentTime); // A5 | ||
| osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1); // Drop to A4 | ||
|
|
||
| gainNode.gain.setValueAtTime(0.1, ctx.currentTime); | ||
| gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5); | ||
|
|
||
| osc.connect(gainNode); | ||
| gainNode.connect(ctx.destination); | ||
|
|
||
| osc.start(); | ||
| osc.stop(ctx.currentTime + 0.5); | ||
| } catch (e) { | ||
| console.warn('Audio play failed', e); | ||
|
Comment on lines
+18
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Locate file:"
fd -a 'FlybyNotification\.tsx$' . || true
echo
echo "File contents/outline:"
file="$(fd 'FlybyNotification\.tsx$' . | head -n1 || true)"
if [ -n "${file:-}" ]; then
wc -l "$file"
ast-grep outline "$file" --view compact || true
echo
cat -n "$file"
fi
echo
echo "Search for playBeep/usages:"
rg -n "playBeep|AudioContext|FlybyNotification" frontend src . 2>/dev/null | head -n 200 || trueRepository: 7-Blocks/Kepler Length of output: 9976 🌐 Web query:
💡 Result: The recommended practice for using AudioContext is to create a single instance and reuse it throughout your application's lifecycle, rather than creating and closing it frequently [1]. When you no longer need an AudioContext, calling the close method is the standard way to release system audio resources, such as those that might block the creation of additional contexts or impact CPU/battery usage [2][1][3]. Key considerations regarding AudioContext.close: 1. Purpose: The primary purpose of close is to forcibly release system audio resources, stop audio processing, and suspend the progression of audio time [2][1]. 2. Resource Management: Calling close does not automatically release all objects created by the AudioContext; it specifically targets system audio resources [2][3]. Garbage collection will eventually handle the memory associated with the context and its nodes once you release your references to them (e.g., by setting the variable to null) [4]. 3. Promise-based: The close method returns a Promise [2]. It is good practice to wait for this promise to resolve if you need to ensure all blocking resources are released before proceeding (for example, before attempting to initialize a new context) [2][5]. 4. Alternatives for Temporary Pauses: If you only need to temporarily halt audio output or reduce CPU/battery consumption without destroying the context entirely, consider using the suspend method instead of close [1]. In summary, use close when you are finished with the audio functionality for an extended period or the application lifecycle ends [2][1]. For active applications, prefer reusing a single instance and using suspend/resume to manage temporary interruptions or state changes [6][1][7]. Citations:
🌐 Web query:
💡 Result: The Web Audio API specification does not mandate a specific maximum number of active AudioContext instances [1][2]. Instead, it allows browser implementations to define their own limits [2]. When this limit is exceeded, an implementation may throw a NotSupportedError when attempting to create a new AudioContext [2]. Because limits are implementation-defined, they vary across browsers and platforms: - Browser-Specific and Platform-Specific Limits: Browsers may impose different caps based on the underlying OS or hardware [3][2]. For instance, source code for the WebKit engine (used by Safari) has historically included platform-specific constraints, such as a limit of four hardware contexts on Windows [3]. - Historical Context in Chrome: Older versions of Google Chrome (prior to version 66) enforced a limit of six AudioContext instances per tab [1][4]. - Best Practices: It is widely recommended to create a single AudioContext and reuse it throughout your application rather than initializing multiple instances [5]. This is both more resource-efficient and avoids potential issues with browser-imposed limits or performance bottlenecks [5]. If your application requires multiple audio graphs, you should manage them within a single AudioContext or use Citations:
Reuse a single
🤖 Prompt for AI Agents |
||
| } | ||
| }; | ||
|
|
||
| export const FlybyNotification: React.FC<Props> = ({ notification }) => { | ||
| const dismiss = useNotificationStore((s) => s.dismissNotification); | ||
| const preferences = useNotificationStore((s) => s.preferences); | ||
| const setSelectedSatelliteId = useUIStore((s) => s.setSelectedSatelliteId); | ||
| const navigate = useNavigate(); | ||
|
|
||
| useEffect(() => { | ||
| if (preferences.soundEnabled) { | ||
| playBeep(); | ||
| } | ||
|
|
||
| // Optional: Use browser notifications API if permitted | ||
| if (Notification.permission === 'granted') { | ||
| new Notification(`Flyby Alert: ${notification.satelliteName}`, { | ||
| body: `Approaching ${notification.locationName}. ETA: ${notification.eta.toLocaleTimeString()}`, | ||
| icon: '/vite.svg' | ||
| }); | ||
| } else if (Notification.permission !== 'denied') { | ||
| Notification.requestPermission(); | ||
| } | ||
| }, [notification, preferences.soundEnabled]); | ||
|
Comment on lines
+45
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The effect depends on the entire Severity Level: Major
|
||
|
|
||
| const handleTrack = () => { | ||
| setSelectedSatelliteId(notification.satelliteId); | ||
| navigate('/dashboard/satellites'); | ||
| dismiss(notification.id); | ||
| }; | ||
|
|
||
| const minutesAway = Math.max(0, Math.round((notification.eta.getTime() - Date.now()) / 60000)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Files matching FlybyNotification:"
fd -a 'FlybyNotification\.tsx$' . || true
file="$(fd 'FlybyNotification\.tsx$' . | head -n1 || true)"
if [ -n "${file:-}" ]; then
echo
echo "Line count:"
wc -l "$file"
echo
echo "File contents with line numbers:"
cat -n "$file"
fi
echo
echo "Search for FlybyNotification usages:"
rg -n "FlybyNotification|eta|minutesAway" . --glob '!node_modules' --glob '!dist' --glob '!build' || trueRepository: 7-Blocks/Kepler Length of output: 50372 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Matching files:"
fd -a 'FlybyNotification\.tsx$|FlybyNotification\.ts$' . || true
echo
echo "Search focused usages:"
rg -n "FlybyNotification|minutesAway|Date\.now\(\)|eta\.getTime\(\)|T-\{minutesAway MIN\}\"" . \
--glob '!node_modules' --glob '!build' --glob '!dist' \
--glob '!*.svg' --glob '!*.png' --glob '!*.jpg' --glob '!*.jpeg' \
--glob '!*.webp' --glob '!*.gif' --glob '!*.ico' --glob '!*.woff*' || trueRepository: 7-Blocks/Kepler Length of output: 3899 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="$(fd 'FlybyNotification\.tsx$' . | head -n1 || true)"
echo "FlybyNotification file: $file"
wc -l "$file"
echo
sed -n '1,140p' "$file" | cat -n
echo
echo "NotificationStore relevant lines:"
sed -n '1,120p' frontend/src/store/notificationStore.ts | cat -n
echo
echo "NotificationCenter relevant lines:"
sed -n '1,80p' frontend/src/components/ui/NotificationCenter.tsx | cat -nRepository: 7-Blocks/Kepler Length of output: 14011 🌐 Web query:
💡 Result: To correctly handle timer or interval cleanup within a component being unmounted by AnimatePresence, you must leverage the useEffect cleanup function in combination with Framer Motion's usePresence hook [1][2][3]. When a component is removed from the React tree but wrapped in AnimatePresence, it remains mounted in the DOM while its exit animation runs [4][5]. Standard useEffect cleanup functions will run as soon as the component is removed from the React tree, which may stop your timers prematurely or lead to errors if you attempt to update state on an unmounted component [6][7]. To manage this properly: 1. Use the usePresence hook to track the component's exit status [2][3]. 2. Create your timer or interval inside a useEffect that depends on the isPresent state returned by usePresence [2][3]. 3. If you need to manually control when the component is finally removed (e.g., waiting for a timer to finish alongside an animation), use the safeToRemove callback returned by usePresence [1][2][3]. Example pattern for delayed removal with a timer: import { useEffect } from 'react'; import { usePresence, motion } from 'motion/react'; function DelayedComponent { const [isPresent, safeToRemove] = usePresence; useEffect( => { if (!isPresent) { // Logic to run during exit phase (e.g., wait 1s) const timer = setTimeout(safeToRemove, 1000); // Cleanup the timer if the component is somehow // forced to unmount before the timer finishes return => clearTimeout(timer); } }, [isPresent, safeToRemove]); return <motion.div exit={{ opacity: 0 }}>Content</motion.div>; } By including a cleanup function (return => clearTimeout(timer)), you ensure that no memory leaks or errors occur if the exit state changes rapidly [6]. If your goal is simply to stop an interval from trying to update state on an unmounted component, the standard useEffect cleanup is sufficient; however, if you are coordinating custom exit timing, usePresence and safeToRemove are the authoritative tools [1][2][7]. Citations:
Update the ETA each minute.
🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <motion.div | ||
| initial={{ opacity: 0, x: 50, scale: 0.95 }} | ||
| animate={{ opacity: 1, x: 0, scale: 1 }} | ||
| exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }} | ||
| className="bg-bg-deep-space/90 backdrop-blur-md border border-primary-container/40 p-4 w-80 shadow-[0_4px_24px_rgba(0,229,255,0.15)] relative overflow-hidden" | ||
| > | ||
| <div className="absolute top-0 left-0 w-1 h-full bg-primary-container glow-cyan" /> | ||
|
|
||
| <div className="flex justify-between items-start mb-2"> | ||
| <div className="flex items-center gap-2"> | ||
| <MaterialIcon name="radar" className="text-primary-container animate-pulse text-sm" /> | ||
| <span className="font-label-caps text-[10px] tracking-widest text-primary-container font-bold"> | ||
| INCOMING FLYBY | ||
| </span> | ||
| </div> | ||
| <button | ||
| onClick={() => dismiss(notification.id)} | ||
| className="text-on-surface-variant hover:text-primary-container transition-ui" | ||
| > | ||
| <MaterialIcon name="close" className="text-sm" /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <h3 className="font-display-lg text-lg text-on-surface leading-tight mb-1"> | ||
| {notification.satelliteName} | ||
| </h3> | ||
| <p className="font-technical-data text-[11px] text-on-surface-variant mb-3"> | ||
| Target: <span className="text-primary">{notification.locationName}</span> | ||
| </p> | ||
|
|
||
| <div className="grid grid-cols-2 gap-2 mb-4 bg-surface-container/30 p-2 border border-border-panel"> | ||
| <div> | ||
| <p className="font-label-caps text-[9px] text-on-surface-variant">ETA</p> | ||
| <p className="font-technical-data text-xs text-status-warning font-bold"> | ||
| T-{minutesAway} MIN | ||
| </p> | ||
| </div> | ||
| <div> | ||
| <p className="font-label-caps text-[9px] text-on-surface-variant">MAX ELEVATION</p> | ||
| <p className="font-technical-data text-xs text-primary-fixed font-bold"> | ||
| {notification.maxElevationDeg.toFixed(1)}° | ||
| </p> | ||
| </div> | ||
| <div> | ||
| <p className="font-label-caps text-[9px] text-on-surface-variant">ALTITUDE</p> | ||
| <p className="font-technical-data text-xs text-on-surface"> | ||
| {notification.altitudeKm.toFixed(0)} KM | ||
| </p> | ||
| </div> | ||
| <div> | ||
| <p className="font-label-caps text-[9px] text-on-surface-variant">VELOCITY</p> | ||
| <p className="font-technical-data text-xs text-on-surface"> | ||
| {notification.velocityKms.toFixed(2)} KM/S | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex gap-2"> | ||
| <button | ||
| onClick={handleTrack} | ||
| className="flex-1 bg-primary-container/10 border border-primary-container text-primary-container hover:bg-primary-container hover:text-bg-deep-space transition-ui font-technical-data text-xs py-1.5 font-bold" | ||
| > | ||
| TRACK LIVE | ||
| </button> | ||
| <button | ||
| onClick={() => dismiss(notification.id)} | ||
| className="flex-1 border border-border-panel text-on-surface-variant hover:text-primary transition-ui font-technical-data text-xs py-1.5" | ||
| > | ||
| DISMISS | ||
| </button> | ||
| </div> | ||
| </motion.div> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import React from 'react'; | ||
| import { AnimatePresence, motion } from 'framer-motion'; | ||
| import { useNotificationStore } from '@/store/notificationStore'; | ||
| import { useUIStore } from '@/store/uiStore'; | ||
| import { useFlybyEngine } from '@/hooks/useFlybyEngine'; | ||
| import { FlybyNotification } from './FlybyNotification'; | ||
| import { MaterialIcon } from '../MaterialIcon'; | ||
|
|
||
| export const NotificationCenter: React.FC = () => { | ||
| // Mount engine here so it runs globally | ||
| useFlybyEngine(); | ||
|
|
||
| const notifications = useNotificationStore((s) => s.notifications); | ||
| const preferences = useNotificationStore((s) => s.preferences); | ||
| const updatePreferences = useNotificationStore((s) => s.updatePreferences); | ||
| const clearAll = useNotificationStore((s) => s.clearAll); | ||
|
|
||
| const isHistoryOpen = useUIStore((s) => s.isFlybyHistoryOpen); | ||
| const toggleHistory = useUIStore((s) => s.toggleFlybyHistory); | ||
|
|
||
| const activeNotifications = notifications.filter(n => !n.dismissed); | ||
|
|
||
| return ( | ||
| <> | ||
| {/* Active Toasts - Bottom Right */} | ||
| <div className="fixed bottom-6 right-6 z-50 flex flex-col-reverse gap-4 items-end pointer-events-none"> | ||
| <AnimatePresence> | ||
| {activeNotifications.slice(0, 3).map((notification) => ( | ||
| <div key={notification.id} className="pointer-events-auto"> | ||
| <FlybyNotification notification={notification} /> | ||
| </div> | ||
| ))} | ||
| </AnimatePresence> | ||
| </div> | ||
|
|
||
| {/* History & Settings Panel */} | ||
| <AnimatePresence> | ||
| {isHistoryOpen && ( | ||
| <motion.div | ||
| initial={{ opacity: 0, scale: 0.95, y: -20 }} | ||
| animate={{ opacity: 1, scale: 1, y: 0 }} | ||
| exit={{ opacity: 0, scale: 0.95, y: -20 }} | ||
| transition={{ type: 'spring', damping: 25, stiffness: 300 }} | ||
| className="fixed top-16 right-16 sm:right-72 z-50 w-80 max-h-[80vh] flex flex-col bg-bg-deep-space/95 backdrop-blur-xl border border-primary-container/40 shadow-[0_10px_40px_rgba(0,0,0,0.8)] overflow-hidden" | ||
| > | ||
| <div className="p-4 border-b border-border-panel/70 flex justify-between items-center bg-surface-container/30"> | ||
| <div> | ||
| <h2 className="font-display-lg text-base font-bold text-on-surface">FLYBY ALERTS</h2> | ||
| <p className="font-label-caps text-[9px] tracking-widest text-primary-container/70">HISTORY & SETTINGS</p> | ||
| </div> | ||
| <button | ||
| onClick={toggleHistory} | ||
| className="text-on-surface-variant hover:text-primary-container transition-ui" | ||
| > | ||
| <MaterialIcon name="close" /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="flex-1 overflow-y-auto custom-scrollbar p-4 flex flex-col gap-4"> | ||
| {/* Settings Section */} | ||
| <div className="bg-surface-container/20 border border-border-panel p-3"> | ||
| <h3 className="font-label-caps text-[10px] tracking-widest text-on-surface-variant mb-3">PREFERENCES</h3> | ||
|
|
||
| <div className="flex items-center justify-between mb-3"> | ||
| <span className="font-technical-data text-xs text-on-surface">Audio Alerts</span> | ||
| <button | ||
| onClick={() => updatePreferences({ soundEnabled: !preferences.soundEnabled })} | ||
| className={`text-lg transition-ui ${preferences.soundEnabled ? 'text-primary-container' : 'text-on-surface-variant'}`} | ||
| > | ||
| <MaterialIcon name={preferences.soundEnabled ? 'volume_up' : 'volume_off'} /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-2"> | ||
| <span className="font-technical-data text-xs text-on-surface">Warning Window</span> | ||
| <div className="flex gap-2"> | ||
| {[5, 10, 15].map(min => ( | ||
| <button | ||
| key={min} | ||
| onClick={() => updatePreferences({ warningMinutes: min })} | ||
| className={`flex-1 py-1 font-technical-data text-[10px] font-bold border transition-ui ${ | ||
| preferences.warningMinutes === min | ||
| ? 'bg-primary-container/20 border-primary-container text-primary-container' | ||
| : 'border-border-panel text-on-surface-variant hover:text-primary' | ||
| }`} | ||
| > | ||
| {min} MIN | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* History Section */} | ||
| <div> | ||
| <div className="flex justify-between items-center mb-2"> | ||
| <h3 className="font-label-caps text-[10px] tracking-widest text-on-surface-variant">LOG</h3> | ||
| {notifications.length > 0 && ( | ||
| <button onClick={clearAll} className="font-technical-data text-[9px] text-primary-fixed hover:text-primary-container transition-ui"> | ||
| CLEAR ALL | ||
| </button> | ||
| )} | ||
| </div> | ||
|
|
||
| {notifications.length === 0 ? ( | ||
| <p className="font-technical-data text-xs text-on-surface-variant text-center py-4 italic"> | ||
| No flyby alerts recorded. | ||
| </p> | ||
| ) : ( | ||
| <div className="flex flex-col gap-2"> | ||
| {notifications.map(n => ( | ||
| <div key={n.id} className={`p-2 border ${n.dismissed ? 'border-border-panel bg-surface-container-low/50' : 'border-primary-container/40 bg-primary-container/10'}`}> | ||
| <div className="flex justify-between items-start mb-1"> | ||
| <span className="font-technical-data text-xs font-bold text-on-surface">{n.satelliteName}</span> | ||
| <span className="font-technical-data text-[9px] text-on-surface-variant"> | ||
| {n.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} | ||
| </span> | ||
| </div> | ||
| <p className="font-technical-data text-[10px] text-primary">Over {n.locationName}</p> | ||
| <p className="font-technical-data text-[10px] text-on-surface-variant mt-1"> | ||
| Max El: {n.maxElevationDeg.toFixed(1)}° | Alt: {n.altitudeKm.toFixed(0)}km | ||
| </p> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </motion.div> | ||
| )} | ||
| </AnimatePresence> | ||
| </> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Each alert creates a new
AudioContext, but the context is never closed after the oscillator stops. Repeated flyby alerts or preference-triggered effect reruns therefore retain audio contexts and can eventually hit browser audio-resource limits; close the context after playback completes. [resource leak]Severity Level: Major⚠️
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖