-
Notifications
You must be signed in to change notification settings - Fork 41
feat: add Mission Intelligence & Operations Logbook #173
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
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,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; |
| 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
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 new-entry detector relies only on Severity Level: Major
|
||
|
|
||
| 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; | ||
| 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'; | ||
| } |
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: 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 byCAMERA; record it as a camera event like the other fly-to actions. [api mismatch]Severity Level: Major⚠️
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖