From 6bdaeae0b63c09870e2d6c5f47e1eb317c5c5b4e Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Fri, 11 Sep 2026 12:25:41 -0300 Subject: [PATCH] Show unique traffic on cards and add a Charts catalog tab Cards now surface unique visitors and unique cloners next to totals. A Charts tab ranks the filtered catalog: top unique views/clones, a 14-day pulse, unique visitors by language, and unique visitors per star. --- web/src/components/CatalogCharts.svelte | 88 +++++++++ web/src/components/CatalogCharts.test.js | 60 ++++++ web/src/components/RankChart.svelte | 32 ++++ web/src/components/RepoCard.svelte | 10 +- web/src/components/RepoCard.test.js | 6 + web/src/lib/trafficCharts.js | 122 +++++++++++++ web/src/lib/trafficCharts.test.js | 140 ++++++++++++++ web/src/pages/Home.svelte | 44 +++-- web/src/pages/Home.test.js | 32 ++++ web/static/css/atlas.css | 223 +++++++++++++++++++++++ web/static/js/sw.js | 2 +- 11 files changed, 738 insertions(+), 21 deletions(-) create mode 100644 web/src/components/CatalogCharts.svelte create mode 100644 web/src/components/CatalogCharts.test.js create mode 100644 web/src/components/RankChart.svelte create mode 100644 web/src/lib/trafficCharts.js create mode 100644 web/src/lib/trafficCharts.test.js diff --git a/web/src/components/CatalogCharts.svelte b/web/src/components/CatalogCharts.svelte new file mode 100644 index 0000000..9c069ae --- /dev/null +++ b/web/src/components/CatalogCharts.svelte @@ -0,0 +1,88 @@ + + +{#if tracked.length === 0} +
+ No traffic snapshots in this catalog yet. + Charts fill in after traffic jobs run for repositories you can push to. +
+{:else} +
+
+
+ Unique visitors + {formatCount(totals.viewUniques)} + {formatCount(totals.tracked)} of {formatCount(totals.loaded)} repos +
+
+ Unique cloners + {formatCount(totals.cloneUniques)} + Summed GitHub windows +
+
+ Views + {formatCount(totals.views)} + Last 14 days +
+
+ Clones + {formatCount(totals.clones)} + Last 14 days +
+
+ + + + + {#if pulse.length} +
+
+

Last 14 days

+

Unique visitors and cloners summed across this catalog.

+
+ +

+ Unique visitors + Unique cloners +

+
+ {/if} + + + +
+{/if} diff --git a/web/src/components/CatalogCharts.test.js b/web/src/components/CatalogCharts.test.js new file mode 100644 index 0000000..36d99e2 --- /dev/null +++ b/web/src/components/CatalogCharts.test.js @@ -0,0 +1,60 @@ +import { render, screen, within } from "@testing-library/svelte"; +import { describe, expect, test } from "vitest"; +import CatalogCharts from "./CatalogCharts.svelte"; + +const repos = [ + { + name: "cais", + language: "Go", + stargazers_count: 2, + traffic: { + available: true, + views: 80, + view_uniques: 70, + clones: 5, + clone_uniques: 4, + views_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 10, uniques: 8 }], + clones_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 2, uniques: 1 }], + }, + }, + { + name: "atlas", + language: "Python", + stargazers_count: 40, + traffic: { + available: true, + views: 20, + view_uniques: 12, + clones: 90, + clone_uniques: 60, + }, + }, + { name: "silent", language: "Rust", stargazers_count: 1 }, +]; + +describe("CatalogCharts", () => { + test("explains when the catalog has no traffic snapshots", () => { + render(CatalogCharts, { props: { repos: [{ name: "silent" }] } }); + expect(screen.getByText(/No traffic snapshots/)).toBeInTheDocument(); + }); + + test("shows top unique visitors and unique cloners", () => { + render(CatalogCharts, { props: { repos } }); + const visitors = screen.getByRole("region", { name: "Top unique visitors" }); + expect(within(visitors).getByText("cais")).toBeInTheDocument(); + expect(within(visitors).getByText("70")).toBeInTheDocument(); + const cloners = screen.getByRole("region", { name: "Top unique cloners" }); + expect(within(cloners).getByText("atlas")).toBeInTheDocument(); + expect(within(cloners).getByText("60")).toBeInTheDocument(); + }); + + test("shows catalog totals, language mix, pulse, and quiet stars", () => { + render(CatalogCharts, { props: { repos } }); + expect(screen.getByText("82")).toBeInTheDocument(); + expect(screen.getByText("64")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Unique visitors by language" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Last 14 days" })).toBeInTheDocument(); + const quiet = screen.getByRole("region", { name: "Quiet stars" }); + expect(within(quiet).getByText("cais")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/RankChart.svelte b/web/src/components/RankChart.svelte new file mode 100644 index 0000000..b242f46 --- /dev/null +++ b/web/src/components/RankChart.svelte @@ -0,0 +1,32 @@ + + +
+
+

{title}

+ {#if caption}

{caption}

{/if} +
+ {#if rows.length} +
    + {#each rows as row} +
  1. + {row[nameKey]} + + {formatCount(row.value)} +
  2. + {/each} +
+ {:else} +

Nothing with traffic in this slice.

+ {/if} +
diff --git a/web/src/components/RepoCard.svelte b/web/src/components/RepoCard.svelte index 1f925cc..de6f16d 100644 --- a/web/src/components/RepoCard.svelte +++ b/web/src/components/RepoCard.svelte @@ -37,12 +37,14 @@ ★ {formatCount(repo.stargazers_count)} {@html FORK_ICON} {formatCount(repo.forks_count)} - {#if traffic.available} - ↗ {formatCount(traffic.views)} - ↓ {formatCount(traffic.clones)} - {/if} + {#if traffic.available} +
+ ↗ {formatCount(traffic.views)} · {formatCount(traffic.view_uniques)} unique + ↓ {formatCount(traffic.clones)} · {formatCount(traffic.clone_uniques)} unique +
+ {/if}

{repo.name}

{#if repo.fork || repo.archived || repo.private}
diff --git a/web/src/components/RepoCard.test.js b/web/src/components/RepoCard.test.js index 26e1f8c..8674728 100644 --- a/web/src/components/RepoCard.test.js +++ b/web/src/components/RepoCard.test.js @@ -33,6 +33,12 @@ describe("RepoCard", () => { expect(within(dialog).getByText("342")).toBeInTheDocument(); }); + test("shows unique views and unique clones on the card", () => { + render(RepoCard, { props: { repo, index: 0 } }); + expect(screen.getByTitle("17 views · 3 unique")).toHaveTextContent("3 unique"); + expect(screen.getByTitle("342 clones · 104 unique")).toHaveTextContent("104 unique"); + }); + test("shows a Private badge on private repositories", () => { render(RepoCard, { props: { repo: { ...repo, private: true, traffic: {} }, index: 0 } }); expect(screen.getByText("Private")).toBeInTheDocument(); diff --git a/web/src/lib/trafficCharts.js b/web/src/lib/trafficCharts.js new file mode 100644 index 0000000..e830efe --- /dev/null +++ b/web/src/lib/trafficCharts.js @@ -0,0 +1,122 @@ +export function trafficRepos(repos) { + return (repos || []).filter((repo) => repo.traffic?.available); +} + +function withShare(rows, valueOf) { + const peak = Math.max(1, ...rows.map(valueOf)); + return rows.map((row) => ({ ...row, share: Math.round((valueOf(row) / peak) * 100) })); +} + +export function rankTraffic(repos, key, limit = 10) { + const rows = trafficRepos(repos) + .map((repo) => ({ + name: repo.name, + language: repo.language || "Not specified", + stars: repo.stargazers_count || 0, + value: Number(repo.traffic?.[key]) || 0, + })) + .filter((row) => row.value > 0) + .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)) + .slice(0, limit); + return withShare(rows, (row) => row.value); +} + +function sumField(repos, key) { + return trafficRepos(repos).reduce((total, repo) => total + (Number(repo.traffic?.[key]) || 0), 0); +} + +export function catalogTrafficTotals(repos) { + return { + tracked: trafficRepos(repos).length, + loaded: (repos || []).length, + views: sumField(repos, "views"), + viewUniques: sumField(repos, "view_uniques"), + clones: sumField(repos, "clones"), + cloneUniques: sumField(repos, "clone_uniques"), + }; +} + +function emptyDay(date) { + return { date, views: 0, viewUniques: 0, clones: 0, cloneUniques: 0 }; +} + +function addDayPoints(byDay, points, countKey, uniqueKey) { + for (const point of points || []) { + const date = (point.timestamp || "").slice(0, 10); + if (!date) continue; + const row = byDay.get(date) || emptyDay(date); + row[countKey] += point.count || 0; + row[uniqueKey] += point.uniques || 0; + byDay.set(date, row); + } +} + +function fillDaySpan(rows) { + if (rows.length < 2) return rows; + const byDate = new Map(rows.map((row) => [row.date, row])); + const start = Date.parse(`${rows[0].date}T00:00:00Z`); + const end = Date.parse(`${rows[rows.length - 1].date}T00:00:00Z`); + const out = []; + for (let ts = start; ts <= end; ts += 864e5) { + const date = new Date(ts).toISOString().slice(0, 10); + out.push(byDate.get(date) || emptyDay(date)); + } + return out; +} + +export function dailyCatalogPulse(repos) { + const byDay = new Map(); + for (const repo of trafficRepos(repos)) { + addDayPoints(byDay, repo.traffic.views_by_day, "views", "viewUniques"); + addDayPoints(byDay, repo.traffic.clones_by_day, "clones", "cloneUniques"); + } + const rows = [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date)); + return fillDaySpan(rows); +} + +function pulseHeight(value, peak) { + if (value <= 0) return "0%"; + return `${Math.max(6, Math.round((value / peak) * 100))}%`; +} + +export function pulseBars(days) { + const peak = Math.max(1, ...(days || []).flatMap((day) => [day.viewUniques, day.cloneUniques])); + return (days || []).map((day) => { + const utc = new Date(`${day.date}T00:00:00Z`); + return { + ...day, + label: utc.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }), + viewHeight: pulseHeight(day.viewUniques, peak), + cloneHeight: pulseHeight(day.cloneUniques, peak), + }; + }); +} + +export function uniqueVisitorsByLanguage(repos, limit = 8) { + const totals = new Map(); + for (const repo of trafficRepos(repos)) { + const language = repo.language || "Not specified"; + totals.set(language, (totals.get(language) || 0) + (repo.traffic.view_uniques || 0)); + } + const ranked = [...totals.entries()] + .map(([language, value]) => ({ language, value })) + .filter((row) => row.value > 0) + .sort((a, b) => b.value - a.value); + const head = ranked.slice(0, limit); + const rest = ranked.slice(limit).reduce((total, row) => total + row.value, 0); + if (rest > 0) head.push({ language: "Other", value: rest }); + return withShare(head, (row) => row.value); +} + +export function quietStarsRank(repos, limit = 10) { + const rows = trafficRepos(repos) + .map((repo) => { + const uniques = repo.traffic.view_uniques || 0; + const stars = repo.stargazers_count || 0; + return { name: repo.name, uniques, stars, score: uniques / Math.max(stars, 1) }; + }) + .filter((row) => row.uniques > 0) + .sort((a, b) => b.score - a.score || b.uniques - a.uniques) + .slice(0, limit); + return withShare(rows, (row) => row.score); +} diff --git a/web/src/lib/trafficCharts.test.js b/web/src/lib/trafficCharts.test.js new file mode 100644 index 0000000..9ae8b60 --- /dev/null +++ b/web/src/lib/trafficCharts.test.js @@ -0,0 +1,140 @@ +import { describe, expect, test } from "vitest"; +import { + catalogTrafficTotals, + dailyCatalogPulse, + pulseBars, + quietStarsRank, + rankTraffic, + trafficRepos, + uniqueVisitorsByLanguage, +} from "./trafficCharts.js"; + +const catalog = [ + { + name: "alpha", + language: "Go", + stargazers_count: 100, + traffic: { + available: true, + views: 50, + view_uniques: 40, + clones: 10, + clone_uniques: 8, + views_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 7, uniques: 3 }], + clones_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 2, uniques: 1 }], + }, + }, + { + name: "beta", + language: "Go", + stargazers_count: 2, + traffic: { + available: true, + views: 80, + view_uniques: 70, + clones: 5, + clone_uniques: 4, + views_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 10, uniques: 8 }], + clones_by_day: [{ timestamp: "2026-08-28T00:00:00Z", count: 5, uniques: 4 }], + }, + }, + { + name: "gamma", + language: "Python", + stargazers_count: 10, + traffic: { + available: true, + views: 20, + view_uniques: 5, + clones: 90, + clone_uniques: 60, + }, + }, + { name: "delta", language: "Rust", stargazers_count: 1 }, +]; + +describe("trafficRepos", () => { + test("keeps only repositories with a traffic snapshot", () => { + expect(trafficRepos(catalog).map((repo) => repo.name)).toEqual(["alpha", "beta", "gamma"]); + }); +}); + +describe("rankTraffic", () => { + test("ranks top unique visitors and unique cloners", () => { + expect(rankTraffic(catalog, "view_uniques", 10).map((row) => row.name)).toEqual(["beta", "alpha", "gamma"]); + expect(rankTraffic(catalog, "clone_uniques", 2).map((row) => [row.name, row.value])).toEqual([ + ["gamma", 60], + ["alpha", 8], + ]); + }); + + test("scales bar share against the peak value", () => { + const [lead, second] = rankTraffic(catalog, "view_uniques", 10); + expect(lead.share).toBe(100); + expect(second.share).toBe(57); + }); +}); + +describe("catalogTrafficTotals", () => { + test("sums GitHub windows across tracked repositories", () => { + expect(catalogTrafficTotals(catalog)).toEqual({ + tracked: 3, + loaded: 4, + views: 150, + viewUniques: 115, + clones: 105, + cloneUniques: 72, + }); + }); +}); + +describe("dailyCatalogPulse", () => { + test("merges daily unique views and clones by date", () => { + expect(dailyCatalogPulse(catalog)).toEqual([ + { date: "2026-08-27", views: 17, viewUniques: 11, clones: 2, cloneUniques: 1 }, + { date: "2026-08-28", views: 0, viewUniques: 0, clones: 5, cloneUniques: 4 }, + ]); + }); + + test("fills missing dates between the first and last day", () => { + const sparse = [ + { + name: "alpha", + traffic: { + available: true, + views_by_day: [ + { timestamp: "2026-08-27T00:00:00Z", count: 1, uniques: 1 }, + { timestamp: "2026-08-29T00:00:00Z", count: 2, uniques: 2 }, + ], + }, + }, + ]; + expect(dailyCatalogPulse(sparse).map((day) => day.date)).toEqual(["2026-08-27", "2026-08-28", "2026-08-29"]); + }); +}); + +describe("pulseBars", () => { + test("labels UTC days and scales both series to the same peak", () => { + const bars = pulseBars(dailyCatalogPulse(catalog)); + expect(bars[0].label).toBe("Aug 27"); + expect(bars[0].viewHeight).toBe("100%"); + expect(bars[1].cloneHeight).toBe(`${Math.round((4 / 11) * 100)}%`); + }); +}); + +describe("uniqueVisitorsByLanguage", () => { + test("groups unique visitors by language", () => { + expect(uniqueVisitorsByLanguage(catalog).map((row) => [row.language, row.value])).toEqual([ + ["Go", 110], + ["Python", 5], + ]); + }); +}); + +describe("quietStarsRank", () => { + test("ranks repositories with the most unique visitors per star", () => { + expect(quietStarsRank(catalog).map((row) => row.name)).toEqual(["beta", "gamma", "alpha"]); + expect(quietStarsRank(catalog)[0].uniques).toBe(70); + expect(quietStarsRank(catalog)[0].stars).toBe(2); + }); +}); diff --git a/web/src/pages/Home.svelte b/web/src/pages/Home.svelte index 784aab7..3e423e0 100644 --- a/web/src/pages/Home.svelte +++ b/web/src/pages/Home.svelte @@ -2,6 +2,7 @@ import { router } from "@inertiajs/svelte"; import ThemeToggle from "../components/ThemeToggle.svelte"; import RepoCard from "../components/RepoCard.svelte"; + import CatalogCharts from "../components/CatalogCharts.svelte"; import SortModal from "../components/SortModal.svelte"; import { applyFilters, languageOptions } from "../lib/filter.js"; import { absoluteUrl, formatCount } from "../lib/format.js"; @@ -27,6 +28,7 @@ let sortOpen = false; let loading = false; let preferTraffic = false; + let view = "catalog"; $: if (!preferTraffic && (repos || []).some((repo) => repo.traffic?.available)) { sortBy = "traffic"; preferTraffic = true; @@ -235,23 +237,33 @@ {/if} {#if populated && !loading} -
- {#if visible.length === 0} -
- {#if source?.type === "org"} - Nothing in {source.login} matches.Loosen language, stars, or the homepage toggle. - {:else if source?.type === "all"} - Nothing across personal and orgs matches.Loosen language, stars, or the homepage toggle. - {:else} - Nothing matches these filters.Loosen language, stars, or the homepage toggle. - {/if} -
- {:else} - {#each visible as repo, index} - - {/each} - {/if} +
+ +
+ {#if view === "catalog"} +
+ {#if visible.length === 0} +
+ {#if source?.type === "org"} + Nothing in {source.login} matches.Loosen language, stars, or the homepage toggle. + {:else if source?.type === "all"} + Nothing across personal and orgs matches.Loosen language, stars, or the homepage toggle. + {:else} + Nothing matches these filters.Loosen language, stars, or the homepage toggle. + {/if} +
+ {:else} + {#each visible as repo, index} + + {/each} + {/if} +
+ {:else} +
+ +
+ {/if} {/if}
diff --git a/web/src/pages/Home.test.js b/web/src/pages/Home.test.js index ba1da8a..135474b 100644 --- a/web/src/pages/Home.test.js +++ b/web/src/pages/Home.test.js @@ -92,4 +92,36 @@ describe("Home", () => { await fireEvent.click(allChip); expect(router.get).toHaveBeenCalledWith("/u/puppe1990/all", {}, expect.any(Object)); }); + + test("switches from catalog cards to a charts tab", async () => { + render(Home, { + props: { + populated: true, + lookup: "octocat", + profile: { login: "octocat", name: "The Octocat", html_url: "https://github.com/octocat", avatar_url: "https://example.com/a.png", public_repos: 1, followers: 1, following: 1 }, + orgs: [], + source: { type: "user", login: "octocat" }, + repos: [ + { + name: "hello-world", + html_url: "https://github.com/octocat/hello-world", + stargazers_count: 10, + forks_count: 2, + language: "Go", + description: "demo", + updated_at: "2026-08-01T00:00:00Z", + traffic: { available: true, views: 40, view_uniques: 22, clones: 9, clone_uniques: 6 }, + }, + ], + error: "", + }, + }); + expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("tab", { name: "Charts" })); + expect(screen.getByRole("tab", { name: "Charts" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("region", { name: "Top unique visitors" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "hello-world" })).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole("tab", { name: "Catalog" })); + expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument(); + }); }); diff --git a/web/static/css/atlas.css b/web/static/css/atlas.css index fc2115f..cc6d2e5 100644 --- a/web/static/css/atlas.css +++ b/web/static/css/atlas.css @@ -967,6 +967,13 @@ a.stat:hover { flex: 0 0 auto; } +.card-traffic { + display: flex; + align-items: center; + gap: 0.85rem; + margin: -0.45rem 0 0.85rem; +} + .lang, .stars, .forks, @@ -986,6 +993,16 @@ a.stat:hover { flex: 0 0 auto; } +.views, +.clones-metric { + white-space: nowrap; +} + +.metric-uniques { + color: var(--ink-mute); + font-size: 0.64rem; +} + .lang i { width: 8px; height: 8px; @@ -1143,6 +1160,202 @@ a.stat:hover { border: 0; } +.view-tabs { + display: inline-flex; + margin: 1.15rem 0 0; + padding: 0.22rem; + gap: 0.15rem; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--bg-elev); +} + +.view-tab { + border: 0; + background: transparent; + color: var(--ink-dim); + border-radius: 999px; + padding: 0.42rem 1rem; + font: 600 0.88rem/1 var(--font-ui); + cursor: pointer; +} + +.view-tab[aria-selected="true"] { + background: var(--copper-dim); + color: var(--copper-hot); +} + +.view-tab:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--copper-dim); +} + +.catalog-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 1.1rem; +} + +.chart-totals { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + grid-column: 1 / -1; +} + +.chart-totals .traffic-stat { + margin: 0; +} + +.chart-panel { + padding: 1.05rem 1.1rem 1.15rem; + border: 1px solid var(--line); + border-radius: 20px; + background: linear-gradient(180deg, var(--card-from), var(--card-to)); + box-shadow: var(--card-shadow); +} + +.chart-panel-wide { + grid-column: 1 / -1; +} + +.chart-head { + margin-bottom: 0.95rem; +} + +.chart-head h3 { + margin: 0; + font-family: var(--font-display); + font-size: 1.55rem; + font-weight: 400; + letter-spacing: -0.02em; +} + +.chart-head p, +.chart-empty, +.chart-legend { + margin: 0.28rem 0 0; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.02em; + color: var(--ink-mute); +} + +.rank-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.55rem; +} + +.rank-row { + display: grid; + grid-template-columns: minmax(4.8rem, 7.2rem) 1fr auto; + gap: 0.65rem; + align-items: center; +} + +.rank-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--ink-dim); +} + +.rank-track { + height: 9px; + border-radius: 999px; + background: var(--inset); + overflow: hidden; +} + +.rank-fill { + display: block; + height: 100%; + width: var(--bar, 0%); + border-radius: inherit; + background: var(--copper); +} + +.rank-fill.is-clone { + background: var(--moss); +} + +.rank-value { + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 500; + color: var(--ink); +} + +.pulse-chart { + display: flex; + align-items: flex-end; + justify-content: flex-start; + gap: 0.45rem; + height: 148px; + overflow-x: auto; +} + +.pulse-col { + flex: 1 1 1.7rem; + max-width: 2.4rem; + min-width: 1.35rem; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; +} + +.pulse-pair { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 2px; + width: 100%; + height: 100%; +} + +.pulse-pair .traffic-bar { + flex: 1; + max-width: 9px; + min-height: 0; +} + +.pulse-col span { + margin-top: 0.35rem; + font-family: var(--font-mono); + font-size: 0.58rem; + color: var(--ink-mute); + white-space: nowrap; +} + +.chart-legend { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; + margin-top: 0.85rem; +} + +.chart-swatch { + display: inline-block; + width: 0.7rem; + height: 0.55rem; + border-radius: 2px; + background: var(--copper); +} + +.chart-swatch.is-clone { + background: var(--moss); + margin-left: 0.45rem; +} + @keyframes rise { from { opacity: 0; @@ -1184,6 +1397,11 @@ a.stat:hover { .profile-copy h2 { font-size: 1.55rem; } + + .catalog-charts, + .chart-totals { + grid-template-columns: 1fr 1fr; + } } @media (max-width: 560px) { @@ -1218,6 +1436,11 @@ a.stat:hover { grid-column: span 12; } + .catalog-charts, + .chart-totals { + grid-template-columns: 1fr; + } + .split, .split-sort { grid-template-columns: 1fr 1fr; diff --git a/web/static/js/sw.js b/web/static/js/sw.js index 792b019..b13bd37 100644 --- a/web/static/js/sw.js +++ b/web/static/js/sw.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = 1; +const CACHE_VERSION = 3; const CACHE = "cais-static-v" + CACHE_VERSION; const PRECACHE = [