From 156d1e6609e161aca40ab41d78d484051b74adfa Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 28 Jul 2026 22:53:55 -0700 Subject: [PATCH 1/8] feat(analytics): hovering a day updates country and file breakdowns Hovering a bar on the product downloads chart now narrows the by-country list and top-files table to that UTC day, matching the stats row. The breakdown fetch gains a second query wave scoped to the window's top countries/files via IN (bounded rows), keyed by ISO day so independently cached grids can't misalign. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 96 +++++++++++-------- src/lib/clients/analytics/index.test.ts | 46 ++++++++- src/lib/clients/analytics/index.ts | 69 ++++++++++++- 3 files changed, 163 insertions(+), 48 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 4b64a4e7..2396babf 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -46,11 +46,30 @@ export function ProductAnalyticsView({ }: ProductAnalyticsViewProps) { const [hovered, setHovered] = useState(null); const shown = hovered === null ? totals : days[hovered]; - const maxCountry = Math.max( - 1, - ...(breakdowns?.countries.map((c) => c.requests) ?? []), - breakdowns?.otherCountries?.requests ?? 0, - ); + // Hovering a bar narrows the country/file panels to that UTC day. + const day = hovered === null ? null : days[hovered].date; + const countryRows = breakdowns + ? [ + ...breakdowns.countries.map((c) => ({ + code: c.code, + label: c.name, + requests: day === null ? c.requests : (c.byDay[day] ?? 0), + })), + ...(breakdowns.otherCountries + ? [ + { + code: "·", + label: `${breakdowns.otherCountries.count} others`, + requests: + day === null + ? breakdowns.otherCountries.requests + : (breakdowns.otherCountries.byDay[day] ?? 0), + }, + ] + : []), + ] + : []; + const maxCountry = Math.max(1, ...countryRows.map((row) => row.requests)); return ( @@ -113,22 +132,7 @@ export function ProductAnalyticsView({ ) : ( - {[ - ...breakdowns.countries.map((c) => ({ - code: c.code, - label: c.name, - requests: c.requests, - })), - ...(breakdowns.otherCountries - ? [ - { - code: "·", - label: `${breakdowns.otherCountries.count} others`, - requests: breakdowns.otherCountries.requests, - }, - ] - : []), - ].map((row) => ( + {countryRows.map((row) => ( - {breakdowns.files.map((file) => ( - - - - - {file.path} - - - - - - {numberFormat.format(Math.round(file.requests))} - - - - - {formatBytes(file.bytes, 1)} - - - - ))} + {breakdowns.files.map((file) => { + const shownFile = + day === null + ? file + : (file.byDay[day] ?? { requests: 0, bytes: 0 }); + return ( + + + + + {file.path} + + + + + + {numberFormat.format(Math.round(shownFile.requests))} + + + + + {formatBytes(shownFile.bytes, 1)} + + + + ); + })} )} diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts index 2b678a7a..b61b0104 100644 --- a/src/lib/clients/analytics/index.test.ts +++ b/src/lib/clients/analytics/index.test.ts @@ -192,6 +192,21 @@ describe("getProductBreakdowns", () => { it("ranks countries with an others aggregate and lists top files", async () => { fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { const sql = init.body; + if (sql.includes("GROUP BY day, country")) { + return jsonResponse([ + { day: "2026-07-27 00:00:00", country: "US", requests: 60 }, + { day: "2026-07-26 00:00:00", country: "US", requests: "40" }, + { day: "2026-07-27 00:00:00", country: "DE", requests: 50 }, + ]); + } + if (sql.includes("GROUP BY day, file")) { + return jsonResponse([ + { day: "2026-07-27 00:00:00", file: "a.tif", requests: 25, bytes: 400 }, + ]); + } + if (sql.includes("NOT IN")) { + return jsonResponse([{ day: "2026-07-27 00:00:00", requests: 15 }]); + } if (sql.includes("GROUP BY country")) { return jsonResponse([ { country: "US", requests: 100 }, @@ -216,13 +231,38 @@ describe("getProductBreakdowns", () => { code: "US", name: "United States", requests: 100, + byDay: { + "2026-07-27T00:00:00.000Z": 60, + "2026-07-26T00:00:00.000Z": 40, + }, + }); + expect(breakdowns!.otherCountries).toEqual({ + count: 2, + requests: 15, + byDay: { "2026-07-27T00:00:00.000Z": 15 }, }); - expect(breakdowns!.otherCountries).toEqual({ count: 2, requests: 15 }); expect(breakdowns!.files).toEqual([ - { path: "a.tif", requests: 60, bytes: 1000 }, - { path: "b.json", requests: 40, bytes: 500 }, + { + path: "a.tif", + requests: 60, + bytes: 1000, + byDay: { "2026-07-27T00:00:00.000Z": { requests: 25, bytes: 400 } }, + }, + { path: "b.json", requests: 40, bytes: 500, byDay: {} }, ]); + // Per-day wave is scoped to the window's top entries, escaped and quoted. + const dayCountrySql = sentSql().find((sql) => + sql.includes("GROUP BY day, country"), + ); + expect(dayCountrySql).toContain("blob6 IN ('US', 'DE', 'BR', 'GB', 'IN')"); + const othersSql = sentSql().find((sql) => sql.includes("NOT IN")); + expect(othersSql).toContain("blob6 NOT IN ('US', 'DE', 'BR', 'GB', 'IN')"); + const dayFileSql = sentSql().find((sql) => + sql.includes("GROUP BY day, file"), + ); + expect(dayFileSql).toContain("blob3 IN ('a.tif', 'b.json')"); + const fileSql = sentSql().find((sql) => sql.includes("GROUP BY file")); expect(fileSql).toContain("ORDER BY requests DESC"); expect(fileSql).toContain("LIMIT 10"); diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 8690f4be..399cbf5b 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -392,13 +392,21 @@ function parseUsageAggregates(row: Row): UsageTotals { const COUNTRY_LIST_LIMIT = 5; const FILE_LIST_LIMIT = 10; +/** Per-UTC-day values keyed by the day's ISO timestamp (UsagePoint.date). */ +type ByDay = Record; + export interface ProductBreakdowns { /** Top countries by downloads */ - countries: { code: string; name: string; requests: number }[]; + countries: { code: string; name: string; requests: number; byDay: ByDay }[]; /** Aggregate of the remaining countries, if any */ - otherCountries: { count: number; requests: number } | null; + otherCountries: { count: number; requests: number; byDay: ByDay } | null; /** Top objects by downloads */ - files: { path: string; requests: number; bytes: number }[]; + files: { + path: string; + requests: number; + bytes: number; + byDay: ByDay<{ requests: number; bytes: number }>; + }[]; } /** @@ -426,22 +434,77 @@ export async function getProductBreakdowns( ]); const rest = countryRows.slice(COUNTRY_LIST_LIMIT); + const topCountries = countryRows + .slice(0, COUNTRY_LIST_LIMIT) + .map((row) => str(row.country)); + const topFiles = fileRows.map((row) => str(row.file)); + + // Second wave, scoped to the window's top entries with IN (bounded rows, + // unlike a full GROUP BY day+file) — per-day values for hover. + const countryIn = topCountries.map(sqlQuote).join(", "); + const [countryDayRows, otherDayRows, fileDayRows] = await Promise.all([ + topCountries.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, SUM(_sample_interval) AS requests ${from} AND blob6 IN (${countryIn}) GROUP BY day, country`, + ) + : [], + rest.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, SUM(_sample_interval) AS requests ${from} AND blob6 NOT IN (${countryIn}) GROUP BY day`, + ) + : [], + topFiles.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 IN (${topFiles.map(sqlQuote).join(", ")}) GROUP BY day, file`, + ) + : [], + ]); + + const countryByDay = new Map>(); + for (const row of countryDayRows) { + const key = str(row.country); + const days = countryByDay.get(key) ?? {}; + days[parseDateTime(row.day)] = num(row.requests); + countryByDay.set(key, days); + } + const fileByDay = new Map< + string, + Record + >(); + for (const row of fileDayRows) { + const key = str(row.file); + const days = fileByDay.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + fileByDay.set(key, days); + } + return { countries: countryRows.slice(0, COUNTRY_LIST_LIMIT).map((row) => ({ code: str(row.country) || "??", name: countryName(str(row.country)), requests: num(row.requests), + byDay: countryByDay.get(str(row.country)) ?? {}, })), otherCountries: rest.length ? { count: rest.length, requests: rest.reduce((sum, row) => sum + num(row.requests), 0), + byDay: Object.fromEntries( + otherDayRows.map((row) => [ + parseDateTime(row.day), + num(row.requests), + ]), + ), } : null, files: fileRows.map((row) => ({ path: str(row.file), requests: num(row.requests), bytes: num(row.bytes), + byDay: fileByDay.get(str(row.file)) ?? {}, })), }; } catch (error) { From 5b202dcc034b6a22c815a8bb83265cd0c53d2827 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 28 Jul 2026 23:03:24 -0700 Subject: [PATCH 2/8] feat(analytics): bidirectional hover across chart, countries, and files Hovering a country or file now narrows the other panels too: the chart shows that slice's daily series, and the opposite list re-ranks to its values via a bounded country-x-file cross query. Hovered slices sort the country/file lists descending (a list never re-sorts on its own hover, so rows can't shuffle under the cursor). Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 171 +++++++++++++----- src/components/features/analytics/panels.tsx | 11 +- src/lib/clients/analytics/index.test.ts | 28 ++- src/lib/clients/analytics/index.ts | 47 ++++- 4 files changed, 208 insertions(+), 49 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 2396babf..4db503d9 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -44,16 +44,50 @@ export function ProductAnalyticsView({ users, breakdowns, }: ProductAnalyticsViewProps) { - const [hovered, setHovered] = useState(null); - const shown = hovered === null ? totals : days[hovered]; - // Hovering a bar narrows the country/file panels to that UTC day. - const day = hovered === null ? null : days[hovered].date; + // One hover at a time; each panel narrows the other two to its slice. + const [hover, setHover] = useState< + | { type: "day"; index: number } + | { type: "country"; code: string } + | { type: "file"; path: string } + | null + >(null); + const dayIndex = hover?.type === "day" ? hover.index : null; + const shown = dayIndex === null ? totals : days[dayIndex]; + const day = dayIndex === null ? null : days[dayIndex].date; + const hoveredCountry = hover?.type === "country" ? hover.code : null; + const hoveredFile = + hover?.type === "file" + ? breakdowns?.files.find((f) => f.path === hover.path) + : undefined; + + // The "·" code is the others aggregate's row. + const countryByDay = (code: string) => + (code === "·" + ? breakdowns?.otherCountries?.byDay + : breakdowns?.countries.find((c) => c.code === code)?.byDay) ?? {}; + let chartDays = days; + if (hoveredCountry !== null) { + const byDay = countryByDay(hoveredCountry); + chartDays = days.map((d) => ({ ...d, requests: byDay[d.date] ?? 0 })); + } else if (hoveredFile) { + chartDays = days.map((d) => ({ + ...d, + requests: hoveredFile.byDay[d.date]?.requests ?? 0, + bytes: hoveredFile.byDay[d.date]?.bytes ?? 0, + })); + } + const countryRows = breakdowns ? [ ...breakdowns.countries.map((c) => ({ code: c.code, label: c.name, - requests: day === null ? c.requests : (c.byDay[day] ?? 0), + requests: + day !== null + ? (c.byDay[day] ?? 0) + : hoveredFile + ? (hoveredFile.byCountry[c.code]?.requests ?? 0) + : c.requests, })), ...(breakdowns.otherCountries ? [ @@ -61,16 +95,50 @@ export function ProductAnalyticsView({ code: "·", label: `${breakdowns.otherCountries.count} others`, requests: - day === null - ? breakdowns.otherCountries.requests - : (breakdowns.otherCountries.byDay[day] ?? 0), + day !== null + ? (breakdowns.otherCountries.byDay[day] ?? 0) + : hoveredFile + ? hoveredFile.otherCountries.requests + : breakdowns.otherCountries.requests, }, ] : []), ] : []; + // Re-rank by the hovered slice's values, others pinned last. A hovered + // country never re-sorts its own list (its values stay window-wide), so + // rows can't shuffle under the cursor. + if (day !== null || hoveredFile) { + countryRows.sort( + (a, b) => + Number(a.code === "·") - Number(b.code === "·") || + b.requests - a.requests, + ); + } const maxCountry = Math.max(1, ...countryRows.map((row) => row.requests)); + const fileRows = (breakdowns?.files ?? []).map((file) => ({ + path: file.path, + shown: + day !== null + ? (file.byDay[day] ?? { requests: 0, bytes: 0 }) + : hoveredCountry !== null + ? hoveredCountry === "·" + ? file.otherCountries + : (file.byCountry[hoveredCountry] ?? { requests: 0, bytes: 0 }) + : file, + })); + if (day !== null || hoveredCountry !== null) { + fileRows.sort((a, b) => b.shown.requests - a.shown.requests); + } + + const filterLabel = + hoveredCountry !== null + ? countryRows.find((row) => row.code === hoveredCountry)?.label + : hover?.type === "file" + ? hover.path + : null; + return ( @@ -115,11 +183,13 @@ export function ProductAnalyticsView({ - + + setHover(index === null ? null : { type: "day", index }) + } height={180} /> @@ -133,7 +203,21 @@ export function ProductAnalyticsView({ ) : ( {countryRows.map((row) => ( - + + setHover({ type: "country", code: row.code }) + } + onMouseLeave={() => setHover(null)} + style={ + hoveredCountry === row.code + ? { background: "var(--green-a2)" } + : undefined + } + > - {breakdowns.files.map((file) => { - const shownFile = - day === null - ? file - : (file.byDay[day] ?? { requests: 0, bytes: 0 }); - return ( - - - - - {file.path} - - - - - - {numberFormat.format(Math.round(shownFile.requests))} - - - - - {formatBytes(shownFile.bytes, 1)} - - - - ); - })} + {fileRows.map((file) => ( + + setHover({ type: "file", path: file.path }) + } + onMouseLeave={() => setHover(null)} + style={ + hoveredFile?.path === file.path + ? { background: "var(--green-a2)" } + : undefined + } + > + + + + {file.path} + + + + + + {numberFormat.format(Math.round(file.shown.requests))} + + + + + {formatBytes(file.shown.bytes, 1)} + + + + ))} )} diff --git a/src/components/features/analytics/panels.tsx b/src/components/features/analytics/panels.tsx index 2f6e0869..265adfb5 100644 --- a/src/components/features/analytics/panels.tsx +++ b/src/components/features/analytics/panels.tsx @@ -105,13 +105,16 @@ export function Stat({ export function HoverCaption({ days, hovered, + filterLabel, }: { days: UsagePoint[]; hovered: number | null; + /** Non-day filter (hovered country/file) the chart is narrowed to */ + filterLabel?: string | null; }) { return ( - {hovered !== null && ( + {(hovered !== null || filterLabel) && ( )} - {hovered === null - ? `${days.length}-day downloads` - : formatDateSSR(days[hovered].date)} + {hovered !== null + ? formatDateSSR(days[hovered].date) + : (filterLabel ?? `${days.length}-day downloads`)} ); diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts index b61b0104..8b3eaaf0 100644 --- a/src/lib/clients/analytics/index.test.ts +++ b/src/lib/clients/analytics/index.test.ts @@ -204,6 +204,15 @@ describe("getProductBreakdowns", () => { { day: "2026-07-27 00:00:00", file: "a.tif", requests: 25, bytes: 400 }, ]); } + if (sql.includes("GROUP BY country, file")) { + return jsonResponse([ + { country: "US", file: "a.tif", requests: 20, bytes: 300 }, + { country: "DE", file: "a.tif", requests: 5, bytes: 100 }, + ]); + } + if (sql.includes("NOT IN") && sql.includes("GROUP BY file")) { + return jsonResponse([{ file: "b.json", requests: 4, bytes: 50 }]); + } if (sql.includes("NOT IN")) { return jsonResponse([{ day: "2026-07-27 00:00:00", requests: 15 }]); } @@ -247,8 +256,20 @@ describe("getProductBreakdowns", () => { requests: 60, bytes: 1000, byDay: { "2026-07-27T00:00:00.000Z": { requests: 25, bytes: 400 } }, + byCountry: { + US: { requests: 20, bytes: 300 }, + DE: { requests: 5, bytes: 100 }, + }, + otherCountries: { requests: 0, bytes: 0 }, + }, + { + path: "b.json", + requests: 40, + bytes: 500, + byDay: {}, + byCountry: {}, + otherCountries: { requests: 4, bytes: 50 }, }, - { path: "b.json", requests: 40, bytes: 500, byDay: {} }, ]); // Per-day wave is scoped to the window's top entries, escaped and quoted. @@ -262,6 +283,11 @@ describe("getProductBreakdowns", () => { sql.includes("GROUP BY day, file"), ); expect(dayFileSql).toContain("blob3 IN ('a.tif', 'b.json')"); + const crossSql = sentSql().find((sql) => + sql.includes("GROUP BY country, file"), + ); + expect(crossSql).toContain("blob6 IN ('US', 'DE', 'BR', 'GB', 'IN')"); + expect(crossSql).toContain("blob3 IN ('a.tif', 'b.json')"); const fileSql = sentSql().find((sql) => sql.includes("GROUP BY file")); expect(fileSql).toContain("ORDER BY requests DESC"); diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 399cbf5b..81388ec0 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -406,6 +406,10 @@ export interface ProductBreakdowns { requests: number; bytes: number; byDay: ByDay<{ requests: number; bytes: number }>; + /** Window values per top-country code (countries[].code) */ + byCountry: Record; + /** Window values from countries outside the top list */ + otherCountries: { requests: number; bytes: number }; }[]; } @@ -442,7 +446,9 @@ export async function getProductBreakdowns( // Second wave, scoped to the window's top entries with IN (bounded rows, // unlike a full GROUP BY day+file) — per-day values for hover. const countryIn = topCountries.map(sqlQuote).join(", "); - const [countryDayRows, otherDayRows, fileDayRows] = await Promise.all([ + const fileIn = topFiles.map(sqlQuote).join(", "); + const [countryDayRows, otherDayRows, fileDayRows, crossRows, fileOtherRows] = + await Promise.all([ topCountries.length ? usageQuery( `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, SUM(_sample_interval) AS requests ${from} AND blob6 IN (${countryIn}) GROUP BY day, country`, @@ -455,7 +461,19 @@ export async function getProductBreakdowns( : [], topFiles.length ? usageQuery( - `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 IN (${topFiles.map(sqlQuote).join(", ")}) GROUP BY day, file`, + `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 IN (${fileIn}) GROUP BY day, file`, + ) + : [], + // Country × file cross — serves both hover directions (a country's + // per-file values and a file's per-country values). + topCountries.length && topFiles.length + ? usageQuery( + `SELECT blob6 AS country, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY country, file`, + ) + : [], + rest.length && topFiles.length + ? usageQuery( + `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY file`, ) : [], ]); @@ -480,6 +498,26 @@ export async function getProductBreakdowns( }; fileByDay.set(key, days); } + const fileByCountry = new Map< + string, + Record + >(); + for (const row of crossRows) { + const key = str(row.file); + const countries = fileByCountry.get(key) ?? {}; + // Same code normalization as countries[].code, so lookups line up. + countries[str(row.country) || "??"] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + fileByCountry.set(key, countries); + } + const fileOthers = new Map( + fileOtherRows.map((row) => [ + str(row.file), + { requests: num(row.requests), bytes: num(row.bytes) }, + ]), + ); return { countries: countryRows.slice(0, COUNTRY_LIST_LIMIT).map((row) => ({ @@ -505,6 +543,11 @@ export async function getProductBreakdowns( requests: num(row.requests), bytes: num(row.bytes), byDay: fileByDay.get(str(row.file)) ?? {}, + byCountry: fileByCountry.get(str(row.file)) ?? {}, + otherCountries: fileOthers.get(str(row.file)) ?? { + requests: 0, + bytes: 0, + }, })), }; } catch (error) { From 801ff43caa75e360b85009223c46db8a9b19b340 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 28 Jul 2026 23:19:56 -0700 Subject: [PATCH 3/8] feat(analytics): stats follow hovered slice; click pins a country/file The Downloads / Daily avg / Data served / Countries stats now update for a hovered country or file (window queries gain per-country bytes and per-file distinct-country counts). Clicking a country/file row pins that slice: the chart and stats stay scoped to it, and hovering a chart day then reads that day's values within the pin. Clicking again unpins; hovering another row previews it over the pin. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 159 +++++++++++------- src/lib/clients/analytics/index.test.ts | 50 ++++-- src/lib/clients/analytics/index.ts | 53 ++++-- 3 files changed, 172 insertions(+), 90 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 4db503d9..ad70ca45 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -45,70 +45,95 @@ export function ProductAnalyticsView({ breakdowns, }: ProductAnalyticsViewProps) { // One hover at a time; each panel narrows the other two to its slice. - const [hover, setHover] = useState< - | { type: "day"; index: number } + // Clicking a country/file row pins that slice so the chart and stats stay + // scoped to it — hovering a day then interrogates values within the pin. + type Slice = | { type: "country"; code: string } - | { type: "file"; path: string } - | null - >(null); + | { type: "file"; path: string }; + const [hover, setHover] = useState<({ type: "day"; index: number } | Slice) | null>( + null, + ); + const [pin, setPin] = useState(null); const dayIndex = hover?.type === "day" ? hover.index : null; - const shown = dayIndex === null ? totals : days[dayIndex]; const day = dayIndex === null ? null : days[dayIndex].date; - const hoveredCountry = hover?.type === "country" ? hover.code : null; - const hoveredFile = - hover?.type === "file" - ? breakdowns?.files.find((f) => f.path === hover.path) + // A hovered country/file previews that slice, overriding any pin. + const slice = hover && hover.type !== "day" ? hover : pin; + const sliceCountry = + slice?.type === "country" + ? slice.code === "·" + ? breakdowns?.otherCountries + : breakdowns?.countries.find((c) => c.code === slice.code) + : undefined; + const sliceFile = + slice?.type === "file" + ? breakdowns?.files.find((f) => f.path === slice.path) : undefined; + const sliceEntity = sliceCountry ?? sliceFile; + + // Stats row: the active slice's window totals, or its single-day values + // while a chart day is hovered. `countries` is 1 for one country, the + // aggregate's count for the others row, and per-slice for files. + const sliceCountries = sliceFile + ? sliceFile.countries + : slice?.type === "country" && slice.code === "·" + ? (breakdowns?.otherCountries?.count ?? 0) + : 1; + const shown = sliceEntity + ? day !== null + ? { + ...(sliceEntity.byDay[day] ?? { requests: 0, bytes: 0 }), + countries: + sliceFile ? (sliceFile.byDay[day]?.countries ?? 0) : sliceCountries, + } + : { requests: sliceEntity.requests, bytes: sliceEntity.bytes, countries: sliceCountries } + : day !== null + ? days[dayIndex!] + : totals; + const avgRequests = (sliceEntity?.requests ?? totals.requests) / days.length; - // The "·" code is the others aggregate's row. - const countryByDay = (code: string) => - (code === "·" - ? breakdowns?.otherCountries?.byDay - : breakdowns?.countries.find((c) => c.code === code)?.byDay) ?? {}; let chartDays = days; - if (hoveredCountry !== null) { - const byDay = countryByDay(hoveredCountry); - chartDays = days.map((d) => ({ ...d, requests: byDay[d.date] ?? 0 })); - } else if (hoveredFile) { + if (sliceEntity) { chartDays = days.map((d) => ({ ...d, - requests: hoveredFile.byDay[d.date]?.requests ?? 0, - bytes: hoveredFile.byDay[d.date]?.bytes ?? 0, + requests: sliceEntity.byDay[d.date]?.requests ?? 0, + bytes: sliceEntity.byDay[d.date]?.bytes ?? 0, })); } + // Lists: a file slice re-values the country list and vice versa; a hovered + // day re-values both only when nothing is pinned (no day×country×file data). + const dayValuesLists = day !== null && !pin; + const countrySliceFile = sliceFile; const countryRows = breakdowns ? [ ...breakdowns.countries.map((c) => ({ code: c.code, label: c.name, - requests: - day !== null - ? (c.byDay[day] ?? 0) - : hoveredFile - ? (hoveredFile.byCountry[c.code]?.requests ?? 0) - : c.requests, + requests: countrySliceFile + ? (countrySliceFile.byCountry[c.code]?.requests ?? 0) + : dayValuesLists + ? (c.byDay[day!]?.requests ?? 0) + : c.requests, })), ...(breakdowns.otherCountries ? [ { code: "·", label: `${breakdowns.otherCountries.count} others`, - requests: - day !== null - ? (breakdowns.otherCountries.byDay[day] ?? 0) - : hoveredFile - ? hoveredFile.otherCountries.requests - : breakdowns.otherCountries.requests, + requests: countrySliceFile + ? countrySliceFile.otherCountries.requests + : dayValuesLists + ? (breakdowns.otherCountries.byDay[day!]?.requests ?? 0) + : breakdowns.otherCountries.requests, }, ] : []), ] : []; - // Re-rank by the hovered slice's values, others pinned last. A hovered + // Re-rank by the active slice's values, others pinned last. A hovered // country never re-sorts its own list (its values stay window-wide), so // rows can't shuffle under the cursor. - if (day !== null || hoveredFile) { + if (countrySliceFile || dayValuesLists) { countryRows.sort( (a, b) => Number(a.code === "·") - Number(b.code === "·") || @@ -117,27 +142,43 @@ export function ProductAnalyticsView({ } const maxCountry = Math.max(1, ...countryRows.map((row) => row.requests)); + const fileSliceCountry = slice?.type === "country" ? slice.code : null; const fileRows = (breakdowns?.files ?? []).map((file) => ({ path: file.path, shown: - day !== null - ? (file.byDay[day] ?? { requests: 0, bytes: 0 }) - : hoveredCountry !== null - ? hoveredCountry === "·" - ? file.otherCountries - : (file.byCountry[hoveredCountry] ?? { requests: 0, bytes: 0 }) + fileSliceCountry !== null + ? fileSliceCountry === "·" + ? file.otherCountries + : (file.byCountry[fileSliceCountry] ?? { requests: 0, bytes: 0 }) + : dayValuesLists + ? (file.byDay[day!] ?? { requests: 0, bytes: 0 }) : file, })); - if (day !== null || hoveredCountry !== null) { + if (fileSliceCountry !== null || dayValuesLists) { fileRows.sort((a, b) => b.shown.requests - a.shown.requests); } - const filterLabel = - hoveredCountry !== null - ? countryRows.find((row) => row.code === hoveredCountry)?.label - : hover?.type === "file" - ? hover.path - : null; + const filterLabel = slice + ? slice.type === "country" + ? countryRows.find((row) => row.code === slice.code)?.label + : slice.path + : null; + const togglePin = (next: Slice) => + setPin( + pin && + pin.type === next.type && + (pin.type === "country" + ? pin.code === (next as { code: string }).code + : pin.path === (next as { path: string }).path) + ? null + : next, + ); + const rowHighlight = (isPin: boolean, isHover: boolean) => + isPin + ? { background: "var(--green-a3)", cursor: "pointer" } + : isHover + ? { background: "var(--green-a2)", cursor: "pointer" } + : { cursor: "pointer" }; return ( @@ -164,7 +205,7 @@ export function ProductAnalyticsView({ setHover(null)} - style={ - hoveredCountry === row.code - ? { background: "var(--green-a2)" } - : undefined + onClick={() => + togglePin({ type: "country", code: row.code }) } + style={rowHighlight( + pin?.type === "country" && pin.code === row.code, + hover?.type === "country" && hover.code === row.code, + )} > setHover(null)} - style={ - hoveredFile?.path === file.path - ? { background: "var(--green-a2)" } - : undefined - } + onClick={() => togglePin({ type: "file", path: file.path })} + style={rowHighlight( + pin?.type === "file" && pin.path === file.path, + hover?.type === "file" && hover.path === file.path, + )} > diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts index 8b3eaaf0..88f15291 100644 --- a/src/lib/clients/analytics/index.test.ts +++ b/src/lib/clients/analytics/index.test.ts @@ -194,14 +194,20 @@ describe("getProductBreakdowns", () => { const sql = init.body; if (sql.includes("GROUP BY day, country")) { return jsonResponse([ - { day: "2026-07-27 00:00:00", country: "US", requests: 60 }, - { day: "2026-07-26 00:00:00", country: "US", requests: "40" }, - { day: "2026-07-27 00:00:00", country: "DE", requests: 50 }, + { day: "2026-07-27 00:00:00", country: "US", requests: 60, bytes: 600 }, + { day: "2026-07-26 00:00:00", country: "US", requests: "40", bytes: 300 }, + { day: "2026-07-27 00:00:00", country: "DE", requests: 50, bytes: 200 }, ]); } if (sql.includes("GROUP BY day, file")) { return jsonResponse([ - { day: "2026-07-27 00:00:00", file: "a.tif", requests: 25, bytes: 400 }, + { + day: "2026-07-27 00:00:00", + file: "a.tif", + requests: 25, + bytes: 400, + countries: 2, + }, ]); } if (sql.includes("GROUP BY country, file")) { @@ -214,22 +220,24 @@ describe("getProductBreakdowns", () => { return jsonResponse([{ file: "b.json", requests: 4, bytes: 50 }]); } if (sql.includes("NOT IN")) { - return jsonResponse([{ day: "2026-07-27 00:00:00", requests: 15 }]); + return jsonResponse([ + { day: "2026-07-27 00:00:00", requests: 15, bytes: 70 }, + ]); } if (sql.includes("GROUP BY country")) { return jsonResponse([ - { country: "US", requests: 100 }, - { country: "DE", requests: 50 }, - { country: "BR", requests: 40 }, - { country: "GB", requests: 30 }, - { country: "IN", requests: "20" }, - { country: "FR", requests: 10 }, - { country: "", requests: 5 }, + { country: "US", requests: 100, bytes: 900 }, + { country: "DE", requests: 50, bytes: 500 }, + { country: "BR", requests: 40, bytes: 400 }, + { country: "GB", requests: 30, bytes: 300 }, + { country: "IN", requests: "20", bytes: "200" }, + { country: "FR", requests: 10, bytes: 100 }, + { country: "", requests: 5, bytes: 40 }, ]); } return jsonResponse([ - { file: "a.tif", requests: 60, bytes: 1000 }, - { file: "b.json", requests: "40", bytes: "500" }, + { file: "a.tif", requests: 60, bytes: 1000, countries: 3 }, + { file: "b.json", requests: "40", bytes: "500", countries: "2" }, ]); }); @@ -240,22 +248,27 @@ describe("getProductBreakdowns", () => { code: "US", name: "United States", requests: 100, + bytes: 900, byDay: { - "2026-07-27T00:00:00.000Z": 60, - "2026-07-26T00:00:00.000Z": 40, + "2026-07-27T00:00:00.000Z": { requests: 60, bytes: 600 }, + "2026-07-26T00:00:00.000Z": { requests: 40, bytes: 300 }, }, }); expect(breakdowns!.otherCountries).toEqual({ count: 2, requests: 15, - byDay: { "2026-07-27T00:00:00.000Z": 15 }, + bytes: 140, + byDay: { "2026-07-27T00:00:00.000Z": { requests: 15, bytes: 70 } }, }); expect(breakdowns!.files).toEqual([ { path: "a.tif", requests: 60, bytes: 1000, - byDay: { "2026-07-27T00:00:00.000Z": { requests: 25, bytes: 400 } }, + countries: 3, + byDay: { + "2026-07-27T00:00:00.000Z": { requests: 25, bytes: 400, countries: 2 }, + }, byCountry: { US: { requests: 20, bytes: 300 }, DE: { requests: 5, bytes: 100 }, @@ -266,6 +279,7 @@ describe("getProductBreakdowns", () => { path: "b.json", requests: 40, bytes: 500, + countries: 2, byDay: {}, byCountry: {}, otherCountries: { requests: 4, bytes: 50 }, diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 81388ec0..f1a03dc7 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -395,21 +395,36 @@ const FILE_LIST_LIMIT = 10; /** Per-UTC-day values keyed by the day's ISO timestamp (UsagePoint.date). */ type ByDay = Record; +interface SliceTotals { + requests: number; + bytes: number; +} + export interface ProductBreakdowns { /** Top countries by downloads */ - countries: { code: string; name: string; requests: number; byDay: ByDay }[]; + countries: { + code: string; + name: string; + requests: number; + bytes: number; + byDay: ByDay; + }[]; /** Aggregate of the remaining countries, if any */ - otherCountries: { count: number; requests: number; byDay: ByDay } | null; + otherCountries: + | { count: number; requests: number; bytes: number; byDay: ByDay } + | null; /** Top objects by downloads */ files: { path: string; requests: number; bytes: number; - byDay: ByDay<{ requests: number; bytes: number }>; + /** Distinct countries the object was downloaded from */ + countries: number; + byDay: ByDay; /** Window values per top-country code (countries[].code) */ - byCountry: Record; + byCountry: Record; /** Window values from countries outside the top list */ - otherCountries: { requests: number; bytes: number }; + otherCountries: SliceTotals; }[]; } @@ -428,12 +443,12 @@ export async function getProductBreakdowns( try { const [countryRows, fileRows] = await Promise.all([ usageQuery( - `SELECT blob6 AS country, SUM(_sample_interval) AS requests ${from} GROUP BY country ORDER BY requests DESC`, + `SELECT blob6 AS country, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} GROUP BY country ORDER BY requests DESC`, ), // blob3 = '' is a keyless product GET (trailing-slash/probe requests, // not a real file) — keep those out of the top-files ranking. usageQuery( - `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 != '' GROUP BY file ORDER BY requests DESC LIMIT ${FILE_LIST_LIMIT}`, + `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes, COUNT(DISTINCT blob6) AS countries ${from} AND blob3 != '' GROUP BY file ORDER BY requests DESC LIMIT ${FILE_LIST_LIMIT}`, ), ]); @@ -451,17 +466,17 @@ export async function getProductBreakdowns( await Promise.all([ topCountries.length ? usageQuery( - `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, SUM(_sample_interval) AS requests ${from} AND blob6 IN (${countryIn}) GROUP BY day, country`, + `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) GROUP BY day, country`, ) : [], rest.length ? usageQuery( - `SELECT toStartOfDay(timestamp) AS day, SUM(_sample_interval) AS requests ${from} AND blob6 NOT IN (${countryIn}) GROUP BY day`, + `SELECT toStartOfDay(timestamp) AS day, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) GROUP BY day`, ) : [], topFiles.length ? usageQuery( - `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 IN (${fileIn}) GROUP BY day, file`, + `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes, COUNT(DISTINCT blob6) AS countries ${from} AND blob3 IN (${fileIn}) GROUP BY day, file`, ) : [], // Country × file cross — serves both hover directions (a country's @@ -478,16 +493,22 @@ export async function getProductBreakdowns( : [], ]); - const countryByDay = new Map>(); + const countryByDay = new Map< + string, + Record + >(); for (const row of countryDayRows) { const key = str(row.country); const days = countryByDay.get(key) ?? {}; - days[parseDateTime(row.day)] = num(row.requests); + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; countryByDay.set(key, days); } const fileByDay = new Map< string, - Record + Record >(); for (const row of fileDayRows) { const key = str(row.file); @@ -495,6 +516,7 @@ export async function getProductBreakdowns( days[parseDateTime(row.day)] = { requests: num(row.requests), bytes: num(row.bytes), + countries: num(row.countries), }; fileByDay.set(key, days); } @@ -524,16 +546,18 @@ export async function getProductBreakdowns( code: str(row.country) || "??", name: countryName(str(row.country)), requests: num(row.requests), + bytes: num(row.bytes), byDay: countryByDay.get(str(row.country)) ?? {}, })), otherCountries: rest.length ? { count: rest.length, requests: rest.reduce((sum, row) => sum + num(row.requests), 0), + bytes: rest.reduce((sum, row) => sum + num(row.bytes), 0), byDay: Object.fromEntries( otherDayRows.map((row) => [ parseDateTime(row.day), - num(row.requests), + { requests: num(row.requests), bytes: num(row.bytes) }, ]), ), } @@ -542,6 +566,7 @@ export async function getProductBreakdowns( path: str(row.file), requests: num(row.requests), bytes: num(row.bytes), + countries: num(row.countries), byDay: fileByDay.get(str(row.file)) ?? {}, byCountry: fileByCountry.get(str(row.file)) ?? {}, otherCountries: fileOthers.get(str(row.file)) ?? { From b16658f7ae3f7a8e35e4993eaff23502e6100faf Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 28 Jul 2026 23:24:45 -0700 Subject: [PATCH 4/8] feat(analytics): add 'other files' remainder row to top-files table Reconciles the table with the file-traffic total: a footer row shows the distinct-file count, requests, and bytes outside the top list, making long-tail traffic (e.g. bulk mirrors) visible instead of alarming. The remainder is window-wide only, so it blanks to an em dash while a day/country slice re-values the table. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 28 +++++++++++++++++++ src/lib/clients/analytics/index.test.ts | 10 +++++++ src/lib/clients/analytics/index.ts | 28 ++++++++++++++++++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index ad70ca45..7fbb2224 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -359,6 +359,34 @@ export function ProductAnalyticsView({ ))} + {breakdowns.otherFiles && ( + + + + {numberFormat.format(breakdowns.otherFiles.count)} other + files + + + {/* The remainder is window-wide only — no per-day/country + slice data, so it blanks while a slice is active. */} + + + {fileSliceCountry !== null || dayValuesLists + ? "—" + : numberFormat.format( + Math.round(breakdowns.otherFiles.requests), + )} + + + + + {fileSliceCountry !== null || dayValuesLists + ? "—" + : formatBytes(breakdowns.otherFiles.bytes, 1)} + + + + )} )} diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts index 88f15291..586b802a 100644 --- a/src/lib/clients/analytics/index.test.ts +++ b/src/lib/clients/analytics/index.test.ts @@ -224,6 +224,9 @@ describe("getProductBreakdowns", () => { { day: "2026-07-27 00:00:00", requests: 15, bytes: 70 }, ]); } + if (sql.includes("COUNT(DISTINCT blob3)")) { + return jsonResponse([{ files: 12, requests: 500, bytes: 5000 }]); + } if (sql.includes("GROUP BY country")) { return jsonResponse([ { country: "US", requests: 100, bytes: 900 }, @@ -285,6 +288,13 @@ describe("getProductBreakdowns", () => { otherCountries: { requests: 4, bytes: 50 }, }, ]); + // Remainder reconciles the table to the file-traffic total: 12 distinct + // files less the 2 listed; 500-100 requests; 5000-1500 bytes. + expect(breakdowns!.otherFiles).toEqual({ + count: 10, + requests: 400, + bytes: 3500, + }); // Per-day wave is scoped to the window's top entries, escaped and quoted. const dayCountrySql = sentSql().find((sql) => diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index f1a03dc7..10fdc38c 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -426,6 +426,11 @@ export interface ProductBreakdowns { /** Window values from countries outside the top list */ otherCountries: SliceTotals; }[]; + /** + * Remainder of file traffic outside the top list — lets the table sum to + * the file-traffic total. Window-wide only (no per-day/country slices). + */ + otherFiles: { count: number; requests: number; bytes: number } | null; } /** @@ -441,7 +446,7 @@ export async function getProductBreakdowns( const from = usageFrom(accountId, productId, undefined, days); try { - const [countryRows, fileRows] = await Promise.all([ + const [countryRows, fileRows, fileTotalRows] = await Promise.all([ usageQuery( `SELECT blob6 AS country, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} GROUP BY country ORDER BY requests DESC`, ), @@ -450,6 +455,9 @@ export async function getProductBreakdowns( usageQuery( `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes, COUNT(DISTINCT blob6) AS countries ${from} AND blob3 != '' GROUP BY file ORDER BY requests DESC LIMIT ${FILE_LIST_LIMIT}`, ), + usageQuery( + `SELECT COUNT(DISTINCT blob3) AS files, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 != ''`, + ), ]); const rest = countryRows.slice(COUNTRY_LIST_LIMIT); @@ -574,6 +582,24 @@ export async function getProductBreakdowns( bytes: 0, }, })), + // Sampling makes the parts fractional estimates; clamp the remainder. + otherFiles: (() => { + const count = num(fileTotalRows[0]?.files) - fileRows.length; + if (count <= 0) return null; + return { + count, + requests: Math.max( + 0, + num(fileTotalRows[0]?.requests) - + fileRows.reduce((sum, row) => sum + num(row.requests), 0), + ), + bytes: Math.max( + 0, + num(fileTotalRows[0]?.bytes) - + fileRows.reduce((sum, row) => sum + num(row.bytes), 0), + ), + }; + })(), }; } catch (error) { LOGGER.warn("Analytics breakdown query failed", { From 800608cb351a0df6962df95e745524721aed78e8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 29 Jul 2026 09:35:26 -0700 Subject: [PATCH 5/8] fix(analytics): make pin + cross-type hover additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering the opposite type of an active pin no longer replaces the pin (which snapped both lists back to window values and re-sorted them under the cursor). The pin keeps scoping the lists and chart; the stats row narrows to pin ∩ hover from the country×file cross data, and the caption shows both. Same-type hover still previews that row. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 84 ++++++++++++++----- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 7fbb2224..4fa1755c 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -56,8 +56,28 @@ export function ProductAnalyticsView({ const [pin, setPin] = useState(null); const dayIndex = hover?.type === "day" ? hover.index : null; const day = dayIndex === null ? null : days[dayIndex].date; - // A hovered country/file previews that slice, overriding any pin. - const slice = hover && hover.type !== "day" ? hover : pin; + const hoverEntity = hover && hover.type !== "day" ? hover : null; + // Hovering the opposite type of an active pin is additive: the pin keeps + // scoping the lists and chart, and the stats narrow to pin ∩ hover (from + // the country×file cross data). A same-type hover previews that row instead. + const cross = + pin && hoverEntity && pin.type !== hoverEntity.type + ? { + path: + pin.type === "file" + ? pin.path + : hoverEntity.type === "file" + ? hoverEntity.path + : "", + code: + pin.type === "country" + ? pin.code + : hoverEntity.type === "country" + ? hoverEntity.code + : "", + } + : null; + const slice = cross ? pin : (hoverEntity ?? pin); const sliceCountry = slice?.type === "country" ? slice.code === "·" @@ -78,18 +98,34 @@ export function ProductAnalyticsView({ : slice?.type === "country" && slice.code === "·" ? (breakdowns?.otherCountries?.count ?? 0) : 1; - const shown = sliceEntity - ? day !== null - ? { - ...(sliceEntity.byDay[day] ?? { requests: 0, bytes: 0 }), - countries: - sliceFile ? (sliceFile.byDay[day]?.countries ?? 0) : sliceCountries, - } - : { requests: sliceEntity.requests, bytes: sliceEntity.bytes, countries: sliceCountries } - : day !== null - ? days[dayIndex!] - : totals; - const avgRequests = (sliceEntity?.requests ?? totals.requests) / days.length; + // Intersection values for an additive pin + hover; countries is 1 for a + // single country and unknowable (NaN → rendered "—") for the others row. + const crossValues = cross + ? { + ...((cross.code === "·" + ? breakdowns?.files.find((f) => f.path === cross.path)?.otherCountries + : breakdowns?.files.find((f) => f.path === cross.path)?.byCountry[ + cross.code + ]) ?? { requests: 0, bytes: 0 }), + countries: cross.code === "·" ? NaN : 1, + } + : null; + const shown = + crossValues ?? + (sliceEntity + ? day !== null + ? { + ...(sliceEntity.byDay[day] ?? { requests: 0, bytes: 0 }), + countries: + sliceFile ? (sliceFile.byDay[day]?.countries ?? 0) : sliceCountries, + } + : { requests: sliceEntity.requests, bytes: sliceEntity.bytes, countries: sliceCountries } + : day !== null + ? days[dayIndex!] + : totals); + const avgRequests = + (crossValues?.requests ?? sliceEntity?.requests ?? totals.requests) / + days.length; let chartDays = days; if (sliceEntity) { @@ -158,11 +194,15 @@ export function ProductAnalyticsView({ fileRows.sort((a, b) => b.shown.requests - a.shown.requests); } - const filterLabel = slice - ? slice.type === "country" - ? countryRows.find((row) => row.code === slice.code)?.label - : slice.path - : null; + const countryRowLabel = (code: string) => + countryRows.find((row) => row.code === code)?.label; + const filterLabel = cross + ? `${countryRowLabel(cross.code)} · ${cross.path}` + : slice + ? slice.type === "country" + ? countryRowLabel(slice.code) + : slice.path + : null; const togglePin = (next: Slice) => setPin( pin && @@ -217,7 +257,11 @@ export function ProductAnalyticsView({ From bca8b8ddab618bd5d2fb8807256a88f7b5415410 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 29 Jul 2026 09:48:22 -0700 Subject: [PATCH 6/8] feat(analytics): chart the pin-hover intersection via day-cube query Pinning a country and hovering a file (or vice versa) now redraws the chart with that intersection's daily series. Two more bounded queries fetch the day-x-country-x-file cube for the top 5 countries x top 10 files plus an others-x-file-x-day aggregate, nested under each file's byCountry/otherCountries entries. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 25 +++++-- src/lib/clients/analytics/index.test.ts | 34 ++++++++-- src/lib/clients/analytics/index.ts | 65 ++++++++++++++++--- 3 files changed, 105 insertions(+), 19 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 4fa1755c..f75f2b2c 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -100,13 +100,18 @@ export function ProductAnalyticsView({ : 1; // Intersection values for an additive pin + hover; countries is 1 for a // single country and unknowable (NaN → rendered "—") for the others row. + const crossFile = cross + ? breakdowns?.files.find((f) => f.path === cross.path) + : undefined; + const crossEntry = cross + ? ((cross.code === "·" + ? crossFile?.otherCountries + : crossFile?.byCountry[cross.code]) ?? null) + : null; const crossValues = cross ? { - ...((cross.code === "·" - ? breakdowns?.files.find((f) => f.path === cross.path)?.otherCountries - : breakdowns?.files.find((f) => f.path === cross.path)?.byCountry[ - cross.code - ]) ?? { requests: 0, bytes: 0 }), + requests: crossEntry?.requests ?? 0, + bytes: crossEntry?.bytes ?? 0, countries: cross.code === "·" ? NaN : 1, } : null; @@ -128,7 +133,15 @@ export function ProductAnalyticsView({ days.length; let chartDays = days; - if (sliceEntity) { + if (cross) { + // Additive pin + hover: chart the intersection's daily series (from the + // day×country×file cube). + chartDays = days.map((d) => ({ + ...d, + requests: crossEntry?.byDay[d.date]?.requests ?? 0, + bytes: crossEntry?.byDay[d.date]?.bytes ?? 0, + })); + } else if (sliceEntity) { chartDays = days.map((d) => ({ ...d, requests: sliceEntity.byDay[d.date]?.requests ?? 0, diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts index 586b802a..39123f78 100644 --- a/src/lib/clients/analytics/index.test.ts +++ b/src/lib/clients/analytics/index.test.ts @@ -192,6 +192,22 @@ describe("getProductBreakdowns", () => { it("ranks countries with an others aggregate and lists top files", async () => { fetchMock.mockImplementation(async (_url: string, init: { body: string }) => { const sql = init.body; + if (sql.includes("GROUP BY day, country, file")) { + return jsonResponse([ + { + day: "2026-07-27 00:00:00", + country: "US", + file: "a.tif", + requests: 12, + bytes: 150, + }, + ]); + } + if (sql.includes("NOT IN") && sql.includes("GROUP BY day, file")) { + return jsonResponse([ + { day: "2026-07-27 00:00:00", file: "b.json", requests: 2, bytes: 20 }, + ]); + } if (sql.includes("GROUP BY day, country")) { return jsonResponse([ { day: "2026-07-27 00:00:00", country: "US", requests: 60, bytes: 600 }, @@ -273,10 +289,16 @@ describe("getProductBreakdowns", () => { "2026-07-27T00:00:00.000Z": { requests: 25, bytes: 400, countries: 2 }, }, byCountry: { - US: { requests: 20, bytes: 300 }, - DE: { requests: 5, bytes: 100 }, + US: { + requests: 20, + bytes: 300, + byDay: { + "2026-07-27T00:00:00.000Z": { requests: 12, bytes: 150 }, + }, + }, + DE: { requests: 5, bytes: 100, byDay: {} }, }, - otherCountries: { requests: 0, bytes: 0 }, + otherCountries: { requests: 0, bytes: 0, byDay: {} }, }, { path: "b.json", @@ -285,7 +307,11 @@ describe("getProductBreakdowns", () => { countries: 2, byDay: {}, byCountry: {}, - otherCountries: { requests: 4, bytes: 50 }, + otherCountries: { + requests: 4, + bytes: 50, + byDay: { "2026-07-27T00:00:00.000Z": { requests: 2, bytes: 20 } }, + }, }, ]); // Remainder reconciles the table to the file-traffic total: 12 distinct diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts index 10fdc38c..09321927 100644 --- a/src/lib/clients/analytics/index.ts +++ b/src/lib/clients/analytics/index.ts @@ -421,10 +421,10 @@ export interface ProductBreakdowns { /** Distinct countries the object was downloaded from */ countries: number; byDay: ByDay; - /** Window values per top-country code (countries[].code) */ - byCountry: Record; - /** Window values from countries outside the top list */ - otherCountries: SliceTotals; + /** Window and per-day values per top-country code (countries[].code) */ + byCountry: Record }>; + /** Window and per-day values from countries outside the top list */ + otherCountries: SliceTotals & { byDay: ByDay }; }[]; /** * Remainder of file traffic outside the top list — lets the table sum to @@ -470,8 +470,15 @@ export async function getProductBreakdowns( // unlike a full GROUP BY day+file) — per-day values for hover. const countryIn = topCountries.map(sqlQuote).join(", "); const fileIn = topFiles.map(sqlQuote).join(", "); - const [countryDayRows, otherDayRows, fileDayRows, crossRows, fileOtherRows] = - await Promise.all([ + const [ + countryDayRows, + otherDayRows, + fileDayRows, + crossRows, + fileOtherRows, + cubeRows, + otherCubeRows, + ] = await Promise.all([ topCountries.length ? usageQuery( `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) GROUP BY day, country`, @@ -499,6 +506,19 @@ export async function getProductBreakdowns( `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY file`, ) : [], + // Day × country × file cube for the pinned-intersection chart. Bounded + // to top-5 × top-10 × window days (< AE's ~10k row cap; sparse in + // practice since rows only exist where traffic occurred). + topCountries.length && topFiles.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob6 AS country, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY day, country, file`, + ) + : [], + rest.length && topFiles.length + ? usageQuery( + `SELECT toStartOfDay(timestamp) AS day, blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob6 NOT IN (${countryIn}) AND blob3 IN (${fileIn}) GROUP BY day, file`, + ) + : [], ]); const countryByDay = new Map< @@ -528,24 +548,50 @@ export async function getProductBreakdowns( }; fileByDay.set(key, days); } + const cube = new Map>(); + for (const row of cubeRows) { + const key = `${str(row.file)}${str(row.country) || "??"}`; + const days = cube.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + cube.set(key, days); + } + const otherCube = new Map>(); + for (const row of otherCubeRows) { + const key = str(row.file); + const days = otherCube.get(key) ?? {}; + days[parseDateTime(row.day)] = { + requests: num(row.requests), + bytes: num(row.bytes), + }; + otherCube.set(key, days); + } const fileByCountry = new Map< string, - Record + Record }> >(); for (const row of crossRows) { const key = str(row.file); const countries = fileByCountry.get(key) ?? {}; // Same code normalization as countries[].code, so lookups line up. - countries[str(row.country) || "??"] = { + const code = str(row.country) || "??"; + countries[code] = { requests: num(row.requests), bytes: num(row.bytes), + byDay: cube.get(`${key}${code}`) ?? {}, }; fileByCountry.set(key, countries); } const fileOthers = new Map( fileOtherRows.map((row) => [ str(row.file), - { requests: num(row.requests), bytes: num(row.bytes) }, + { + requests: num(row.requests), + bytes: num(row.bytes), + byDay: otherCube.get(str(row.file)) ?? {}, + }, ]), ); @@ -580,6 +626,7 @@ export async function getProductBreakdowns( otherCountries: fileOthers.get(str(row.file)) ?? { requests: 0, bytes: 0, + byDay: {}, }, })), // Sampling makes the parts fractional estimates; clamp the remainder. From 11407e88767950a6ac5529e720ef03e8d12814e7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 29 Jul 2026 10:03:12 -0700 Subject: [PATCH 7/8] feat(analytics): independent pins per dimension (country, file, day) Replace the single pin with one pin per dimension, toggled by clicking a row or a chart bar; pins compose into an intersection filter (all combos are served by the existing cross/cube data). A hover substitutes for its own dimension's pin while the cursor is there, so sweeping rows previews siblings without dropping the other pins. This also collapses the special-cased slice/cross logic into one effective-filter model. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 273 ++++++++---------- src/components/features/analytics/panels.tsx | 14 +- 2 files changed, 132 insertions(+), 155 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index f75f2b2c..8d513a7f 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -44,145 +44,123 @@ export function ProductAnalyticsView({ users, breakdowns, }: ProductAnalyticsViewProps) { - // One hover at a time; each panel narrows the other two to its slice. - // Clicking a country/file row pins that slice so the chart and stats stay - // scoped to it — hovering a day then interrogates values within the pin. - type Slice = + // One pin per dimension (country, file, day), each toggled by clicking a + // row or chart bar; the pins compose into an intersection filter. A hover + // substitutes for its own dimension's pin while the cursor is there, so + // sweeping rows previews siblings without dropping the other pins. Lists + // never take values from their own dimension, so hovering/pinning a row + // re-values only the *other* panels and rows can't shuffle under the + // cursor. + const [hover, setHover] = useState< + | { type: "day"; index: number } | { type: "country"; code: string } - | { type: "file"; path: string }; - const [hover, setHover] = useState<({ type: "day"; index: number } | Slice) | null>( - null, - ); - const [pin, setPin] = useState(null); - const dayIndex = hover?.type === "day" ? hover.index : null; - const day = dayIndex === null ? null : days[dayIndex].date; - const hoverEntity = hover && hover.type !== "day" ? hover : null; - // Hovering the opposite type of an active pin is additive: the pin keeps - // scoping the lists and chart, and the stats narrow to pin ∩ hover (from - // the country×file cross data). A same-type hover previews that row instead. - const cross = - pin && hoverEntity && pin.type !== hoverEntity.type - ? { - path: - pin.type === "file" - ? pin.path - : hoverEntity.type === "file" - ? hoverEntity.path - : "", - code: - pin.type === "country" - ? pin.code - : hoverEntity.type === "country" - ? hoverEntity.code - : "", - } - : null; - const slice = cross ? pin : (hoverEntity ?? pin); - const sliceCountry = - slice?.type === "country" - ? slice.code === "·" + | { type: "file"; path: string } + | null + >(null); + const [pins, setPins] = useState<{ + country: string | null; + file: string | null; + day: number | null; + }>({ country: null, file: null, day: null }); + const effCountry = hover?.type === "country" ? hover.code : pins.country; + const effFile = hover?.type === "file" ? hover.path : pins.file; + const effDay = hover?.type === "day" ? hover.index : pins.day; + const day = effDay === null ? null : days[effDay].date; + + const zeroEntry = { + requests: 0, + bytes: 0, + byDay: {} as Record, + }; + // The "·" code is the others aggregate's row. + const countryEntity = + effCountry !== null + ? effCountry === "·" ? breakdowns?.otherCountries - : breakdowns?.countries.find((c) => c.code === slice.code) + : breakdowns?.countries.find((c) => c.code === effCountry) : undefined; - const sliceFile = - slice?.type === "file" - ? breakdowns?.files.find((f) => f.path === slice.path) + const fileEntity = + effFile !== null + ? breakdowns?.files.find((f) => f.path === effFile) : undefined; - const sliceEntity = sliceCountry ?? sliceFile; + // The active country∩file entity: window totals plus a per-day series + // (from the day×country×file cube when both dimensions are set). + const entity = + countryEntity && fileEntity + ? ((effCountry === "·" + ? fileEntity.otherCountries + : fileEntity.byCountry[effCountry!]) ?? zeroEntry) + : (countryEntity ?? fileEntity ?? null); - // Stats row: the active slice's window totals, or its single-day values - // while a chart day is hovered. `countries` is 1 for one country, the - // aggregate's count for the others row, and per-slice for files. - const sliceCountries = sliceFile - ? sliceFile.countries - : slice?.type === "country" && slice.code === "·" - ? (breakdowns?.otherCountries?.count ?? 0) - : 1; - // Intersection values for an additive pin + hover; countries is 1 for a - // single country and unknowable (NaN → rendered "—") for the others row. - const crossFile = cross - ? breakdowns?.files.find((f) => f.path === cross.path) - : undefined; - const crossEntry = cross - ? ((cross.code === "·" - ? crossFile?.otherCountries - : crossFile?.byCountry[cross.code]) ?? null) - : null; - const crossValues = cross - ? { - requests: crossEntry?.requests ?? 0, - bytes: crossEntry?.bytes ?? 0, - countries: cross.code === "·" ? NaN : 1, - } - : null; - const shown = - crossValues ?? - (sliceEntity + // Countries stat: 1 for one country; the others row's distinct count is + // window-wide only, so it's unknowable (NaN → "—") when further narrowed. + const shownCountries = countryEntity + ? effCountry === "·" + ? fileEntity || day !== null + ? NaN + : (breakdowns?.otherCountries?.count ?? 0) + : 1 + : fileEntity ? day !== null - ? { - ...(sliceEntity.byDay[day] ?? { requests: 0, bytes: 0 }), - countries: - sliceFile ? (sliceFile.byDay[day]?.countries ?? 0) : sliceCountries, - } - : { requests: sliceEntity.requests, bytes: sliceEntity.bytes, countries: sliceCountries } + ? (fileEntity.byDay[day]?.countries ?? 0) + : fileEntity.countries : day !== null - ? days[dayIndex!] - : totals); - const avgRequests = - (crossValues?.requests ?? sliceEntity?.requests ?? totals.requests) / - days.length; + ? days[effDay!].countries + : totals.countries; + const shown = { + ...(entity + ? day !== null + ? (entity.byDay[day] ?? zeroEntry) + : entity + : day !== null + ? days[effDay!] + : totals), + countries: shownCountries, + }; + const avgRequests = (entity?.requests ?? totals.requests) / days.length; let chartDays = days; - if (cross) { - // Additive pin + hover: chart the intersection's daily series (from the - // day×country×file cube). + if (entity) { chartDays = days.map((d) => ({ ...d, - requests: crossEntry?.byDay[d.date]?.requests ?? 0, - bytes: crossEntry?.byDay[d.date]?.bytes ?? 0, - })); - } else if (sliceEntity) { - chartDays = days.map((d) => ({ - ...d, - requests: sliceEntity.byDay[d.date]?.requests ?? 0, - bytes: sliceEntity.byDay[d.date]?.bytes ?? 0, + requests: entity.byDay[d.date]?.requests ?? 0, + bytes: entity.byDay[d.date]?.bytes ?? 0, })); } - // Lists: a file slice re-values the country list and vice versa; a hovered - // day re-values both only when nothing is pinned (no day×country×file data). - const dayValuesLists = day !== null && !pin; - const countrySliceFile = sliceFile; + // Country list: driven by the effective file and day, never by a country. const countryRows = breakdowns ? [ - ...breakdowns.countries.map((c) => ({ - code: c.code, - label: c.name, - requests: countrySliceFile - ? (countrySliceFile.byCountry[c.code]?.requests ?? 0) - : dayValuesLists - ? (c.byDay[day!]?.requests ?? 0) - : c.requests, - })), + ...breakdowns.countries.map((c) => { + const e = fileEntity + ? (fileEntity.byCountry[c.code] ?? zeroEntry) + : c; + return { + code: c.code, + label: c.name, + requests: day !== null ? (e.byDay[day]?.requests ?? 0) : e.requests, + }; + }), ...(breakdowns.otherCountries ? [ { code: "·", label: `${breakdowns.otherCountries.count} others`, - requests: countrySliceFile - ? countrySliceFile.otherCountries.requests - : dayValuesLists - ? (breakdowns.otherCountries.byDay[day!]?.requests ?? 0) - : breakdowns.otherCountries.requests, + requests: (() => { + const e = fileEntity + ? fileEntity.otherCountries + : breakdowns.otherCountries; + return day !== null + ? (e.byDay[day]?.requests ?? 0) + : e.requests; + })(), }, ] : []), ] : []; - // Re-rank by the active slice's values, others pinned last. A hovered - // country never re-sorts its own list (its values stay window-wide), so - // rows can't shuffle under the cursor. - if (countrySliceFile || dayValuesLists) { + // Re-rank by the active filter's values, others pinned last. + if (fileEntity || day !== null) { countryRows.sort( (a, b) => Number(a.code === "·") - Number(b.code === "·") || @@ -191,41 +169,33 @@ export function ProductAnalyticsView({ } const maxCountry = Math.max(1, ...countryRows.map((row) => row.requests)); - const fileSliceCountry = slice?.type === "country" ? slice.code : null; - const fileRows = (breakdowns?.files ?? []).map((file) => ({ - path: file.path, - shown: - fileSliceCountry !== null - ? fileSliceCountry === "·" + // Files table: driven by the effective country and day, never by a file. + const fileRows = (breakdowns?.files ?? []).map((file) => { + const e = + effCountry !== null + ? effCountry === "·" ? file.otherCountries - : (file.byCountry[fileSliceCountry] ?? { requests: 0, bytes: 0 }) - : dayValuesLists - ? (file.byDay[day!] ?? { requests: 0, bytes: 0 }) - : file, - })); - if (fileSliceCountry !== null || dayValuesLists) { + : (file.byCountry[effCountry] ?? zeroEntry) + : file; + return { + path: file.path, + shown: day !== null ? (e.byDay[day] ?? zeroEntry) : e, + }; + }); + if (effCountry !== null || day !== null) { fileRows.sort((a, b) => b.shown.requests - a.shown.requests); } const countryRowLabel = (code: string) => countryRows.find((row) => row.code === code)?.label; - const filterLabel = cross - ? `${countryRowLabel(cross.code)} · ${cross.path}` - : slice - ? slice.type === "country" - ? countryRowLabel(slice.code) - : slice.path - : null; - const togglePin = (next: Slice) => - setPin( - pin && - pin.type === next.type && - (pin.type === "country" - ? pin.code === (next as { code: string }).code - : pin.path === (next as { path: string }).path) - ? null - : next, - ); + const filterLabel = + [effCountry !== null ? countryRowLabel(effCountry) : null, effFile] + .filter(Boolean) + .join(" · ") || null; + const togglePin = ( + dim: K, + value: NonNullable<(typeof pins)[K]>, + ) => setPins((p) => ({ ...p, [dim]: p[dim] === value ? null : value })); const rowHighlight = (isPin: boolean, isHover: boolean) => isPin ? { background: "var(--green-a3)", cursor: "pointer" } @@ -281,13 +251,14 @@ export function ProductAnalyticsView({ - + setHover(index === null ? null : { type: "day", index }) } + onSelect={(index) => index !== null && togglePin("day", index)} height={180} /> @@ -310,11 +281,9 @@ export function ProductAnalyticsView({ setHover({ type: "country", code: row.code }) } onMouseLeave={() => setHover(null)} - onClick={() => - togglePin({ type: "country", code: row.code }) - } + onClick={() => togglePin("country", row.code)} style={rowHighlight( - pin?.type === "country" && pin.code === row.code, + pins.country === row.code, hover?.type === "country" && hover.code === row.code, )} > @@ -391,9 +360,9 @@ export function ProductAnalyticsView({ setHover({ type: "file", path: file.path }) } onMouseLeave={() => setHover(null)} - onClick={() => togglePin({ type: "file", path: file.path })} + onClick={() => togglePin("file", file.path)} style={rowHighlight( - pin?.type === "file" && pin.path === file.path, + pins.file === file.path, hover?.type === "file" && hover.path === file.path, )} > @@ -428,7 +397,7 @@ export function ProductAnalyticsView({ slice data, so it blanks while a slice is active. */} - {fileSliceCountry !== null || dayValuesLists + {effCountry !== null || day !== null ? "—" : numberFormat.format( Math.round(breakdowns.otherFiles.requests), @@ -437,7 +406,7 @@ export function ProductAnalyticsView({ - {fileSliceCountry !== null || dayValuesLists + {effCountry !== null || day !== null ? "—" : formatBytes(breakdowns.otherFiles.bytes, 1)} diff --git a/src/components/features/analytics/panels.tsx b/src/components/features/analytics/panels.tsx index 265adfb5..172896c0 100644 --- a/src/components/features/analytics/panels.tsx +++ b/src/components/features/analytics/panels.tsx @@ -122,9 +122,9 @@ export function HoverCaption({ /> )} - {hovered !== null - ? formatDateSSR(days[hovered].date) - : (filterLabel ?? `${days.length}-day downloads`)} + {[filterLabel, hovered !== null ? formatDateSSR(days[hovered].date) : null] + .filter(Boolean) + .join(" · ") || `${days.length}-day downloads`} ); @@ -135,11 +135,14 @@ export function DownloadsChart({ days, hovered, onHover, + onSelect, height, }: { days: UsagePoint[]; hovered: number | null; onHover: (index: number | null) => void; + /** Bar click, for pinning a day */ + onSelect?: (index: number | null) => void; height: number; }) { return ( @@ -167,6 +170,11 @@ export function DownloadsChart({ onMouseMove={(state) => onHover(parseActiveIndex(state?.activeTooltipIndex, days.length)) } + onClick={ + onSelect && + ((state) => + onSelect(parseActiveIndex(state?.activeTooltipIndex, days.length))) + } > From 90b7b46a84a1d12cb995bceb4135fd6815385f3a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 29 Jul 2026 10:11:29 -0700 Subject: [PATCH 8/8] feat(analytics): separate pinned-filter caption from hover caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins get their own persistent line above the chart (green square, 'PINNED: …' with the pinned country/file/date), and the line below is purely transient hover state (hovered date or row preview), so the two no longer overwrite each other in the same slot. Co-Authored-By: Claude Fable 5 --- .../analytics/ProductAnalyticsView.tsx | 34 ++++++++++++++++--- src/components/features/analytics/panels.tsx | 8 ++--- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 8d513a7f..e3756b91 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -9,7 +9,7 @@ import type { UsageTotals, UsageUsers, } from "@/lib/clients/analytics"; -import { formatBytes } from "@/lib/format"; +import { formatBytes, formatDateSSR } from "@/lib/format"; import { objectUrl } from "@/lib/urls"; import { DownloadsChart, @@ -188,10 +188,22 @@ export function ProductAnalyticsView({ const countryRowLabel = (code: string) => countryRows.find((row) => row.code === code)?.label; - const filterLabel = - [effCountry !== null ? countryRowLabel(effCountry) : null, effFile] + // Persistent pins live on their own caption line above the transient hover + // line, so the two states stay visually distinct. + const pinnedLabel = + [ + pins.country !== null ? countryRowLabel(pins.country) : null, + pins.file, + pins.day !== null ? formatDateSSR(days[pins.day].date) : null, + ] .filter(Boolean) .join(" · ") || null; + const hoverLabel = + hover && hover.type !== "day" + ? hover.type === "country" + ? countryRowLabel(hover.code) + : hover.path + : null; const togglePin = ( dim: K, value: NonNullable<(typeof pins)[K]>, @@ -251,7 +263,21 @@ export function ProductAnalyticsView({ - + {pinnedLabel && ( + + + pinned: {pinnedLabel} + + )} + )} - {[filterLabel, hovered !== null ? formatDateSSR(days[hovered].date) : null] - .filter(Boolean) - .join(" · ") || `${days.length}-day downloads`} + {hovered !== null + ? formatDateSSR(days[hovered].date) + : (filterLabel ?? `${days.length}-day downloads`)} );