diff --git a/i18n/english.js b/i18n/english.js
index d81db594..68500b23 100644
--- a/i18n/english.js
+++ b/i18n/english.js
@@ -245,7 +245,14 @@ const ui = {
deps: "deps",
direct: "direct",
modeDepth: "Depth",
- modeTree: "Tree"
+ modeTree: "Tree",
+ modeActivity: "Activity",
+ activityFresh: "< 1 week",
+ activityRecent: "< 1 month",
+ activityActive: "< 6 months",
+ activityStable: "< 1 year",
+ activitySlow: "< 2 years",
+ activityStale: "Stale"
},
search_command: {
placeholder: "Search packages...",
diff --git a/i18n/french.js b/i18n/french.js
index 007b8211..79922565 100644
--- a/i18n/french.js
+++ b/i18n/french.js
@@ -245,7 +245,14 @@ const ui = {
deps: "dépendances",
direct: "directes",
modeDepth: "Profondeur",
- modeTree: "Arbre"
+ modeTree: "Arbre",
+ modeActivity: "Activité",
+ activityFresh: "< 1 semaine",
+ activityRecent: "< 1 mois",
+ activityActive: "< 6 mois",
+ activityStable: "< 1 an",
+ activitySlow: "< 2 ans",
+ activityStale: "Abandonné"
},
search_command: {
placeholder: "Rechercher des packages...",
diff --git a/public/components/views/tree/tree-card.js b/public/components/views/tree/tree-card.js
index 85bd5199..bf315692 100644
--- a/public/components/views/tree/tree-card.js
+++ b/public/components/views/tree/tree-card.js
@@ -5,9 +5,18 @@ import prettyBytes from "pretty-bytes";
// Import Internal Dependencies
import { EVENTS } from "../../../core/events.js";
+import { currentLang } from "../../../common/utils.js";
// CONSTANTS
const kWarningCriticalThreshold = 10;
+const kLangToLocale = {
+ english: "en",
+ french: "fr"
+};
+const kOneDay = 1_000 * 60 * 60 * 24;
+const kOneWeek = kOneDay * 7;
+const kOneMonth = kOneDay * 30;
+const kOneYear = kOneDay * 365;
const kModuleTypeColors = {
esm: "#10b981",
dual: "#06b6d4",
@@ -16,6 +25,25 @@ const kModuleTypeColors = {
faux: "#6b7280"
};
+function formatTimeAgo(isoDate) {
+ const ageMs = Date.now() - new Date(isoDate).getTime();
+ const rtf = new Intl.RelativeTimeFormat(kLangToLocale[currentLang()] ?? "en", {
+ numeric: "auto"
+ });
+
+ if (ageMs < kOneWeek) {
+ return rtf.format(-Math.floor(ageMs / kOneDay), "day");
+ }
+ if (ageMs < kOneMonth) {
+ return rtf.format(-Math.floor(ageMs / kOneWeek), "week");
+ }
+ if (ageMs < kOneYear) {
+ return rtf.format(-Math.floor(ageMs / kOneMonth), "month");
+ }
+
+ return rtf.format(-Math.floor(ageMs / kOneYear), "year");
+}
+
function renderFlag(flag) {
const ignoredFlags = window.settings.config.ignore.flags ?? [];
const ignoredSet = new Set(ignoredFlags);
@@ -35,7 +63,14 @@ function getVersionData(secureDataSet, name, version) {
return secureDataSet.data.dependencies[name]?.versions[version];
}
-export function renderCardContent(secureDataSet, { nodeId, parentId = null, isRoot = false }) {
+export function renderCardContent(secureDataSet, options) {
+ const {
+ nodeId,
+ parentId = null,
+ isRoot = false,
+ publishedAt = null,
+ publishedColor = null
+ } = options;
const entry = secureDataSet.linker.get(nodeId);
const versionData = getVersionData(secureDataSet, entry.name, entry.version);
if (!versionData) {
@@ -107,6 +142,17 @@ export function renderCardContent(secureDataSet, { nodeId, parentId = null, isRo
? nothing
: html`
↳ ${parentName}
`
}
+ ${publishedAt === null
+ ? nothing
+ : html`
+
+ ↻ ${formatTimeAgo(publishedAt)}
+
+ `
+ }
`;
}
diff --git a/public/components/views/tree/tree-layout.js b/public/components/views/tree/tree-layout.js
index 05f191fd..698f5490 100644
--- a/public/components/views/tree/tree-layout.js
+++ b/public/components/views/tree/tree-layout.js
@@ -3,6 +3,23 @@ export const CARD_WIDTH = 250;
export const CONNECTOR_GAP = 16;
export const GAP_ROW_HEIGHT = 16;
+const kEpochFallback = kEpochFallback;
+const kOneDay = 1_000 * 60 * 60 * 24;
+const kOneWeek = kOneDay * 7;
+const kOneMonth = kOneDay * 30;
+const kSixMonths = kOneDay * 30 * 6;
+const kOneYear = kOneDay * 365;
+const kTwoYears = kOneDay * 365 * 2;
+
+export const ACTIVITY_GROUPS = [
+ { key: "fresh", color: "#10b981", threshold: kOneWeek },
+ { key: "recent", color: "#84cc16", threshold: kOneMonth },
+ { key: "active", color: "#eab308", threshold: kSixMonths },
+ { key: "stable", color: "#f97316", threshold: kOneYear },
+ { key: "slow", color: "#ef4444", threshold: kTwoYears },
+ { key: "stale", color: "#6b7280", threshold: Infinity }
+];
+
export function getSortedChildren(nodeId, childrenByParent, linker) {
return (childrenByParent.get(nodeId) ?? [])
.sort((idA, idB) => linker.get(idA).name.localeCompare(linker.get(idB).name));
@@ -19,6 +36,37 @@ export function buildChildrenMap(rawEdgesData) {
return childrenByParent;
}
+export function computeActivityGroups(linker, dependencies) {
+ const now = Date.now();
+ const groups = new Map(ACTIVITY_GROUPS.map(({ key }) => [key, []]));
+ const seen = new Set();
+
+ for (const [nodeId, entry] of linker) {
+ const spec = `${entry.name}@${entry.version}`;
+ if (seen.has(spec)) {
+ continue;
+ }
+ seen.add(spec);
+
+ const lastUpdateAt = dependencies[entry.name]?.metadata?.lastUpdateAt;
+ const ageMs = lastUpdateAt ? now - new Date(lastUpdateAt).getTime() : Infinity;
+
+ const bucket = ACTIVITY_GROUPS.find(({ threshold }) => ageMs < threshold) ?? ACTIVITY_GROUPS.at(-1);
+ groups.get(bucket.key).push(nodeId);
+ }
+
+ for (const [, nodeIds] of groups) {
+ nodeIds.sort((idA, idB) => {
+ const dateA = dependencies[linker.get(idA).name]?.metadata?.lastUpdateAt ?? kEpochFallback;
+ const dateB = dependencies[linker.get(idB).name]?.metadata?.lastUpdateAt ?? kEpochFallback;
+
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+ }
+
+ return groups;
+}
+
export function computeDepthGroups(rawEdgesData) {
const childrenByParent = buildChildrenMap(rawEdgesData);
diff --git a/public/components/views/tree/tree-styles.js b/public/components/views/tree/tree-styles.js
index 24101a11..7b8e9d77 100644
--- a/public/components/views/tree/tree-styles.js
+++ b/public/components/views/tree/tree-styles.js
@@ -297,6 +297,24 @@ export const treeStyles = css`
margin-left: auto;
}
+ .tree-card--published-row {
+ display: flex;
+ margin-top: 2px;
+ }
+
+ .tree-card--published-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--published-color) 15%, transparent);
+ border: 1px solid color-mix(in srgb, var(--published-color) 35%, transparent);
+ color: var(--published-color);
+ }
+
.depth-container {
display: flex;
flex-direction: row;
diff --git a/public/components/views/tree/tree.js b/public/components/views/tree/tree.js
index 7d0ca258..d95c5fc0 100644
--- a/public/components/views/tree/tree.js
+++ b/public/components/views/tree/tree.js
@@ -5,7 +5,15 @@ import { LitElement, html, nothing } from "lit";
import { currentLang } from "../../../common/utils.js";
import { EVENTS } from "../../../core/events.js";
import { treeStyles } from "./tree-styles.js";
-import { CARD_WIDTH, CONNECTOR_GAP, GAP_ROW_HEIGHT, computeDepthGroups, computeTreeLayout } from "./tree-layout.js";
+import {
+ CARD_WIDTH,
+ CONNECTOR_GAP,
+ GAP_ROW_HEIGHT,
+ ACTIVITY_GROUPS,
+ computeDepthGroups,
+ computeTreeLayout,
+ computeActivityGroups
+} from "./tree-layout.js";
import { renderCardContent } from "./tree-card.js";
import { drawConnectors } from "./tree-connectors.js";
import "../../../components/root-selector/root-selector.js";
@@ -138,6 +146,41 @@ class TreeView extends LitElement {
`;
}
+ #renderActivityColumn(bucket, nodeIds) {
+ const i18n = window.i18n[currentLang()];
+ const labelKey = `activity${bucket.key.charAt(0).toUpperCase()}${bucket.key.slice(1)}`;
+
+ return html`
+
+
+ ${i18n.tree[labelKey]}
+ ${nodeIds.length}
+
+
+ ${nodeIds.map((nodeId) => {
+ const entry = this.secureDataSet.linker.get(nodeId);
+ const publishedAt = this.secureDataSet.data.dependencies[entry.name]?.metadata?.lastUpdateAt ?? null;
+
+ return renderCardContent(this.secureDataSet, { nodeId, publishedAt, publishedColor: bucket.color });
+ })}
+
+
+ `;
+ }
+
+ #renderActivityMode() {
+ const activityGroups = computeActivityGroups(
+ this.secureDataSet.linker,
+ this.secureDataSet.data.dependencies
+ );
+
+ return html`
+
+ ${ACTIVITY_GROUPS.map((bucket) => this.#renderActivityColumn(bucket, activityGroups.get(bucket.key)))}
+
+ `;
+ }
+
#renderHeader(depthGroups) {
const totalDeps = Object.keys(this.secureDataSet.data.dependencies).length;
const directDeps = (depthGroups.get(1) ?? []).length;
@@ -165,11 +208,28 @@ class TreeView extends LitElement {
this._mode = "tree";
}}
>${i18n.tree.modeTree}
+
`;
}
+ #renderBody(depthGroups, maxDepth) {
+ if (this._mode === "tree") {
+ return this.#renderTreeMode(maxDepth);
+ }
+ else if (this._mode === "activity") {
+ return this.#renderActivityMode();
+ }
+
+ return this.#renderDepthMode(depthGroups);
+ }
+
render() {
if (!this.secureDataSet?.data) {
return nothing;
@@ -183,10 +243,7 @@ class TreeView extends LitElement {
return html`
${this.#renderHeader(depthGroups)}
- ${this._mode === "tree"
- ? this.#renderTreeMode(maxDepth)
- : this.#renderDepthMode(depthGroups)
- }
+ ${this.#renderBody(depthGroups, maxDepth)}
`;
}
}