Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 1 addition & 40 deletions frontend/src/components/EarthTwin.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react';
import { prefersReducedMotion } from './SatelliteSpotlight/GlowEffect';
import { keplerToLatLonAlt } from '@/utils/orbitCalc';
import { useUIStore } from '@/store/uiStore';
import { logEvent } from '@/store/logbookStore';
import { MaterialIcon } from './MaterialIcon';
Expand Down Expand Up @@ -50,48 +51,8 @@ interface CollisionRisk {



function keplerToLatLonAlt(obj: CatalogObject, timeOffsetSec: number = 0): { lat: number; lon: number; alt: number } | null {
if (obj.semimajor_axis == null || obj.inclination == null || obj.raan == null ||
obj.arg_of_perigee == null || obj.mean_anomaly == null || obj.mean_motion == null) {
return null;
}

const EARTH_RADIUS = 6371;
const alt = obj.semimajor_axis - EARTH_RADIUS;
if (alt < 0 || alt > 100000) return null;


const epochDate = obj.epoch ? new Date(obj.epoch) : new Date();
const now = new Date();
const elapsedDays = (now.getTime() - epochDate.getTime()) / 86400000 + (timeOffsetSec / 86400);
const currentMeanAnomaly = ((obj.mean_anomaly + (elapsedDays * obj.mean_motion * 360)) % 360) * Math.PI / 180;


const ecc = obj.eccentricity ?? 0;
const trueAnomaly = currentMeanAnomaly + 2 * ecc * Math.sin(currentMeanAnomaly);


const argLat = (obj.arg_of_perigee * Math.PI / 180) + trueAnomaly;


const raanRad = obj.raan * Math.PI / 180;
const incRad = obj.inclination * Math.PI / 180;


const J2000 = new Date('2000-01-01T12:00:00Z').getTime();
const daysSinceJ2000 = (now.getTime() + timeOffsetSec * 1000 - J2000) / 86400000;
const GMST = (280.46061837 + 360.98564736629 * daysSinceJ2000) % 360;

const lon = ((Math.atan2(
Math.cos(incRad) * Math.sin(argLat),
Math.cos(argLat)
) * 180 / Math.PI + (raanRad * 180 / Math.PI) - GMST + 540) % 360) - 180;

const lat = Math.asin(Math.sin(incRad) * Math.sin(argLat)) * 180 / Math.PI;

return { lat, lon, alt };
}


const CATEGORY_COLORS = {
PAYLOAD: { css: '#00E5FF', cesium: Cesium.Color.fromCssColorString('#00E5FF'), label: 'Active Satellites', icon: 'satellite_alt' },
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useUIStore } from '@/store/uiStore';
import { DynamicBackground } from '@/components/DynamicBackground/DynamicBackground';
import { LogbookPanel } from '@/components/Logbook/LogbookPanel';
import { useLogbookStore, logEvent } from '@/store/logbookStore';
import { NotificationCenter } from '@/components/ui/NotificationCenter';

/** Ensures the mission-init System log fires once per browser tab session. */
let hasLoggedMissionInit = false;
Expand All @@ -14,8 +15,10 @@ export const MainLayout: React.FC = () => {
const {
sidebarCollapsed,
rightDrawerOpen,
isFlybyHistoryOpen,
toggleSidebar,
toggleRightDrawer
toggleRightDrawer,
toggleFlybyHistory
} = useUIStore();

const location = useLocation();
Expand Down Expand Up @@ -251,8 +254,11 @@ export const MainLayout: React.FC = () => {
</span>
)}
</button>
<button className="text-primary hover:text-primary-fixed cursor-pointer transition-ui p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<MaterialIcon name="schedule" />
<button
onClick={toggleFlybyHistory}
className={`relative transition-ui cursor-pointer p-2 min-w-[44px] min-h-[44px] flex items-center justify-center ${isFlybyHistoryOpen ? 'text-primary-container drop-shadow-[0_0_8px_rgba(0,229,255,0.6)]' : 'text-primary hover:text-primary-fixed'}`}
>
<MaterialIcon name="radar" />
</button>
<button className="text-primary hover:text-primary-fixed cursor-pointer transition-ui p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<MaterialIcon name="account_circle" />
Expand Down Expand Up @@ -406,6 +412,7 @@ export const MainLayout: React.FC = () => {

</div>

<NotificationCenter />
</div>
</div>
);
Expand Down
143 changes: 143 additions & 0 deletions frontend/src/components/ui/FlybyNotification.tsx
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();
Comment on lines +18 to +20

Copy link
Copy Markdown

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 ⚠️
- ⚠️ Long-running sessions accumulate audio contexts.
- ⚠️ Repeated flybys increase browser audio-resource usage.
- ⚠️ Excessive alerts may eventually prevent further sound playback.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** frontend/src/components/ui/FlybyNotification.tsx
**Line:** 18:20
**Comment:**
	*Resource Leak: 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.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 || true

Repository: 7-Blocks/Kepler

Length of output: 9976


🌐 Web query:

MDN AudioContext close recommended practice after use

💡 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:

Web Audio API AudioContext context limits number of active contexts browser

💡 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 OfflineAudioContext for non-real-time rendering tasks where applicable [5].

Citations:


Reuse a single AudioContext instead of creating one per alert.

playBeep creates a new AudioContext for every notification while never closing or reusing it. Keep one persistent context in module state and have each beep start/stop on that context to avoid context limits and resource buildup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/ui/FlybyNotification.tsx` around lines 18 - 35,
Update playBeep to reuse a single persistent AudioContext stored in module state
instead of constructing one per alert; initialize it lazily when needed, and
create each oscillator and gain node from that context while preserving the
existing beep timing and warning behavior.

}
};

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The effect depends on the entire notification object and preferences.soundEnabled, so changing the audio preference reruns it for every currently active toast. This replays browser notifications for old alerts and can replay their sounds when audio is enabled; trigger the alert side effects only when a notification is newly created, using a stable notification identifier. [logic error]

Severity Level: Major ⚠️
- ⚠️ Toggling Audio Alerts repeats sounds for active flyby toasts.
- ⚠️ Existing alerts can produce duplicate browser notifications.
- ⚠️ Preference changes cause unwanted alert side effects.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** frontend/src/components/ui/FlybyNotification.tsx
**Line:** 45:59
**Comment:**
	*Logic Error: The effect depends on the entire `notification` object and `preferences.soundEnabled`, so changing the audio preference reruns it for every currently active toast. This replays browser notifications for old alerts and can replay their sounds when audio is enabled; trigger the alert side effects only when a notification is newly created, using a stable notification identifier.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +45 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate FlybyNotification.tsx"
fd -a 'FlybyNotification\.tsx$' . || true

echo
echo "Show relevant file outline and lines"
file="$(fd 'FlybyNotification\.tsx$' . | head -n 1)"
if [ -n "$file" ]; then
  echo "FILE=$file"
  wc -l "$file"
  cat -n "$file"
fi

echo
echo "Search notification data shape and prefs usage"
rg -n "soundEnabled|FlybyNotification|notification/id|id:" -S .

Repository: 7-Blocks/Kepler

Length of output: 30719


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Show notification store"
cat -n frontend/src/store/notificationStore.ts

echo
echo "Show NotificationCenter component"
cat -n frontend/src/components/ui/NotificationCenter.tsx

echo
echo "Static verifier: FlybyNotification effect dependencies and delivery side effects"
python3 - <<'PY'
from pathlib import Path
import re

p = Path('frontend/src/components/ui/FlybyNotification.tsx')
text = p.read_text()

m = re.search(r"useEffect\s*\(\s*\(\)\s*=>\s*\{(.*?)\n\s*\}\s*,\s*\[(.*?)\]\s*\);", text, re.S)
if not m:
    print("NO_DETECTED_EFFECT")
    raise SystemExit(1)

body = m.group(1)
deps = [d.strip() for d in m.group(2).split(',') if d.strip()]
print("deps=", deps)
print("has_sound_effect=", "preferences.soundEnabled" in body)
print("has_create_notification=", "new Notification(" in body)
print("has_request_permission=", "Notification.requestPermission(" in body)

uses_notification_id = bool(re.search(r"notification\.id", body))
uses_sound_enabled = bool(re.search(r"preferences\.soundEnabled", body))
uses_notification_name_or_eta = bool(re.search(r"notification\.(satelliteName|locationName|eta)", body))

print("uses_notification_id_in_effect=", uses_notification_id)
print("uses_sound_enabled_in_effect=", uses_sound_enabled)
print("uses_notification_field_in_effect=", uses_notification_name_or_eta)
print("issue_sound_dependency_retriggers=", uses_sound_enabled)
print("issue_notification_creates_on_each_retrieval=", uses_notification_id is False and uses_notification_name_or_eta)
PY

Repository: 7-Blocks/Kepler

Length of output: 10860


Deliver alert alerts only on first appearance.

preferences.soundEnabled changes the dependency for every active FlybyNotification, so changing audio settings re-runs delivery for dismissed/old toasts. Track notification.id in a delivery set and create only the browser notification, or split the sound and browser-notification effects so audio changes do not create new browser notifications.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/ui/FlybyNotification.tsx` around lines 45 - 59,
Update the FlybyNotification delivery logic in the useEffect so browser alerts
are emitted only once per notification.id, even when preferences.soundEnabled
changes or the component re-renders. Track delivered IDs with a suitable
persistent set or split the sound effect from the browser-notification effect,
ensuring sound preference changes only affect playback and do not recreate
browser notifications.


const handleTrack = () => {
setSelectedSatelliteId(notification.satelliteId);
navigate('/dashboard/satellites');
dismiss(notification.id);
};

const minutesAway = Math.max(0, Math.round((notification.eta.getTime() - Date.now()) / 60000));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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' || true

Repository: 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*' || true

Repository: 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 -n

Repository: 7-Blocks/Kepler

Length of output: 14011


🌐 Web query:

React Framer Motion AnimatePresence motion.div timer interval state cleanup clear on unmount

💡 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.

Date.now() is cached in render, so T-{minutesAway} MIN stays fixed while the toast is rendered with no other FlybyNotification state changes. Track a current timestamp state and update it until the toast is dismissed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/ui/FlybyNotification.tsx` at line 67, Update
FlybyNotification’s ETA calculation to use a current timestamp state rather than
only the render-time Date.now() value. Add minute-based updates while the toast
remains visible, clean up the timer when dismissed or unmounted, and derive
minutesAway from that tracked timestamp so the displayed countdown refreshes
each minute.


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>
);
};
134 changes: 134 additions & 0 deletions frontend/src/components/ui/NotificationCenter.tsx
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>
</>
);
};
Loading
Loading