feat(ui): add a progress loading screen and lazy project list

The loading screen shows live scan progress from the server, with a pulsing outline of the dashboard. The server now reports how many repositories the running scan has finished.

The project list renders 20 rows first and adds more as the end scrolls into view. Groups on very wide screens fill two balanced columns, the fact figures sit in a row under the health strip, and the headline counts match the rows listed below it.
This commit is contained in:
2026-10-02 04:50:38 +01:00
parent 1852ea0885
commit a56a12a6e0
19 changed files with 552 additions and 60 deletions
+9
View File
@@ -33,6 +33,9 @@ type View struct {
Warn int
Loaded bool
Refreshing bool
// ScanDone and ScanTotal count the repos of the running (or last) scan.
ScanDone int
ScanTotal int
}
// Stats are monotonic counters and timings of the refresh loop.
@@ -56,6 +59,8 @@ type Collector struct {
th config.Thresholds
interval time.Duration
refreshes atomic.Uint64
scanDone atomic.Int64
scanTotal atomic.Int64
repoErrs atomic.Uint64
lastDur time.Duration
concurrency int
@@ -118,6 +123,8 @@ func (c *Collector) Refresh(ctx context.Context) {
start := c.now()
c.limited.Store(false)
c.scanDone.Store(0)
c.scanTotal.Store(int64(len(c.names)))
results := make([]model.Repo, len(c.names))
sem := make(chan struct{}, c.concurrency)
var wg sync.WaitGroup
@@ -128,6 +135,7 @@ func (c *Collector) Refresh(ctx context.Context) {
defer wg.Done()
defer func() { <-sem }()
results[i] = c.refreshOne(ctx, name, start)
c.scanDone.Add(1)
}()
}
wg.Wait()
@@ -193,6 +201,7 @@ func (c *Collector) View() View {
v := View{
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
ScanDone: int(c.scanDone.Load()), ScanTotal: int(c.scanTotal.Load()),
}
v.Repos = make([]model.Repo, 0, len(c.names))
for _, n := range c.names {
+25
View File
@@ -312,3 +312,28 @@ func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
t.Fatalf("got %q", got)
}
}
func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{}, 4)
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
started <- struct{}{}
<-release
return model.Repo{FullName: name}, nil
}}
c := newCollector(f, "o/a", "o/b", "o/c")
if v := c.View(); v.ScanDone != 0 || v.ScanTotal != 0 {
t.Fatalf("before any scan: %+v", v)
}
done := make(chan struct{})
go func() { c.Refresh(context.Background()); close(done) }()
<-started
if v := c.View(); v.ScanTotal != 3 || v.ScanDone != 0 || !v.Refreshing {
t.Fatalf("during scan: done=%d total=%d refreshing=%v", v.ScanDone, v.ScanTotal, v.Refreshing)
}
close(release)
<-done
if v := c.View(); v.ScanDone != 3 || v.ScanTotal != 3 {
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
}
}
+8
View File
@@ -16,6 +16,7 @@ type apiState struct {
Alerts []apiAlert `json:"alerts"`
Thresholds apiThresholds `json:"thresholds"`
Counts apiCounts `json:"counts"`
Scan apiScan `json:"scan"`
IntervalSeconds int64 `json:"intervalSeconds"`
Loaded bool `json:"loaded"`
Refreshing bool `json:"refreshing"`
@@ -30,6 +31,12 @@ type apiThresholds struct {
WorkflowDormant int64 `json:"workflowDormantSeconds"`
}
// apiScan reports how far the running scan has got, for the loading screen.
type apiScan struct {
Done int `json:"done"`
Total int `json:"total"`
}
type apiCounts struct {
Crit int `json:"crit"`
Warn int `json:"warn"`
@@ -98,6 +105,7 @@ func buildState(v collector.View, manualRefresh bool) apiState {
Refreshing: v.Refreshing,
ManualRefresh: manualRefresh,
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
Scan: apiScan{Done: v.ScanDone, Total: v.ScanTotal},
Thresholds: apiThresholds{
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
+5 -1
View File
@@ -45,7 +45,7 @@ func testView() collector.View {
}
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
return collector.View{
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1,
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, ScanDone: 5, ScanTotal: 9,
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
Repos: []model.Repo{repo, bare},
Alerts: []model.Alert{
@@ -125,6 +125,7 @@ func TestStateJSONContract(t *testing.T) {
Repos []map[string]any
Alerts []map[string]any
Counts struct{ Crit, Warn int }
Scan struct{ Done, Total int }
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
IntervalSeconds int64
Loaded bool
@@ -133,6 +134,9 @@ func TestStateJSONContract(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Scan.Done != 5 || got.Scan.Total != 9 {
t.Fatalf("scan: %+v", got.Scan)
}
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
t.Fatalf("top level: %+v", got)
}
+2 -1
View File
@@ -68,9 +68,10 @@ describe('App', () => {
})
it('waits while the server has not finished its first refresh', async () => {
const w = mountWith(state({ loaded: false }))
const w = mountWith(state({ loaded: false, scan: { done: 4, total: 10 } }))
await flushPromises()
expect(w.find('[data-test=waiting]').exists()).toBe(true)
expect(w.get('[data-test=loading-detail]').text()).toContain('4 of 10 scanned')
expect(w.find('[data-test=headline]').exists()).toBe(false)
})
+16 -10
View File
@@ -3,6 +3,7 @@ import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AttentionList from '@/components/AttentionList.vue'
import LoadingScreen from '@/components/LoadingScreen.vue'
import FleetOverview from '@/components/FleetOverview.vue'
import ProjectList from '@/components/ProjectList.vue'
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
@@ -26,9 +27,15 @@ const {
visibleRepos,
activeFilters,
repoCounts,
rowCounts,
facts,
} = storeToRefs(store)
const list = ref<InstanceType<typeof AttentionList> | null>(null)
const progress = computed(() =>
state.value && scanning.value && state.value.scan.total > 0
? `${state.value.scan.done}/${state.value.scan.total}`
: '',
)
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
let timers: number[] = []
@@ -71,6 +78,7 @@ onBeforeUnmount(() => {
:interval-seconds="state?.intervalSeconds ?? 0"
:loaded="state?.loaded ?? false"
:scanning="scanning"
:progress="progress"
:manual-refresh="state?.manualRefresh ?? false"
@rescan="store.rescan()"
/>
@@ -83,19 +91,17 @@ onBeforeUnmount(() => {
{{ notice }}
</p>
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
<p
v-else-if="!state.loaded"
class="text-muted-foreground mt-16 text-3xl font-semibold"
data-test="waiting"
>
Fetching data from GitHub
</p>
<LoadingScreen
v-if="!state || !state.loaded"
:done="state?.scan.done ?? 0"
:total="state?.scan.total ?? 0"
:connecting="!state"
/>
<template v-else>
<div class="mt-8 sm:mt-12">
<FleetOverview
:crit="state.counts.crit"
:warn="state.counts.warn"
:crit="rowCounts.crit"
:warn="rowCounts.warn"
:block-count="blocks.length"
:repos="state.repos"
:counts="repoCounts"
+5 -2
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
intervalSeconds: number
loaded: boolean
scanning: boolean
progress: string
manualRefresh: boolean
}>()
defineEmits<{ rescan: [] }>()
@@ -56,8 +57,10 @@ const nextIn = computed(() => {
@click="$emit('rescan')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
<span class="hidden sm:inline">{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}</span>
<span class="sm:hidden">{{ scanning ? 'Scanning…' : 'Rescan' }}</span>
<span class="hidden sm:inline">{{
scanning ? `Scanning ${progress}`.trim() : 'Rescan repositories'
}}</span>
<span class="sm:hidden">{{ scanning ? progress || 'Scanning…' : 'Rescan' }}</span>
</Button>
</div>
</header>
+14 -4
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import FilterBar from '@/components/FilterBar.vue'
import RepoGroup from '@/components/RepoGroup.vue'
import { useColumns } from '@/composables/useColumns'
import { balanceColumns } from '@/lib/layout'
import type { Facets, Filters } from '@/lib/filters'
import type { Block } from '@/lib/types'
defineProps<{
const props = defineProps<{
blocks: Block[]
nowMs: number
critSeconds: number
@@ -18,6 +20,12 @@ defineProps<{
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const columnCount = useColumns()
// A group's height is its header plus its rows, so rows+2 approximates it.
const columns = computed(() =>
balanceColumns(props.blocks, columnCount.value, (b) => b.rows.length + 2),
)
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
defineExpose({ focusSearch: () => bar.value?.focus() })
</script>
@@ -43,14 +51,16 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
Nothing matches the current filters.
</div>
<div v-else class="grid grid-cols-[minmax(0,1fr)] items-start gap-4 2xl:grid-cols-2">
<div v-else class="flex items-start gap-4" data-test="columns">
<div v-for="(column, i) in columns" :key="i" class="flex min-w-0 flex-1 flex-col gap-4">
<RepoGroup
v-for="block in blocks"
v-for="block in column"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</div>
</div>
</section>
</template>
+19 -12
View File
@@ -56,10 +56,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
</script>
<template>
<section
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_22rem]"
aria-label="Fleet status"
>
<section class="flex flex-col gap-7 sm:gap-8" aria-label="Fleet status">
<div>
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
<template v-if="blockCount > 0"
@@ -68,7 +65,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
<template v-else>All {{ repos.length }} projects are healthy</template>
</p>
<h1
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 sm:gap-x-10 font-semibold tracking-tight"
class="flex flex-wrap items-baseline gap-x-6 gap-y-1 font-semibold tracking-tight sm:gap-x-10"
data-test="headline"
>
<template v-if="crit > 0 || warn > 0">
@@ -83,7 +80,9 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
</template>
<span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
</h1>
<div class="mt-6 sm:mt-8">
</div>
<div>
<FleetStrip
:repos="repos"
:counts="counts"
@@ -104,14 +103,22 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
<span class="sm:ml-auto">Tap a segment to focus a project</span>
</p>
</div>
</div>
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
<dt class="flex-1">{{ row.label }}</dt>
<dl
class="border-border grid grid-cols-2 gap-x-6 border-t sm:grid-cols-3 lg:grid-cols-5"
data-test="facts"
>
<div
v-for="row in rows"
:key="row.label"
class="border-border border-b py-3 last:col-span-2 sm:last:col-span-1 lg:border-b-0"
>
<dt class="text-muted-foreground flex items-center gap-2 text-xs">
<FontAwesomeIcon :icon="row.icon" class="w-3.5" aria-hidden="true" />
{{ row.label }}
</dt>
<dd
class="font-mono text-base tabular-nums"
class="mt-1 font-mono text-3xl tabular-nums"
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
>
{{ row.value }}
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import { Progress } from '@/components/ui/progress'
const props = defineProps<{ done: number; total: number; connecting: boolean }>()
// Static class names so Tailwind can see every delay; cells cycle through them for a wave effect.
const DELAYS = [
'[animation-delay:0ms]',
'[animation-delay:140ms]',
'[animation-delay:280ms]',
'[animation-delay:420ms]',
'[animation-delay:560ms]',
'[animation-delay:700ms]',
]
const delay = (i: number) => DELAYS[i % DELAYS.length]
const MIN_CELLS = 12
const MAX_CELLS = 63
const cells = computed(() => Math.min(MAX_CELLS, Math.max(MIN_CELLS, props.total)))
const percent = computed(() => (props.total > 0 ? Math.round((props.done / props.total) * 100) : 0))
const known = computed(() => !props.connecting && props.total > 0)
</script>
<template>
<section role="status" aria-live="polite" class="mt-10 sm:mt-16" data-test="waiting">
<div class="flex items-center gap-4">
<span
class="border-border bg-surface text-link relative flex size-12 items-center justify-center rounded-lg border"
>
<span
class="border-link/50 absolute inset-0 rounded-lg border motion-safe:animate-ping"
></span>
<FontAwesomeIcon :icon="faSatelliteDish" class="text-xl" aria-hidden="true" />
</span>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl" data-test="loading-title">
{{ connecting ? 'Connecting' : 'Scanning repositories' }}
</h1>
<p class="text-muted-foreground mt-0.5 text-sm" data-test="loading-detail">
<template v-if="known">{{ done }} of {{ total }} scanned</template>
<template v-else-if="connecting">Waiting for the server</template>
<template v-else>Contacting GitHub</template>
<span class="hidden sm:inline"> · the first scan can take a minute or two</span>
</p>
</div>
</div>
<div class="mt-7 max-w-2xl">
<Progress v-if="known" :model-value="percent" class="h-1.5" aria-label="Scan progress" />
<div v-else class="bg-secondary h-1.5 w-full overflow-hidden rounded-full">
<div class="bg-primary h-full w-1/3 rounded-full motion-safe:animate-pulse"></div>
</div>
</div>
<div class="mt-10" aria-hidden="true">
<div class="flex items-baseline gap-8">
<div
class="bg-secondary h-14 w-28 rounded-md motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
></div>
<div
class="bg-secondary h-14 w-28 rounded-md [animation-delay:200ms] motion-safe:animate-pulse sm:h-[4.5rem] sm:w-36"
></div>
</div>
<div class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1">
<div
v-for="i in cells"
:key="i"
class="bg-secondary h-9 rounded-[3px] motion-safe:animate-pulse sm:h-11"
:class="delay(i)"
></div>
</div>
<div
class="border-border mt-7 grid grid-cols-2 gap-x-6 border-t pt-3 sm:grid-cols-3 lg:grid-cols-5"
>
<div v-for="i in 5" :key="i" class="py-2">
<div
class="bg-secondary h-3 w-24 rounded motion-safe:animate-pulse"
:class="delay(i)"
></div>
<div
class="bg-secondary mt-2 h-7 w-12 rounded motion-safe:animate-pulse"
:class="delay(i + 1)"
></div>
</div>
</div>
</div>
<div
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1fr)_24rem]"
aria-hidden="true"
>
<div class="flex flex-col gap-4">
<div v-for="g in 3" :key="g" class="border-border bg-surface rounded-lg border p-4">
<div
class="bg-secondary h-5 w-48 rounded motion-safe:animate-pulse"
:class="delay(g)"
></div>
<div v-for="r in 2" :key="r" class="mt-4 flex items-center gap-3">
<div
class="bg-secondary size-8 shrink-0 rounded-md motion-safe:animate-pulse"
:class="delay(g + r)"
></div>
<div class="flex-1 space-y-2">
<div
class="bg-secondary h-4 w-2/3 rounded motion-safe:animate-pulse"
:class="delay(g + r)"
></div>
<div
class="bg-secondary h-3 w-1/3 rounded motion-safe:animate-pulse"
:class="delay(g + r + 1)"
></div>
</div>
</div>
</div>
</div>
<div class="border-border bg-surface hidden rounded-lg border p-3 lg:block">
<div v-for="i in 6" :key="i" class="py-2.5">
<div
class="bg-secondary h-4 w-3/4 rounded motion-safe:animate-pulse"
:class="delay(i)"
></div>
<div
class="bg-secondary mt-2 h-3 w-1/2 rounded motion-safe:animate-pulse"
:class="delay(i + 1)"
></div>
</div>
</div>
</div>
</section>
</template>
+63 -3
View File
@@ -1,12 +1,63 @@
<script setup lang="ts">
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
defineEmits<{ select: [fullName: string] }>()
/** Rows rendered at first and added each time the list is scrolled near its end. */
const PAGE_SIZE = 20
const shown = ref(PAGE_SIZE)
const visible = computed(() => props.repos.slice(0, shown.value))
const hasMore = computed(() => shown.value < props.repos.length)
const scroller = ref<HTMLElement | null>(null)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
function loadMore(): void {
if (hasMore.value) shown.value += PAGE_SIZE
}
// A new search or filter starts the list over from the top.
watch(
() => props.repos,
() => {
shown.value = PAGE_SIZE
if (scroller.value) scroller.value.scrollTop = 0
},
)
// Observe the end marker; a fresh marker element is rendered while more rows remain.
watch(
sentinel,
(el, prev) => {
if (prev) observer?.unobserve(prev)
if (el) observer?.observe(el)
},
{ flush: 'post' },
)
onMounted(async () => {
await nextTick()
if (typeof IntersectionObserver === 'undefined') {
shown.value = props.repos.length
return
}
observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadMore()
},
{ root: scroller.value, rootMargin: '200px' },
)
if (sentinel.value) observer.observe(sentinel.value)
})
onBeforeUnmount(() => observer?.disconnect())
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</script>
@@ -28,10 +79,11 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</p>
<ul
v-else
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border lg:max-h-[calc(100vh-7rem)]"
ref="scroller"
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] lg:max-h-[calc(100vh-7rem)]"
>
<li
v-for="repo in repos"
v-for="repo in visible"
:key="repo.fullName"
class="px-3 py-2.5"
:class="focused === repo.fullName ? 'bg-accent' : ''"
@@ -89,6 +141,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</span>
</div>
</li>
<li
v-if="hasMore"
ref="sentinel"
class="text-muted-foreground px-3 py-3 text-center text-xs"
data-test="more"
>
Showing {{ visible.length }} of {{ repos.length }}
</li>
</ul>
</section>
</template>
+146 -2
View File
@@ -1,9 +1,10 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgeMeter from './AgeMeter.vue'
import AppHeader from './AppHeader.vue'
import AttentionList from './AttentionList.vue'
import FilterBar from './FilterBar.vue'
import LoadingScreen from './LoadingScreen.vue'
import AttentionRow from './AttentionRow.vue'
import FleetOverview from './FleetOverview.vue'
import ProjectList from './ProjectList.vue'
@@ -24,6 +25,7 @@ describe('AppHeader', () => {
intervalSeconds: 21600,
loaded: true,
scanning: false,
progress: '',
}
it('shows when data was fetched and when the next refresh is due', () => {
@@ -44,7 +46,11 @@ describe('AppHeader', () => {
expect(w.emitted('rescan')).toHaveLength(1)
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
expect(busy.get('[data-test=rescan]').text()).toContain('Rescanning…')
expect(busy.get('[data-test=rescan]').text()).toContain('Scanning')
const counted = mount(AppHeader, {
props: { ...base, manualRefresh: true, scanning: true, progress: '23/63' },
})
expect(counted.get('[data-test=rescan]').text()).toContain('Scanning 23/63')
})
})
@@ -304,3 +310,141 @@ describe('ProjectList', () => {
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
})
})
describe('AttentionList columns', () => {
const blocks = ['a/one', 'b/two', 'c/three'].map((fullName) => ({
repo: repo({ fullName, health: 'crit' }),
rows: buildBlocks(mixedState())[0].rows,
}))
const props = {
blocks,
nowMs,
critSeconds: CRIT_SECONDS,
facets: facetCounts(blocks, emptyFilters(), ['a', 'b', 'c']),
owners: ['a'],
active: 0,
filters: emptyFilters(),
}
afterEach(() => vi.unstubAllGlobals())
it('uses one column on narrow screens', () => {
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
}))
const w = mount(AttentionList, { props })
expect(w.findAll('[data-test=columns] > div')).toHaveLength(1)
})
it('balances groups over two columns on very wide screens', async () => {
vi.stubGlobal('matchMedia', () => ({
matches: true,
addEventListener: () => {},
removeEventListener: () => {},
}))
const w = mount(AttentionList, { props })
await w.vm.$nextTick()
const cols = w.findAll('[data-test=columns] > div')
expect(cols).toHaveLength(2)
expect(cols[0].findAll('[data-test=repo-block]')).toHaveLength(2)
expect(cols[1].findAll('[data-test=repo-block]')).toHaveLength(1)
})
})
describe('ProjectList lazy loading', () => {
type Callback = (entries: { isIntersecting: boolean }[]) => void
let callback: Callback = () => {}
function stubObserver() {
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: Callback) {
callback = cb
}
observe() {}
unobserve() {}
disconnect() {}
},
)
}
const many = (n: number) => Array.from({ length: n }, (_, i) => repo({ fullName: `o/repo-${i}` }))
afterEach(() => vi.unstubAllGlobals())
it('renders a first page, then more each time the end marker scrolls into view', async () => {
stubObserver()
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(20)
expect(w.get('[data-test=more]').text()).toBe('Showing 20 of 55')
callback([{ isIntersecting: true }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(40)
callback([{ isIntersecting: false }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(40)
callback([{ isIntersecting: true }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(55)
expect(w.find('[data-test=more]').exists()).toBe(false)
})
it('starts over from the first page when the list changes', async () => {
stubObserver()
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
await w.vm.$nextTick()
callback([{ isIntersecting: true }])
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(40)
await (w as unknown as { setProps(p: object): Promise<void> }).setProps({ repos: many(30) })
expect(w.findAll('[data-test=project]')).toHaveLength(20)
})
it('renders everything when the browser has no IntersectionObserver', async () => {
vi.stubGlobal('IntersectionObserver', undefined)
const w = mount(ProjectList, { props: { repos: many(55), total: 55, focused: null, nowMs } })
await w.vm.$nextTick()
await w.vm.$nextTick()
expect(w.findAll('[data-test=project]')).toHaveLength(55)
})
})
describe('LoadingScreen', () => {
it('shows scan progress with a count once the server reports a total', () => {
const w = mount(LoadingScreen, { props: { done: 23, total: 63, connecting: false } })
expect(w.get('[data-test=loading-title]').text()).toBe('Scanning repositories')
expect(w.get('[data-test=loading-detail]').text()).toContain('23 of 63 scanned')
expect(w.get('[role=progressbar]').attributes('aria-valuenow')).toBe('37')
})
it('draws one skeleton cell per repo, within sensible limits', () => {
const cells = (total: number) =>
mount(LoadingScreen, { props: { done: 0, total, connecting: false } }).findAll(
'.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(1\\.5rem\\,1fr\\)\\)\\] > div',
).length
expect(cells(5)).toBe(12)
expect(cells(30)).toBe(30)
expect(cells(500)).toBe(63)
})
it('says it is connecting before the first response', () => {
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: true } })
expect(w.get('[data-test=loading-title]').text()).toBe('Connecting')
expect(w.find('[role=progressbar]').exists()).toBe(false)
})
it('uses an indeterminate bar while the total is not known yet', () => {
const w = mount(LoadingScreen, { props: { done: 0, total: 0, connecting: false } })
expect(w.get('[data-test=loading-detail]').text()).toContain('Contacting GitHub')
expect(w.find('[role=progressbar]').exists()).toBe(false)
})
it('is announced politely to screen readers', () => {
const w = mount(LoadingScreen, { props: { done: 1, total: 2, connecting: false } })
expect(w.attributes('role')).toBe('status')
expect(w.attributes('aria-live')).toBe('polite')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> {
const columns = ref(1)
let query: MediaQueryList | null = null
const update = () => {
columns.value = query?.matches ? 2 : 1
}
onMounted(() => {
if (typeof window.matchMedia !== 'function') return
query = window.matchMedia(minWidth)
update()
query.addEventListener('change', update)
})
onBeforeUnmount(() => query?.removeEventListener('change', update))
return columns
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { balanceColumns } from './layout'
const weight = (n: number) => n
describe('balanceColumns', () => {
it('returns one column holding everything in order', () => {
expect(balanceColumns([3, 1, 2], 1, weight)).toEqual([[3, 1, 2]])
})
it('puts each item in the currently shortest column', () => {
// heights after each step: [5,0] -> [5,2] -> [5,5] -> [5,6]... ties go to the first column
expect(balanceColumns([5, 2, 3, 1], 2, weight)).toEqual([
[5, 1],
[2, 3],
])
})
it('keeps the heaviest-first reading order within each column', () => {
const [left, right] = balanceColumns([9, 8, 7, 6, 5], 2, weight)
expect(left).toEqual([9, 6, 5])
expect(right).toEqual([8, 7])
})
it('handles empty input and clamps invalid column counts', () => {
expect(balanceColumns([], 2, weight)).toEqual([[], []])
expect(balanceColumns([1, 2], 0, weight)).toEqual([[1, 2]])
expect(balanceColumns([1, 2], 2.9, weight)).toHaveLength(2)
})
})
+16
View File
@@ -0,0 +1,16 @@
/**
* Splits items into columns so the columns end at about the same height. Each item goes to the
* currently shortest column, which keeps the original order readable (worst items first).
*/
export function balanceColumns<T>(items: T[], columns: number, weight: (item: T) => number): T[][] {
const count = Math.max(1, Math.floor(columns))
const out: T[][] = Array.from({ length: count }, () => [])
const heights = new Array<number>(count).fill(0)
for (const item of items) {
let target = 0
for (let i = 1; i < count; i++) if (heights[i] < heights[target]) target = i
out[target].push(item)
heights[target] += weight(item)
}
return out
}
+1
View File
@@ -49,6 +49,7 @@ export interface TrackerState {
loaded: boolean
refreshing: boolean
manualRefresh: boolean
scan: { done: number; total: number }
counts: { crit: number; warn: number }
thresholds: Thresholds
repos: Repo[]
+9
View File
@@ -134,6 +134,15 @@ describe('tracker store', () => {
expect(store.owners).toEqual(['o'])
})
it('counts rows by severity so the headline matches the lists', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
await store.load()
// three alerts, but the two on the same pull request merge into one row
expect(store.rowCounts).toEqual({ crit: 2, warn: 0 })
expect(store.facets.severity.all).toBe(store.rowCounts.crit + store.rowCounts.warn)
})
it('searches projects by name and clears every filter at once', async () => {
mockFetch(() => json(mixedState()))
const store = useTrackerStore()
+7
View File
@@ -122,6 +122,12 @@ export const useTrackerStore = defineStore('tracker', () => {
)
})
const activeFilters = computed(() => activeFilterCount(filters.value))
/** Rows by severity, so the headline matches the lists below it (alerts on one item merge into one row). */
const rowCounts = computed(() => {
const out = { crit: 0, warn: 0 }
for (const b of blocks.value) for (const r of b.rows) out[r.severity] += 1
return out
})
const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map()))
const facts = computed(() => (state.value ? computeFacts(state.value) : null))
@@ -189,6 +195,7 @@ export const useTrackerStore = defineStore('tracker', () => {
activeFilters,
visibleBlocks,
repoCounts,
rowCounts,
facts,
toggleRepo,
clearFilters,
+1
View File
@@ -49,6 +49,7 @@ export function state(over: Partial<TrackerState> = {}): TrackerState {
loaded: true,
refreshing: false,
manualRefresh: false,
scan: { done: 0, total: 0 },
counts: { crit: 0, warn: 0 },
thresholds: {
prWarnSeconds: 172800,