diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml
index 0a5e894ccab..59a6cff9bbf 100644
--- a/.github/workflows/pr-metadata.yml
+++ b/.github/workflows/pr-metadata.yml
@@ -107,7 +107,7 @@ jobs:
if (trackers.size) {
const marker = '';
const existing = pr.body || '';
- const refs = [...trackers].sort().map((ref) => `- Refs ${ref}`).join('\n');
+ const refs = [...trackers].sort().map((ref) => `- Related to ${ref}`).join('\n');
const block = `${marker}\n\n## Tracking\n${refs}`;
const body = existing.includes(marker)
? existing.replace(new RegExp(`${marker}[\\s\\S]*$`), block)
@@ -160,6 +160,11 @@ jobs:
project_json="$(gh project view "$project_number" --owner "$owner" --format json)"
project_id="$(jq -r '.id' <<< "$project_json")"
item_id="$(gh project item-add "$project_number" --owner "$owner" --url "$PR_URL" --format json --jq '.id')"
+ status_field_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Status") | .id')"
+ status_option_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Status") | .options[] | select(.name == "In Progress") | .id')"
+ if [ -z "$status_option_id" ]; then
+ status_option_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Status") | .options[] | select(.name == "Todo") | .id')"
+ fi
start_field_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Start date") | .id')"
target_field_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Target date") | .id')"
priority_field_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Priority") | .id')"
@@ -168,6 +173,9 @@ jobs:
priority_option_id="$(gh project field-list "$project_number" --owner "$owner" --format json --jq '.fields[] | select(.name == "Priority") | .options[] | select(.name == "Medium") | .id')"
fi
+ if [ -n "$item_id" ] && [ -n "$status_field_id" ] && [ -n "$status_option_id" ]; then
+ gh project item-edit --id "$item_id" --project-id "$project_id" --field-id "$status_field_id" --single-select-option-id "$status_option_id"
+ fi
if [ -n "$item_id" ] && [ -n "$start_field_id" ]; then
gh project item-edit --id "$item_id" --project-id "$project_id" --field-id "$start_field_id" --date "$today"
fi
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 9af43135f92..30636394c3a 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -111,8 +111,8 @@ const endpoints = [
-
Recent syncs
+
Growth timeline
+
+
Loading growth chart...
+
- Loading repository history...
diff --git a/site/src/scripts/techapi.js b/site/src/scripts/techapi.js
index 7f84280c60f..ac788e21ac3 100644
--- a/site/src/scripts/techapi.js
+++ b/site/src/scripts/techapi.js
@@ -191,46 +191,129 @@ function countUp(node, target) {
(function history() {
const totalEl = document.getElementById("history-total");
const countsEl = document.getElementById("history-counts");
+ const chartEl = document.getElementById("history-chart");
const listEl = document.getElementById("history-list");
- if (!totalEl || !countsEl || !listEl) return;
+ if (!totalEl || !countsEl || !chartEl || !listEl) return;
const order = ["smartphones", "socs", "gpus", "cpus", "brands"];
const label = { smartphones: "Phones", socs: "SoCs", gpus: "GPUs", cpus: "CPUs", brands: "Brands" };
+ const shortLabel = { smartphones: "phones", socs: "socs", gpus: "gpus", cpus: "cpus", brands: "brands" };
+ const dumpPath = "site/public/v1/index.json";
+ const countRows = (manifest) => order
+ .map((key) => ({ key, count: manifest.collections?.[key]?.count }))
+ .filter((row) => row.count != null);
+ const totalRecords = (manifest) => countRows(manifest).reduce((sum, row) => sum + row.count, 0);
+ const sumByKey = (rows) => rows.reduce((out, row) => {
+ out[row.key] = row.count;
+ return out;
+ }, {});
- getJSON("v1/index.json").then((manifest) => {
- const rows = order
- .map((key) => ({ key, count: manifest.collections?.[key]?.count }))
- .filter((row) => row.count != null);
+ function renderSnapshot(manifest) {
+ const rows = countRows(manifest);
const total = rows.reduce((sum, row) => sum + row.count, 0);
totalEl.textContent = total.toLocaleString() + " records";
countsEl.innerHTML = rows.map((row) =>
`
${label[row.key]}${row.count.toLocaleString()}
`
).join("");
+ }
+
+ function largestChanges(prevRows, nextRows) {
+ if (!prevRows) return [];
+ const prev = sumByKey(prevRows);
+ return nextRows
+ .map((row) => ({ key: row.key, delta: row.count - (prev[row.key] || 0) }))
+ .filter((row) => row.delta > 0)
+ .sort((a, b) => b.delta - a.delta)
+ .slice(0, 2);
+ }
+
+ function renderHistory(points) {
+ if (!points.length) throw new Error("empty history");
+ const maxTotal = Math.max(...points.map((point) => point.total));
+ const minTotal = Math.min(...points.map((point) => point.total));
+ const range = Math.max(1, maxTotal - minTotal);
+ chartEl.innerHTML = points.map((point) => {
+ const pct = 18 + ((point.total - minTotal) / range) * 82;
+ const deltaText = point.delta > 0 ? "+" + point.delta.toLocaleString() : "baseline";
+ return `
+
+ ${point.total.toLocaleString()}
+ ${esc(deltaText)}
+ `;
+ }).join("");
+
+ listEl.innerHTML = points.slice().reverse().map((point) => {
+ const changes = point.changes.length
+ ? point.changes.map((row) => `${shortLabel[row.key]} +${row.delta.toLocaleString()}`).join(", ")
+ : (point.delta > 0 ? `total +${point.delta.toLocaleString()}` : "baseline snapshot");
+ return `
+ ${esc(point.title)}
+ ${esc(point.when)} - ${esc(point.sha)} - ${esc(changes)}
+ `;
+ }).join("");
+ }
+
+ async function loadCommitHistory(currentManifest) {
+ const commitsUrl = `https://api.github.com/repos/GetTechAPI/TechAPI/commits?path=${encodeURIComponent(dumpPath)}&per_page=8`;
+ const response = await fetch(commitsUrl);
+ if (!response.ok) throw new Error(response.statusText);
+ const commits = await response.json();
+ const items = Array.isArray(commits) ? commits.slice(0, 7) : [];
+ const snapshots = await Promise.all(items.map(async (item) => {
+ const sha = String(item.sha || "");
+ const rawUrl = `https://raw.githubusercontent.com/GetTechAPI/TechAPI/${sha}/${dumpPath}`;
+ const raw = await fetch(rawUrl);
+ if (!raw.ok) return null;
+ const manifest = await raw.json();
+ const date = item.commit?.committer?.date ? new Date(item.commit.committer.date) : null;
+ return {
+ sha: sha.slice(0, 7),
+ dateValue: date ? date.getTime() : 0,
+ when: date ? date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "recent",
+ title: (item.commit?.message || "Dataset sync").split("\n")[0],
+ url: item.html_url || "https://github.com/GetTechAPI/TechAPI",
+ rows: countRows(manifest),
+ total: totalRecords(manifest),
+ };
+ }));
+
+ const points = snapshots.filter(Boolean).sort((a, b) => a.dateValue - b.dateValue);
+ if (!points.length) throw new Error("empty history");
+
+ const currentTotal = totalRecords(currentManifest);
+ const latest = points[points.length - 1];
+ if (latest.total !== currentTotal) {
+ points.push({
+ sha: "current",
+ dateValue: Date.now(),
+ when: "current",
+ title: "Current published snapshot",
+ url: base + "v1/index.json",
+ rows: countRows(currentManifest),
+ total: currentTotal,
+ });
+ }
+
+ for (let i = 0; i < points.length; i++) {
+ const prev = points[i - 1];
+ points[i].delta = prev ? points[i].total - prev.total : 0;
+ points[i].changes = largestChanges(prev?.rows, points[i].rows);
+ }
+ renderHistory(points);
+ }
+
+ getJSON("v1/index.json").then((manifest) => {
+ renderSnapshot(manifest);
+ return loadCommitHistory(manifest).catch(() => {
+ chartEl.innerHTML = '
Growth chart unavailable
';
+ listEl.innerHTML = '
Current static dump is available; commit history could not be loaded.GitHub API unavailable';
+ });
}).catch(() => {
totalEl.textContent = "sync unavailable";
countsEl.innerHTML = '
Static dumpoffline
';
+ chartEl.innerHTML = '
Growth chart unavailable
';
+ listEl.innerHTML = '
Current static dump could not be loaded.Build the public data first';
});
-
- fetch("https://api.github.com/repos/GetTechAPI/TechAPI/commits?path=site/public/v1&per_page=5")
- .then((response) => {
- if (!response.ok) throw new Error(response.statusText);
- return response.json();
- })
- .then((commits) => {
- const items = Array.isArray(commits) ? commits.slice(0, 4) : [];
- if (!items.length) throw new Error("empty history");
- listEl.innerHTML = items.map((item) => {
- const message = (item.commit?.message || "Dataset sync").split("\n")[0];
- const date = item.commit?.committer?.date ? new Date(item.commit.committer.date) : null;
- const when = date ? date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "recent";
- const sha = String(item.sha || "").slice(0, 7);
- const url = item.html_url || "https://github.com/GetTechAPI/TechAPI";
- return `
${esc(message)}${esc(when)} ยท ${esc(sha)}`;
- }).join("");
- })
- .catch(() => {
- listEl.innerHTML = '
Current static dump is available; repository history could not be loaded.GitHub API unavailable';
- });
})();
/* ============================================================
diff --git a/site/src/styles/techapi.css b/site/src/styles/techapi.css
index 894e8973aaa..edb6ae38f6d 100644
--- a/site/src/styles/techapi.css
+++ b/site/src/styles/techapi.css
@@ -297,11 +297,75 @@ code, .mono { font-family: var(--mono); }
.history-count b { color: var(--fg); font-size: 13px; }
.history-list {
list-style: none;
- margin: 18px 0 0;
+ margin: 20px 0 0;
padding: 0;
display: grid;
gap: 14px;
}
+.history-chart {
+ min-height: 210px;
+ margin-top: 18px;
+ padding: 16px 12px 12px;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(62px, 1fr);
+ align-items: end;
+ gap: 10px;
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background:
+ linear-gradient(to top, var(--border) 1px, transparent 1px) 0 25% / 100% 25%,
+ var(--surface-2);
+}
+.history-empty {
+ align-self: center;
+ justify-self: center;
+ grid-column: 1 / -1;
+ font-family: var(--mono);
+ color: var(--muted);
+ font-size: 12px;
+}
+.history-bar {
+ min-width: 0;
+ height: 178px;
+ display: grid;
+ grid-template-rows: 1fr auto auto;
+ gap: 6px;
+ color: var(--fg);
+}
+.history-bar-fill {
+ align-self: end;
+ min-height: 10px;
+ height: var(--h);
+ border: 1px solid color-mix(in srgb, var(--accent) 60%, var(--border));
+ border-radius: 4px 4px 2px 2px;
+ background:
+ linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, white 8%), var(--accent-deep));
+ box-shadow: 0 0 26px -12px var(--accent);
+ transition: filter .16s, transform .16s;
+}
+.history-bar:hover .history-bar-fill {
+ filter: brightness(1.08);
+ transform: translateY(-2px);
+}
+.history-bar-total,
+.history-bar-delta {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--mono);
+ text-align: center;
+}
+.history-bar-total {
+ font-size: 11px;
+ font-weight: 700;
+}
+.history-bar-delta {
+ font-size: 10px;
+ color: var(--accent-text);
+}
.history-list li {
display: grid;
grid-template-columns: 12px 1fr;
@@ -331,6 +395,7 @@ code, .mono { font-family: var(--mono); }
@media (max-width: 760px) {
.history { grid-template-columns: 1fr; }
.history-grid { grid-template-columns: 1fr; }
+ .history-chart { grid-auto-columns: 68px; }
}
/* ============================================================