Skip to content

Commit 5cd52e9

Browse files
committed
web: a toolchain-first view
Rows are toolchain versions and cells name the run of consecutive releases that support one, with the first CI-verified release on a second line. The view, its open cell and its expanded groups persist in the URL. The timeline is now a shared component used by both detail panels.
1 parent 67bfec1 commit 5cd52e9

11 files changed

Lines changed: 625 additions & 120 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ line with its verdict, and a newer beta gets its own line. Clicking a cell
105105
expands the per-version timeline with the declared range, every older
106106
version, and LTS and beta markers.
107107

108+
The "By toolchain" view flips the axes: rows are toolchain versions and a cell
109+
names the run of consecutive releases that support one, `9.0.3+` while the
110+
newest release still does or `8.2.0 – 8.5.3` once a newer release dropped it,
111+
with the release CI first verified on a second line when proof starts later
112+
than the declaration. Clicking a cell shows the same timeline over releases.
113+
108114
## Endpoints
109115

110116
- `/` the app.

‎shared/compute.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,86 @@ export function prettyRange(range: string): string {
480480
function shortVersion(version: string): string {
481481
return version.replace(/(\.0)+$/, "");
482482
}
483+
484+
export interface ToolchainSummary {
485+
state: CellState;
486+
/** "9.0.3+" while the newest stable release supports it, "8.2.0 – 8.5.3" once support ended, empty when nothing does. */
487+
text: string;
488+
since?: { version: string; cell: Cell };
489+
/** The last release that supported it, once a newer one stopped. */
490+
until?: { version: string; cell: Cell };
491+
/** The oldest release of the supported run that CI verified. */
492+
verifiedSince?: string;
493+
/** Every tracked release of the package, newest first. */
494+
releases: Array<{ version: string; prerelease?: boolean; tags: string[]; date?: string; cell: Cell }>;
495+
}
496+
497+
const SUPPORTED_STATES: CellState[] = ["verified", "declared", "advisory"];
498+
499+
/**
500+
* The toolchain-first question: which releases of a package take this
501+
* toolchain version? The answer is the run of consecutive stable releases
502+
* nearest the present that support it, so an old release that happened to
503+
* work does not claim support for everything after it.
504+
*/
505+
export function summarizeToolchain(
506+
document: CompatibilityDocument,
507+
key: ToolchainKey,
508+
toolchainVersion: string,
509+
packageName: string,
510+
): ToolchainSummary {
511+
const pkg = document.packages[packageName];
512+
if (!pkg) {
513+
return { state: "unverified", text: "", releases: [] };
514+
}
515+
const tags = new Map<string, string[]>();
516+
for (const [tag, version] of Object.entries(pkg.distTags)) {
517+
tags.set(version, [...(tags.get(version) ?? []), tag]);
518+
}
519+
const releases = Object.keys(pkg.versions)
520+
.sort(semver.rcompare)
521+
.map((version) => ({
522+
version,
523+
prerelease: semver.prerelease(version) ? true : undefined,
524+
tags: tags.get(version) ?? [],
525+
date: pkg.versions[version].publishedAt,
526+
cell: cellFor(document, packageName, version, key, toolchainVersion),
527+
}));
528+
529+
const stable = releases.filter((release) => !release.prerelease).reverse();
530+
const newest = stable[stable.length - 1];
531+
let run: typeof stable = [];
532+
let current: typeof stable = [];
533+
for (const release of stable) {
534+
if (SUPPORTED_STATES.includes(release.cell.state)) {
535+
current.push(release);
536+
run = current;
537+
} else {
538+
current = [];
539+
}
540+
}
541+
if (!run.length) {
542+
return { state: newest?.cell.state ?? "unverified", text: "", releases };
543+
}
544+
545+
const since = run[0];
546+
const until = run[run.length - 1];
547+
const ongoing = until === newest;
548+
const verifiedSince = run.find((release) => release.cell.state === "verified")?.version;
549+
const state: CellState = !ongoing
550+
? newest.cell.state
551+
: newest.cell.state === "advisory"
552+
? "advisory"
553+
: verifiedSince
554+
? "verified"
555+
: "declared";
556+
557+
return {
558+
state,
559+
text: ongoing ? `${since.version}+` : `${since.version} – ${until.version}`,
560+
since: { version: since.version, cell: since.cell },
561+
...(ongoing ? {} : { until: { version: until.version, cell: until.cell } }),
562+
...(verifiedSince ? { verifiedSince } : {}),
563+
releases,
564+
};
565+
}

‎test/compute.test.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { buildDocument, cellFor, effectiveRequirements, matchingAdvisories } from "../shared/compute";
2+
import { buildDocument, cellFor, effectiveRequirements, matchingAdvisories, summarizeToolchain } from "../shared/compute";
33
import type { CompatibilityDocument, Overrides, VerificationResult } from "../shared/types";
44

55
const overrides: Overrides = {
@@ -308,3 +308,48 @@ describe("failure attribution", () => {
308308
expect(cellFor(doc, "@nativescript/android", "9.1.1", "compileSdk", "36").state).toBe("unverified");
309309
});
310310
});
311+
312+
describe("summarizeToolchain", () => {
313+
it("names the run of releases that support a toolchain version", () => {
314+
const doc = document();
315+
const verified = summarizeToolchain(doc, "xcode", "26.2", "@nativescript/ios");
316+
expect(verified).toMatchObject({ state: "verified", text: "9.1.0+", verifiedSince: "9.1.0" });
317+
expect(verified.releases.map((r) => r.version)).toEqual(["9.1.0", "8.9.0"]);
318+
expect(verified.releases[0].tags).toEqual(["latest"]);
319+
320+
const declared = summarizeToolchain(doc, "xcode", "16.4", "@nativescript/ios");
321+
expect(declared).toMatchObject({ state: "declared", text: "8.9.0+" });
322+
expect(declared.verifiedSince).toBeUndefined();
323+
324+
expect(summarizeToolchain(doc, "xcode", "27.0", "@nativescript/ios")).toMatchObject({ state: "unsupported", text: "" });
325+
expect(summarizeToolchain(doc, "xcode", "26.2", "@nativescript/android")).toMatchObject({ state: "unverified", text: "" });
326+
});
327+
328+
it("reports a range once a newer release dropped the toolchain", () => {
329+
const doc = buildDocument({
330+
generatedAt: "2026-09-09T00:00:00.000Z",
331+
toolchains: { xcode: [], cocoapods: [], compileSdk: [], buildTools: [], jdk: [{ version: "17" }, { version: "11" }], node: [] },
332+
packages: [
333+
{
334+
spec: { name: "@nativescript/android", toolchains: ["jdk"], keep: 5 },
335+
document: {
336+
distTags: {},
337+
manifests: [
338+
{ version: "9.1.0", requirements: { jdk: ">=17" } },
339+
{ version: "9.0.0", requirements: { jdk: ">=11" } },
340+
{ version: "8.9.0", requirements: { jdk: ">=11" } },
341+
],
342+
},
343+
},
344+
],
345+
overrides: { requirements: [], advisories: [] },
346+
verified: [],
347+
});
348+
expect(summarizeToolchain(doc, "jdk", "11", "@nativescript/android")).toMatchObject({
349+
state: "unsupported",
350+
text: "8.9.0 – 9.0.0",
351+
until: { version: "9.0.0" },
352+
});
353+
expect(summarizeToolchain(doc, "jdk", "17", "@nativescript/android")).toMatchObject({ state: "declared", text: "8.9.0+" });
354+
});
355+
});

‎web/src/App.vue‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import CompatGrid from "./components/CompatGrid.vue";
66
import LegendItem from "./components/LegendItem.vue";
77
import StateIcon from "./components/StateIcon.vue";
88
import ThemeToggle from "./components/ThemeToggle.vue";
9+
import ToolchainGrid from "./components/ToolchainGrid.vue";
10+
import ViewToggle from "./components/ViewToggle.vue";
911
import { useUrlState } from "./urlState";
1012
1113
const STATES: CellState[] = ["verified", "declared", "unverified", "advisory", "unsupported"];
1214
1315
const document = ref<CompatibilityDocument | null>(null);
1416
const error = ref<string | null>(null);
15-
const { query, showPrereleases, open, expanded } = useUrlState();
17+
const { query, showPrereleases, open, expanded, byToolchain, openToolchain } = useUrlState();
1618
1719
onMounted(async () => {
1820
try {
@@ -68,10 +70,11 @@ function label(key: string): string {
6870
</nav>
6971
<div class="mt-4 flex flex-wrap items-center justify-between gap-x-8 gap-y-3 md:mt-5">
7072
<div class="flex flex-wrap items-center gap-5">
73+
<ViewToggle v-model="byToolchain" />
7174
<input
7275
v-model="query"
7376
type="search"
74-
placeholder="Filter by package, version or tag…"
77+
placeholder="Filter by package, version, tag or toolchain…"
7578
aria-label="Filter releases"
7679
class="w-full rounded-lg border sm:w-80 border-neutral-300 bg-white px-3 py-2 text-sm placeholder:text-neutral-400 focus:border-declared focus:ring-2 focus:ring-declared/30 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
7780
/>
@@ -92,7 +95,15 @@ function label(key: string): string {
9295
<p v-if="error" class="px-6 text-bad">Could not load compatibility data: {{ error }}</p>
9396
<p v-else-if="!document" class="px-6 text-neutral-500 dark:text-neutral-400">Loading…</p>
9497
<div v-else class="min-w-(--grid-min-width)">
95-
<CompatGrid v-model:open="open" v-model:expanded="expanded" :document="document" :query="query" :show-prereleases="showPrereleases" />
98+
<ToolchainGrid
99+
v-if="byToolchain"
100+
v-model:open="openToolchain"
101+
v-model:expanded="expanded"
102+
:document="document"
103+
:query="query"
104+
:show-prereleases="showPrereleases"
105+
/>
106+
<CompatGrid v-else v-model:open="open" v-model:expanded="expanded" :document="document" :query="query" :show-prereleases="showPrereleases" />
96107

97108
<section v-if="document.advisories.length" class="sticky left-0 max-w-[100cqw] px-6 pt-10">
98109
<h2 class="text-base font-semibold">Advisories</h2>

‎web/src/components/CellDetails.vue‎

Lines changed: 8 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
<script setup lang="ts">
2-
import { computed, ref } from "vue";
2+
import { computed } from "vue";
33
import type { CellSummary } from "../../../shared/compute";
4-
import { TOOLCHAIN_LABELS, type CellState, type CompatibilityDocument, type ToolchainKey } from "../../../shared/types";
5-
import { STATE_HELP } from "../states";
6-
import BetaChip from "./BetaChip.vue";
7-
import Chip from "./Chip.vue";
8-
import StateIcon from "./StateIcon.vue";
9-
10-
const INITIAL_NODES = 5;
4+
import { TOOLCHAIN_LABELS, type CompatibilityDocument, type ToolchainKey } from "../../../shared/types";
5+
import Timeline, { type TimelineItem } from "./Timeline.vue";
116
127
const props = defineProps<{ summary: CellSummary; toolchain: ToolchainKey; document: CompatibilityDocument }>();
138
14-
const showAll = ref(false);
15-
169
const label = computed(() => TOOLCHAIN_LABELS[props.toolchain]);
1710
const sourceText = computed(() =>
1811
props.summary.source === "manifest"
@@ -21,57 +14,14 @@ const sourceText = computed(() =>
2114
? "maintainer override"
2215
: "nothing published",
2316
);
24-
const dates = computed(() =>
25-
Object.fromEntries(props.document.toolchains[props.toolchain].map((tool) => [tool.version, tool.date])),
26-
);
27-
28-
/** Oldest first, so time runs left to right; the newest few unless expanded. */
29-
const nodes = computed(() => {
30-
const all = [...props.summary.breakdown].reverse();
31-
return showAll.value ? all : all.slice(-INITIAL_NODES);
17+
const items = computed<TimelineItem[]>(() => {
18+
const dates = new Map(props.document.toolchains[props.toolchain].map((tool) => [tool.version, tool.date]));
19+
return props.summary.breakdown.map((item) => ({ ...item, date: dates.get(item.version) }));
3220
});
33-
const hiddenCount = computed(() => Math.max(0, props.summary.breakdown.length - INITIAL_NODES));
34-
35-
/** Only nodes that need an explanation; plain declared, verified and unknown states speak for themselves. */
36-
const notes = computed(() =>
37-
[...nodes.value].reverse().filter((item) => {
38-
switch (item.cell.state) {
39-
case "declared":
40-
return false;
41-
case "verified":
42-
return item.cell.reason !== "verified by CI";
43-
case "unverified":
44-
return item.cell.reason !== "no requirement published" && item.cell.reason !== "no data";
45-
default:
46-
return true;
47-
}
48-
}),
49-
);
50-
51-
const SEGMENT: Record<CellState, string> = {
52-
verified: "bg-ok/55",
53-
declared: "bg-ok/55",
54-
unverified: "bg-unverified/40",
55-
advisory: "bg-advisory/60",
56-
unsupported: "bg-bad/55",
57-
};
58-
const LABEL: Record<CellState, string> = {
59-
verified: "text-ok",
60-
declared: "",
61-
unverified: "",
62-
advisory: "text-advisory",
63-
unsupported: "text-bad",
64-
};
65-
66-
function shortDate(version: string): string {
67-
const date = dates.value[version];
68-
return date ? date.slice(0, 7) : "";
69-
}
7021
</script>
7122

7223
<template>
73-
<!-- isolate: the timeline raises its icons above the track, and that must not reach the grid's sticky headers. -->
74-
<div class="isolate">
24+
<div>
7525
<p class="text-sm">
7626
<strong>{{ label }}</strong>
7727
<template v-if="summary.rawRange">
@@ -82,62 +32,6 @@ function shortDate(version: string): string {
8232
<span v-if="summary.verified.length" class="text-ok"> · verified by CI with {{ summary.verified.join(", ") }}</span>
8333
</p>
8434

85-
<div class="mt-4 flex items-stretch" role="list">
86-
<div v-if="hiddenCount" class="mr-3 flex shrink-0 flex-col items-center" role="presentation">
87-
<span class="h-6"></span>
88-
<span class="flex h-6 items-center">
89-
<button
90-
type="button"
91-
class="rounded-full border border-neutral-300 bg-neutral-100 px-2.5 py-0.5 text-xs font-medium whitespace-nowrap hover:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
92-
@click="showAll = !showAll"
93-
>
94-
{{ showAll ? "Hide older" : `${hiddenCount} older` }}
95-
</button>
96-
</span>
97-
<span class="h-5"></span>
98-
</div>
99-
<div
100-
v-for="(item, index) in nodes"
101-
:key="item.version"
102-
class="flex min-w-0 flex-1 flex-col items-center text-center"
103-
role="listitem"
104-
:title="`${label} ${item.version}: ${STATE_HELP[item.cell.state]} ${item.cell.reason}`"
105-
>
106-
<span class="flex h-6 items-center gap-1 text-sm leading-6 font-semibold tabular-nums whitespace-nowrap" :class="LABEL[item.cell.state]">
107-
{{ item.version }}
108-
<BetaChip v-if="item.prerelease" />
109-
<Chip v-else-if="item.lts" :title="`${label} ${item.version} is a long-term-support line`">LTS</Chip>
110-
</span>
111-
<span class="relative flex h-6 w-full items-center justify-center">
112-
<span
113-
class="absolute top-1/2 h-1 -translate-y-1/2"
114-
:class="[SEGMENT[item.cell.state], index === 0 ? 'left-1/2 rounded-l-full' : 'left-0', index === nodes.length - 1 ? 'right-1/2 rounded-r-full' : 'right-0']"
115-
></span>
116-
<StateIcon
117-
:state="item.cell.state"
118-
:size="16"
119-
class="relative z-10 rounded-full bg-white ring-[3px] ring-white dark:bg-neutral-950 dark:ring-neutral-950"
120-
/>
121-
</span>
122-
<span class="h-5 text-[11px] leading-5 text-neutral-500 dark:text-neutral-400 tabular-nums">{{ shortDate(item.version) }}</span>
123-
</div>
124-
</div>
125-
126-
<ul v-if="notes.length" class="mt-4 max-w-4xl space-y-1.5 text-[13px]">
127-
<li v-for="item in notes" :key="item.version" class="flex items-start gap-2">
128-
<StateIcon :state="item.cell.state" :size="14" class="mt-0.5" />
129-
<span>
130-
<strong :class="LABEL[item.cell.state]">{{ label }} {{ item.version }}<template v-if="item.prerelease"> beta</template></strong>
131-
<span class="text-neutral-500 dark:text-neutral-400"> · </span>
132-
<template v-if="item.cell.advisory">
133-
{{ item.cell.advisory.message }}
134-
<em v-if="item.cell.advisory.fix" class="ml-1">{{ item.cell.advisory.fix }}</em>
135-
<a v-if="item.cell.advisory.url" :href="item.cell.advisory.url" class="ml-1 text-declared hover:underline">details</a>
136-
<code class="ml-2 text-[11px] text-neutral-400">{{ item.cell.advisory.id }}</code>
137-
</template>
138-
<template v-else>{{ item.cell.reason }}</template>
139-
</span>
140-
</li>
141-
</ul>
35+
<Timeline class="mt-4" :items="items" :label="label" />
14236
</div>
14337
</template>

0 commit comments

Comments
 (0)