feat: make the dashboard readable at a glance

The dashboard showed the anonymize date under "Expired", a dash in the
Expires column for 54 of 60 rows, raw status codes such as
branch_not_found, and a chip reading "REMOVED" that actually meant
"removed items are hidden". Muted text sat at 3.5:1 contrast, keyboard
focus was invisible, numbers were left-aligned, and a legacy record
without an identifier rendered as an empty link to /pr/undefined/.

This rewrites the dashboard template and controller and tightens the
theme tokens:

- Status sub-line is labelled ("anonymized on Sep 6, 2026"); Expires
  shows Never, the date, or "Expired on <date>".
- Conference folded into the name cell as a tag; Views right-aligned;
  every column header is a keyboard-sortable button with aria-sort.
- Sort/Status buttons show their state; chips read "Hiding Removed";
  result count and Clear filters; empty state with a way out.
- Status filter covers Ready, In progress, Error, Expired, Removed.
  Stuck downloads say so; codes map to sentences; statuses to labels.
- Lists load in parallel and merge once behind a skeleton; broken
  records are flagged instead of linking nowhere.
- Actions menu: View, View page, Edit, Force update, divider, Remove,
  all as buttons rather than href="#" anchors.
- Theme: warm dark canvas, muted text >= 4.5:1 in both modes, semantic
  status tokens, 11/13/14/16px type scale, mono only for identifiers,
  6px/10px/pill radii, :focus-visible ring, quota bars in ink until a
  limit is near, unlimited shown as such.
- Mobile: no 9px text, filter row wraps in two lines, meta on one line.

Adds scripts/dev-mock.js to serve the dashboard with fixture data and
test/dashboard-ui.test.js covering the filters, template invariants and
token contrast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tdurieux
2026-09-08 20:20:17 +02:00
co-authored by Claude Fable 5.1
parent 9f00641529
commit 8e9b5b9a45
8 changed files with 1157 additions and 325 deletions
+260 -77
View File
@@ -212,11 +212,19 @@ angular
seconds = Math.round((Date.now() - new Date(seconds)) / 1000);
var suffix = seconds < 0 ? "from now" : "ago";
// more than 2 days ago display Date
// more than 2 days ago display Date. Spell the month out so the date
// is unambiguous regardless of the reader's locale (9/6 vs 6/9).
if (Math.abs(seconds) > 2 * 60 * 60 * 24) {
const now = new Date();
now.setSeconds(now.getSeconds() - seconds);
return "on " + now.toLocaleDateString();
return (
"on " +
now.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
})
);
}
seconds = Math.abs(seconds);
@@ -255,7 +263,44 @@ angular
return capitalized.join(" ");
};
})
// Human-readable labels for the raw status values stored on repositories,
// pull requests and gists. Anything unknown falls back to Title Case.
.filter("statusLabel", function () {
var labels = {
ready: "Ready",
error: "Error",
expired: "Expired",
expiring: "Expiring",
removed: "Removed",
removing: "Removing",
queue: "Queued",
download: "Downloading",
downloaded: "Downloaded",
preparing: "Preparing",
anonymizing: "Anonymizing",
};
return function (status) {
if (!status) return "";
if (labels[status]) return labels[status];
var s = String(status).replace(/[_-]+/g, " ").toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};
})
.filter("statusMsg", function () {
// Known machine codes → sentences. Unknown snake_case codes are
// converted to a sentence instead of leaking `branch_not_found`.
var codes = {
branch_not_found: "Branch not found on GitHub",
repo_not_found: "Repository not found on GitHub",
repository_not_found: "Repository not found on GitHub",
repo_not_accessible: "Repository is not accessible with your token",
pr_not_found: "Pull request not found on GitHub",
gist_not_found: "Gist not found on GitHub",
commit_not_found: "Commit not found on GitHub",
repo_too_big: "Repository exceeds the size limit",
quota_exceeded: "Storage quota exceeded",
incomplete_record: "Incomplete record: missing identifier",
};
return function (msg) {
if (!msg) return msg;
var m = msg.match(/^rate_limited:(\d+)$/);
@@ -266,6 +311,11 @@ angular
var sec = remaining % 60;
return "Rate limited — retrying in " + (min > 0 ? min + "m " + sec + "s" : sec + "s");
}
if (codes[msg]) return codes[msg];
if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(msg)) {
var s = msg.replace(/_/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
}
return msg;
};
})
@@ -1327,7 +1377,9 @@ angular
"$scope",
"$http",
"$location",
function ($scope, $http, $location) {
"$q",
"$window",
function ($scope, $http, $location, $q, $window) {
$scope.$on("$routeChangeStart", function () {
$('[data-toggle="tooltip"]').tooltip("dispose");
});
@@ -1346,11 +1398,35 @@ angular
$scope.items = [];
$scope.search = "";
$scope.loading = true;
// Status buckets used by the Status filter. Raw statuses are mapped
// onto these keys so in-progress and error items can be filtered too.
$scope.statusKeyLabels = {
ready: "Ready",
progress: "In progress",
error: "Error",
expired: "Expired",
removed: "Removed",
};
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
function statusKey(status) {
if (status === "ready" || status === "error") return status;
if (status === "expired" || status === "expiring") return "expired";
if (status === "removed" || status === "removing") return "removed";
if (inProgress.indexOf(status) > -1) return "progress";
return "progress";
}
// An in-progress item whose last activity is older than this is
// probably stuck; the row says so instead of showing "Downloading".
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
const dashboardPrefsKey = "dashboard.filterPrefs";
const dashboardPrefDefaults = {
typeFilter: "all",
filters: { status: { ready: true, expired: true, removed: false } },
filters: {
status: { ready: true, progress: true, error: true, expired: true, removed: false },
},
orderBy: "-anonymizeDate",
};
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
@@ -1364,6 +1440,37 @@ angular
};
$scope.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
// ---- Sorting -------------------------------------------------------
// `orderBy` is kept as the Angular orderBy expression ("-field" for
// descending) so saved preferences stay compatible.
const sortFields = {
_name: { label: "Name", defaultDesc: false },
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
status: { label: "Status", defaultDesc: false },
lastView: { label: "Last view", defaultDesc: true },
pageView: { label: "Views", defaultDesc: true },
"options.expirationDate": { label: "Expiration", defaultDesc: false },
};
$scope.sortFields = sortFields;
$scope.sortField = () => $scope.orderBy.replace(/^-/, "");
$scope.sortDesc = () => $scope.orderBy.charAt(0) === "-";
$scope.sortLabel = () => {
const f = sortFields[$scope.sortField()];
return f ? f.label : "Custom";
};
$scope.isSortedBy = (field) => $scope.sortField() === field;
$scope.setSort = (field, desc) => {
if (typeof desc !== "boolean") {
desc = $scope.isSortedBy(field)
? !$scope.sortDesc()
: !!(sortFields[field] && sortFields[field].defaultDesc);
}
$scope.orderBy = (desc ? "-" : "") + field;
};
$scope.toggleSortDirection = () => {
$scope.setSort($scope.sortField(), !$scope.sortDesc());
};
$scope.$watchGroup(
["typeFilter", "orderBy"],
() => {
@@ -1386,94 +1493,168 @@ angular
true
);
// ---- Quota ---------------------------------------------------------
// A quota with total 0 is unlimited: no percentage, no fill. Colour is
// only introduced once a quota is nearly used up.
function decorateQuota(q) {
q.unlimited = !q.total;
q.percent = q.unlimited ? 0 : Math.min(100, (q.used * 100) / q.total);
q.level = q.unlimited
? "unlimited"
: q.percent >= 95
? "danger"
: q.percent >= 80
? "warn"
: "ok";
return q;
}
function getQuota() {
$http.get("/api/user/quota").then((res) => {
$scope.quota = res.data;
$scope.quota.storage.percent = $scope.quota.storage.total
? ($scope.quota.storage.used * 100) / $scope.quota.storage.total
: 100;
$scope.quota.file.percent = $scope.quota.file.total
? ($scope.quota.file.used * 100) / $scope.quota.file.total
: 100;
$scope.quota.repository.percent = $scope.quota.repository.total
? ($scope.quota.repository.used * 100) /
$scope.quota.repository.total
: 100;
decorateQuota($scope.quota.storage);
decorateQuota($scope.quota.file);
decorateQuota($scope.quota.repository);
}, console.error);
}
getQuota();
let loadedRepos = null;
let loadedPRs = null;
let loadedGists = null;
function mergeItems() {
$scope.items = (loadedRepos || [])
.concat(loadedPRs || [])
.concat(loadedGists || []);
// ---- Items ---------------------------------------------------------
// Fields shared by repositories, pull requests and gists. Records that
// lost their identifier (legacy data) are flagged as broken instead of
// rendering an empty link to /pr/undefined/.
function decorateItem(item, id, name, source, editUrl, viewUrl) {
if (!item.pageView) item.pageView = 0;
if (!item.lastView) item.lastView = "";
item.options = item.options || {};
item.options.terms = (item.options.terms || []).filter((f) => f);
item._id = id || "";
item._source = source;
item._broken = !id;
item._name = id || source || "(unnamed)";
item._editUrl = id ? editUrl : null;
item._viewUrl = id ? viewUrl : null;
if (item._broken) {
item.status = "error";
item.statusMessage = "incomplete_record";
}
item._statusKey = statusKey(item.status);
const last = item.anonymizeDate || item.lastView;
item._stale =
item._statusKey === "progress" &&
!!last &&
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
// What the Expires column should show.
if (item.status === "expired" || item.status === "expiring") {
item._expiry = { kind: "expired", date: item.options.expirationDate };
} else if (item.status !== "ready") {
item._expiry = { kind: "none" };
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
item._expiry = { kind: "never" };
} else {
item._expiry = { kind: "date", date: item.options.expirationDate };
}
return item;
}
function safeGet(url) {
return $http.get(url).then(
(res) => res.data || [],
(err) => {
console.error(err);
return [];
}
);
}
// All three lists load in parallel and are merged once, so the table
// does not re-sort three times while it fills in.
function loadAll() {
loadedRepos = null;
loadedPRs = null;
loadedGists = null;
$http.get("/api/user/anonymized_repositories").then(
(res) => {
loadedRepos = res.data.map((repo) => {
if (!repo.pageView) repo.pageView = 0;
if (!repo.lastView) repo.lastView = "";
repo.options.terms = repo.options.terms.filter((f) => f);
$scope.loading = true;
return $q
.all([
safeGet("/api/user/anonymized_repositories"),
safeGet("/api/user/anonymized_pull_requests"),
safeGet("/api/user/anonymized_gists"),
])
.then((results) => {
const repos = results[0];
const prs = results[1];
const gists = results[2];
const items = [];
repos.forEach((repo) => {
repo._type = "repo";
repo._id = repo.repoId;
repo._name = repo.repoId;
repo._source = repo.source.fullName;
repo._editUrl = "/anonymize/" + repo.repoId;
repo._viewUrl = "/r/" + repo.repoId + "/";
return repo;
const src = repo.source || {};
items.push(
decorateItem(
repo,
repo.repoId,
repo.repoId,
src.fullName,
"/anonymize/" + repo.repoId,
"/r/" + repo.repoId + "/"
)
);
});
mergeItems();
},
(err) => { console.error(err); }
);
$http.get("/api/user/anonymized_pull_requests").then(
(res2) => {
loadedPRs = res2.data.map((pr) => {
if (!pr.pageView) pr.pageView = 0;
if (!pr.lastView) pr.lastView = "";
pr.options.terms = pr.options.terms.filter((f) => f);
prs.forEach((pr) => {
pr._type = "pr";
pr._id = pr.pullRequestId;
pr._name = pr.pullRequestId;
pr._source = pr.source.repositoryFullName + "#" + pr.source.pullRequestId;
pr._editUrl = "/pull-request-anonymize/" + pr.pullRequestId;
pr._viewUrl = "/pr/" + pr.pullRequestId + "/";
return pr;
const src = pr.source || {};
items.push(
decorateItem(
pr,
pr.pullRequestId,
pr.pullRequestId,
src.repositoryFullName + "#" + src.pullRequestId,
"/pull-request-anonymize/" + pr.pullRequestId,
"/pr/" + pr.pullRequestId + "/"
)
);
});
mergeItems();
},
(err) => { console.error(err); }
);
$http.get("/api/user/anonymized_gists").then(
(res3) => {
loadedGists = res3.data.map((g) => {
if (!g.pageView) g.pageView = 0;
if (!g.lastView) g.lastView = "";
g.options.terms = (g.options.terms || []).filter((f) => f);
gists.forEach((g) => {
g._type = "gist";
g._id = g.gistId;
g._name = g.gistId;
g._source = g.source.gistId;
g._editUrl = "/gist-anonymize/" + g.gistId;
g._viewUrl = "/gist/" + g.gistId + "/";
return g;
const src = g.source || {};
items.push(
decorateItem(
g,
g.gistId,
g.gistId,
src.gistId,
"/gist-anonymize/" + g.gistId,
"/gist/" + g.gistId + "/"
)
);
});
mergeItems();
},
(err) => { console.error(err); }
);
$scope.items = items;
$scope.loading = false;
});
}
loadAll();
// Whole row opens the anonymized view; clicks on links, buttons and the
// actions menu keep their own behaviour.
$scope.openItem = (item, $event) => {
if (!item._viewUrl) return;
const target = $event && $event.target;
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
$window.location.href = item._viewUrl;
};
$scope.hiddenStatusCount = () =>
Object.keys($scope.filters.status).filter((k) => $scope.filters.status[k] === false).length;
$scope.hasHiddenStatus = () => $scope.hiddenStatusCount() > 0;
$scope.hasActiveFilters = () =>
$scope.typeFilter !== "all" ||
$scope.search.trim().length > 0 ||
Object.keys($scope.filters.status).some((k) => $scope.filters.status[k] === false);
$scope.clearFilters = () => {
$scope.typeFilter = "all";
$scope.search = "";
Object.keys($scope.filters.status).forEach((k) => {
$scope.filters.status[k] = true;
});
};
function waitRepoToBeReady(repoId, callback) {
$http.get("/api/repo/" + repoId).then((res) => {
for (const item of $scope.items) {
@@ -1597,10 +1778,12 @@ angular
$scope.itemFilter = (item) => {
if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false;
if ($scope.filters.status[item.status] == false) return false;
if ($scope.search.trim().length == 0) return true;
if (item._source && item._source.indexOf($scope.search) > -1) return true;
if (item._id.indexOf($scope.search) > -1) return true;
if ($scope.filters.status[item._statusKey] === false) return false;
const needle = $scope.search.trim().toLowerCase();
if (needle.length == 0) return true;
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
return false;
};
},
+1 -1
View File
File diff suppressed because one or more lines are too long