Skip to content

Commit 0188516

Browse files
committed
feat: clickable charts
1 parent f1284dc commit 0188516

15 files changed

Lines changed: 341 additions & 32 deletions

File tree

apps/api/src/app/pull-requests/resolvers/pull-requests.schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ export default /* GraphQL */ `
124124
125125
"The size to filter by"
126126
sizes: [PullRequestSize!]
127+
128+
"The repository ids to filter by"
129+
repositoryIds: [SweetID!]
127130
}
128131
129132
type PullRequestsInProgressResponse {

apps/api/src/app/pull-requests/resolvers/queries/pull-requests.query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const pullRequestsQuery = createFieldResolver("Workspace", {
3434
},
3535
states: input.states || undefined,
3636
sizes: input.sizes || undefined,
37+
repositoryIds: input.repositoryIds || undefined,
3738
});
3839

3940
return pullRequests.map(transformPullRequest);

apps/api/src/app/pull-requests/services/pull-request.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export const paginatePullRequests = async (
117117
};
118118
}
119119

120+
if (args.repositoryIds?.length) {
121+
query.where = {
122+
...query.where,
123+
repositoryId: { in: args.repositoryIds },
124+
};
125+
}
126+
120127
return getPrisma(workspaceId).pullRequest.findMany(query);
121128
};
122129

apps/api/src/app/pull-requests/services/pull-request.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface PaginatePullRequestsArgs {
99
completedAt?: DateTimeRange;
1010
states?: PullRequestState[];
1111
sizes?: PullRequestSize[];
12+
repositoryIds?: number[];
1213
}
1314

1415
export interface PullRequestFile {

apps/web/src/api/pull-request.api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@ export const usePullRequestsInfiniteQuery = (
7575
}
7676
}
7777
`),
78-
{ ...args, input: { ...args.input, cursor: pageParam as string } },
78+
{
79+
...args,
80+
input: {
81+
...args.input,
82+
cursor: pageParam as string,
83+
},
84+
},
7985
),
8086
...options,
8187
});

apps/web/src/app/metrics-and-insights/pr-flow/components/card-chart/card-chart.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
} from "@mantine/core";
1212
import {
1313
IconDotsVertical,
14+
IconExternalLink,
1415
IconInfoCircle,
1516
IconSpeakerphone,
1617
} from "@tabler/icons-react";
18+
import { Link } from "react-router";
1719
import { useDisclosure } from "@mantine/hooks";
1820

1921
interface ChartCardProps {
@@ -22,6 +24,7 @@ interface ChartCardProps {
2224
children: ReactNode;
2325
height?: number;
2426
style?: React.CSSProperties;
27+
href?: string;
2528
}
2629

2730
export const CardChart = ({
@@ -30,6 +33,7 @@ export const CardChart = ({
3033
children,
3134
height = 340,
3235
style,
36+
href,
3337
}: ChartCardProps) => {
3438
const [menuOpened, { toggle: toggleMenu, close: closeMenu }] =
3539
useDisclosure(false);
@@ -99,6 +103,16 @@ export const CardChart = ({
99103
</Menu.Target>
100104

101105
<Menu.Dropdown>
106+
{href && (
107+
<Menu.Item
108+
leftSection={<IconExternalLink size={14} stroke={1.5} />}
109+
component={Link}
110+
to={href}
111+
onClick={closeMenu}
112+
>
113+
View Pull Requests
114+
</Menu.Item>
115+
)}
102116
<Menu.Item
103117
leftSection={<IconSpeakerphone size={14} stroke={1.5} />}
104118
component="a"

apps/web/src/app/metrics-and-insights/pr-flow/components/chart-cycle-time-breakdown/chart-cycle-time-breakdown.tsx

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface ChartCycleTimeBreakdownProps {
1818
chartId: string;
1919
chartData?: CycleTimeBreakdownChartData | null;
2020
period: Period;
21+
onColumnClick?: (columnDate: string) => void;
2122
}
2223

2324
const STACKED_SERIES: {
@@ -28,8 +29,8 @@ const STACKED_SERIES: {
2829
name: string;
2930
color: string;
3031
}[] = [
31-
{ key: "timeToCode", name: "Coding", color: "#FFF " },
32-
{ key: "timeToFirstReview", name: "First Review", color: "#38d9a9" },
32+
{ key: "timeToCode", name: "Coding", color: "#8ce0e9 " },
33+
{ key: "timeToFirstReview", name: "First Review", color: "#8ce9c7" },
3334
{ key: "timeToApproval", name: "Approval", color: "#8ce99a" },
3435
{ key: "timeToMerge", name: "Merge", color: "#b197fc" },
3536
];
@@ -43,6 +44,7 @@ export const ChartCycleTimeBreakdown = ({
4344
chartId,
4445
chartData,
4546
period,
47+
onColumnClick,
4648
}: ChartCycleTimeBreakdownProps) => {
4749
const containerRef = useRef<HTMLDivElement>(null);
4850

@@ -56,11 +58,34 @@ export const ChartCycleTimeBreakdown = ({
5658
chart.group = PR_FLOW_GROUP;
5759
echarts.connect(PR_FLOW_GROUP);
5860

59-
const stackedSeries = STACKED_SERIES.map(({ key, name, color }, i) => ({
61+
// ECharts' barMinHeight breaks stacking (segments overlap instead of stack).
62+
// Instead, we clamp non-zero values to a % of the tallest column so every
63+
// phase stays visible regardless of how small it is relative to others.
64+
// Tooltips and labels read from the original chartData to show real values.
65+
const maxColumnTotal = Math.max(
66+
...columns.map((_, idx) =>
67+
STACKED_SERIES.reduce(
68+
(sum, { key }) => sum + (Number(chartData[key][idx]) || 0),
69+
0,
70+
),
71+
),
72+
);
73+
const minVisibleValue = maxColumnTotal * 0.03;
74+
75+
const clampedData: Record<string, number[]> = {};
76+
for (const { key } of STACKED_SERIES) {
77+
clampedData[key] = chartData[key].map((_, idx) => {
78+
const raw = Number(chartData[key][idx]) || 0;
79+
if (raw === 0) return 0;
80+
return Math.max(raw, minVisibleValue);
81+
});
82+
}
83+
84+
const stackedSeries = STACKED_SERIES.map(({ key, name, color }) => ({
6085
type: "bar" as const,
6186
name,
6287
stack: "cycle-time",
63-
data: chartData[key],
88+
data: clampedData[key],
6489
color,
6590
barMaxWidth: 24,
6691
itemStyle: {
@@ -70,20 +95,29 @@ export const ChartCycleTimeBreakdown = ({
7095
emphasis: { focus: "series" as const },
7196
}));
7297

98+
// Line must use clamped totals so it sits above the inflated bars.
99+
const clampedCycleTime = columns.map((_, idx) =>
100+
STACKED_SERIES.reduce(
101+
(sum, { key }) => sum + (clampedData[key][idx] || 0),
102+
0,
103+
),
104+
);
105+
73106
const cycleTimeSeries = {
74107
type: "line" as const,
75108
name: "Cycle Time",
76-
data: cycleTime,
109+
data: clampedCycleTime,
77110
smooth: true,
78111
connectNulls: true,
79-
color: "#8ce99a",
112+
color: "#FFFFFF",
80113
symbolSize: 0,
81114
lineStyle: { width: 2 },
82115
label: {
83-
show: true,
116+
show: cycleTime.length <= 15,
84117
position: "top" as const,
85-
formatter: ({ value }: { value?: unknown }) => {
86-
const val = Number(value) || 0;
118+
// Use original cycleTime, not the clamped value ECharts passes.
119+
formatter: ({ dataIndex }: { dataIndex?: number }) => {
120+
const val = Number(cycleTime[dataIndex ?? 0]) || 0;
87121
if (!val) return "0s";
88122
return getAbbreviatedDuration(val);
89123
},
@@ -138,7 +172,7 @@ export const ChartCycleTimeBreakdown = ({
138172

139173
html += `
140174
<div style="margin: 0 -15px; padding: 5px 15px; border-top:1px solid #404040; display:flex; align-items:center; gap:5px;">
141-
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#8ce99a"></span>
175+
<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#FFFFFF"></span>
142176
<span>Cycle Time</span>
143177
<span style="margin-left:auto;font-weight:600">${formatDurationValue(cycleTime[idx])}</span>
144178
</div>`;
@@ -177,14 +211,31 @@ export const ChartCycleTimeBreakdown = ({
177211

178212
chart.setOption(options);
179213

214+
if (onColumnClick) {
215+
chart.getZr().on("click", (e) => {
216+
if (!chart.containPixel("grid", [e.offsetX, e.offsetY])) return;
217+
const [dataIndex] = chart.convertFromPixel("grid", [
218+
e.offsetX,
219+
e.offsetY,
220+
]);
221+
const col = columns[Math.round(dataIndex)];
222+
if (col) onColumnClick(col);
223+
});
224+
chart.getZr().on("mousemove", (e) => {
225+
if (chart.containPixel("grid", [e.offsetX, e.offsetY])) {
226+
chart.getZr().setCursorStyle("pointer");
227+
}
228+
});
229+
}
230+
180231
const handleResize = () => chart.resize();
181232
window.addEventListener("resize", handleResize);
182233

183234
return () => {
184235
window.removeEventListener("resize", handleResize);
185236
chart.dispose();
186237
};
187-
}, [chartData, period, chartId]);
238+
}, [chartData, period, chartId, onColumnClick]);
188239

189240
return (
190241
<div

apps/web/src/app/metrics-and-insights/pr-flow/components/chart-size-distribution/chart-size-distribution.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ interface ChartSizeDistributionProps {
1717
chartId: string;
1818
chartData?: PullRequestSizeDistributionChartData | null;
1919
period: Period;
20+
onColumnClick?: (columnDate: string) => void;
2021
}
2122

2223
export const ChartSizeDistribution = ({
2324
chartId,
2425
chartData,
2526
period,
27+
onColumnClick,
2628
}: ChartSizeDistributionProps) => {
2729
const containerRef = useRef<HTMLDivElement>(null);
2830

@@ -152,16 +154,18 @@ export const ChartSizeDistribution = ({
152154
],
153155
series: [
154156
...chartData.series.map((chartSeries) => ({
155-
barCategoryGap: "50%",
156-
barGap: "50%",
157157
name: chartSeries.name,
158158
data: chartSeries.data,
159159
color: chartSeries.color || undefined,
160-
smooth: true,
161-
symbolSize: 7,
162160
type: "bar" as const,
163161
stack: "Total",
164162
yAxisIndex: 0,
163+
barMaxWidth: 24,
164+
itemStyle: {
165+
borderColor: "#1A1B1E",
166+
borderWidth: 1,
167+
},
168+
emphasis: { focus: "series" as const },
165169
})),
166170
{
167171
name: "Avg. Lines Changed",
@@ -180,14 +184,31 @@ export const ChartSizeDistribution = ({
180184

181185
chart.setOption(options);
182186

187+
if (onColumnClick) {
188+
chart.getZr().on("click", (e) => {
189+
if (!chart.containPixel("grid", [e.offsetX, e.offsetY])) return;
190+
const [dataIndex] = chart.convertFromPixel("grid", [
191+
e.offsetX,
192+
e.offsetY,
193+
]);
194+
const col = chartData.columns[Math.round(dataIndex)];
195+
if (col) onColumnClick(col);
196+
});
197+
chart.getZr().on("mousemove", (e) => {
198+
if (chart.containPixel("grid", [e.offsetX, e.offsetY])) {
199+
chart.getZr().setCursorStyle("pointer");
200+
}
201+
});
202+
}
203+
183204
const handleResize = () => chart.resize();
184205
window.addEventListener("resize", handleResize);
185206

186207
return () => {
187208
window.removeEventListener("resize", handleResize);
188209
chart.dispose();
189210
};
190-
}, [chartData, period, chartId]);
211+
}, [chartData, period, chartId, onColumnClick]);
191212

192213
return (
193214
<div

0 commit comments

Comments
 (0)