diff --git a/ui/public/github-dark.svg b/ui/public/github-dark.svg new file mode 100644 index 0000000..cf8a0ab --- /dev/null +++ b/ui/public/github-dark.svg @@ -0,0 +1,14 @@ + + + github + + diff --git a/ui/public/mozilla-dark.svg b/ui/public/mozilla-dark.svg new file mode 100644 index 0000000..321ce73 --- /dev/null +++ b/ui/public/mozilla-dark.svg @@ -0,0 +1,30 @@ + + + + + diff --git a/ui/src/components/SummaryEntry.tsx b/ui/src/components/SummaryEntry.tsx index 4c5cb89..21dbed9 100644 --- a/ui/src/components/SummaryEntry.tsx +++ b/ui/src/components/SummaryEntry.tsx @@ -1,7 +1,10 @@ +import { Fragment } from 'react'; import { Link } from 'react-router-dom'; import { Calendar3 } from 'react-bootstrap-icons'; import { VerticalTimelineElement } from 'react-vertical-timeline-component'; import type { RepoPeriodCount, SummaryBucket } from '../api/client'; +import { LanguageBar } from './LanguageBar'; +import { forgeIcon } from '../lib/forge'; import { fmtDate } from '../lib/ranges'; interface Props { @@ -10,11 +13,15 @@ interface Props { bucket: SummaryBucket; /** Rows for this period, already sorted by count descending. */ repos: RepoPeriodCount[]; + /** Bytes-by-language keyed by `source:repo` (see useRepoLanguages). */ + langsByRepo: Map>; + colorMap: Record; } -/** One timeline card summarising a period: each repo touched and how many - * changes landed in it, with a drill-down link to the per-event view. */ -export function SummaryEntry({ period, bucket, repos }: Props) { +/** One timeline card summarising a period: each repo touched, its language + * mix and how many changes landed in it, with a drill-down link to the + * per-event view. */ +export function SummaryEntry({ period, bucket, repos, langsByRepo, colorMap }: Props) { const total = repos.reduce((sum, r) => sum + r.count, 0); const end = periodEnd(period, bucket); const timespan = bucket === 'day' ? period : `${period}..${end}`; @@ -29,20 +36,38 @@ export function SummaryEntry({ period, bucket, repos }: Props) { {total} {total === 1 ? 'change' : 'changes'} in {repos.length}{' '} {repos.length === 1 ? 'repository' : 'repositories'} - +
+ {repos.map((r) => { + const icon = forgeIcon(r.source, /* onLight */ true); + const langs = langsByRepo.get(`${r.source}:${r.repo}`); + return ( + + + {icon && {r.source}} + {r.repo} + {!icon && ( + <> + {' '} + {r.source} + + )} + + + {langs && } + + {r.count} + + ); + })} +
view activity → diff --git a/ui/src/lib/forge.ts b/ui/src/lib/forge.ts new file mode 100644 index 0000000..57b0456 --- /dev/null +++ b/ui/src/lib/forge.ts @@ -0,0 +1,19 @@ +/** Icon asset for a forge source, or null when we have none (e.g. bugzilla — + * callers fall back to a text label). + * + * The default assets are white-filled for the dark dashboard cards; pass + * `onLight` for surfaces with a light background (e.g. the timeline cards), + * which swaps in dark-filled variants where the white ones would vanish. */ +export function forgeIcon(source: string, onLight = false): string | null { + switch (source) { + case 'github': + return onLight ? '/github-dark.svg' : '/github.svg'; + case 'gitea': + // The gitea mark is a green pot on a white tile — it reads on both. + return '/gitea.svg'; + case 'hg': + return onLight ? '/mozilla-dark.svg' : '/mozilla.svg'; + default: + return null; + } +} diff --git a/ui/src/lib/useRepoLanguages.ts b/ui/src/lib/useRepoLanguages.ts new file mode 100644 index 0000000..19a4d09 --- /dev/null +++ b/ui/src/lib/useRepoLanguages.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchRepoLanguages } from '../api/client'; + +/** The repo-languages query shaped for rendering: bytes-by-language keyed by + * `source:repo`, plus a global language → color map. */ +export function useRepoLanguages(enabled = true) { + const langsQ = useQuery({ + queryKey: ['repo-languages'], + queryFn: fetchRepoLanguages, + staleTime: 10 * 60_000, + enabled, + }); + + const langsByRepo = useMemo(() => { + const map = new Map>(); + for (const entry of langsQ.data ?? []) { + const key = `${entry.source}:${entry.repo}`; + if (!map.has(key)) map.set(key, {}); + map.get(key)![entry.language] = entry.bytes; + } + return map; + }, [langsQ.data]); + + const colorMap = useMemo(() => { + const map: Record = {}; + for (const e of langsQ.data ?? []) { + if (e.color && !map[e.language]) map[e.language] = e.color; + } + return map; + }, [langsQ.data]); + + return { langsByRepo, colorMap }; +} diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index bc5d62d..38894ba 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -1,15 +1,16 @@ -import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; -import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client'; +import { fetchProjects, type ProjectSummary } from '../api/client'; import { LanguageBar } from '../components/LanguageBar'; import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph'; import { ContributionStats } from '../components/ContributionStats'; import { LanguageStreamGraph } from '../components/LanguageStreamGraph'; import { TopLanguages } from '../components/TopLanguages'; +import { forgeIcon } from '../lib/forge'; +import { useRepoLanguages } from '../lib/useRepoLanguages'; export function DashPage() { const projectsQ = useQuery({ @@ -18,29 +19,7 @@ export function DashPage() { refetchInterval: 60_000, }); - const langsQ = useQuery({ - queryKey: ['repo-languages'], - queryFn: fetchRepoLanguages, - staleTime: 10 * 60_000, - }); - - const langsByRepo = useMemo(() => { - const map = new Map>(); - for (const entry of langsQ.data ?? []) { - const key = `${entry.source}:${entry.repo}`; - if (!map.has(key)) map.set(key, {}); - map.get(key)![entry.language] = entry.bytes; - } - return map; - }, [langsQ.data]); - - const langColors = useMemo(() => { - const map: Record = {}; - for (const e of langsQ.data ?? []) { - if (e.color && !map[e.language]) map[e.language] = e.color; - } - return map; - }, [langsQ.data]); + const { langsByRepo, colorMap: langColors } = useRepoLanguages(); const projects = projectsQ.data ?? []; const ranked = rankProjects(projects); @@ -84,10 +63,11 @@ export function DashPage() { } function ProjectCard({ project: p, langs, colorMap }: { project: ProjectSummary; langs: Record | null; colorMap: Record }) { + const icon = forgeIcon(p.source); return (
-
{p.source}{p.repo}
+
{icon && {p.source}}{p.repo}
{langs && }
{p.commit_count > 0 && {p.commit_count} commits} @@ -102,15 +82,6 @@ function ProjectCard({ project: p, langs, colorMap }: { project: ProjectSummary; ); } -function forgeIcon(source: string): string { - switch (source) { - case 'github': return '/github.svg'; - case 'gitea': return '/gitea.svg'; - case 'hg': return '/mozilla.svg'; - default: return '/github.svg'; - } -} - function formatRange(first: string | null, last: string | null): string { const fmt = (iso: string) => new Date(iso) diff --git a/ui/src/pages/ProjectPage.tsx b/ui/src/pages/ProjectPage.tsx index e6ec4c7..3d239ed 100644 --- a/ui/src/pages/ProjectPage.tsx +++ b/ui/src/pages/ProjectPage.tsx @@ -9,6 +9,7 @@ import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Sourc import { LanguageBar } from '../components/LanguageBar'; import { Markdown } from '../components/Markdown'; import { TimelineEntry } from '../components/TimelineEntry'; +import { forgeIcon } from '../lib/forge'; export function ProjectPage() { const { source, '*': repoPath } = useParams(); @@ -73,7 +74,7 @@ export function ProjectPage() { <> -

{source}{repo}

+

{forgeIcon(source ?? '') && {source}}{repo}

{langs && }
@@ -117,13 +118,3 @@ function repoUrl(source: string, host: string, repo: string): string { } } -function forgeIcon(source: string): string { - switch (source) { - case 'github': return '/github.svg'; - case 'gitea': return '/gitea.svg'; - case 'hg': return '/mozilla.svg'; - default: return '/github.svg'; - } -} - - diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index 460ac50..52601d2 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -19,6 +19,7 @@ import { import { Filters } from '../components/Filters'; import { SummaryEntry } from '../components/SummaryEntry'; import { TimelineEntry } from '../components/TimelineEntry'; +import { useRepoLanguages } from '../lib/useRepoLanguages'; import { defaultActivityRange, endOfTodayMs, @@ -137,6 +138,9 @@ export function TimelineHome() { enabled: summaryMode, }); + // Language mix per repo for the summary cards' distribution bars. + const { langsByRepo, colorMap } = useRepoLanguages(summaryMode); + // Group the flat rows into periods. The API orders by period desc then // count desc, so Map insertion order is already the display order. const periods = useMemo(() => { @@ -222,6 +226,8 @@ export function TimelineHome() { period={period} bucket={bucket} repos={repos} + langsByRepo={langsByRepo} + colorMap={colorMap} /> )) : events.map((item) => ( diff --git a/ui/src/prerender/prefetch.ts b/ui/src/prerender/prefetch.ts index 7063a0e..064cec2 100644 --- a/ui/src/prerender/prefetch.ts +++ b/ui/src/prerender/prefetch.ts @@ -80,6 +80,7 @@ async function prefetchActivity(qc: QueryClient): Promise { const range = summaryRange('day', endOfTodayMs()); await Promise.all([ qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }), + qc.prefetchQuery({ queryKey: ['repo-languages'], queryFn: fetchRepoLanguages }), qc.prefetchQuery({ queryKey: ['activity-summary', range.fromStr, range.toStr, 'day', ALL_SOURCES], queryFn: () =>