// views.jsx — Base Camp dashboard + Project detail
// Depends on globals: TopoSVG, EVEREST_DATA
const { useState, useRef, useEffect, useMemo } = React;
// Static fields from seed data (user identity, date display, weather).
// The three context panels (schedule, inbox, news) fetch their own live data.
const { WEATHER, TODAY_STR, TODAY_SHORT, DAY_OF_EXPEDITION, USER } = window.EVEREST_DATA;
// ─── Shared bits ─────────────────────────────────────────────────────
function Eyebrow({ children }) {
return
{children}
;
}
function EmptyState({ title, sub, action, large }) {
return (
{title}
{sub}
{action &&
{action}
}
);
}
// ─── Topbar ──────────────────────────────────────────────────────────
function Topbar({ onHome, view, onNav, onLogout, currentUser }) {
const NAV = [
{ id: "dashboard", label: "Base Camp" },
{ id: "notes", label: "Notes" },
{ id: "receipts", label: "Receipts" },
{ id: "symphony", label: "Symphony" },
];
return (
{TODAY_SHORT}
{WEATHER}
{DAY_OF_EXPEDITION}
{currentUser ? (currentUser.name || currentUser.email).toUpperCase() : USER.name.toUpperCase()}
{currentUser
? (currentUser.name || currentUser.email).slice(0, 2).toUpperCase()
: USER.initials}
);
}
// ─── Pinned expedition card ──────────────────────────────────────────
function PinnedExpeditionCard({ project, onOpen, onMarkWorked }) {
return (
onOpen(project.id)}>
{project.status}
{project.elevation}
FT · ELEV
{project.coords}
{project.codename.split('·')[0].trim()}
{project.codename}
{project.name}
{project.description}
Last ascended
{project.last_worked}
Summit ETA
{project.end_date || "Not set"}
Progress
{Math.round(project.progress * 100)}%
);
}
// ─── Compact expedition row ──────────────────────────────────────────
function ExpeditionRow({ project, onOpen }) {
return (
onOpen(project.id)}>
{project.codename.split('·')[0].trim()}
{project.name}
{project.description}
{project.last_worked}
{project.elevation} ft
→
);
}
// ─── Field notes ─────────────────────────────────────────────────────
function FieldNotes({ notes, onAdd, onDelete }) {
const [text, setText] = useState("");
const submit = () => {
const t = text.trim();
if (!t) return;
onAdd(t);
setText("");
};
return (
<>
{notes.length === 0 ? (
) : (
{notes.map((n) => (
{new Date(n.created_at).toLocaleString()}
{n.text}
{onDelete &&
}
))}
)}
>
);
}
// ─── Detail Drawer ───────────────────────────────────────────────────
function DetailDrawer({ item, type, onClose }) {
useEffect(() => {
if (!item) return;
const handler = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [item, onClose]);
const open = !!item;
let typeLabel = "", title = "", meta = [], preview = "", links = [];
if (item) {
if (type === "dispatch") {
typeLabel = "Dispatch";
title = item.subject;
meta = [
{ label: "From", value: item.from },
{ label: "Received", value: item.time },
...(item.important ? [{ label: "Priority", value: "Flagged", accent: true }] : []),
];
preview = item.preview;
const q = encodeURIComponent(`subject:${item.subject} from:${item.from}`);
links = [{ icon: "✉", label: "Open in Gmail", url: `https://mail.google.com/mail/#search/${q}` }];
} else if (type === "news") {
typeLabel = "From the Wire";
title = item.title;
meta = [
{ label: "Source", value: item.source },
...(item.time ? [{ label: "Published", value: item.time }] : []),
];
if (item.url) links = [{ icon: "⛰", label: "Read article", url: item.url }];
} else if (type === "event") {
typeLabel = "Today's Traverse";
title = item.title;
meta = [
{ label: "Time", value: item.time },
...(item.sub ? [{ label: "Details", value: item.sub }] : []),
...(item.now ? [{ label: "Status", value: "Happening now", accent: true }] : []),
];
links = [{ icon: "📅", label: "Open Google Calendar", url: "https://calendar.google.com/calendar/r" }];
}
}
return (
<>
{meta.map((m, i) => (
{m.label}
{m.value}
))}
{preview &&
{preview}
}
{links.length > 0 && (
)}
>
);
}
// ─── Schedule panel ──────────────────────────────────────────────────
function SchedulePanel({ data: initialData }) {
const [data, setData] = useState(initialData || null);
const [selected, setSelected] = useState(null);
useEffect(() => {
if (initialData) { setData(initialData); return; }
fetch('/api/v1/context/calendar')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(setData)
.catch(err => {
console.error('Failed to load calendar:', err);
const fallback = window.EVEREST_DATA && window.EVEREST_DATA.TODAY_EVENTS;
if (fallback) {
setData({ events: fallback.map(e => ({
time: e.time, title: e.title, sub: e.sub, now: e.now || false,
}))});
}
});
}, [initialData]);
if (!data) return null;
const events = data.events || [];
return (
Today's traverse
{events.length} events
{events.map((e, i) => (
setSelected(e)}
>
{e.time}
→
))}
setSelected(null)} />
);
}
// ─── Inbox panel ─────────────────────────────────────────────────────
function InboxPanel({ data: initialData }) {
const [data, setData] = useState(initialData || null);
const [selected, setSelected] = useState(null);
useEffect(() => {
if (initialData) { setData(initialData); return; }
fetch('/api/v1/context/inbox')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(setData)
.catch(err => {
console.error('Failed to load inbox:', err);
const fallback = window.EVEREST_DATA && window.EVEREST_DATA.INBOX;
if (fallback) {
setData({ messages: fallback.map(m => ({
from: m.from, subject: m.subj, preview: m.prev,
time: m.when, important: m.urgent || false,
}))});
}
});
}, [initialData]);
if (!data) return null;
const messages = data.messages || [];
const important = messages.filter(m => m.important);
const rest = messages.filter(m => !m.important);
return (
Dispatches
{important.length} flagged
{[...important, ...rest].map((m, i) => (
setSelected(m)}
>
{m.from}
{m.time}
{m.subject}
{m.preview}
))}
setSelected(null)} />
);
}
// ─── News panel ──────────────────────────────────────────────────────
function NewsPanel() {
const [data, setData] = useState(null);
const [selected, setSelected] = useState(null);
useEffect(() => {
fetch('/api/v1/context/news')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(setData)
.catch(err => {
console.error('Failed to load news:', err);
const fallback = window.EVEREST_DATA && window.EVEREST_DATA.NEWS;
if (fallback) {
setData({ headlines: fallback.map(n => ({
title: n.head, source: n.src, time: null, url: null,
}))});
}
});
}, []);
if (!data) return null;
const headlines = data.headlines || [];
return (
From the wire
{TODAY_SHORT}
{headlines.map((n, i) => (
setSelected(n)}>
{n.source}
{n.title}
))}
setSelected(null)} />
);
}
// ─── Supply drop (receipts) ─────────────────────────────────────────
// onUpload(file, projectId) — async callback defined in app.jsx that POSTs to /api/receipts/upload
function SupplyDropPanel({ projects, receipts, onUpload }) {
const [over, setOver] = useState(false);
const [pending, setPending] = useState(null); // { file, fname }
const inputRef = useRef(null);
const handleFiles = (files) => {
if (!files || files.length === 0) return;
const f = files[0];
setPending({ file: f, fname: f.name });
};
const onDrop = (e) => {
e.preventDefault();
setOver(false);
handleFiles(e.dataTransfer.files);
};
const onPick = (projectId) => {
if (!pending) return;
onUpload(pending.file, projectId);
setPending(null);
};
return (
Supply cache
{receipts.length} on file
inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={onDrop}
>
⛰
Drop receipts here
PDF · JPG · PNG — classify by project after drop
handleFiles(e.target.files)}
/>
{pending && (
Classify supply
{pending.fname}
{projects.map((p) => (
))}
)}
{receipts.length > 0 && (
{receipts.slice().reverse().map((r) => {
const proj = projects.find(p => p.id === r.project_id);
return (
{r.fname}
{proj ? proj.codename.split('·')[0].trim() : 'UNFILED'}
);
})}
)}
);
}
// ─── Dashboard view ──────────────────────────────────────────────────
function Dashboard({ projects, notes, receipts, onOpen, onMarkWorked, onAddNote, onUploadReceipt, onCreateProject, onDeleteNote }) {
const [showNewCamp, setShowNewCamp] = useState(false);
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [newFolder, setNewFolder] = useState("");
const [calendarData, setCalendarData] = useState(null);
const [inboxData, setInboxData] = useState(null);
const pinned = projects.find(p => p.pinned) || projects[0] || null;
const rest = pinned ? projects.filter(p => p.id !== pinned.id) : [];
const greetingTime = (() => {
const h = new Date().getHours();
if (h < 5) return "Still up";
if (h < 12) return "Good morning";
if (h < 18) return "Good afternoon";
return "Good evening";
})();
useEffect(() => {
fetch('/api/v1/context/calendar')
.then(r => r.ok ? r.json() : Promise.reject())
.then(setCalendarData)
.catch(() => {
const fb = window.EVEREST_DATA && window.EVEREST_DATA.TODAY_EVENTS;
if (fb) setCalendarData({ events: fb.map(e => ({ time: e.time, title: e.title, sub: e.sub, now: e.now || false })) });
});
fetch('/api/v1/context/inbox')
.then(r => r.ok ? r.json() : Promise.reject())
.then(setInboxData)
.catch(() => {
const fb = window.EVEREST_DATA && window.EVEREST_DATA.INBOX;
if (fb) setInboxData({ messages: fb.map(m => ({ from: m.from, subject: m.subj, preview: m.prev, time: m.when, important: m.urgent || false })) });
});
}, []);
return (
<>
Base Camp · {TODAY_STR}
{greetingTime},
{USER.name.split(' ')[0]}.
On the rope
{projects.length}
active expeditions
Today
{calendarData ? calendarData.events.length : "—"}
events today
Conditions
{inboxData
? inboxData.messages.filter(m => m.important).length === 0 ? "Clear"
: inboxData.messages.filter(m => m.important).length <= 2 ? "Flagged"
: "Heavy"
: "—"}
{inboxData ? `${inboxData.messages.filter(m => m.important).length} flagged` : "loading…"}
{/* Expeditions */}
Expeditions
{projects.length} · ALL CAMPS
{showNewCamp && (
)}
{pinned ? (
<>
{rest.map((p) => (
))}
>
) : (
setShowNewCamp(true)}>+ New camp}
/>
)}
{/* Field notes */}
Field notes
{notes.length} · TIMESTAMPED
>
);
}
Object.assign(window, { Dashboard, EmptyState, Eyebrow, Topbar });
// ─── Notes view ──────────────────────────────────────────────────────
function NotesView({ notes, onAdd, onDelete }) {
const [text, setText] = React.useState("");
const submit = (e) => {
e.preventDefault();
const t = text.trim(); if (!t) return;
onAdd(t, null); setText("");
};
const fmt = (ts) => ts ? new Date(ts).toLocaleString(undefined, { month:"short", day:"numeric", hour:"2-digit", minute:"2-digit" }) : "";
return (
{notes.length === 0
?
No notes yet
Notes you add will appear here.
:
{notes.map(n => (
{n.text || n.content}
{fmt(n.created_at)}{n.project_id ? ` · ${n.project_id}` : ""}
{onDelete && }
))}
}
);
}
// ─── Receipts view ───────────────────────────────────────────────────
function ReceiptsView({ receipts, projects, onUpload }) {
const [dragOver, setDragOver] = React.useState(false);
const [projectId, setProjectId] = React.useState("");
const inputRef = React.useRef();
const fmt = (ts) => ts ? new Date(ts).toLocaleDateString(undefined, { month:"short", day:"numeric", year:"numeric" }) : "";
const fmtSize = (b) => b < 1024 ? b + " B" : b < 1048576 ? (b/1024).toFixed(1) + " KB" : (b/1048576).toFixed(1) + " MB";
const handleFiles = (files) => {
const f = files[0]; if (!f) return;
onUpload(f, projectId || null);
};
const onDrop = (e) => {
e.preventDefault(); setDragOver(false);
handleFiles(e.dataTransfer.files);
};
return (
{ e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}>
handleFiles(e.target.files)} />
📎
Drop a receipt here or click to upload
PDF, JPG, PNG, WEBP · max 10 MB
{projects.length > 0 && (
)}
{receipts.length === 0
?
No receipts yet
Drop files above to classify them to a project.
:
{receipts.map(r => (
{r.fname?.endsWith(".pdf") ? "📄" : "🖼"}
{r.fname}
{fmtSize(r.size)} · {fmt(r.created_at)}{r.project_id ? ` · ${r.project_id}` : ""}
))}
}
);
}
// ─── Symphony view ───────────────────────────────────────────────────
function SymphonyView() {
const [health, setHealth] = React.useState(null);
const [state, setState] = React.useState(null);
const [metrics, setMetrics] = React.useState(null);
const [err, setErr] = React.useState("");
React.useEffect(() => {
const load = async () => {
try {
const [h, s, m] = await Promise.all([
fetch("/api/v1/symphony/healthz").then(r => r.json()),
fetch("/api/v1/symphony/state").then(r => r.json()),
fetch("/api/v1/symphony/metrics").then(r => r.json()),
]);
setHealth(h); setState(s); setMetrics(m);
} catch(e) { setErr("Symphony layer unavailable — check server logs."); }
};
load();
}, []);
return (
{err ?
: (
Status
{health ? (health.paused ? "⏸ Paused" : "✓ Running") : "…"}
Active runs
{state ? state.active_attempts : "…"}
Retry queue
{state ? state.retry_queue_size : "…"}
Recent attempts
{metrics ? metrics.recent_attempts : "…"}
Success / Failed
{metrics ? `${metrics.succeeded} / ${metrics.failed}` : "…"}
Total cost (recent)
{metrics ? `$${metrics.total_cost_usd.toFixed(4)}` : "…"}
)}
);
}
Object.assign(window, { NotesView, ReceiptsView, SymphonyView });