const { useState, useEffect, useRef, useMemo } = React;
// ─── Icons (inline SVGs) ─────────────────────────────────────────────────────
const Icons = {
search: (
),
mic: (
),
micOff: (
),
check: (
),
checkCircle: (
),
phone: (
),
mail: (
),
file: (
),
clock: (
),
arrowUp: (
),
arrowDown: (
),
plus: (
),
flag: (
),
zap: (
),
calendar: (
),
reply: (
),
snooze: (
),
notes: (
),
target: (
),
chevronRight: (
),
trendUp: (
),
trendDown: (
),
activity: (
),
circle: (
),
};
// ─── Utility Helpers ─────────────────────────────────────────────────────────
function getGreeting() {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 17) return "Good afternoon";
return "Good evening";
}
function formatDate(d) {
return new Date(d).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
}
function formatTime(d) {
return new Date(d).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
function formatDuration(seconds) {
if (!seconds || seconds <= 0) return "0m";
const totalSec = Math.round(seconds);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
return `${s}s`;
}
function timeAgo(dateStr) {
const now = new Date();
const d = new Date(dateStr);
const diffMs = now - d;
const diffMins = Math.floor(diffMs / 60000);
const diffHrs = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHrs / 24);
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays === 1) return "Yesterday";
return `${diffDays}d ago`;
}
function ageLabel(dateStr) {
const now = new Date();
const d = new Date(dateStr);
const diffMs = now - d;
const diffHrs = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffHrs / 24);
if (diffHrs < 24) return { text: `${diffHrs} hours`, urgent: false };
if (diffDays <= 1) return { text: "1 day", urgent: false };
if (diffDays >= 3) return { text: `${diffDays} days waiting`, urgent: true };
return { text: `${diffDays} days`, urgent: false };
}
function isOverdue(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
const today = new Date();
today.setHours(0,0,0,0);
return d < today;
}
function isDueToday(dateStr) {
if (!dateStr) return false;
const d = new Date(dateStr);
const today = new Date();
return d.toDateString() === today.toDateString();
}
function dueDateDisplay(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
const today = new Date();
today.setHours(0,0,0,0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
if (d < today) {
const diffDays = Math.floor((today - d) / 86400000);
return `${diffDays}d overdue`;
}
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
// ─── Priority Pill ───────────────────────────────────────────────────────────
function PriorityPill({ priority }) {
const colors = {
high: "bg-red-100 text-red-700",
medium: "bg-amber-100 text-amber-700",
low: "bg-emerald-100 text-emerald-700",
};
return (
{priority}
);
}
// ─── Category Badge ──────────────────────────────────────────────────────────
function CategoryBadge({ type }) {
const config = {
call: { icon: Icons.phone, label: "Call", cls: "bg-violet-100 text-violet-700" },
email: { icon: Icons.mail, label: "Email", cls: "bg-sky-100 text-sky-700" },
document: { icon: Icons.file, label: "Document", cls: "bg-orange-100 text-orange-700" },
other: { icon: Icons.flag, label: "Other", cls: "bg-slate-100 text-slate-600" },
};
const c = config[type] || config.other;
return (
{c.icon} {c.label}
);
}
// ─── Due Date Label ──────────────────────────────────────────────────────────
function DueDateLabel({ dateStr }) {
if (!dateStr) return null;
const label = dueDateDisplay(dateStr);
const over = isOverdue(dateStr);
const today = isDueToday(dateStr);
let cls = "text-slate-500";
if (over) cls = "text-red-600 font-semibold";
else if (today) cls = "text-amber-600 font-semibold";
return (
{Icons.calendar} {label}
);
}
// ─── Card Wrapper ────────────────────────────────────────────────────────────
function Card({ children, className = "", ...props }) {
return (
{children}
);
}
function CardHeader({ title, icon, action, count }) {
return (
{icon && {icon}}
{title}
{count !== undefined && (
{count}
)}
{action}
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// MAIN DASHBOARD COMPONENT
// ═══════════════════════════════════════════════════════════════════════════════
function Dashboard() {
const [viewMode, setViewMode] = useState("today");
const [searchQuery, setSearchQuery] = useState("");
const [recording, setRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const timerRef = useRef(null);
// Browser recording
const [recordingMode, setRecordingMode] = useState(null); // "wasapi" | "browser" | null
const [browserRecording, setBrowserRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
// ── Live data from backend ───────────────────────────────────────────────
const [liveData, setLiveData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData();
// Poll recording status
const statusInterval = setInterval(checkRecordingStatus, 3000);
return () => clearInterval(statusInterval);
}, []);
async function fetchDashboardData() {
try {
const resp = await fetch("/api/dashboard/data");
if (resp.ok) {
const data = await resp.json();
setLiveData(data);
}
} catch (e) {
console.warn("Failed to fetch dashboard data, using sample data", e);
} finally {
setLoading(false);
}
}
async function checkRecordingStatus() {
try {
const resp = await fetch("/api/recording/status");
if (resp.ok) {
const data = await resp.json();
setRecording(data.state === "recording");
}
} catch (e) { /* ignore */ }
}
// ── Detect recording mode (WASAPI vs browser) ─────────────────────────
useEffect(() => {
fetch("/api/recording/status")
.then(r => r.json())
.then(data => {
setRecordingMode(data.state === "unavailable" ? "browser" : "wasapi");
})
.catch(() => setRecordingMode("browser"));
}, []);
// ── Recording timer ────────────────────────────────────────────────────
useEffect(() => {
if (recording || browserRecording) {
timerRef.current = setInterval(() => {
setRecordingSeconds(s => s + 1);
}, 1000);
} else {
clearInterval(timerRef.current);
setRecordingSeconds(0);
}
return () => clearInterval(timerRef.current);
}, [recording, browserRecording]);
async function toggleRecording() {
const endpoint = recording ? "/api/recording/stop" : "/api/recording/start";
try {
const resp = await fetch(endpoint, { method: "POST" });
if (resp.ok) {
setRecording(!recording);
if (!recording) setRecordingSeconds(0);
}
} catch (e) {
console.error("Recording toggle failed", e);
}
}
// ── Browser recording functions ────────────────────────────────────────
async function startBrowserRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Detect supported MIME type (Safari only supports audio/mp4)
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/mp4')
? 'audio/mp4'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType });
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(chunksRef.current, { type: mimeType });
await uploadBrowserRecording(blob, mimeType);
};
recorder.start(1000); // Collect data every second
mediaRecorderRef.current = recorder;
setBrowserRecording(true);
setRecordingSeconds(0);
} catch (e) {
console.error("Mic access denied or unavailable:", e);
alert("Could not access microphone. Please allow mic access and try again.");
}
}
function stopBrowserRecording() {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
setBrowserRecording(false);
}
async function uploadBrowserRecording(blob, mimeType) {
setUploading(true);
try {
const ext = mimeType.includes("mp4") ? ".m4a" : ".webm";
const formData = new FormData();
formData.append("audio", blob, `recording${ext}`);
const resp = await fetch("/api/calls/upload", { method: "POST", body: formData });
if (resp.ok) {
const data = await resp.json();
// Refresh dashboard data after a delay to allow processing
setTimeout(fetchDashboardData, 3000);
setTimeout(fetchDashboardData, 10000);
} else {
const err = await resp.json();
alert(`Upload failed: ${err.error || "Unknown error"}`);
}
} catch (e) {
console.error("Upload failed:", e);
alert("Failed to upload recording. Check your connection.");
} finally {
setUploading(false);
}
}
// ── Task actions ───────────────────────────────────────────────────────
async function handleCompleteTask(taskId) {
try {
await fetch(`/tasks/${taskId}/complete`, { method: "POST" });
fetchDashboardData();
} catch (e) { console.error(e); }
}
async function handleAcceptInsight(insightId) {
try {
await fetch(`/api/email/insights/${insightId}/accept`, { method: "POST" });
fetchDashboardData();
} catch (e) { console.error(e); }
}
async function handleDismissInsight(insightId) {
try {
await fetch(`/api/email/insights/${insightId}/dismiss`, { method: "POST" });
fetchDashboardData();
} catch (e) { console.error(e); }
}
// ── Quick Add ──────────────────────────────────────────────────────────
const [quickTask, setQuickTask] = useState("");
const [quickPriority, setQuickPriority] = useState("medium");
const [quickDue, setQuickDue] = useState("");
const [quickCategory, setQuickCategory] = useState("other");
async function handleQuickAdd(e) {
e.preventDefault();
if (!quickTask.trim()) return;
try {
const resp = await fetch("/api/tasks/quick-add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: quickTask.trim(),
priority: quickPriority,
due_date: quickDue || null,
source_type: quickCategory,
}),
});
if (resp.ok) {
setQuickTask("");
setQuickDue("");
setQuickPriority("medium");
setQuickCategory("other");
fetchDashboardData();
}
} catch (e) { console.error(e); }
}
// ── Email Sync ─────────────────────────────────────────────────────────
async function handleEmailSync() {
try {
await fetch("/api/email/sync", { method: "POST" });
setTimeout(fetchDashboardData, 5000);
} catch (e) { console.error(e); }
}
// ── Derive display data from liveData or use sample ────────────────────
const data = useMemo(() => {
if (liveData) return transformLiveData(liveData);
return getSampleData();
}, [liveData]);
// ── Render ─────────────────────────────────────────────────────────────
const recordTimerStr = `${Math.floor(recordingSeconds/60)}:${String(recordingSeconds%60).padStart(2,"0")}`;
return (
{/* ── SIDEBAR (desktop only — hidden on mobile) ────────────────── */}
{/* ── MAIN CONTENT ──────────────────────────────────────────────── */}
{/* ── 1. HEADER ─────────────────────────────────────────────────── */}
{/* Row 1: Greeting + controls */}
{getGreeting()}, JD
{formatDate(new Date())}
{/* View Toggle */}
{/* Record Button */}
{recordingMode === "wasapi" ? (
) : recordingMode === "browser" ? (
) : null}
{/* Row 2: Always-visible search bar */}
{Icons.search}
setSearchQuery(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && searchQuery.trim()) {
window.location.href = `/search?q=${encodeURIComponent(searchQuery.trim())}`;
}
if (e.key === "Escape") { setSearchQuery(""); e.target.blur(); }
}}
className="w-full h-10 pr-3 text-base md:text-sm bg-transparent border-none outline-none placeholder-slate-400 text-slate-800"
/>
{/* ── QUICK-ADD TASK BAR ──────────────────────────────────────── */}
{/* ── TWO COLUMN LAYOUT ───────────────────────────────────────── */}
{/* ── LEFT COLUMN ──────────────────────────────────────────────── */}
{/* ── My Tasks (grouped by category) ────────────────────────── */}
View All →
}
/>
{groupTasksByCategory(data.tasks).map(group => (
{group.label}
{group.items.map(task => (
handleCompleteTask(task.id)} />
))}
))}
{data.tasks.length === 0 && (
No open tasks — nice work! 🎉
)}
{/* ── Completed Today ────────────────────────────────────────── */}
{data.completedToday.length > 0 ? (
{data.completedToday.map(item => (
{Icons.checkCircle}
{item.title}
{item.completedTime}
))}
) : (
Nothing completed yet — let's get started!
)}
{/* ── RIGHT COLUMN ─────────────────────────────────────────────── */}
{/* ── Email Insights ──────────────────────────────────────────── */}
View All →
}
/>
{data.emailInsights.map(insight => (
handleAcceptInsight(insight.id)}
onDismiss={() => handleDismissInsight(insight.id)}
/>
))}
{data.emailInsights.length === 0 && (
)}
{/* ── Recent Calls ────────────────────────────────────────────── */}
{Icons.phone}
Recent Calls
{data.recentCalls.length}
{data.callStats.callsMade} of {data.callStats.callTarget}
·
{data.callStats.totalCallTime}
·
View All →
{data.recentCalls.map(call => (
))}
{data.recentCalls.length === 0 && (
No calls recorded yet
)}
{/* ── Today's Timeline ────────────────────────────────────────── */}
{data.timeline.length > 0 && (
{data.timeline.map((event, i) => (
{i < data.timeline.length - 1 && (
)}
{event.title}
{event.time}
))}
)}
{/* ── BOTTOM NAV BAR (mobile only) ────────────────────────────── */}
);
}
// ─── Sidebar Component ───────────────────────────────────────────────────────
function Sidebar() {
const currentPath = window.location.pathname;
const navItems = [
{ href: "/", label: "Dashboard", icon: (
)},
{ href: "/tasks", label: "Tasks", icon: (
)},
{ href: "/calls", label: "Calls", icon: (
)},
{ href: "/email/setup", label: "Email", icon: (
)},
{ href: "/digests", label: "Digests", icon: (
)},
{ href: "/search", label: "Search", icon: (
)},
];
function isActive(href) {
if (href === "/") return currentPath === "/";
return currentPath.startsWith(href);
}
return (
);
}
// ─── Bottom Nav Bar (mobile only) ─────────────────────────────────────────────
function BottomNav() {
const currentPath = window.location.pathname;
const tabs = [
{ href: "/", label: "Home", icon: (
)},
{ href: "/tasks", label: "Tasks", icon: (
)},
{ href: "/calls", label: "Calls", icon: (
)},
{ href: "/email/setup", label: "Email", icon: (
)},
{ href: "/digests", label: "Digests", icon: (
)},
];
function isActive(href) {
if (href === "/") return currentPath === "/";
return currentPath.startsWith(href);
}
return (
);
}
// ─── Sub-Components ──────────────────────────────────────────────────────────
function TaskRow({ task, onComplete }) {
return (
{task.contact && {task.contact}}
);
}
function EmailInsightRow({ insight, onAccept, onDismiss }) {
const age = ageLabel(insight.emailDate);
return (
{insight.subject}
{insight.sender}
{insight.suggestedTask}
);
}
function CallRow({ call }) {
return (
{Icons.phone}
{call.title}
{call.hasFollowUp &&
{Icons.flag}}
{call.time} · {call.duration}
{call.summary && (
{call.summary}
)}
);
}
// ─── Data Transformation ─────────────────────────────────────────────────────
function groupTasksByCategory(tasks) {
const groups = {
call: { label: "Calls to Make", items: [] },
email: { label: "Emails to Send", items: [] },
document: { label: "Documents to Prepare", items: [] },
other: { label: "Other Tasks", items: [] },
};
tasks.forEach(t => {
const cat = t.sourceType || "other";
if (groups[cat]) groups[cat].items.push(t);
else groups.other.items.push(t);
});
return Object.entries(groups)
.filter(([_, g]) => g.items.length > 0)
.map(([category, g]) => ({ category, ...g }));
}
function transformLiveData(d) {
// Transform API data to component format
const tasks = (d.tasks || []).map(t => ({
id: t.id,
title: t.title,
priority: t.priority || "medium",
dueDate: t.due_date,
sourceType: t.source_type || "other",
contact: t.assignee || null,
}));
const completedToday = (d.completed_today || []).map(t => ({
id: t.id,
title: t.title,
completedTime: t.completed_at ? formatTime(t.completed_at) : "",
}));
const emailInsights = (d.email_insights || []).map(i => ({
id: i.id,
subject: i.email_subject || "(no subject)",
sender: i.email_sender || "unknown",
emailDate: i.email_date || i.created_at,
suggestedTask: i.suggested_task,
priority: i.suggested_priority || "medium",
}));
const recentCalls = (d.recent_calls || []).map(c => ({
id: c.id,
title: d.call_titles?.[c.id] || "Untitled Call",
time: c.started_at ? formatTime(c.started_at) : "",
duration: formatDuration(c.duration_seconds),
summary: c.notes || null,
hasFollowUp: false,
}));
const timeline = (d.timeline || []).map(e => ({
title: e.title,
time: e.time,
completed: e.completed,
current: e.current || false,
}));
return {
callStats: {
callsMade: d.stats?.call_count || 0,
callTarget: 8,
totalCallTime: d.stats?.total_duration ? formatDuration(d.stats.total_duration) : "0m",
},
tasks,
completedToday,
emailInsights,
recentCalls,
timeline,
gmailConnected: d.gmail_connected || false,
};
}
function getSampleData() {
return {
callStats: {
callsMade: 2,
callTarget: 8,
totalCallTime: "47m",
},
tasks: [
{ id: "1", title: "Call Marcus Greene — finalize flatbed trailer quote", priority: "high", dueDate: new Date().toISOString().slice(0,10), sourceType: "call", contact: "Greene Construction" },
{ id: "2", title: "Call Hank's Excavation — schedule equipment return", priority: "high", dueDate: new Date(Date.now() - 86400000).toISOString().slice(0,10), sourceType: "call", contact: "Hank's Excavation" },
{ id: "3", title: "Return follow-up call to Riverside Paving", priority: "medium", dueDate: new Date(Date.now() + 86400000).toISOString().slice(0,10), sourceType: "call", contact: "Riverside Paving" },
{ id: "4", title: "Reply to Jenny at BlueLine about insurance cert", priority: "high", dueDate: new Date().toISOString().slice(0,10), sourceType: "email", contact: "BlueLine Logistics" },
{ id: "5", title: "Send updated rental agreement to Metro Builders", priority: "medium", dueDate: new Date(Date.now() + 172800000).toISOString().slice(0,10), sourceType: "email", contact: "Metro Builders" },
{ id: "6", title: "Prepare Q1 fleet utilization report", priority: "medium", dueDate: new Date(Date.now() + 432000000).toISOString().slice(0,10), sourceType: "document", contact: null },
{ id: "7", title: "Update maintenance log for Unit #TR-4422", priority: "low", dueDate: null, sourceType: "document", contact: null },
{ id: "8", title: "Review vendor pricing from AllState Equipment", priority: "low", dueDate: new Date(Date.now() + 604800000).toISOString().slice(0,10), sourceType: "other", contact: "AllState Equipment" },
],
completedToday: [
{ id: "c1", title: "Called Dave at Summit Cranes — confirmed delivery", completedTime: "9:15 AM" },
{ id: "c2", title: "Sent invoice #4455 to Tri-County Excavating", completedTime: "8:30 AM" },
{ id: "c3", title: "Updated fleet availability spreadsheet", completedTime: "8:05 AM" },
],
emailInsights: [
{
id: "e1",
subject: "RE: Flatbed Availability for March",
sender: "Marcus Greene ",
emailDate: new Date(Date.now() - 7200000).toISOString(),
suggestedTask: "Reply to Marcus with updated flatbed availability and 6-month pricing",
priority: "high",
},
{
id: "e2",
subject: "Insurance Certificate Renewal",
sender: "Jenny Park ",
emailDate: new Date(Date.now() - 259200000).toISOString(),
suggestedTask: "Send renewed insurance certificate to BlueLine — their current one expires Friday",
priority: "high",
},
{
id: "e3",
subject: "Equipment Return Scheduling",
sender: "accounting@hanksexcavation.com",
emailDate: new Date(Date.now() - 86400000).toISOString(),
suggestedTask: "Schedule pickup for 2 backhoes from Hank's Excavation job site",
priority: "medium",
},
{
id: "e4",
subject: "2025 Vendor Pricing Update",
sender: "sales@allstateequipment.com",
emailDate: new Date(Date.now() - 172800000).toISOString(),
suggestedTask: "Review updated vendor pricing and compare against current fleet costs",
priority: "low",
},
],
recentCalls: [
{ id: "r1", title: "Dave Mitchell — Summit Cranes", time: "9:12 AM", duration: "12m 34s", summary: "Confirmed delivery of 60-ton crane for Monday. Access route cleared.", hasFollowUp: true },
{ id: "r2", title: "Sarah Chen — Riverside Paving", time: "Yesterday", duration: "8m 22s", summary: "Left voicemail — follow up Thursday about roller rental extension", hasFollowUp: true },
{ id: "r3", title: "Tom Barrett — Metro Builders", time: "Yesterday", duration: "22m 15s", summary: "Discussed Q2 equipment needs. Wants proposal for 3 excavators + 2 dump trucks.", hasFollowUp: false },
],
timeline: [
{ title: "Updated fleet availability", time: "8:05 AM", completed: true },
{ title: "Sent invoice to Tri-County", time: "8:30 AM", completed: true },
{ title: "Called Dave at Summit Cranes", time: "9:12 AM", completed: true },
{ title: "Follow up with Marcus Greene", time: "10:30 AM", completed: false, current: true },
{ title: "Lunch with regional sales team", time: "12:00 PM", completed: false },
{ title: "Review vendor proposals", time: "2:00 PM", completed: false },
{ title: "Daily digest review", time: "6:00 PM", completed: false },
],
gmailConnected: true,
};
}
// ─── Mount ───────────────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById("dashboard-root"));
root.render(React.createElement(Dashboard));