chore: phrasing

This commit is contained in:
2026-05-12 13:20:11 +03:00
parent 25eab2d795
commit 9a8c0955b5

View File

@@ -1,8 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { fetchDailyCounts, fetchLanguageDailyCounts, fetchProjects, fetchSources } from '../api/client'; import {
fetchDailyCounts,
fetchLanguageDailyCounts,
fetchProjects,
fetchSources,
} from "../api/client";
const CELL_SIZE = 12; const CELL_SIZE = 12;
const GAP = 3; const GAP = 3;
@@ -11,11 +16,24 @@ const ROWS = 7;
const LEFT_LABEL_WIDTH = 28; const LEFT_LABEL_WIDTH = 28;
const TOP_LABEL_HEIGHT = 16; const TOP_LABEL_HEIGHT = 16;
const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', '']; const DAY_LABELS = ["", "mon", "", "wed", "", "fri", ""];
const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const MONTH_LABELS = [
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
];
const EMPTY_COLOR = 'rgba(255,255,255,0.05)'; const EMPTY_COLOR = "rgba(255,255,255,0.05)";
const FALLBACK_COLOR = '#39d353'; const FALLBACK_COLOR = "#39d353";
/** Daily contribution graph — last 1 year, one circle per day. */ /** Daily contribution graph — last 1 year, one circle per day. */
export function ContributionGraph() { export function ContributionGraph() {
@@ -27,19 +45,19 @@ export function ContributionGraph() {
const toStr = fmt(to); const toStr = fmt(to);
const dailyQ = useQuery({ const dailyQ = useQuery({
queryKey: ['daily-counts', fromStr, toStr], queryKey: ["daily-counts", fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr), queryFn: () => fetchDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const langQ = useQuery({ const langQ = useQuery({
queryKey: ['language-daily', fromStr, toStr], queryKey: ["language-daily", fromStr, toStr],
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
const projectsQ = useQuery({ const projectsQ = useQuery({
queryKey: ['projects'], queryKey: ["projects"],
queryFn: fetchProjects, queryFn: fetchProjects,
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -49,7 +67,9 @@ export function ContributionGraph() {
const fromMs = from.getTime(); const fromMs = from.getTime();
const toMs = to.getTime(); const toMs = to.getTime();
return projectsQ.data.filter((p) => { return projectsQ.data.filter((p) => {
const first = p.first_activity ? new Date(p.first_activity).getTime() : Infinity; const first = p.first_activity
? new Date(p.first_activity).getTime()
: Infinity;
const last = p.last_activity ? new Date(p.last_activity).getTime() : 0; const last = p.last_activity ? new Date(p.last_activity).getTime() : 0;
return last >= fromMs && first <= toMs; return last >= fromMs && first <= toMs;
}).length; }).length;
@@ -69,14 +89,15 @@ export function ContributionGraph() {
const start = new Date(from); const start = new Date(from);
start.setDate(start.getDate() - start.getDay()); start.setDate(start.getDate() - start.getDay());
const weeks: { date: string; count: number; col: number; row: number }[][] = []; const weeks: { date: string; count: number; col: number; row: number }[][] =
[];
const monthMarkers: { col: number; label: string }[] = []; const monthMarkers: { col: number; label: string }[] = [];
let col = 0; let col = 0;
let prevMonth = -1; let prevMonth = -1;
const cursor = new Date(start); const cursor = new Date(start);
while (cursor <= to) { while (cursor <= to) {
const week: typeof weeks[0] = []; const week: (typeof weeks)[0] = [];
for (let row = 0; row < ROWS; row++) { for (let row = 0; row < ROWS; row++) {
const dateStr = fmt(cursor); const dateStr = fmt(cursor);
const count = countMap.get(dateStr) ?? 0; const count = countMap.get(dateStr) ?? 0;
@@ -94,7 +115,10 @@ export function ContributionGraph() {
col++; col++;
} }
const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b); const nonZero = counts
.map((d) => d.count)
.filter((c) => c > 0)
.sort((a, b) => a - b);
const thresholds = computeThresholds(nonZero); const thresholds = computeThresholds(nonZero);
const totalCount = counts.reduce((sum, d) => sum + d.count, 0); const totalCount = counts.reduce((sum, d) => sum + d.count, 0);
@@ -105,17 +129,23 @@ export function ContributionGraph() {
const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP); const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP);
const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP); const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP);
if (dailyQ.isLoading) return <p style={{ fontSize: '0.8rem' }}>loading contribution graph...</p>; if (dailyQ.isLoading)
return <p style={{ fontSize: "0.8rem" }}>loading contribution graph...</p>;
if (dailyQ.isError) return null; if (dailyQ.isError) return null;
return ( return (
<div className="contribution-graph mb-3"> <div className="contribution-graph mb-3">
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}> <p style={{ fontSize: "0.8rem", opacity: 0.6 }}>
{totalCount} contributions in the last year {new Intl.NumberFormat().format(totalCount)} contributions
{repoCount > 0 && ` in ${repoCount} repositories`} {repoCount > 0 && `, across ${repoCount} repositories, `}
in the last year
</p> </p>
<div> <div>
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block"> <svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
width="100%"
className="d-block"
>
{DAY_LABELS.map((label, i) => {DAY_LABELS.map((label, i) =>
label ? ( label ? (
<text <text
@@ -148,12 +178,16 @@ export function ContributionGraph() {
cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS} cx={LEFT_LABEL_WIDTH + col * (CELL_SIZE + GAP) + RADIUS}
cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS} cy={TOP_LABEL_HEIGHT + row * (CELL_SIZE + GAP) + RADIUS}
r={RADIUS - 1} r={RADIUS - 1}
fill={count === 0 ? EMPTY_COLOR : (dayColorMap.get(date) ?? FALLBACK_COLOR)} fill={
count === 0
? EMPTY_COLOR
: (dayColorMap.get(date) ?? FALLBACK_COLOR)
}
opacity={count === 0 ? 1 : opacityFor(count, thresholds)} opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
className="graph-cell" className="graph-cell"
onClick={() => navigate(`/activity/${date}`)} onClick={() => navigate(`/activity/${date}`)}
> >
<title>{`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title> <title>{`${date}: ${count} ${count === 1 ? "contribution" : "contributions"}`}</title>
</circle> </circle>
)), )),
)} )}
@@ -166,7 +200,7 @@ export function ContributionGraph() {
/** All-time monthly contribution graph — years on X axis, months on Y axis. */ /** All-time monthly contribution graph — years on X axis, months on Y axis. */
export function AllTimeGraph() { export function AllTimeGraph() {
const sourcesQ = useQuery({ const sourcesQ = useQuery({
queryKey: ['sources'], queryKey: ["sources"],
queryFn: fetchSources, queryFn: fetchSources,
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -177,11 +211,13 @@ export function AllTimeGraph() {
.map((s) => s.earliest) .map((s) => s.earliest)
.filter((d): d is string => d != null) .filter((d): d is string => d != null)
.map((d) => new Date(d)); .map((d) => new Date(d));
return dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null; return dates.length > 0
? new Date(Math.min(...dates.map((d) => d.getTime())))
: null;
}, [sourcesQ.data]); }, [sourcesQ.data]);
const projectsQ = useQuery({ const projectsQ = useQuery({
queryKey: ['projects'], queryKey: ["projects"],
queryFn: fetchProjects, queryFn: fetchProjects,
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -193,14 +229,14 @@ export function AllTimeGraph() {
const toStr = fmt(to); const toStr = fmt(to);
const dailyQ = useQuery({ const dailyQ = useQuery({
queryKey: ['daily-counts-alltime', fromStr, toStr], queryKey: ["daily-counts-alltime", fromStr, toStr],
queryFn: () => fetchDailyCounts(fromStr, toStr), queryFn: () => fetchDailyCounts(fromStr, toStr),
enabled: !!earliest, enabled: !!earliest,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
}); });
const langQ = useQuery({ const langQ = useQuery({
queryKey: ['language-daily-alltime', fromStr, toStr], queryKey: ["language-daily-alltime", fromStr, toStr],
queryFn: () => fetchLanguageDailyCounts(fromStr, toStr), queryFn: () => fetchLanguageDailyCounts(fromStr, toStr),
enabled: !!earliest, enabled: !!earliest,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
@@ -212,7 +248,10 @@ export function AllTimeGraph() {
const monthColorMap = useMemo(() => { const monthColorMap = useMemo(() => {
const entries = langQ.data ?? []; const entries = langQ.data ?? [];
if (entries.length === 0) return new Map<string, string>(); if (entries.length === 0) return new Map<string, string>();
const map = new Map<string, Map<string, { commits: number; color: string }>>(); const map = new Map<
string,
Map<string, { commits: number; color: string }>
>();
for (const e of entries) { for (const e of entries) {
const key = e.date.slice(0, 7); // YYYY-MM const key = e.date.slice(0, 7); // YYYY-MM
if (!map.has(key)) map.set(key, new Map()); if (!map.has(key)) map.set(key, new Map());
@@ -221,7 +260,10 @@ export function AllTimeGraph() {
if (cur) { if (cur) {
cur.commits += e.commits; cur.commits += e.commits;
} else { } else {
langMap.set(e.language, { commits: e.commits, color: e.color ?? FALLBACK_COLOR }); langMap.set(e.language, {
commits: e.commits,
color: e.color ?? FALLBACK_COLOR,
});
} }
} }
const result = new Map<string, string>(); const result = new Map<string, string>();
@@ -237,7 +279,8 @@ export function AllTimeGraph() {
const { years, monthGrid, thresholds, totalCount } = useMemo(() => { const { years, monthGrid, thresholds, totalCount } = useMemo(() => {
const counts = dailyQ.data ?? []; const counts = dailyQ.data ?? [];
if (counts.length === 0) return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 }; if (counts.length === 0)
return { years: [], monthGrid: [], thresholds: [1, 2, 3], totalCount: 0 };
const countMap = new Map(counts.map((d) => [d.date, d.count])); const countMap = new Map(counts.map((d) => [d.date, d.count]));
@@ -247,16 +290,30 @@ export function AllTimeGraph() {
for (let yr = startYear; yr <= endYear; yr++) years.push(yr); for (let yr = startYear; yr <= endYear; yr++) years.push(yr);
// Build a 12 x years grid of monthly totals // Build a 12 x years grid of monthly totals
const monthGrid: { year: number; month: number; count: number; monthStart: string; monthEnd: string; monthKey: string }[][] = []; const monthGrid: {
year: number;
month: number;
count: number;
monthStart: string;
monthEnd: string;
monthKey: string;
}[][] = [];
for (let m = 0; m < 12; m++) { for (let m = 0; m < 12; m++) {
const row: typeof monthGrid[0] = []; const row: (typeof monthGrid)[0] = [];
for (const yr of years) { for (const yr of years) {
const monthStart = new Date(yr, m, 1); const monthStart = new Date(yr, m, 1);
const monthEnd = new Date(yr, m + 1, 0); // last day of month const monthEnd = new Date(yr, m + 1, 0); // last day of month
const monthKey = `${yr}-${String(m + 1).padStart(2, '0')}`; const monthKey = `${yr}-${String(m + 1).padStart(2, "0")}`;
// Don't include months entirely outside our data range // Don't include months entirely outside our data range
if (monthStart > to || monthEnd < from) { if (monthStart > to || monthEnd < from) {
row.push({ year: yr, month: m, count: 0, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey }); row.push({
year: yr,
month: m,
count: 0,
monthStart: fmt(monthStart),
monthEnd: fmt(monthEnd),
monthKey,
});
continue; continue;
} }
let total = 0; let total = 0;
@@ -265,7 +322,14 @@ export function AllTimeGraph() {
total += countMap.get(fmt(cursor)) ?? 0; total += countMap.get(fmt(cursor)) ?? 0;
cursor.setDate(cursor.getDate() + 1); cursor.setDate(cursor.getDate() + 1);
} }
row.push({ year: yr, month: m, count: total, monthStart: fmt(monthStart), monthEnd: fmt(monthEnd), monthKey }); row.push({
year: yr,
month: m,
count: total,
monthStart: fmt(monthStart),
monthEnd: fmt(monthEnd),
monthKey,
});
} }
monthGrid.push(row); monthGrid.push(row);
} }
@@ -290,12 +354,17 @@ export function AllTimeGraph() {
return ( return (
<div className="contribution-graph mb-4"> <div className="contribution-graph mb-4">
<p style={{ fontSize: '0.8rem', opacity: 0.6 }}> <p style={{ fontSize: "0.8rem", opacity: 0.6 }}>
{totalCount} contributions since {fmt(from)} {new Intl.NumberFormat().format(totalCount)} contributions
{repoCount > 0 && ` in ${repoCount} repositories`} {repoCount > 0 && `, across ${repoCount} repos, `}
since {fmt(from).split("-")[0]}
</p> </p>
<div> <div>
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} width="100%" className="d-block"> <svg
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
width="100%"
className="d-block"
>
{/* Year labels along the top */} {/* Year labels along the top */}
{years.map((year, colIdx) => ( {years.map((year, colIdx) => (
<text <text
@@ -323,20 +392,28 @@ export function AllTimeGraph() {
))} ))}
{/* Monthly contribution circles */} {/* Monthly contribution circles */}
{monthGrid.map((row, rowIdx) => {monthGrid.map((row, rowIdx) =>
row.map(({ year, count, monthStart, monthEnd, monthKey }, colIdx) => ( row.map(
<circle ({ year, count, monthStart, monthEnd, monthKey }, colIdx) => (
key={`${year}-${rowIdx}`} <circle
cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS} key={`${year}-${rowIdx}`}
cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS} cx={monthLabelWidth + colIdx * (CELL_SIZE + GAP) + RADIUS}
r={RADIUS - 1} cy={topLabelHeight + rowIdx * (CELL_SIZE + GAP) + RADIUS}
fill={count === 0 ? EMPTY_COLOR : (monthColorMap.get(monthKey) ?? FALLBACK_COLOR)} r={RADIUS - 1}
opacity={count === 0 ? 1 : opacityFor(count, thresholds)} fill={
className="graph-cell" count === 0
onClick={() => navigate(`/activity/${monthStart}..${monthEnd}`)} ? EMPTY_COLOR
> : (monthColorMap.get(monthKey) ?? FALLBACK_COLOR)
<title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`}</title> }
</circle> opacity={count === 0 ? 1 : opacityFor(count, thresholds)}
)), className="graph-cell"
onClick={() =>
navigate(`/activity/${monthStart}..${monthEnd}`)
}
>
<title>{`${MONTH_LABELS[rowIdx]} ${year}: ${count} ${count === 1 ? "contribution" : "contributions"}`}</title>
</circle>
),
),
)} )}
</svg> </svg>
</div> </div>
@@ -349,7 +426,14 @@ function fmt(d: Date): string {
} }
/** Build a map of date → dominant (highest commit count) language color. */ /** Build a map of date → dominant (highest commit count) language color. */
function buildDominantColorMap(entries: { date: string; language: string; color: string | null; commits: number }[]): Map<string, string> { function buildDominantColorMap(
entries: {
date: string;
language: string;
color: string | null;
commits: number;
}[],
): Map<string, string> {
const map = new Map<string, { commits: number; color: string }>(); const map = new Map<string, { commits: number; color: string }>();
for (const e of entries) { for (const e of entries) {
const cur = map.get(e.date); const cur = map.get(e.date);
@@ -374,6 +458,7 @@ function opacityFor(count: number, thresholds: number[]): number {
function computeThresholds(sorted: number[]): number[] { function computeThresholds(sorted: number[]): number[] {
if (sorted.length === 0) return [1, 2, 3]; if (sorted.length === 0) return [1, 2, 3];
const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)]; const p = (pct: number) =>
sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)];
return [p(0.25), p(0.5), p(0.75)]; return [p(0.25), p(0.5), p(0.75)];
} }