Skip to content

Commit f132faf

Browse files
committed
Improve statistics UX
1 parent 765175e commit f132faf

File tree

2 files changed

+99
-71
lines changed

2 files changed

+99
-71
lines changed

express/backend/src/api/statistics.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ router.get("/api/statistics", async function (req, res) {
145145
const stream = repos.aggregate(pipeline).stream();
146146

147147
const stats = new Map<string, Map<string, number>>();
148+
let total = 0;
148149
for await (const doc of stream) {
150+
total++;
149151
for (const [key, value] of Object.entries(doc)) {
150152
if (req.query[key]) {
151153
// skip stats that are used as filter
@@ -161,15 +163,15 @@ router.get("/api/statistics", async function (req, res) {
161163
}
162164
}
163165

164-
const result: Record<string, Record<string, number>> = {};
166+
const statistics: Record<string, Record<string, number>> = {};
165167
for (const [key, stat] of stats.entries()) {
166-
result[key] = {};
168+
statistics[key] = {};
167169
for (const [value, count] of stat.entries()) {
168-
result[key][value] = count;
170+
statistics[key][value] = count;
169171
}
170172
}
171173

172-
res.send(result);
174+
res.send({ total, statistics });
173175
});
174176

175177
export default router;

express/frontend/src/tools/repo-statistics/RepoStatistics.tsx

Lines changed: 93 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { default as AddIcon } from "@mui/icons-material/Add";
22
import { default as ChecklistIcon } from "@mui/icons-material/Checklist";
33
import { default as DeleteIcon } from "@mui/icons-material/Delete";
44
import { default as FilterIcon } from "@mui/icons-material/FilterAlt";
5-
import { default as InfoIcon } from "@mui/icons-material/InfoOutlined";
65
import {
76
Autocomplete,
87
Badge,
@@ -21,8 +20,8 @@ import {
2120
IconButton,
2221
Menu,
2322
MenuItem,
23+
Switch,
2424
TextField,
25-
Tooltip,
2625
Typography,
2726
} from "@mui/material";
2827
import axios from "axios";
@@ -39,6 +38,11 @@ type StatisticsData = {
3938
data: GraphData;
4039
};
4140

41+
type StatisticsResponse = {
42+
total: number;
43+
statistics: Record<string, Record<string, number>>;
44+
};
45+
4246
const allStats: {
4347
section: string;
4448
stats: { name: string; title: string; description: string }[];
@@ -194,12 +198,19 @@ export function RepoStatistics() {
194198
const [statistics, setStatistics] = useState<
195199
Record<string, StatisticsData[]>
196200
>({ Loading: [] });
201+
const [total, setTotal] = useState<number>();
197202
const [chooseStatsOpen, setChooseStatsOpen] = useState(false);
198203
const [filtersOpen, setFiltersOpen] = useState(false);
204+
const [includeUnknown, setIncludeUnknown] = useState(false);
199205

200206
// ensure user is logged in
201207
useUserToken();
202208

209+
const selectedFilters = useMemo(
210+
() => [...searchParams.entries()].filter(([k]) => k !== "stats"),
211+
[searchParams],
212+
);
213+
203214
useEffect(() => {
204215
const loadStatistics = async () => {
205216
setStatistics({ Loading: [] });
@@ -209,18 +220,24 @@ export function RepoStatistics() {
209220
);
210221
const params = new URLSearchParams(searchParams);
211222
params.set("stats", selectedStats.join(","));
212-
const { data } = await axios.get<
213-
Record<string, Record<string, number>>
214-
>(getApiUrl(`statistics?${params}`));
215-
const statistics = Object.entries(data).map(([title, stat]) => {
216-
const valueCounts = Object.entries(stat).map(
217-
([value, count]) =>
218-
[value, count] satisfies [string, number],
219-
);
220-
valueCounts.sort((a, b) => b[1] - a[1]);
221-
const data: GraphData = [["Value", "Count"], ...valueCounts];
222-
return { title, data };
223-
});
223+
const { data } = await axios.get<StatisticsResponse>(
224+
getApiUrl(`statistics?${params}`),
225+
);
226+
setTotal(data.total);
227+
const statistics = Object.entries(data.statistics).map(
228+
([title, stat]) => {
229+
const valueCounts = Object.entries(stat).map(
230+
([value, count]) =>
231+
[value, count] satisfies [string, number],
232+
);
233+
valueCounts.sort((a, b) => b[1] - a[1]);
234+
const data: GraphData = [
235+
["Value", "Count"],
236+
...valueCounts,
237+
];
238+
return { title, data };
239+
},
240+
);
224241
setStatistics({
225242
...allStats
226243
.filter(({ stats }) =>
@@ -234,9 +251,11 @@ export function RepoStatistics() {
234251
)
235252
.map(({ name }) => ({
236253
name,
237-
data:
254+
data: addUnknown(
238255
statistics.find((s) => s.title === name)
239256
?.data ?? [],
257+
includeUnknown ? data.total : undefined,
258+
),
240259
}))
241260
.filter(({ data }) => data.length);
242261
if (add.length > 0) {
@@ -249,7 +268,7 @@ export function RepoStatistics() {
249268
});
250269
};
251270
loadStatistics().catch(console.error);
252-
}, [searchParams]);
271+
}, [searchParams, includeUnknown]);
253272

254273
return (
255274
<>
@@ -300,26 +319,40 @@ export function RepoStatistics() {
300319
)}
301320
</Box>
302321
<Box>
303-
{[...searchParams.entries()]
304-
.filter(([k]) => k !== "stats")
305-
.map(([key, value]) => (
306-
<Chip
307-
key={key}
308-
label={`${getStatTitle(key)}: ${value}`}
309-
sx={{ marginRight: 1, marginBottom: 1 }}
310-
icon={<FilterIcon />}
311-
onDelete={() => {
312-
const params = new URLSearchParams(
313-
searchParams,
314-
);
315-
params.delete(key);
316-
setSearchParams(params);
317-
}}
318-
/>
319-
))}
322+
{selectedFilters.map(([key, value]) => (
323+
<Chip
324+
key={key}
325+
label={`${getStatTitle(key)}: ${value}`}
326+
sx={{ marginRight: 1, marginBottom: 1 }}
327+
icon={<FilterIcon />}
328+
onDelete={() => {
329+
const params = new URLSearchParams(searchParams);
330+
params.delete(key);
331+
setSearchParams(params);
332+
}}
333+
/>
334+
))}
335+
</Box>
336+
<Box sx={{ marginBottom: 1 }}>
337+
{total !== undefined && (
338+
<Typography>
339+
{selectedFilters.length ? "Found" : "Total"}
340+
{`: ${total} `}
341+
GitHub Repositories
342+
</Typography>
343+
)}
320344
</Box>
321-
{Object.entries(statistics).map(([section, stats], index) => (
322-
<>
345+
<FormControlLabel
346+
control={
347+
<Switch
348+
checked={includeUnknown}
349+
onChange={(e) => setIncludeUnknown(e.target.checked)}
350+
/>
351+
}
352+
label="Include unknown"
353+
/>
354+
{Object.entries(statistics).map(([section, stats]) => (
355+
<Fragment key={section}>
323356
<Typography
324357
variant="h6"
325358
sx={{
@@ -337,14 +370,31 @@ export function RepoStatistics() {
337370
/>
338371
))}
339372
</CardGrid>
340-
</>
373+
</Fragment>
341374
))}
342375
</>
343376
);
344377
}
345378

379+
function addUnknown(data: GraphData, totalCount?: number): GraphData {
380+
if (totalCount === undefined) {
381+
return data;
382+
}
383+
const knownCount = data
384+
.slice(1)
385+
.reduce(
386+
(acc, [, count]) => acc + (typeof count === "number" ? count : 0),
387+
0,
388+
);
389+
const unknownCount = totalCount - knownCount;
390+
if (unknownCount <= 0) {
391+
return data;
392+
}
393+
return [...data, ["<unknown>", unknownCount]];
394+
}
395+
346396
function StatisticsCard({ name, data }: { name: string; data: GraphData }) {
347-
const title = useMemo(() => getStatTitle(name), [name]);
397+
const stat = useMemo(() => getStat(name), [name]);
348398
return (
349399
<Card
350400
key={name}
@@ -380,38 +430,14 @@ function StatisticsCard({ name, data }: { name: string; data: GraphData }) {
380430
}}
381431
>
382432
<Typography gutterBottom variant="h6" component="h2">
383-
{title}
384-
<StatDescriptionIcon name={name} />
433+
{stat?.title}
385434
</Typography>
435+
<Typography>{stat?.description}</Typography>
386436
</CardContent>
387437
</Card>
388438
);
389439
}
390440

391-
function StatDescriptionIcon({ name }: { name: string }) {
392-
const stat = useMemo(() => {
393-
for (const s of allStats.flatMap((s) => s.stats)) {
394-
if (s.name === name) {
395-
return s;
396-
}
397-
}
398-
return null;
399-
}, [name]);
400-
401-
if (!stat) {
402-
return null;
403-
}
404-
405-
return (
406-
<Tooltip title={stat.description} arrow>
407-
<InfoIcon
408-
fontSize="small"
409-
sx={{ marginLeft: 1, marginTop: "auto", marginBottom: "auto" }}
410-
/>
411-
</Tooltip>
412-
);
413-
}
414-
415441
function ChooseStatsDialog({
416442
open,
417443
onClose,
@@ -615,10 +641,10 @@ function StatisticsFilter({
615641

616642
useEffect(() => {
617643
const loadOptions = async () => {
618-
const { data } = await axios.get<
619-
Record<string, Record<string, number>>
620-
>(getApiUrl(`statistics?stats=${name}`));
621-
const stat = data[name];
644+
const { data } = await axios.get<StatisticsResponse>(
645+
getApiUrl(`statistics?stats=${name}`),
646+
);
647+
const stat = data.statistics[name];
622648
setOptions(Object.keys(stat ?? {}).sort());
623649
};
624650
loadOptions().catch(console.error);

0 commit comments

Comments
 (0)