<script lang="ts">
import { analytics } from "../../stores/analytics.svelte.js";
import { placeLabel } from "../../utils/places.js";
import LoadingSpinner from "../layout/LoadingSpinner.svelte";
const MAX_PROJECTS = 15;
const BAR_HEIGHT = 20;
const ROW_GAP = 4;
const LABEL_WIDTH = 140;
/** One bar: a place sessions ran, or the places folded into "Other". */
interface Row {
key: string;
label: string;
/** The project a click filters by; null for "Other" and outside `$PRJ`. */
project: string | null;
sessions: number;
messages: number;
agents: Record<string, number>;
}
const rows = $derived.by((): Row[] => {
const places = analytics.projects?.projects;
if (!places || places.length === 0) return [];
const sorted = [...places]
.sort((a, b) => b.messages - a.messages)
.map((place) => ({
key: placeLabel(place),
label: placeLabel(place),
project: place.project,
sessions: place.sessions,
messages: place.messages,
agents: place.agents,
}));
if (sorted.length <= MAX_PROJECTS) return sorted;
const top = sorted.slice(0, MAX_PROJECTS);
const rest = sorted.slice(MAX_PROJECTS);
const other: Row = {
key: "other",
label: `Other (${rest.length})`,
project: null,
sessions: rest.reduce((s, p) => s + p.sessions, 0),
messages: rest.reduce((s, p) => s + p.messages, 0),
agents: {},
};
return [...top, other];
});
const maxMessages = $derived(
rows.length > 0
? Math.max(...rows.map((r) => r.messages), 1)
: 1,
);
function barWidth(messages: number): number {
return (messages / maxMessages) * 100;
}
function truncateName(name: string, max: number): string {
if (name.length <= max) return name;
return name.slice(0, max - 1) + "\u2026";
}
/** A place's bar filters by its project: analytics narrow by project. */
function handleClick(row: Row) {
if (row.project === null) return;
analytics.setProject(row.project);
}
let tooltip = $state<{
x: number;
y: number;
text: string;
} | null>(null);
function handleHover(
e: MouseEvent,
project: Row,
) {
const rect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const agents = Object.entries(project.agents)
.sort(([, a], [, b]) => b - a)
.map(([name, count]) => `${name}: ${count}`)
.join(", ");
const parts = [
`${project.messages.toLocaleString()} messages`,
`${project.sessions} sessions`,
];
if (agents) parts.push(agents);
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: parts.join(" | "),
};
}
function handleLeave() {
tooltip = null;
}
</script>
<div class="breakdown-container">
<div class="breakdown-header">
<h3 class="chart-title">Projects</h3>
{#if rows.length > 0}
<span class="count">{analytics.projects?.projects.length ?? 0} total</span>
{/if}
</div>
{#if !analytics.loaded.projects || analytics.loading.projects}
<LoadingSpinner label="Loading projects" />
{:else if analytics.errors.projects}
<div class="error">
{analytics.errors.projects}
<button
class="retry-btn"
onclick={() => analytics.fetchProjects()}
>
Retry
</button>
</div>
{:else if rows.length > 0}
<div class="bar-list">
{#each rows as project, i}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bar-row"
class:clickable={project.project !== null}
class:selected={project.project !== null && analytics.project === project.project}
class:dimmed={analytics.project !== "" && analytics.project !== project.project}
onclick={() => handleClick(project)}
onmouseenter={(e) => handleHover(e, project)}
onmouseleave={handleLeave}
>
<span class="project-name" title={project.label}>
{truncateName(project.label, 24)}
</span>
<div class="bar-track">
<div
class="bar-fill"
style="width: {barWidth(project.messages)}%"
></div>
</div>
<span class="bar-value">
{project.messages.toLocaleString()}
</span>
</div>
{/each}
</div>
{#if tooltip}
<div
class="tooltip"
style="left: {tooltip.x}px; top: {tooltip.y}px;"
>
{tooltip.text}
</div>
{/if}
{:else}
<div class="empty">No project data</div>
{/if}
</div>
<style>
.breakdown-container {
position: relative;
flex: 1;
}
.breakdown-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--wb-spacing-8);
}
.chart-title {
font-size: var(--wb-font-size-12);
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.count {
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
}
.bar-list {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-3);
}
.bar-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-2) var(--wb-spacing-4);
border-radius: var(--wsb-radius-sm);
transition: background 0.1s;
}
.bar-row.clickable {
cursor: pointer;
}
.bar-row.clickable:hover {
background: var(--vscode-list-hoverBackground);
}
.bar-row.selected {
background: color-mix(
in srgb, var(--vscode-focusBorder) 12%, transparent
);
}
.bar-row.dimmed {
opacity: 0.35;
}
.bar-row.dimmed:hover {
opacity: 0.7;
}
.project-name {
flex-shrink: 0;
width: 140px;
font-size: var(--wb-font-size-11);
color: var(--vscode-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
flex: 1;
height: var(--wb-control-size-14);
background: var(--vscode-input-background);
border-radius: var(--wb-corner-radius-2);
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--vscode-focusBorder);
border-radius: var(--wb-corner-radius-2);
min-width: var(--wb-control-size-2);
}
.bar-value {
flex-shrink: 0;
width: var(--wb-control-size-52);
text-align: right;
font-size: var(--wb-font-size-10);
font-family: var(--wsb-font-mono);
color: var(--vscode-disabledForeground);
}
.tooltip {
position: fixed;
transform: translateX(-50%) translateY(-100%);
padding: var(--wb-spacing-4) var(--wb-spacing-8);
background: var(--vscode-editor-foreground);
color: var(--vscode-editor-background);
font-size: var(--wb-font-size-10);
border-radius: var(--wsb-radius-sm);
white-space: nowrap;
pointer-events: none;
z-index: 100;
}
.empty {
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-12);
padding: var(--wb-spacing-24);
text-align: center;
}
.error {
color: var(--vscode-errorForeground);
font-size: var(--wb-font-size-12);
padding: var(--wb-spacing-12);
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
}
.retry-btn {
padding: var(--wb-spacing-2) var(--wb-spacing-8);
border: 1px solid currentColor;
border-radius: var(--wsb-radius-sm);
font-size: var(--wb-font-size-11);
color: inherit;
cursor: pointer;
}
</style>