Skip to content
Merged
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
9 changes: 9 additions & 0 deletions frontend/src/components/EarthTwin.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react';
import { prefersReducedMotion } from './SatelliteSpotlight/GlowEffect';
import { useUIStore } from '@/store/uiStore';
import { logEvent } from '@/store/logbookStore';
import { MaterialIcon } from './MaterialIcon';
import { useNavigate } from 'react-router-dom';
import * as Cesium from 'cesium';
Expand Down Expand Up @@ -194,6 +195,7 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => {
},
duration: 1.5,
});
logEvent('TRACKING', 'MEDIUM', 'ISS tracking engaged', 'Camera locked onto the International Space Station.');

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 ISS action performs a camera fly-to, but records the event under TRACKING. This places the event in the wrong category and makes camera activity invisible when users filter the logbook by CAMERA; record it as a camera event like the other fly-to actions. [api mismatch]

Severity Level: Major ⚠️
- ⚠️ ISS camera actions disappear from CAMERA filtering.
- ⚠️ Logbook categories become inconsistent across fly-to actions.

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/EarthTwin.tsx
**Line:** 198:198
**Comment:**
	*Api Mismatch: The ISS action performs a camera fly-to, but records the event under `TRACKING`. This places the event in the wrong category and makes camera activity invisible when users filter the logbook by `CAMERA`; record it as a camera event like the other fly-to actions.

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
👍 | 👎

}, []);

const handleShowDebris = useCallback(() => {
Expand Down Expand Up @@ -226,6 +228,7 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => {
},
duration: 1.5,
});
logEvent('CAMERA', 'LOW', 'Camera repositioned', 'Zoomed to regional view: India.');
}, []);

const handleToggleSpaceWeather = useCallback(() => {
Expand Down Expand Up @@ -724,6 +727,12 @@ export const EarthTwin = forwardRef<EarthTwinHandle>((_props, ref) => {
if (pos) {
const destination = Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.alt * 1000 + 2000000);
viewer.camera.flyTo({ destination, duration: prefersReducedMotion() ? 0 : 1.5 });
logEvent(
'CAMERA',
'LOW',
'Camera focused on target',
`Flew to ${obj.name ?? 'Unknown object'} — NORAD ${catalogNumber}`
);
}

// SpotlightManager owns actual selection state; this just tells it
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/components/Logbook/LogEntryItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { MaterialIcon } from '@/components/MaterialIcon';
import type { LogEntry } from '@/types/logbook';
import { CATEGORY_CONFIG, PRIORITY_CONFIG, formatLogTime } from './logbookConfig';

interface LogEntryItemProps {
entry: LogEntry;
}

export const LogEntryItem: React.FC<LogEntryItemProps> = ({ entry }) => {
const [expanded, setExpanded] = useState(false);
const cat = CATEGORY_CONFIG[entry.category];
const pri = PRIORITY_CONFIG[entry.priority];
const detailEntries = entry.details ? Object.entries(entry.details) : [];
const hasDetails = detailEntries.length > 0;

return (
<motion.div
layout
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="border-b border-border-panel/40 pb-2"
>
<button
type="button"
onClick={() => hasDetails && setExpanded((e) => !e)}
aria-expanded={hasDetails ? expanded : undefined}
className={`w-full flex items-start gap-2 text-left py-1 ${hasDetails ? 'cursor-pointer' : 'cursor-default'}`}
>
<span
className={`mt-0.5 flex items-center justify-center w-5 h-5 rounded-full shrink-0 ${pri.pulse ? 'animate-pulse' : ''}`}
style={{ backgroundColor: `${cat.color}20`, border: `1px solid ${cat.color}60` }}
>
<MaterialIcon name={cat.icon} className="text-[11px]" style={{ color: cat.color }} />
</span>

<span className="flex-1 min-w-0">
<span className="flex items-center justify-between gap-2">
<span className="font-technical-data text-[11px] font-bold text-on-surface truncate">
{entry.title}
</span>
<span
className="font-label-caps text-[8px] font-bold px-1.5 py-0.5 rounded shrink-0"
style={{ color: pri.color, border: `1px solid ${pri.color}50`, backgroundColor: `${pri.color}15` }}
>
{pri.label}
</span>
</span>

{entry.description && (
<span className="block text-[10px] text-on-surface-variant font-technical-data mt-0.5">
{entry.description}
</span>
)}

<span className="flex items-center gap-2 mt-1">
<span className="text-[9px] text-primary/40 font-technical-data font-mono">
{formatLogTime(entry.timestamp)}
</span>
<span className="text-[9px] font-label-caps uppercase" style={{ color: cat.color }}>
{cat.label}
</span>
{hasDetails && (
<MaterialIcon
name={expanded ? 'expand_less' : 'expand_more'}
className="text-[10px] text-on-surface-variant/50 ml-auto"
/>
)}
</span>
</span>
</button>

{expanded && hasDetails && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="ml-7 mt-1.5 space-y-1 bg-surface-container/40 border border-border-panel/40 p-2 rounded"
>
{detailEntries.map(([key, value]) => (
<div key={key} className="flex justify-between gap-3 text-[9px] font-technical-data">
<span className="text-on-surface-variant/70 uppercase">{key}</span>
<span className="text-on-surface font-semibold text-right">{value}</span>
</div>
))}
</motion.div>
)}
</motion.div>
);
};

export default LogEntryItem;
225 changes: 225 additions & 0 deletions frontend/src/components/Logbook/LogbookPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { MaterialIcon } from '@/components/MaterialIcon';
import { useLogbookStore } from '@/store/logbookStore';
import type { LogCategory, LogPriority } from '@/types/logbook';
import { LogEntryItem } from './LogEntryItem';
import { CATEGORY_CONFIG, CATEGORY_ORDER, PRIORITY_CONFIG, PRIORITY_ORDER } from './logbookConfig';

/** How close (px) to the top the list must be to count as "viewing latest". */
const AUTO_SCROLL_THRESHOLD = 24;

export const LogbookPanel: React.FC = () => {
const entries = useLogbookStore((s) => s.entries);
const clearAll = useLogbookStore((s) => s.clearAll);

const [query, setQuery] = useState('');
const [activeCategories, setActiveCategories] = useState<Set<LogCategory>>(new Set());
const [activePriorities, setActivePriorities] = useState<Set<LogPriority>>(new Set());

const listRef = useRef<HTMLDivElement>(null);
const [pinnedToTop, setPinnedToTop] = useState(true);
const [newSinceScroll, setNewSinceScroll] = useState(0);
const prevCountRef = useRef(entries.length);

const toggleCategory = (cat: LogCategory) => {
setActiveCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};

const togglePriority = (pri: LogPriority) => {
setActivePriorities((prev) => {
const next = new Set(prev);
if (next.has(pri)) next.delete(pri);
else next.add(pri);
return next;
});
};

const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return entries.filter((e) => {
if (activeCategories.size > 0 && !activeCategories.has(e.category)) return false;
if (activePriorities.size > 0 && !activePriorities.has(e.priority)) return false;
if (q && !e.title.toLowerCase().includes(q) && !(e.description ?? '').toLowerCase().includes(q)) {
return false;
}
return true;
});
}, [entries, activeCategories, activePriorities, query]);

// Track whether the user is parked at the top (viewing the latest entry)
// so we know whether it's safe to auto-scroll, or whether we'd be
// yanking them away from history they're reviewing.
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const atTop = el.scrollTop <= AUTO_SCROLL_THRESHOLD;
setPinnedToTop(atTop);
if (atTop) setNewSinceScroll(0);
}, []);

useEffect(() => {
const prevCount = prevCountRef.current;
const grew = entries.length > prevCount;
prevCountRef.current = entries.length;

if (pinnedToTop && grew) {
listRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}

// Surface a "N new entries" affordance when entries arrive while the
// user is reading history, instead of yanking their scroll position.
setNewSinceScroll((n) => (pinnedToTop ? 0 : grew ? n + (entries.length - prevCount) : n));
}, [entries.length, pinnedToTop]);
Comment on lines +66 to +78

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 new-entry detector relies only on entries.length. Once the store reaches its 300-entry cap, adding an entry replaces an old entry without changing the length, so this effect does not run and neither auto-scrolls to the latest entry nor increments the new-entry indicator. Track a monotonically increasing event/version value or compare the newest entry ID instead of only the array length. [state/lifecycle]

Severity Level: Major ⚠️
- ❌ New entries beyond 300 do not trigger latest-entry auto-scroll.
- ⚠️ History readers miss the “N new entries” indicator.

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/Logbook/LogbookPanel.tsx
**Line:** 66:78
**Comment:**
	*State Lifecycle: The new-entry detector relies only on `entries.length`. Once the store reaches its 300-entry cap, adding an entry replaces an old entry without changing the length, so this effect does not run and neither auto-scrolls to the latest entry nor increments the new-entry indicator. Track a monotonically increasing event/version value or compare the newest entry ID instead of only the array length.

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 +66 to +78

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

Detect new entries by identity, not only by list length.

After the store reaches 300 entries, each new entry replaces an old entry and keeps entries.length unchanged. Line 68 then treats the update as not new. The panel does not auto-scroll or show the new-entry control.

If clearAll() runs while the user is not pinned to the top, line 77 also retains a stale new-entry count.

Track the previous newest entry ID or previous entry IDs. Reset newSinceScroll when the list is cleared.

🤖 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/Logbook/LogbookPanel.tsx` around lines 66 - 78,
Update the LogbookPanel useEffect to detect additions by comparing the current
newest entry ID or entry IDs with the previous render, rather than relying only
on entries.length, so replacements at the 300-entry cap still scroll or
increment the new-entry affordance. Also reset newSinceScroll when the entries
list is cleared, while preserving the existing pinnedToTop behavior.


const jumpToLatest = () => {
listRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
setNewSinceScroll(0);
setPinnedToTop(true);
};

return (
<div className="flex flex-col h-full min-h-0">
{/* Search */}
<div className="relative mb-2 shrink-0">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="SEARCH LOGBOOK..."
className="w-full bg-surface-container-low border border-border-panel text-[10px] font-technical-data px-2.5 py-2 pl-7 focus:outline-none focus:border-primary-container transition-ui"
/>
<MaterialIcon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 text-[11px] text-primary/50" />
</div>

{/* Category filter chips */}
<div className="flex flex-wrap gap-1 mb-1.5 shrink-0">
{CATEGORY_ORDER.map((cat) => {
const cfg = CATEGORY_CONFIG[cat];
const active = activeCategories.has(cat);
return (
<button
key={cat}
type="button"
onClick={() => toggleCategory(cat)}
className="flex items-center gap-1 px-1.5 py-0.5 text-[8px] font-label-caps font-bold uppercase rounded transition-ui"
style={{
color: active ? '#0C1220' : cfg.color,
backgroundColor: active ? cfg.color : `${cfg.color}12`,
border: `1px solid ${cfg.color}50`,
}}
>
<MaterialIcon name={cfg.icon} className="text-[9px]" />
{cfg.label}
</button>
);
})}
</div>

{/* Priority filter chips */}
<div className="flex flex-wrap gap-1 mb-3 shrink-0">
{PRIORITY_ORDER.map((pri) => {
const cfg = PRIORITY_CONFIG[pri];
const active = activePriorities.has(pri);
return (
<button
key={pri}
type="button"
onClick={() => togglePriority(pri)}
className="px-1.5 py-0.5 text-[8px] font-label-caps font-bold uppercase rounded transition-ui"
style={{
color: active ? '#0C1220' : cfg.color,
backgroundColor: active ? cfg.color : `${cfg.color}12`,
border: `1px solid ${cfg.color}50`,
}}
>
{cfg.label}
</button>
);
})}
{(activeCategories.size > 0 || activePriorities.size > 0 || query) && (
<button
type="button"
onClick={() => {
setActiveCategories(new Set());
setActivePriorities(new Set());
setQuery('');
}}
className="px-1.5 py-0.5 text-[8px] font-label-caps font-bold uppercase rounded text-on-surface-variant border border-border-panel hover:text-primary transition-ui"
>
Reset
</button>
)}
</div>

{/* Entry count / clear */}
<div className="flex items-center justify-between mb-1.5 shrink-0">
<span className="text-[9px] text-on-surface-variant font-technical-data">
{filtered.length} of {entries.length} {entries.length === 1 ? 'ENTRY' : 'ENTRIES'}
</span>
{entries.length > 0 && (
<button
type="button"
onClick={clearAll}
className="text-[9px] font-label-caps text-on-surface-variant hover:text-status-emergency transition-ui"
>
CLEAR LOG
</button>
)}
</div>

{/* New entries indicator */}
<AnimatePresence>
{newSinceScroll > 0 && (
<motion.button
type="button"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
onClick={jumpToLatest}
className="mb-2 shrink-0 flex items-center justify-center gap-1.5 text-[9px] font-label-caps font-bold text-bg-deep-space bg-primary-container py-1.5 rounded"
>
<MaterialIcon name="arrow_upward" className="text-[10px]" />
{newSinceScroll} NEW {newSinceScroll === 1 ? 'ENTRY' : 'ENTRIES'}
</motion.button>
)}
</AnimatePresence>

{/* Entry list */}
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto custom-scrollbar space-y-1 min-h-0"
>
{entries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center gap-2 py-8">
<MaterialIcon name="history_edu" className="text-primary/30 text-3xl" />
<p className="text-[10px] text-on-surface-variant font-technical-data">
No mission events recorded yet.
</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center gap-2 py-8">
<MaterialIcon name="search_off" className="text-primary/30 text-3xl" />
<p className="text-[10px] text-on-surface-variant font-technical-data">
No entries match the current filters.
</p>
</div>
) : (
<AnimatePresence initial={false}>
{filtered.map((entry) => (
<LogEntryItem key={entry.id} entry={entry} />
))}
</AnimatePresence>
)}
</div>
</div>
);
};

export default LogbookPanel;
24 changes: 24 additions & 0 deletions frontend/src/components/Logbook/logbookConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { LogCategory, LogPriority } from '@/types/logbook';

export const CATEGORY_CONFIG: Record<LogCategory, { label: string; icon: string; color: string }> = {
TRACKING: { label: 'Tracking', icon: 'satellite_alt', color: '#00e5ff' },
CAMERA: { label: 'Camera', icon: 'videocam', color: '#7c3aed' },
SEARCH: { label: 'Search', icon: 'search', color: '#34C759' },
ALERTS: { label: 'Alerts', icon: 'crisis_alert', color: '#FF3B30' },
SYSTEM: { label: 'System', icon: 'memory', color: '#8892A6' },
MISSION: { label: 'Mission', icon: 'flag', color: '#FF9500' },
};

export const PRIORITY_CONFIG: Record<LogPriority, { label: string; color: string; pulse?: boolean }> = {
LOW: { label: 'LOW', color: '#8892A6' },
MEDIUM: { label: 'MEDIUM', color: '#FF9500' },
HIGH: { label: 'HIGH', color: '#FF3B30' },
CRITICAL: { label: 'CRITICAL', color: '#FF3B30', pulse: true },
};

export const CATEGORY_ORDER: LogCategory[] = ['TRACKING', 'CAMERA', 'SEARCH', 'ALERTS', 'SYSTEM', 'MISSION'];
export const PRIORITY_ORDER: LogPriority[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'];

export function formatLogTime(ts: number): string {
return new Date(ts).toISOString().substring(11, 19) + 'Z';
}
Loading
Loading