Refocus the Home dashboard and make metric tiles interactive
The home page was 13 cards deep with empty roll-ups (Today, In progress, Team duplicated other pages) and a stark full-black goal card that fought the rest of the surface. Now: - Three intent cards lead — Quarter goal (light, integrated), Monthly budget, and Waiting-on-you with a real "all caught up" empty state. - A four-up metric strip (workload, run activity, success rate, token usage) replaces the redundant budget chart. Each tile lifts on hover and its header shows a live readout of the hovered datum. - Activity (agent runs, recent messages) closes the page. No data wiring or approval logic changed; HomePage drops 500 → 344 lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ea9f27916e
commit
8b6b8ba4b7
3 files changed
+270
-414
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import { Avatar, Button, Icon, Pill } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import type {
|
||||
@@ -12,10 +12,14 @@ import type {
|
||||
TaskRunRecord,
|
||||
} from "../../../shared/domain";
|
||||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { AgentWorkloadChart, BudgetUtilizationChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
|
||||
import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types";
|
||||
|
||||
const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
||||
const METRICS: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
||||
const ACTIVITY: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 16 };
|
||||
|
||||
export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const organization = requireActiveOrganization(snapshot);
|
||||
@@ -24,15 +28,9 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
|
||||
const approvals = useMemo(() => snapshot.approvals.filter((item) => item.orgId === orgId), [snapshot.approvals, orgId]);
|
||||
const goals = useMemo(() => snapshot.goals.filter((item) => item.orgId === orgId), [snapshot.goals, orgId]);
|
||||
const providers = useMemo(
|
||||
() => snapshot.providers.filter((provider) => provider.orgId === orgId),
|
||||
[snapshot.providers, orgId],
|
||||
);
|
||||
const providers = useMemo(() => snapshot.providers.filter((provider) => provider.orgId === orgId), [snapshot.providers, orgId]);
|
||||
const budgetCapCents = useMemo(
|
||||
() =>
|
||||
snapshot.hiringPolicies
|
||||
.filter((policy) => policy.orgId === orgId)
|
||||
.reduce((sum, policy) => sum + policy.monthlyBudgetLimitCents, 0),
|
||||
() => snapshot.hiringPolicies.filter((policy) => policy.orgId === orgId).reduce((sum, policy) => sum + policy.monthlyBudgetLimitCents, 0),
|
||||
[snapshot.hiringPolicies, orgId],
|
||||
);
|
||||
const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]);
|
||||
@@ -42,57 +40,41 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||
|
||||
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
|
||||
const inProgressTasks = tasks.filter((task) => task.status === "in_progress");
|
||||
const today = new Date();
|
||||
const agendaTasks = collectAgendaTasks(tasks, today);
|
||||
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
|
||||
const goalProgress = computeGoalProgress(primaryGoal, tasks);
|
||||
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
|
||||
const greeting = greetingFor(today.getHours());
|
||||
const summaryText = renderSummary(pendingApprovals.length, agents.length);
|
||||
const greeting = greetingFor(new Date().getHours());
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-wide" style={{ maxWidth: 1240 }}>
|
||||
<TopbarActions>
|
||||
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={13} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
<Button tone="ghost" onClick={() => void actions.reload()} title="Refresh"><Icon name="refresh" size={16} /></Button>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={16} /> New task</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">{greeting}, {firstName}</h1>
|
||||
<p className="page-sub">{summaryText}</p>
|
||||
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginBottom: 12 }}>
|
||||
<TodayCard date={today} tasks={agendaTasks} agents={agents} employee={employee} approvals={pendingApprovals} />
|
||||
<div style={STRIP}>
|
||||
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
|
||||
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
||||
<WaitingCard
|
||||
approvals={pendingApprovals}
|
||||
agents={agents}
|
||||
employees={[employee]}
|
||||
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({
|
||||
orgId,
|
||||
approvalId,
|
||||
state: "approved",
|
||||
decidedByEmployeeId: employee.id,
|
||||
}))}
|
||||
onApprove={(approvalId) => void actions.run(() => window.agentCompany.decideApproval({ orgId, approvalId, state: "approved", decidedByEmployeeId: employee.id }))}
|
||||
onOpen={() => navigate("approvals")}
|
||||
/>
|
||||
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)", gap: 12 }}>
|
||||
<InProgressCard tasks={inProgressTasks} agents={agents} onViewAll={() => navigate("work")} />
|
||||
<TeamCard agents={agents} employee={employee} />
|
||||
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 12, marginTop: 12 }}>
|
||||
<div style={METRICS}>
|
||||
<AgentWorkloadChart agents={agents} tasks={tasks} />
|
||||
<BudgetUtilizationChart agents={agents} providers={providers} />
|
||||
<RunActivityChart taskRuns={taskRuns} />
|
||||
<SuccessRateChart taskRuns={taskRuns} />
|
||||
<CostTrendChart providerRequests={providerRequests} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", gap: 12, marginTop: 12 }}>
|
||||
<div style={ACTIVITY}>
|
||||
<RunActivityCard taskRuns={taskRuns} tasks={tasks} agents={agents} onViewAll={() => navigate("runs")} />
|
||||
<RecentMessagesCard messages={chatMessages} agents={agents} employees={[employee]} spaces={chatSpaces} onViewAll={() => navigate("chat")} />
|
||||
</div>
|
||||
@@ -102,86 +84,28 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function TodayCard({
|
||||
date,
|
||||
tasks,
|
||||
agents,
|
||||
employee,
|
||||
approvals,
|
||||
function GoalCard({
|
||||
goal,
|
||||
progress,
|
||||
organizationName,
|
||||
}: {
|
||||
date: Date;
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
employee: EmployeeRecord;
|
||||
approvals: ApprovalRecord[];
|
||||
goal: GoalRecord | null;
|
||||
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
|
||||
organizationName: string;
|
||||
}) {
|
||||
const formatted = date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
const rows = tasks.slice(0, 4);
|
||||
const percent = progress?.percent ?? 0;
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<span className="card-label">Today</span>
|
||||
<span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)" }}>{formatted}</span>
|
||||
<section className="card">
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>Quarter goal</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 540, lineHeight: 1.4, letterSpacing: "-0.01em", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: 44 }}>
|
||||
{goal ? goal.title : `Set the first goal for ${organizationName}.`}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{rows.map((task) => {
|
||||
const owner = agents.find((agent) => agent.id === task.assigneeAgentId);
|
||||
const ownerName = owner?.name ?? employee.displayName;
|
||||
const time = agendaTime(task, date);
|
||||
const requiresApproval = approvals.some(
|
||||
(approval) => approval.sourceEntityType === "task" && approval.sourceEntityId === task.id,
|
||||
);
|
||||
return (
|
||||
<div key={task.id} className="row" style={{ gap: 12 }}>
|
||||
<span className="num mono" style={{ width: 36, fontSize: 12, color: "var(--text-3)" }}>{time}</span>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>
|
||||
<span style={{ fontWeight: 500 }}>{task.title}</span>
|
||||
<span style={{ color: "var(--text-3)", marginLeft: 6 }}>· {ownerName}</span>
|
||||
</div>
|
||||
{requiresApproval && <Pill tone="amber">You</Pill>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
|
||||
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{progress ? `${percent}%` : "—"}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : "no work yet"}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WaitingCard({
|
||||
approvals,
|
||||
agents,
|
||||
employees,
|
||||
onApprove,
|
||||
onOpen,
|
||||
}: {
|
||||
approvals: ApprovalRecord[];
|
||||
agents: AgentRecord[];
|
||||
employees: EmployeeRecord[];
|
||||
onApprove: (approvalId: string) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<span className="card-label">Waiting on you</span>
|
||||
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{approvals.slice(0, 2).map((approval) => {
|
||||
const from = resolveRequester(approval, employees, agents);
|
||||
return (
|
||||
<div key={approval.id} style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 2 }}>{approval.title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
|
||||
{humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {from}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
|
||||
<Button size="small" onClick={onOpen}>Open</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
|
||||
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -201,143 +125,159 @@ function SpendCard({
|
||||
const connected = providers.filter((provider) => provider.enabled);
|
||||
const fillPct = capCents > 0 ? Math.min(100, Math.round((totalBudgetCents / capCents) * 100)) : 0;
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="card-label" style={{ marginBottom: 14 }}>Monthly budget</div>
|
||||
<section className="card">
|
||||
<div className="card-label" style={{ marginBottom: 12 }}>Monthly budget</div>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 6 }}>
|
||||
<span className="num" style={{ fontSize: 28, fontWeight: 600, letterSpacing: "-0.02em" }}>{money(totalBudgetCents)}</span>
|
||||
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{money(totalBudgetCents)}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{capCents > 0 ? `/ ${money(capCents)}` : "allocated"}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, height: 4, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
|
||||
<div style={{ width: `${fillPct}%`, height: "100%", background: "var(--text-1)" }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{connected.slice(0, 3).map((provider) => {
|
||||
const allocatedCents = agents
|
||||
.filter((agent) => agent.modelProviderId === provider.id)
|
||||
.reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||
const allocatedCents = agents.filter((agent) => agent.modelProviderId === provider.id).reduce((sum, agent) => sum + agent.budgetCents, 0);
|
||||
return (
|
||||
<div key={provider.id} className="row" style={{ fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text-2)" }}>{provider.label}</span>
|
||||
<span className="num mono" style={{ marginLeft: "auto", color: "var(--text-1)", fontWeight: 500 }}>
|
||||
{money(allocatedCents)}
|
||||
</span>
|
||||
<div key={provider.id} className="row" style={{ fontSize: 14 }}>
|
||||
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</span>
|
||||
<span className="num mono" style={{ marginLeft: "auto", color: "var(--text-1)", fontWeight: 500 }}>{money(allocatedCents)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{connected.length === 0 && <span style={{ fontSize: 14, color: "var(--text-3)" }}>No providers connected</span>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InProgressCard({ tasks, agents, onViewAll }: { tasks: TaskRecord[]; agents: AgentRecord[]; onViewAll: () => void }) {
|
||||
const rows = tasks.slice(0, 4);
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<span className="card-label">In progress</span>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
style={{ marginLeft: "auto", fontSize: 11 }}
|
||||
onClick={onViewAll}
|
||||
>
|
||||
All work →
|
||||
</button>
|
||||
</div>
|
||||
{rows.map((task, index) => {
|
||||
const owner = agents.find((agent) => agent.id === task.assigneeAgentId);
|
||||
const last = index === rows.length - 1;
|
||||
return (
|
||||
<div key={task.id} className="row" style={{ gap: 10, padding: "10px 0", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--text-4)", flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task.title}</div>
|
||||
{owner && <Pill>{owner.department}</Pill>}
|
||||
{owner && <Avatar name={owner.name} size="sm" bot />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCard({ agents, employee }: { agents: AgentRecord[]; employee: EmployeeRecord }) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="card-label" style={{ marginBottom: 14 }}>Team</div>
|
||||
{agents.slice(0, 4).map((agent) => (
|
||||
<div key={agent.id} className="row" style={{ gap: 10, padding: "7px 0" }}>
|
||||
<Avatar name={agent.name} size="sm" bot />
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 500, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{agent.name}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{agent.role}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ gap: 10, padding: "7px 0" }}>
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{employee.displayName}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{employee.role} · human</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalCard({
|
||||
goal,
|
||||
progress,
|
||||
organizationName,
|
||||
function WaitingCard({
|
||||
approvals,
|
||||
agents,
|
||||
employees,
|
||||
onApprove,
|
||||
onOpen,
|
||||
}: {
|
||||
goal: GoalRecord | null;
|
||||
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
|
||||
organizationName: string;
|
||||
approvals: ApprovalRecord[];
|
||||
agents: AgentRecord[];
|
||||
employees: EmployeeRecord[];
|
||||
onApprove: (approvalId: string) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="card"
|
||||
style={{
|
||||
padding: 18,
|
||||
background: "linear-gradient(180deg, #0a0a0b 0%, #18181b 100%)",
|
||||
color: "#fff",
|
||||
border: "1px solid #18181b",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.5)", fontWeight: 540, letterSpacing: "0.02em", marginBottom: 12 }}>
|
||||
Quarter goal
|
||||
<section className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="card-label">Waiting on you</span>
|
||||
{approvals.length > 0 && <span style={{ marginLeft: "auto" }}><Pill tone="amber">{approvals.length}</Pill></span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 540, lineHeight: 1.35, marginBottom: 18, letterSpacing: "-0.01em" }}>
|
||||
{goal ? goal.title : `Set the first goal for ${organizationName}.`}
|
||||
{approvals.length === 0 ? (
|
||||
<div className="row" style={{ gap: 8, color: "var(--text-3)", fontSize: 14 }}>
|
||||
<Icon name="check" size={16} /><span>You're all caught up</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{approvals.slice(0, 2).map((approval) => (
|
||||
<div key={approval.id}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", margin: "2px 0 8px" }}>
|
||||
{humanizeLabel(approval.kind)} · {humanizeLabel(approval.risk)} risk · from {resolveRequester(approval, employees, agents)}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Button tone="primary" size="small" onClick={() => onApprove(approval.id)}>Approve</Button>
|
||||
<Button size="small" onClick={onOpen}>Open</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RunActivityCard({
|
||||
taskRuns,
|
||||
tasks,
|
||||
agents,
|
||||
onViewAll,
|
||||
}: {
|
||||
taskRuns: TaskRunRecord[];
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
const recent = taskRuns.slice(0, 6);
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="card-label">Agent runs</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>All runs →</button>
|
||||
</div>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8 }}>
|
||||
<span className="num" style={{ fontSize: 24, fontWeight: 600 }}>{progress ? `${progress.percent}%` : "—"}</span>
|
||||
<span style={{ fontSize: 12, color: "rgba(255,255,255,0.5)" }}>
|
||||
{progress ? statusLabel(progress.status) : "no work yet"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 10, height: 3, background: "rgba(255,255,255,0.12)", borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ width: `${progress?.percent ?? 0}%`, height: "100%", background: "#fff" }} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{recent.map((run) => {
|
||||
const task = tasks.find((t) => t.id === run.taskId);
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
return (
|
||||
<div key={run.id} className="row" style={{ gap: 10 }}>
|
||||
{agent && <Avatar name={agent.name} size="sm" bot />}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{task?.title ?? run.taskId}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{agent?.name ?? "Agent"} · {run.trigger}</div>
|
||||
</div>
|
||||
<Pill tone={runStateTone(run.state)}>{runStateLabel(run.state)}</Pill>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(run.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No runs yet</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function agendaTime(task: TaskRecord, reference: Date): string {
|
||||
const updated = new Date(task.updatedAt);
|
||||
if (Number.isNaN(updated.getTime())) return "—";
|
||||
const sameDay =
|
||||
updated.getFullYear() === reference.getFullYear() &&
|
||||
updated.getMonth() === reference.getMonth() &&
|
||||
updated.getDate() === reference.getDate();
|
||||
if (!sameDay) return "—";
|
||||
return formatTime(task.updatedAt);
|
||||
}
|
||||
|
||||
function collectAgendaTasks(tasks: TaskRecord[], reference: Date): TaskRecord[] {
|
||||
const startOfDay = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate()).getTime();
|
||||
const endOfDay = startOfDay + 24 * 60 * 60 * 1000;
|
||||
const todays = tasks.filter((task) => {
|
||||
const updated = new Date(task.updatedAt).getTime();
|
||||
return updated >= startOfDay && updated < endOfDay && task.status !== "done";
|
||||
});
|
||||
if (todays.length > 0) return todays.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
||||
return tasks.filter((task) => task.status !== "done").slice(0, 4);
|
||||
function RecentMessagesCard({
|
||||
messages,
|
||||
agents,
|
||||
employees,
|
||||
spaces,
|
||||
onViewAll,
|
||||
}: {
|
||||
messages: ChatMessageRecord[];
|
||||
agents: AgentRecord[];
|
||||
employees: EmployeeRecord[];
|
||||
spaces: Array<{ id: string; name: string }>;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
const recent = messages.slice(-6).reverse();
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<span className="card-label">Recent messages</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 12 }} onClick={onViewAll}>Open chat →</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{recent.map((msg) => {
|
||||
const senderAgent = msg.senderType === "agent" ? agents.find((a) => a.id === msg.senderId) : null;
|
||||
const senderEmployee = msg.senderType === "employee" ? employees.find((e) => e.id === msg.senderId) : null;
|
||||
const name = senderAgent?.name ?? senderEmployee?.displayName ?? msg.senderType;
|
||||
const space = spaces.find((s) => s.id === msg.spaceId);
|
||||
const preview = msg.bodyMd.length > 80 ? `${msg.bodyMd.slice(0, 77)}...` : msg.bodyMd;
|
||||
return (
|
||||
<div key={msg.id} className="row" style={{ gap: 10, alignItems: "flex-start" }}>
|
||||
<Avatar name={name} size="sm" bot={msg.senderType === "agent"} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 540 }}>{name}</span>
|
||||
{space && <span style={{ fontSize: 12, color: "var(--text-3)" }}>#{space.name}</span>}
|
||||
<span className="mono" style={{ marginLeft: "auto", fontSize: 12, color: "var(--text-3)", flexShrink: 0 }}>{formatTime(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: 1.4 }}>{preview}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <div style={{ fontSize: 14, color: "var(--text-3)" }}>No messages yet</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { percent: number; status: "ahead" | "on_track" | "behind" } | null {
|
||||
@@ -387,107 +327,11 @@ function resolveRequester(approval: ApprovalRecord, employees: EmployeeRecord[],
|
||||
return "System";
|
||||
}
|
||||
|
||||
function RunActivityCard({
|
||||
taskRuns,
|
||||
tasks,
|
||||
agents,
|
||||
onViewAll,
|
||||
}: {
|
||||
taskRuns: TaskRunRecord[];
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
const recent = taskRuns.slice(0, 8);
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<span className="card-label">Agent runs</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 11 }} onClick={onViewAll}>
|
||||
All work →
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{recent.map((run) => {
|
||||
const task = tasks.find((t) => t.id === run.taskId);
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
return (
|
||||
<div key={run.id} className="row" style={{ gap: 10 }}>
|
||||
{agent && <Avatar name={agent.name} size="sm" bot />}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{task?.title ?? run.taskId}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>
|
||||
{agent?.name ?? "Agent"} · {run.trigger}
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone={runStateTone(run.state)}>{runStateLabel(run.state)}</Pill>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>{formatRelative(run.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <div style={{ fontSize: 13, color: "var(--text-3)" }}>No runs yet</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentMessagesCard({
|
||||
messages,
|
||||
agents,
|
||||
employees,
|
||||
spaces,
|
||||
onViewAll,
|
||||
}: {
|
||||
messages: ChatMessageRecord[];
|
||||
agents: AgentRecord[];
|
||||
employees: EmployeeRecord[];
|
||||
spaces: Array<{ id: string; name: string }>;
|
||||
onViewAll: () => void;
|
||||
}) {
|
||||
const recent = messages.slice(-8).reverse();
|
||||
return (
|
||||
<section className="card" style={{ padding: 18 }}>
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<span className="card-label">Recent messages</span>
|
||||
<button type="button" className="link-button" style={{ marginLeft: "auto", fontSize: 11 }} onClick={onViewAll}>
|
||||
Open chat →
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{recent.map((msg) => {
|
||||
const senderAgent = msg.senderType === "agent" ? agents.find((a) => a.id === msg.senderId) : null;
|
||||
const senderEmployee = msg.senderType === "employee" ? employees.find((e) => e.id === msg.senderId) : null;
|
||||
const name = senderAgent?.name ?? senderEmployee?.displayName ?? msg.senderType;
|
||||
const space = spaces.find((s) => s.id === msg.spaceId);
|
||||
const preview = msg.bodyMd.length > 80 ? `${msg.bodyMd.slice(0, 77)}...` : msg.bodyMd;
|
||||
return (
|
||||
<div key={msg.id} className="row" style={{ gap: 10, alignItems: "flex-start" }}>
|
||||
<Avatar name={name} size="sm" bot={msg.senderType === "agent"} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 540 }}>{name}</span>
|
||||
{space && <span style={{ fontSize: 11, color: "var(--text-3)" }}>#{space.name}</span>}
|
||||
<span className="mono" style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>{formatTime(msg.createdAt)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", lineHeight: 1.4 }}>
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <div style={{ fontSize: 13, color: "var(--text-3)" }}>No messages yet</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function runStateTone(state: string): "red" | "amber" | "violet" | undefined {
|
||||
function runStateTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
|
||||
if (state === "failed") return "red";
|
||||
if (state === "running") return "violet";
|
||||
if (state === "queued") return "amber";
|
||||
if (state === "completed") return "green";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain";
|
||||
import { TASK_STATUS } from "../../../../shared/domain";
|
||||
|
||||
const CHART_H = 120;
|
||||
const CHART_H = 116;
|
||||
const CHART_W = 260;
|
||||
const BAR_GAP = 4;
|
||||
|
||||
/** A metric tile that lifts on hover and surfaces a live readout in its
|
||||
* header — the hovered datum's value, or a resting summary otherwise. */
|
||||
function ChartCard({ title, readout, children }: { title: string; readout: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="card metric-card">
|
||||
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
|
||||
<span className="card-label">{title}</span>
|
||||
<span className="metric-readout num">{readout}</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; tasks: TaskRecord[] }) {
|
||||
const data = agents.slice(0, 8).map((a) => ({
|
||||
name: a.name.split(/\s+/)[0],
|
||||
name: a.name.split(/\s+/)[0] ?? a.name,
|
||||
count: tasks.filter((t) => t.assigneeAgentId === a.id && t.status !== TASK_STATUS.DONE).length,
|
||||
}));
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
const max = Math.max(1, ...data.map((d) => d.count));
|
||||
const barW = data.length > 0 ? (CHART_W - (data.length - 1) * BAR_GAP) / data.length : 0;
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const readout = hover !== null && data[hover] ? `${data[hover]!.name} · ${data[hover]!.count}` : `${total} open`;
|
||||
|
||||
return (
|
||||
<ChartCard title="Agent workload">
|
||||
<ChartCard title="Agent workload" readout={readout}>
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
{data.map((d, i) => {
|
||||
const h = (d.count / max) * (CHART_H - 20);
|
||||
const h = (d.count / max) * (CHART_H - 22);
|
||||
const x = i * (barW + BAR_GAP);
|
||||
return (
|
||||
<g key={d.name}>
|
||||
<rect x={x} y={CHART_H - h - 16} width={barW} height={h} rx={3} fill="var(--accent)" opacity={0.7 + (d.count / max) * 0.3} />
|
||||
<g key={d.name} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||
<rect x={x} y={0} width={barW} height={CHART_H - 16} fill="transparent" />
|
||||
<rect
|
||||
x={x}
|
||||
y={CHART_H - h - 16}
|
||||
width={barW}
|
||||
height={Math.max(2, h)}
|
||||
rx={4}
|
||||
fill="var(--accent)"
|
||||
opacity={hover === null || hover === i ? 0.85 : 0.32}
|
||||
style={{ transition: "opacity var(--motion-fast) var(--ease-out)" }}
|
||||
/>
|
||||
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{d.name}</text>
|
||||
</g>
|
||||
);
|
||||
@@ -31,61 +59,34 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetUtilizationChart({ agents, providers }: { agents: AgentRecord[]; providers: ProviderRecord[] }) {
|
||||
const connected = providers.filter((p) => p.enabled);
|
||||
const data = connected.slice(0, 6).map((p) => {
|
||||
const allocated = agents.filter((a) => a.modelProviderId === p.id).reduce((s, a) => s + a.budgetCents, 0);
|
||||
return { label: p.label.split(/\s+/)[0], cents: allocated };
|
||||
});
|
||||
const total = Math.max(1, data.reduce((s, d) => s + d.cents, 0));
|
||||
|
||||
const segments = stackSegments(data, total);
|
||||
const colors = ["var(--accent)", "var(--green)", "var(--amber)", "var(--violet)", "var(--red)", "var(--text-3)"];
|
||||
|
||||
return (
|
||||
<ChartCard title="Budget utilization">
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
{segments.map((segment, i) => (
|
||||
<g key={segment.label}>
|
||||
<rect x={segment.x} y={20} width={Math.max(1, segment.w - 2)} height={CHART_H - 40} rx={4} fill={colors[i % colors.length]} opacity={0.8} />
|
||||
<text x={segment.x + segment.w / 2} y={CHART_H - 4} textAnchor="middle" fontSize="9" fill="var(--text-3)">{segment.label}</text>
|
||||
<text x={segment.x + segment.w / 2} y={14} textAnchor="middle" fontSize="9" fill="var(--text-2)">${Math.round(segment.cents / 100)}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
function stackSegments(
|
||||
data: Array<{ label: string; cents: number }>,
|
||||
total: number,
|
||||
): Array<{ label: string; cents: number; x: number; w: number }> {
|
||||
const segments: Array<{ label: string; cents: number; x: number; w: number }> = [];
|
||||
let cumX = 0;
|
||||
for (const d of data) {
|
||||
const w = (d.cents / total) * CHART_W;
|
||||
segments.push({ label: d.label, cents: d.cents, x: cumX, w });
|
||||
cumX += w;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
||||
const buckets = last7DayBuckets(taskRuns.map((r) => r.createdAt));
|
||||
const total = buckets.reduce((s, b) => s + b.count, 0);
|
||||
const max = Math.max(1, ...buckets.map((b) => b.count));
|
||||
const barW = (CHART_W - 6 * BAR_GAP) / 7;
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : `${total} this week`;
|
||||
|
||||
return (
|
||||
<ChartCard title="Run activity (7d)">
|
||||
<ChartCard title="Run activity" readout={readout}>
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
{buckets.map((b, i) => {
|
||||
const h = (b.count / max) * (CHART_H - 24);
|
||||
const x = i * (barW + BAR_GAP);
|
||||
return (
|
||||
<g key={b.label}>
|
||||
<rect x={x} y={CHART_H - h - 16} width={barW} height={h} rx={3} fill="var(--text-1)" opacity={0.6 + (b.count / max) * 0.4} />
|
||||
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="8" fill="var(--text-3)">{b.label}</text>
|
||||
<g key={`${b.label}-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||
<rect x={x} y={0} width={barW} height={CHART_H - 16} fill="transparent" />
|
||||
<rect
|
||||
x={x}
|
||||
y={CHART_H - h - 16}
|
||||
width={barW}
|
||||
height={Math.max(2, h)}
|
||||
rx={4}
|
||||
fill="var(--text-1)"
|
||||
opacity={hover === null || hover === i ? 0.82 : 0.28}
|
||||
style={{ transition: "opacity var(--motion-fast) var(--ease-out)" }}
|
||||
/>
|
||||
<text x={x + barW / 2} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{b.label}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -103,17 +104,17 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
||||
const offset = circumference - (rate / 100) * circumference;
|
||||
|
||||
return (
|
||||
<ChartCard title="Success rate">
|
||||
<svg viewBox="0 0 100 100" style={{ width: 100, height: 100, margin: "0 auto", display: "block" }}>
|
||||
<ChartCard title="Success rate" readout={`${completed} ok · ${failed} failed`}>
|
||||
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
|
||||
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--green)" strokeWidth="8"
|
||||
<circle
|
||||
cx="50" cy="50" r="42" fill="none" stroke="var(--green)" strokeWidth="8"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
strokeLinecap="round" transform="rotate(-90 50 50)" />
|
||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="18" fontWeight="600" fill="var(--text-1)">{rate}%</text>
|
||||
strokeLinecap="round" transform="rotate(-90 50 50)"
|
||||
style={{ transition: "stroke-dashoffset var(--motion-slow) var(--ease-out)" }}
|
||||
/>
|
||||
<text x="50" y="50" textAnchor="middle" dominantBaseline="central" fontSize="20" fontWeight="600" fill="var(--text-1)">{rate}%</text>
|
||||
</svg>
|
||||
<div style={{ textAlign: "center", fontSize: 11, color: "var(--text-3)", marginTop: 4 }}>
|
||||
{completed} passed · {failed} failed
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -123,44 +124,43 @@ export function CostTrendChart({ providerRequests }: { providerRequests: Array<{
|
||||
const charBuckets = buckets.map((b, i) => {
|
||||
const dayStart = dayStartMs(i);
|
||||
const dayEnd = dayStart + 86400000;
|
||||
const dayRequests = providerRequests.filter((r) => {
|
||||
const t = new Date(r.createdAt).getTime();
|
||||
return t >= dayStart && t < dayEnd;
|
||||
});
|
||||
return { ...b, chars: dayRequests.reduce((s, r) => s + r.inputCharCount + r.outputCharCount, 0) };
|
||||
const chars = providerRequests
|
||||
.filter((r) => { const t = new Date(r.createdAt).getTime(); return t >= dayStart && t < dayEnd; })
|
||||
.reduce((s, r) => s + r.inputCharCount + r.outputCharCount, 0);
|
||||
return { ...b, chars };
|
||||
});
|
||||
const totalChars = charBuckets.reduce((s, b) => s + b.chars, 0);
|
||||
const max = Math.max(1, ...charBuckets.map((b) => b.chars));
|
||||
const points = charBuckets.map((b, i) => {
|
||||
const x = (i / 6) * (CHART_W - 10) + 5;
|
||||
const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30);
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
const coords = charBuckets.map((b, i) => ({
|
||||
x: (i / 6) * (CHART_W - 10) + 5,
|
||||
y: CHART_H - 20 - (b.chars / max) * (CHART_H - 30),
|
||||
b,
|
||||
}));
|
||||
const points = coords.map((c) => `${c.x},${c.y}`).join(" ");
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const readout = hover !== null && charBuckets[hover]
|
||||
? `${charBuckets[hover]!.label} · ${kchars(charBuckets[hover]!.chars)}`
|
||||
: `${kchars(totalChars)} this week`;
|
||||
|
||||
return (
|
||||
<ChartCard title="Token usage (7d)">
|
||||
<ChartCard title="Token usage" readout={readout}>
|
||||
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
|
||||
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" />
|
||||
{charBuckets.map((b, i) => {
|
||||
const x = (i / 6) * (CHART_W - 10) + 5;
|
||||
const y = CHART_H - 20 - (b.chars / max) * (CHART_H - 30);
|
||||
return <circle key={b.label} cx={x} cy={y} r="3" fill="var(--accent)" />;
|
||||
})}
|
||||
{charBuckets.map((b, i) => {
|
||||
const x = (i / 6) * (CHART_W - 10) + 5;
|
||||
return <text key={`l-${b.label}`} x={x} y={CHART_H - 2} textAnchor="middle" fontSize="8" fill="var(--text-3)">{b.label}</text>;
|
||||
})}
|
||||
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
{coords.map((c, i) => (
|
||||
<g key={`${c.b.label}-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||
<circle cx={c.x} cy={c.y} r="9" fill="transparent" />
|
||||
<circle cx={c.x} cy={c.y} r={hover === i ? 4 : 3} fill="var(--accent)" style={{ transition: "r var(--motion-fast) var(--ease-out)" }} />
|
||||
<text x={c.x} y={CHART_H - 2} textAnchor="middle" fontSize="9" fill="var(--text-3)">{c.b.label}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 14 }}>
|
||||
<div className="card-label" style={{ marginBottom: 10 }}>{title}</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
function kchars(chars: number): string {
|
||||
if (chars >= 1000) return `${(chars / 1000).toFixed(chars >= 10000 ? 0 : 1)}k`;
|
||||
return String(chars);
|
||||
}
|
||||
|
||||
function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> {
|
||||
@@ -171,11 +171,8 @@ function last7DayBuckets(dates: string[]): Array<{ label: string; count: number
|
||||
const start = new Date(now - offset * 86400000);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start.getTime() + 86400000);
|
||||
const count = dates.filter((d) => {
|
||||
const t = new Date(d).getTime();
|
||||
return t >= start.getTime() && t < end.getTime();
|
||||
}).length;
|
||||
return { label: dayNames[start.getDay()], count };
|
||||
const count = dates.filter((d) => { const t = new Date(d).getTime(); return t >= start.getTime() && t < end.getTime(); }).length;
|
||||
return { label: dayNames[start.getDay()] ?? "", count };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,21 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
||||
.clickable-card { transition: border-color var(--motion-fast) var(--ease-out), box-shadow var(--motion-fast) var(--ease-out); }
|
||||
.clickable-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* Dashboard metric tiles lift on hover; the readout brightens with the card. */
|
||||
.metric-card {
|
||||
position: relative;
|
||||
transition: transform var(--motion-base) var(--ease-out), box-shadow var(--motion-base) var(--ease-out), border-color var(--motion-base) var(--ease-out);
|
||||
}
|
||||
.metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); border-color: var(--border-strong); }
|
||||
.metric-readout {
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color var(--motion-fast) var(--ease-out);
|
||||
}
|
||||
.metric-card:hover .metric-readout { color: var(--text-1); }
|
||||
|
||||
.link-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
|
||||
Reference in new issue
Block a user