diff --git a/apps/web/src/components/ForageApp.svelte b/apps/web/src/components/ForageApp.svelte index 5f8afae..acb4c86 100644 --- a/apps/web/src/components/ForageApp.svelte +++ b/apps/web/src/components/ForageApp.svelte @@ -1,5 +1,5 @@ -
+

Forage

@@ -239,9 +249,14 @@ {themeLabel} - {state.authenticated ? "Authenticated" : state.sessionStatus} + {initialLoadPending ? "Loading" : state.authenticated ? "Authenticated" : state.sessionStatus} - {#if !state.authenticated} + {#if initialLoadPending} + + {:else if !state.authenticated} Connect GitHub @@ -254,6 +269,16 @@
+ {#if initialLoadPending} +
+ +
+ Loading your browser library + Checking GitHub session, local data, settings, and saved analysis. +
+
+ {/if} + - {#if state.repositoryCount > 0} + {#if state.repositoryCount > 0 && state.localLibraryConflict}
{getLocalLibraryNoticeTitle(state.localLibraryConflict, state.authenticated)} @@ -276,6 +301,19 @@ })}
+ {:else if state.repositoryCount > 0} +
+
{/if}
diff --git a/apps/web/src/components/MetricGrid.svelte b/apps/web/src/components/MetricGrid.svelte index 816f5f9..2435fba 100644 --- a/apps/web/src/components/MetricGrid.svelte +++ b/apps/web/src/components/MetricGrid.svelte @@ -8,23 +8,68 @@ } let { repositoryCount, topLanguage, latestImport, user, authenticated }: Props = $props(); + + const emptyMetric = { value: "-", detail: "No data yet" }; + + function parseCountMetric(metric: string, singularLabel: string, pluralLabel: string) { + const match = /^(?.+?)\s+\((?[0-9,]+)\)$/.exec(metric); + if (!match?.groups) return metric === "-" ? emptyMetric : { value: metric, detail: "Most common" }; + + const count = match.groups.count; + const numericCount = Number.parseInt(count.replaceAll(",", ""), 10); + const unit = numericCount === 1 ? singularLabel : pluralLabel; + return { + value: match.groups.value, + detail: `${count} ${unit}`, + }; + } + + function parseImportMetric(metric: string) { + const match = /^(?.+?)\s+\((?[0-9,]+)\)$/.exec(metric); + if (!match?.groups) return metric === "-" ? emptyMetric : { value: metric, detail: "Last import run" }; + + return { + value: toTitleCase(match.groups.status), + detail: `${match.groups.count} repos imported`, + }; + } + + function getUserMetric() { + if (user === "-") return { value: "-", detail: "No GitHub session" }; + return { + value: user.replace(" (not connected)", ""), + detail: authenticated ? "Connected account" : "Local library owner", + }; + } + + function toTitleCase(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); + } + + let topLanguageMetric = $derived(parseCountMetric(topLanguage, "repo", "repos")); + let latestImportMetric = $derived(parseImportMetric(latestImport)); + let userMetric = $derived(getUserMetric());
Stored repos - {repositoryCount} + {repositoryCount.toLocaleString()} + Browser-local records
Top language - {topLanguage} + {topLanguageMetric.value} + {topLanguageMetric.detail}
Latest import - {latestImport} + {latestImportMetric.value} + {latestImportMetric.detail}
GitHub user - {user} + {userMetric.value} + {userMetric.detail}
diff --git a/apps/web/src/components/MetricGrid.test.ts b/apps/web/src/components/MetricGrid.test.ts new file mode 100644 index 0000000..b1d1740 --- /dev/null +++ b/apps/web/src/components/MetricGrid.test.ts @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/svelte"; +import { describe, expect, it } from "vitest"; +import MetricGrid from "./MetricGrid.svelte"; + +describe("MetricGrid", () => { + it("renders formatted metric values with supporting details", () => { + render(MetricGrid, { + repositoryCount: 720, + topLanguage: "JavaScript (150)", + latestImport: "completed (720)", + user: "dills122", + authenticated: true, + }); + + expect(screen.getByText("720")).toBeTruthy(); + expect(screen.getByText("Browser-local records")).toBeTruthy(); + expect(screen.getByText("JavaScript")).toBeTruthy(); + expect(screen.getByText("150 repos")).toBeTruthy(); + expect(screen.getByText("Completed")).toBeTruthy(); + expect(screen.getByText("720 repos imported")).toBeTruthy(); + expect(screen.getByText("Connected account")).toBeTruthy(); + }); +}); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 1cb33dd..71299a7 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -35,6 +35,7 @@ --focus-ring: rgb(30 90 151 / 24%); --toggle-bg: #dce5e1; --toggle-icon: #315f87; + --accent-soft: #ddebf4; background: var(--page-bg); color: var(--text); } @@ -74,6 +75,7 @@ --focus-ring: rgb(103 164 220 / 28%); --toggle-bg: #263631; --toggle-icon: #f1c96b; + --accent-soft: #233a4c; } * { @@ -241,6 +243,55 @@ h2 { color: var(--danger); } +.loading-action { + gap: 8px; +} + +.loading-panel { + display: flex; + align-items: center; + gap: 14px; + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 14px; + padding: 14px 16px; + background: var(--panel-bg); + color: var(--text-soft); +} + +.loading-panel strong, +.loading-panel span { + display: block; +} + +.loading-panel strong { + color: var(--text); + font-size: 0.98rem; +} + +.loading-panel span { + margin-top: 2px; + color: var(--muted); + font-size: 0.9rem; +} + +.loading-spinner { + display: inline-block; + flex: 0 0 auto; + width: 22px; + height: 22px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 999px; + animation: forage-spin 0.8s linear infinite; +} + +.loading-spinner.compact { + width: 14px; + height: 14px; + border-width: 2px; +} + .metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -271,6 +322,25 @@ h2 { white-space: nowrap; } +.local-library-note { + display: flex; + align-items: center; + gap: 8px; + margin: -2px 0 14px; + color: var(--muted); + font-size: 0.86rem; +} + +.local-library-note svg { + color: var(--primary); +} + +.local-library-note strong { + color: var(--text-soft); + font-weight: 750; + white-space: nowrap; +} + .metric-panel, .panel { border: 1px solid var(--border); @@ -280,11 +350,21 @@ h2 { } .metric-panel { - min-height: 108px; + position: relative; + overflow: hidden; + min-height: 118px; padding: 16px 18px; background: var(--panel-bg-strong); } +.metric-panel::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--accent-soft); + content: ""; +} + .metric-label, .section-kicker { display: block; @@ -295,11 +375,22 @@ h2 { text-transform: uppercase; } -.metric-panel strong { +.metric-value { display: block; - margin-top: 17px; + overflow: hidden; + margin-top: 14px; font-size: 1.55rem; line-height: 1.05; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-detail { + display: block; + margin-top: 8px; + color: var(--muted); + font-size: 0.88rem; + font-weight: 650; } .muted-value { @@ -819,6 +910,10 @@ h2 { flex-direction: column; } + .local-library-note { + align-items: flex-start; + } + .repo-meta { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -840,6 +935,12 @@ h2 { } } +@keyframes forage-spin { + to { + transform: rotate(360deg); + } +} + @media (max-width: 520px) { .metric-grid, .library-controls,