Skip to content

Commit e4f125c

Browse files
maescomuaclaude
andauthored
feat(glook-8): replace timeline line chart with date-anchored bar chart (#47)
* docs(glook-8): spec for timeline chart bars design Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * docs(glook-8): fix bar rect formula and middle label in spec Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * docs(glook-8): resolve all design assumptions in spec Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * docs(glook-8): apply review-loop fixes to spec Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * docs(glook-8): add implementation plan for timeline bar chart Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(glook-8): replace timeline line chart with date-anchored bar chart * fix(glook-8): floor bar height at 1px; use local time for middle axis label --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 09bf20b commit e4f125c

3 files changed

Lines changed: 362 additions & 38 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# GLOOK-8: Timeline Charts End at "This Week" — Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Replace the line chart in `TimelineChart` with a bar chart whose X-axis always spans the last 90 days to today, so inactive developers show an empty right portion rather than a truncated axis.
6+
7+
**Architecture:** Single function rewrite in one file. The `TimelineChart` component in `src/app/report/[id]/dev/[login]/page.tsx` switches from SVG line+area paths to `<rect>` bars positioned by date on a fixed `[cutoff, today]` range. No data layer changes; gaps between bars are structural.
8+
9+
**Tech Stack:** React (SVG), TypeScript. No new dependencies. Jest (ts-jest, node environment) covers `src/lib` only — no automated component tests exist for this change; use visual verification via mock dev server.
10+
11+
---
12+
13+
### Task 1: Rewrite `TimelineChart` to bar chart
14+
15+
**Files:**
16+
- Modify: `src/app/report/[id]/dev/[login]/page.tsx:652-804`
17+
18+
- [ ] **Step 1: Locate the function and understand what to replace**
19+
20+
Open `src/app/report/[id]/dev/[login]/page.tsx`. The `TimelineChart` function runs from line 652 to 804. The section to replace is everything inside the function body — the line/area SVG paths, the index-based `points` array, the old X-axis label logic, and the hover circle.
21+
22+
Keep unchanged: the function signature, the `cutoff`/`filtered` setup, the `values`/`max`/`min`/`range` computation, all `yTicks` logic, the `W/H/padL/padR/padT/padB/chartW/chartH` constants, the trend indicator, and the outer `<div>` and header JSX.
23+
24+
- [ ] **Step 2: Replace the `TimelineChart` function body**
25+
26+
Replace the entire `TimelineChart` function (lines 652–804) with the following:
27+
28+
```tsx
29+
function TimelineChart({
30+
data,
31+
valueKey,
32+
label,
33+
color,
34+
suffix = '',
35+
decimals = 0,
36+
computeValue,
37+
}: {
38+
data: WeeklyData[];
39+
valueKey: string;
40+
label: string;
41+
color: string;
42+
suffix?: string;
43+
decimals?: number;
44+
computeValue?: (d: WeeklyData) => number;
45+
}) {
46+
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
47+
48+
// Last 90 days of data
49+
const cutoff = new Date();
50+
cutoff.setDate(cutoff.getDate() - 90);
51+
const cutoffStr = cutoff.toISOString().split('T')[0];
52+
const filtered = data.filter(d => d.week >= cutoffStr);
53+
54+
if (filtered.length < 1) return null;
55+
56+
const values = filtered.map(d => computeValue ? computeValue(d) : (d as any)[valueKey] as number);
57+
const max = Math.max(...values, 1);
58+
const min = Math.min(...values, 0);
59+
const range = max - min || 1;
60+
61+
// Y-axis: pick nice round tick values
62+
const yTicks: number[] = [];
63+
const step = range <= 5 ? 1 : range <= 20 ? 5 : range <= 100 ? 20 : range <= 500 ? 100 : range <= 2000 ? 500 : Math.ceil(range / 5 / 100) * 100;
64+
for (let v = Math.ceil(min / step) * step; v <= max; v += step) {
65+
yTicks.push(v);
66+
}
67+
if (yTicks.length === 0) yTicks.push(min, max);
68+
if (yTicks.length > 6) {
69+
const keep = [yTicks[0], yTicks[Math.floor(yTicks.length / 2)], yTicks[yTicks.length - 1]];
70+
yTicks.length = 0;
71+
yTicks.push(...keep);
72+
}
73+
74+
const W = 400;
75+
const H = 130;
76+
const padL = 40;
77+
const padR = 12;
78+
const padT = 12;
79+
const padB = 24;
80+
const chartW = W - padL - padR;
81+
const chartH = H - padT - padB;
82+
83+
// Fixed X-axis range: [cutoff, today].
84+
// d.week is a Monday-anchored ISO date string (YYYY-MM-DD) from weekKeyForDate() in timeline.ts.
85+
// Parse with T00:00:00 to stay in local time, consistent with formatWeek below.
86+
const today = new Date();
87+
const totalMs = today.getTime() - cutoff.getTime();
88+
const totalWeeks = totalMs / (7 * 24 * 3600 * 1000);
89+
const barWidth = (chartW / totalWeeks) * 0.85;
90+
91+
const bars = filtered.map((d, i) => {
92+
const x = padL + ((new Date(d.week + 'T00:00:00').getTime() - cutoff.getTime()) / totalMs) * chartW;
93+
const v = values[i];
94+
const barH = ((v - min) / range) * chartH;
95+
const barY = padT + chartH - barH;
96+
return { x, barY, barH, v, week: d.week };
97+
});
98+
99+
const formatWeek = (w: string) => {
100+
const d = new Date(w + 'T00:00:00');
101+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
102+
};
103+
const formatVal = (v: number) => (decimals > 0 ? v.toFixed(decimals) : String(Math.round(v))) + suffix;
104+
105+
// X-axis labels: left = cutoff (axis origin), middle = midpoint of range, right = "This week"
106+
const middleDate = new Date(cutoff.getTime() + totalMs / 2);
107+
const middleLabel = formatWeek(middleDate.toISOString().split('T')[0]);
108+
109+
const latest = values[values.length - 1];
110+
const prev = values.length >= 2 ? values[values.length - 2] : latest;
111+
const trend = latest > prev ? '+' : latest < prev ? '' : '';
112+
const diff = latest - prev;
113+
114+
return (
115+
<div className="bg-gray-900 rounded-xl p-4">
116+
<div className="flex items-baseline justify-between mb-2">
117+
<p className="text-xs text-gray-500 font-medium">{label}</p>
118+
<div className="flex items-baseline gap-2">
119+
<span className="text-sm font-bold text-white">
120+
{formatVal(latest)}
121+
</span>
122+
{diff !== 0 && (
123+
<span className={`text-xs ${diff > 0 ? 'text-green-400' : 'text-red-400'}`}>
124+
{trend}{formatVal(Math.abs(diff))}
125+
</span>
126+
)}
127+
</div>
128+
</div>
129+
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto">
130+
{/* Grid lines + Y-axis labels */}
131+
{yTicks.map(v => {
132+
const y = padT + chartH - ((v - min) / range) * chartH;
133+
return (
134+
<g key={v}>
135+
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#1F2937" strokeWidth="1" />
136+
<text x={padL - 6} y={y + 3.5} textAnchor="end" className="fill-gray-600" fontSize="9">
137+
{decimals > 0 ? v.toFixed(decimals) : v}{suffix}
138+
</text>
139+
</g>
140+
);
141+
})}
142+
{/* Bars — color prop, opacity 1, no corner radius */}
143+
{bars.map((bar, i) => (
144+
<rect
145+
key={i}
146+
x={bar.x - barWidth / 2}
147+
y={bar.barY}
148+
width={barWidth}
149+
height={bar.barH}
150+
fill={color}
151+
opacity={1}
152+
/>
153+
))}
154+
{/* Full-column invisible hover targets (rendered after bars to sit on top) */}
155+
{bars.map((bar, i) => (
156+
<rect
157+
key={i}
158+
x={bar.x - barWidth / 2}
159+
y={padT}
160+
width={barWidth}
161+
height={chartH}
162+
fill="transparent"
163+
onMouseEnter={() => setHoverIdx(i)}
164+
onMouseLeave={() => setHoverIdx(null)}
165+
/>
166+
))}
167+
{/* Hover tooltip */}
168+
{hoverIdx !== null && (() => {
169+
const bar = bars[hoverIdx];
170+
const weekLabel = formatWeek(bar.week);
171+
const valLabel = formatVal(bar.v);
172+
const text = `${weekLabel}: ${valLabel}`;
173+
const textW = text.length * 6 + 16;
174+
const tooltipX = Math.min(Math.max(bar.x - textW / 2, 2), W - textW - 2);
175+
const above = bar.barY > padT + 30;
176+
const tooltipY = above ? bar.barY - 28 : bar.barY + 12;
177+
return (
178+
<g>
179+
{/* Vertical guide line */}
180+
<line x1={bar.x} y1={padT} x2={bar.x} y2={padT + chartH} stroke={color} strokeWidth="1" opacity="0.3" strokeDasharray="3,3" />
181+
{/* Tooltip background */}
182+
<rect x={tooltipX} y={tooltipY} width={textW} height={20} rx="4" fill="#1F2937" stroke="#374151" strokeWidth="1" />
183+
{/* Tooltip text */}
184+
<text x={tooltipX + textW / 2} y={tooltipY + 14} textAnchor="middle" className="fill-gray-200" fontSize="10" fontWeight="500">
185+
{text}
186+
</text>
187+
</g>
188+
);
189+
})()}
190+
{/* X-axis labels: left = cutoff, middle = midpoint, right = "This week" */}
191+
<text x={padL} y={H - 4} textAnchor="start" className="fill-gray-600" fontSize="10">
192+
{formatWeek(cutoffStr)}
193+
</text>
194+
<text x={padL + chartW / 2} y={H - 4} textAnchor="middle" className="fill-gray-600" fontSize="10">
195+
{middleLabel}
196+
</text>
197+
<text x={padL + chartW} y={H - 4} textAnchor="end" className="fill-gray-600" fontSize="10">
198+
This week
199+
</text>
200+
</svg>
201+
</div>
202+
);
203+
}
204+
```
205+
206+
- [ ] **Step 3: Verify TypeScript compiles**
207+
208+
```bash
209+
npx tsc --noEmit
210+
```
211+
212+
Expected: no errors. If you see "Property 'linesChanged' does not exist on type 'WeeklyData'" — that's expected; `linesChanged` is computed via `computeValue` prop, not a direct key. Any other errors in `page.tsx` are a problem.
213+
214+
- [ ] **Step 4: Start mock dev server and verify visually**
215+
216+
```bash
217+
npm run dev:mock
218+
```
219+
220+
Navigate to `http://localhost:3000`. Open any report, then click on a developer. Verify:
221+
222+
1. **Four bar charts appear** in the "Activity Over Time" section
223+
2. **Right edge label reads "This week"** on all four charts
224+
3. **Left edge label shows a date ~90 days ago** (approximately Feb 19 if today is May 20)
225+
4. **Bars are positioned across the full 90-day range** — not bunched at the left
226+
5. **Hovering a bar shows a tooltip** with the week date and value
227+
6. **Vertical guide line appears** on hover, aligned to the center of the hovered bar
228+
229+
- [ ] **Step 5: Commit**
230+
231+
```bash
232+
git add src/app/report/[id]/dev/[login]/page.tsx
233+
git commit -m "feat(glook-8): replace timeline line chart with date-anchored bar chart"
234+
```
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# GLOOK-8: IC Details — Timeline Charts End at "This Week"
2+
3+
## Problem
4+
5+
The four `TimelineChart` instances on `/report/{id}/dev/{login}` position data points by array index, so the chart's right edge always falls on the last available data point. If a developer is on vacation for 2 weeks, the chart ends 2 weeks ago and visually implies recent activity.
6+
7+
## Scope
8+
9+
Single file: `src/app/report/[id]/dev/[login]/page.tsx`, `TimelineChart` function (lines 652–804).
10+
11+
The org report page (`src/app/report/[id]/org/page.tsx`) has a separate `TimelineChart` copy with the same bug; it is explicitly out of scope for this ticket.
12+
13+
## Design
14+
15+
### Switch line chart to bar chart
16+
17+
Replace the SVG line + area fill with `<rect>` bars. This makes gaps structural — a week with no data simply has no bar. No segment-building logic is needed.
18+
19+
### Fixed X-axis range
20+
21+
The X-axis always spans `[cutoff, today]` in milliseconds, where `cutoff` is today minus 90 days (unchanged). The right edge always represents the current week.
22+
23+
Bar x-position (`d.week` is a Monday-anchored ISO date string `YYYY-MM-DD`, produced by `weekKeyForDate()` in `timeline.ts`):
24+
```ts
25+
const today = new Date();
26+
const totalMs = today.getTime() - cutoff.getTime();
27+
const x = padL + ((new Date(d.week).getTime() - cutoff.getTime()) / totalMs) * chartW;
28+
```
29+
30+
### Bar dimensions
31+
32+
Bar width is proportional to one week's share of the full date range, at 85% fill (15% gap between bars):
33+
```ts
34+
const totalWeeks = totalMs / (7 * 24 * 3600 * 1000);
35+
const barWidth = (chartW / totalWeeks) * 0.85;
36+
```
37+
38+
Each bar is centered on its week's Monday x-position:
39+
```ts
40+
const barX = x - barWidth / 2;
41+
const barH = ((v - min) / range) * chartH;
42+
const barY = padT + chartH - barH; // top-left corner of rect
43+
```
44+
45+
All four metrics (commits, lines changed, avg complexity, AI %) are non-negative, so `min = Math.min(...values, 0)` always equals 0 and bars always start from the baseline. No explicit clamping at the right axis edge is required.
46+
47+
Bars are rendered using the `color` prop, opacity 1, no corner radius.
48+
49+
### X-axis labels
50+
51+
Three labels:
52+
- **Left:** cutoff date, formatted as `"Mon D"` (e.g. "Feb 19") — anchored to the axis origin, not the first data point
53+
- **Middle:** the date at `cutoff + totalMs / 2`, formatted as `"Mon D"`
54+
- **Right:** fixed string `"This week"`
55+
56+
### Hover interaction
57+
58+
The invisible hit target is a full-column `<rect>` spanning `padT` to `padT + chartH` (full chart height), centered on the bar's x-position with width equal to `barWidth`. The vertical guide line x-position is the bar's `x`. The tooltip vertical position anchor is `barY` (top of the bar). Tooltip content is unchanged.
59+
60+
### Empty state guard
61+
62+
Changed from `filtered.length < 2` to `filtered.length < 1` — a single bar is valid and should render.
63+
64+
### Unchanged
65+
66+
- Y-axis ticks, grid lines, and label formatting
67+
- Trend indicator and latest-value display in the card header
68+
- `filtered` array construction (still last-90-days filter)

0 commit comments

Comments
 (0)