Skip to content

Commit 9ba42df

Browse files
committed
Make Daily Usage chart fill the card width
The chart used a fixed 600x140 viewBox with maxHeight, so preserveAspectRatio letterboxed it: the plot rendered ~600px wide and floated centered in a much wider card. Measure the container width with a ResizeObserver and render the SVG full width (taller, with gridlines and a sessions Y axis), matching the Usage trend chart.
1 parent 0866cfe commit 9ba42df

1 file changed

Lines changed: 112 additions & 109 deletions

File tree

vis/src/features/statistics/statistics-view.tsx

Lines changed: 112 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
22
import { Clock, Coins, FolderGit2, MessagesSquare } from "lucide-react";
33
import { type AggregateStats, getAggregateStats } from "@/lib/api";
44
import {
@@ -30,27 +30,39 @@ function formatDuration(sec: number): string {
3030
/* Daily Usage Chart (SVG line chart) */
3131
/* ------------------------------------------------------------------ */
3232

33-
const CHART_WIDTH = 600;
34-
const CHART_HEIGHT = 140;
35-
const CHART_PAD_X = 36;
36-
const CHART_PAD_TOP = 14;
37-
const CHART_PAD_BOTTOM = 26;
33+
const DU_HEIGHT = 220;
34+
const DU_PAD_L = 34;
35+
const DU_PAD_R = 16;
36+
const DU_PAD_T = 16;
37+
const DU_PAD_B = 28;
38+
const DU_GRID_STEPS = 4;
3839

3940
function DailyUsageChart({ daily }: { daily: AggregateStats["daily_usage"] }) {
41+
const ref = useRef<HTMLDivElement>(null);
42+
const [width, setWidth] = useState(720);
43+
44+
useLayoutEffect(() => {
45+
const el = ref.current;
46+
if (!el) return;
47+
const update = () => setWidth(el.clientWidth);
48+
update();
49+
const ro = new ResizeObserver(update);
50+
ro.observe(el);
51+
return () => ro.disconnect();
52+
}, []);
53+
4054
if (daily.length === 0) return null;
4155

4256
const maxSessions = Math.max(1, ...daily.map((d) => d.sessions));
4357
const maxTurns = Math.max(1, ...daily.map((d) => d.turns));
4458

45-
const innerW = CHART_WIDTH - CHART_PAD_X * 2;
46-
const innerH = CHART_HEIGHT - CHART_PAD_TOP - CHART_PAD_BOTTOM;
59+
const plotW = Math.max(1, width - DU_PAD_L - DU_PAD_R);
60+
const plotH = DU_HEIGHT - DU_PAD_T - DU_PAD_B;
4761

4862
const toX = (i: number) =>
49-
CHART_PAD_X +
50-
(daily.length > 1 ? (i / (daily.length - 1)) * innerW : innerW / 2);
51-
const toYSessions = (v: number) =>
52-
CHART_PAD_TOP + (1 - v / maxSessions) * innerH;
53-
const toYTurns = (v: number) => CHART_PAD_TOP + (1 - v / maxTurns) * innerH;
63+
DU_PAD_L + (daily.length > 1 ? (i / (daily.length - 1)) * plotW : plotW / 2);
64+
const toYSessions = (v: number) => DU_PAD_T + (1 - v / maxSessions) * plotH;
65+
const toYTurns = (v: number) => DU_PAD_T + (1 - v / maxTurns) * plotH;
5466

5567
const sessionsPath = daily
5668
.map((d, i) => `${i === 0 ? "M" : "L"} ${toX(i)} ${toYSessions(d.sessions)}`)
@@ -63,18 +75,7 @@ function DailyUsageChart({ daily }: { daily: AggregateStats["daily_usage"] }) {
6375
` L ${toX(daily.length - 1)} ${toYSessions(0)}` +
6476
` L ${toX(0)} ${toYSessions(0)} Z`;
6577

66-
const baselineY = CHART_PAD_TOP + innerH;
67-
68-
// X-axis labels: ~5 evenly spaced dates
69-
const labelCount = Math.min(5, daily.length);
70-
const labelIndices: number[] = [];
71-
if (labelCount <= 1) {
72-
if (daily.length > 0) labelIndices.push(0);
73-
} else {
74-
for (let i = 0; i < labelCount; i++) {
75-
labelIndices.push(Math.round((i / (labelCount - 1)) * (daily.length - 1)));
76-
}
77-
}
78+
const labelStep = Math.max(1, Math.ceil(daily.length / 6));
7879

7980
return (
8081
<Card>
@@ -94,91 +95,93 @@ function DailyUsageChart({ daily }: { daily: AggregateStats["daily_usage"] }) {
9495
</span>
9596
</div>
9697
</CardHeader>
97-
<CardContent className="pt-2">
98-
<svg
99-
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
100-
className="w-full"
101-
style={{ maxHeight: CHART_HEIGHT }}
102-
role="img"
103-
aria-label="Daily sessions and turns over the last 30 days"
104-
>
105-
{/* Baseline */}
106-
<line
107-
x1={CHART_PAD_X}
108-
y1={baselineY}
109-
x2={CHART_WIDTH - CHART_PAD_X}
110-
y2={baselineY}
111-
className="stroke-border"
112-
strokeWidth={1}
113-
/>
114-
115-
{/* Sessions area + line (accent) */}
116-
<path d={sessionsArea} className="fill-primary/[0.08]" />
117-
<path
118-
d={sessionsPath}
119-
className="stroke-primary"
120-
strokeWidth={1.5}
121-
fill="none"
122-
strokeLinejoin="round"
123-
/>
124-
125-
{/* Turns line (muted dashed) */}
126-
<path
127-
d={turnsPath}
128-
className="stroke-muted-foreground"
129-
strokeWidth={1.5}
130-
fill="none"
131-
strokeDasharray="4 3"
132-
strokeLinejoin="round"
133-
/>
134-
135-
{/* Y-axis bounds */}
136-
<text
137-
x={CHART_PAD_X - 6}
138-
y={CHART_PAD_TOP + 4}
139-
className="fill-muted-foreground"
140-
fontSize={8}
141-
textAnchor="end"
142-
>
143-
{maxSessions}
144-
</text>
145-
<text
146-
x={CHART_PAD_X - 6}
147-
y={baselineY + 3}
148-
className="fill-muted-foreground"
149-
fontSize={8}
150-
textAnchor="end"
98+
<CardContent className="pt-3">
99+
<div ref={ref} className="w-full" style={{ height: DU_HEIGHT }}>
100+
<svg
101+
width={width}
102+
height={DU_HEIGHT}
103+
role="img"
104+
aria-label="Daily sessions and turns over the last 30 days"
151105
>
152-
0
153-
</text>
154-
155-
{/* X-axis date labels */}
156-
{labelIndices.map((idx) => (
157-
<text
158-
key={idx}
159-
x={toX(idx)}
160-
y={CHART_HEIGHT - 6}
161-
className="fill-muted-foreground"
162-
fontSize={8}
163-
textAnchor="middle"
164-
>
165-
{daily[idx].date.slice(5)}
166-
</text>
167-
))}
168-
169-
{/* Session dots */}
170-
{daily.map((d, i) =>
171-
d.sessions > 0 ? (
172-
<circle
173-
key={i}
174-
cx={toX(i)}
175-
cy={toYSessions(d.sessions)}
176-
r={1.8}
177-
className="fill-primary"
178-
/>
179-
) : null,
180-
)}
181-
</svg>
106+
{/* Horizontal gridlines + sessions Y labels */}
107+
{Array.from({ length: DU_GRID_STEPS + 1 }, (_, i) => {
108+
const v = (maxSessions / DU_GRID_STEPS) * i;
109+
const y = toYSessions(v);
110+
return (
111+
<g key={i}>
112+
<line
113+
x1={DU_PAD_L}
114+
y1={y}
115+
x2={width - DU_PAD_R}
116+
y2={y}
117+
className="stroke-border"
118+
strokeWidth={1}
119+
strokeDasharray={i === 0 ? undefined : "4 4"}
120+
/>
121+
<text
122+
x={DU_PAD_L - 6}
123+
y={y + 3}
124+
className="fill-muted-foreground"
125+
fontSize={9}
126+
textAnchor="end"
127+
>
128+
{Math.round(v)}
129+
</text>
130+
</g>
131+
);
132+
})}
133+
134+
{/* Sessions area + line (accent) */}
135+
<path d={sessionsArea} className="fill-primary/[0.08]" />
136+
<path
137+
d={sessionsPath}
138+
className="stroke-primary"
139+
strokeWidth={2}
140+
fill="none"
141+
strokeLinejoin="round"
142+
strokeLinecap="round"
143+
/>
144+
145+
{/* Turns line (muted dashed) */}
146+
<path
147+
d={turnsPath}
148+
className="stroke-muted-foreground"
149+
strokeWidth={1.5}
150+
fill="none"
151+
strokeDasharray="4 3"
152+
strokeLinejoin="round"
153+
/>
154+
155+
{/* X-axis date labels */}
156+
{daily.map((d, i) =>
157+
i % labelStep === 0 || i === daily.length - 1 ? (
158+
<text
159+
key={i}
160+
x={toX(i)}
161+
y={DU_HEIGHT - 8}
162+
className="fill-muted-foreground"
163+
fontSize={9}
164+
textAnchor="middle"
165+
>
166+
{d.date.slice(5)}
167+
</text>
168+
) : null,
169+
)}
170+
171+
{/* Session dots */}
172+
{daily.map((d, i) =>
173+
d.sessions > 0 ? (
174+
<circle
175+
key={i}
176+
cx={toX(i)}
177+
cy={toYSessions(d.sessions)}
178+
r={2}
179+
className="fill-primary"
180+
/>
181+
) : null,
182+
)}
183+
</svg>
184+
</div>
182185
</CardContent>
183186
</Card>
184187
);

0 commit comments

Comments
 (0)