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)} `; } }