Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions apps/web/src/components/ForageApp.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { LogOut, Moon, Sun } from "@lucide/svelte";
import { Database, LogOut, Moon, Sun } from "@lucide/svelte";
import { onMount } from "svelte";
import ImportPanel from "./ImportPanel.svelte";
import LibraryFilters from "./LibraryFilters.svelte";
Expand Down Expand Up @@ -40,6 +40,7 @@
let activeImportSession: RepositoryImportSession | null = null;
let themeLabel = "Light";
let isDarkTheme = false;
let initialLoadPending = true;
let state: AppState = createInitialState(workerOrigin);

$: importRunning = Boolean(activeImportSession) || state.importRun?.status === "running";
Expand Down Expand Up @@ -70,7 +71,7 @@
onMount(() => {
api = new WorkerApi(state.workerOrigin);
updateThemeState(applyTheme(getCurrentTheme()));
void refreshState();
void refreshState({ initial: true });
});

function patchState(patch: Partial<AppState>) {
Expand All @@ -84,8 +85,12 @@
updateThemeState(toggleStoredTheme());
}

async function refreshState() {
patchState(await loadAppStateSnapshot(api));
async function refreshState({ initial = false } = {}) {
try {
patchState(await loadAppStateSnapshot(api));
} finally {
if (initial) initialLoadPending = false;
}
}

async function logout() {
Expand Down Expand Up @@ -213,7 +218,12 @@
}
</script>

<main class="shell" id="forage-app" data-worker-origin={state.workerOrigin}>
<main
class="shell"
id="forage-app"
data-worker-origin={state.workerOrigin}
aria-busy={initialLoadPending}
>
<header class="topbar">
<div>
<p class="eyebrow">Forage</p>
Expand All @@ -239,9 +249,14 @@
<span id="theme-toggle-label">{themeLabel}</span>
</button>
<span id="session-badge" class:success={state.authenticated} class="status-badge">
{state.authenticated ? "Authenticated" : state.sessionStatus}
{initialLoadPending ? "Loading" : state.authenticated ? "Authenticated" : state.sessionStatus}
</span>
{#if !state.authenticated}
{#if initialLoadPending}
<button class="button secondary loading-action" type="button" disabled>
<span class="loading-spinner compact" aria-hidden="true"></span>
Loading
</button>
{:else if !state.authenticated}
<a id="connect-link" href={api?.connectUrl() ?? `${state.workerOrigin}/auth/github`} class="button">
Connect GitHub
</a>
Expand All @@ -254,6 +269,16 @@
</div>
</header>

{#if initialLoadPending}
<section class="loading-panel" aria-live="polite">
<span class="loading-spinner" aria-hidden="true"></span>
<div>
<strong>Loading your browser library</strong>
<span>Checking GitHub session, local data, settings, and saved analysis.</span>
</div>
</section>
{/if}

<MetricGrid
repositoryCount={state.repositoryCount}
topLanguage={state.topLanguage}
Expand All @@ -262,7 +287,7 @@
authenticated={state.authenticated}
/>

{#if state.repositoryCount > 0}
{#if state.repositoryCount > 0 && state.localLibraryConflict}
<section id="local-library-notice" class="notice" class:warning={state.localLibraryConflict}>
<strong id="local-library-notice-title">
{getLocalLibraryNoticeTitle(state.localLibraryConflict, state.authenticated)}
Expand All @@ -276,6 +301,19 @@
})}
</span>
</section>
{:else if state.repositoryCount > 0}
<section id="local-library-notice" class="local-library-note" aria-label="Local library status">
<Database size={15} aria-hidden="true" />
<strong>Local data</strong>
<span>
{getLocalLibraryNoticeBody({
authenticated: state.authenticated,
localLibraryConflict: state.localLibraryConflict,
localLibraryOwner: state.localLibraryOwner,
sessionLogin: state.sessionUser?.login ?? null,
})}
</span>
</section>
{/if}

<section class="workspace-grid">
Expand Down
53 changes: 49 additions & 4 deletions apps/web/src/components/MetricGrid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /^(?<value>.+?)\s+\((?<count>[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 = /^(?<status>.+?)\s+\((?<count>[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());
</script>

<section class="metric-grid" aria-label="Library status">
<article class="metric-panel">
<span class="metric-label">Stored repos</span>
<strong id="repository-count">{repositoryCount}</strong>
<strong id="repository-count" class="metric-value">{repositoryCount.toLocaleString()}</strong>
<span class="metric-detail">Browser-local records</span>
</article>
<article class="metric-panel">
<span class="metric-label">Top language</span>
<strong id="top-language">{topLanguage}</strong>
<strong id="top-language" class="metric-value">{topLanguageMetric.value}</strong>
<span class="metric-detail">{topLanguageMetric.detail}</span>
</article>
<article class="metric-panel">
<span class="metric-label">Latest import</span>
<strong id="latest-import">{latestImport}</strong>
<strong id="latest-import" class="metric-value">{latestImportMetric.value}</strong>
<span class="metric-detail">{latestImportMetric.detail}</span>
</article>
<article class="metric-panel">
<span class="metric-label">GitHub user</span>
<strong id="github-user" class:muted-value={!authenticated}>{user}</strong>
<strong id="github-user" class="metric-value" class:muted-value={!authenticated}>{userMetric.value}</strong>
<span class="metric-detail">{userMetric.detail}</span>
</article>
</section>
23 changes: 23 additions & 0 deletions apps/web/src/components/MetricGrid.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
107 changes: 104 additions & 3 deletions apps/web/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -74,6 +75,7 @@
--focus-ring: rgb(103 164 220 / 28%);
--toggle-bg: #263631;
--toggle-icon: #f1c96b;
--accent-soft: #233a4c;
}

* {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
}
Expand All @@ -840,6 +935,12 @@ h2 {
}
}

@keyframes forage-spin {
to {
transform: rotate(360deg);
}
}

@media (max-width: 520px) {
.metric-grid,
.library-controls,
Expand Down
Loading