The /inbox surface drops "Genehmigungen" framing in favour of "Inbox" and renders the unified feed. - shape-list.ts: factor renderApprovalRow out of renderApprovalList so it can be reused alongside renderProjectEventInboxRow inside the new renderInboxList (row_action="inbox"). Project_event rows show a compact stream layout with an Öffnen link pointing at the right project tab (deadlines / appointments / notes). - filter-bar gets two new axes: unread_only (binary chip cluster) + inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine / +Fristen). Both round-trip via url-codec; inbox_focus translates to (sources, project_event.event_types, approval_request.entity_types) at the bar's resolve step (applyInboxFocusOverlay). - FilterSpec gains a top-level unread_only flag; the bar writes it when the user toggles the chip; the server overlays the cursor. - /inbox header: new "Alles als gelesen markieren" button POSTs /api/inbox/mark-all-seen with up_to=<newest visible row> for race-safety against a second tab. - INBOX_AXES adds project + project_event_kind as advanced override chips so power users can still narrow per kind. - i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen / inbox.action.open / inbox.empty.feed / views.bar.unread_only.* / views.bar.inbox_focus.* (DE + EN). - url-codec round-trip tests for the two new axes.
536 lines
20 KiB
TypeScript
536 lines
20 KiB
TypeScript
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
|
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
|
|
import { formatDate, formatRelative, parseDateOnly } from "./format";
|
|
|
|
// shape-list: renders ViewRows as a table (density=comfortable) or a
|
|
// compact one-line stream (density=compact). The "activity feed" look
|
|
// is just density=compact + actor/time columns — see Q4 lock-in
|
|
// 2026-05-07 (3 shapes; no separate "activity").
|
|
//
|
|
// Row interaction is controlled by render.list.row_action
|
|
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
|
|
// caller's contract — clicking a row goes to the per-kind detail
|
|
// page. "approve" produces the approval-list layout for /inbox.
|
|
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
|
|
// any row interaction (audit views).
|
|
|
|
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
|
host.innerHTML = "";
|
|
const list = render.list ?? {};
|
|
const density = list.density ?? "comfortable";
|
|
const sort = list.sort ?? "date_asc";
|
|
const rowAction: ListRowAction = list.row_action ?? "navigate";
|
|
|
|
const sorted = [...rows].sort((a, b) => {
|
|
const aT = sortKey(a.event_date);
|
|
const bT = sortKey(b.event_date);
|
|
return sort === "date_asc" ? aT - bT : bT - aT;
|
|
});
|
|
|
|
if (rowAction === "approve") {
|
|
host.appendChild(renderApprovalList(sorted));
|
|
return;
|
|
}
|
|
|
|
if (rowAction === "inbox") {
|
|
host.appendChild(renderInboxList(sorted));
|
|
return;
|
|
}
|
|
|
|
if (density === "compact") {
|
|
host.appendChild(renderCompact(sorted));
|
|
} else {
|
|
host.appendChild(renderTable(sorted, list.columns ?? defaultColumns(rows)));
|
|
}
|
|
}
|
|
|
|
function renderCompact(rows: ViewRow[]): HTMLElement {
|
|
const ul = document.createElement("ul");
|
|
ul.className = "views-list views-list--compact";
|
|
for (const row of rows) {
|
|
const li = document.createElement("li");
|
|
li.className = `views-list-row views-list-row--${row.kind}`;
|
|
|
|
const time = document.createElement("span");
|
|
time.className = "views-list-time";
|
|
time.textContent = formatRelative(row.event_date, row.kind);
|
|
li.appendChild(time);
|
|
|
|
const kindIcon = document.createElement("span");
|
|
kindIcon.className = "views-list-kind";
|
|
kindIcon.textContent = kindLabel(row.kind);
|
|
li.appendChild(kindIcon);
|
|
|
|
const title = document.createElement("span");
|
|
title.className = "views-list-title";
|
|
title.textContent = row.title;
|
|
li.appendChild(title);
|
|
|
|
if (row.project_title) {
|
|
const proj = document.createElement("span");
|
|
proj.className = "views-list-project";
|
|
proj.textContent = row.project_title;
|
|
li.appendChild(proj);
|
|
}
|
|
|
|
if (row.actor_name) {
|
|
const actor = document.createElement("span");
|
|
actor.className = "views-list-actor";
|
|
actor.textContent = row.actor_name;
|
|
li.appendChild(actor);
|
|
}
|
|
|
|
if (row.subtitle) {
|
|
const sub = document.createElement("span");
|
|
sub.className = "views-list-subtitle";
|
|
sub.textContent = row.subtitle;
|
|
li.appendChild(sub);
|
|
}
|
|
ul.appendChild(li);
|
|
}
|
|
return ul;
|
|
}
|
|
|
|
function renderTable(rows: ViewRow[], columns: string[]): HTMLElement {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "entity-table-wrap";
|
|
const table = document.createElement("table");
|
|
table.className = "entity-table views-list views-list--table entity-table--readonly";
|
|
const thead = document.createElement("thead");
|
|
const trHead = document.createElement("tr");
|
|
for (const col of columns) {
|
|
const th = document.createElement("th");
|
|
th.textContent = t(("views.col." + col) as I18nKey);
|
|
trHead.appendChild(th);
|
|
}
|
|
thead.appendChild(trHead);
|
|
table.appendChild(thead);
|
|
|
|
const tbody = document.createElement("tbody");
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
tr.className = `views-table-row views-table-row--${row.kind}`;
|
|
for (const col of columns) {
|
|
const td = document.createElement("td");
|
|
td.textContent = formatColumn(row, col);
|
|
tr.appendChild(td);
|
|
}
|
|
tbody.appendChild(tr);
|
|
}
|
|
table.appendChild(tbody);
|
|
wrap.appendChild(table);
|
|
return wrap;
|
|
}
|
|
|
|
function defaultColumns(rows: ViewRow[]): string[] {
|
|
// Pick a sensible default column set from the kinds present in the
|
|
// result. Keeps the UI honest when a user lands on a saved view that
|
|
// has no explicit list.columns.
|
|
const kinds = new Set(rows.map((r) => r.kind));
|
|
if (kinds.has("project_event") || kinds.has("approval_request")) {
|
|
return ["time", "actor", "title", "project"];
|
|
}
|
|
if (kinds.has("appointment")) {
|
|
return ["date", "title", "project", "location"];
|
|
}
|
|
return ["date", "title", "project", "status"];
|
|
}
|
|
|
|
function formatColumn(row: ViewRow, col: string): string {
|
|
switch (col) {
|
|
case "date":
|
|
return formatDate(row.event_date);
|
|
case "time":
|
|
return formatRelative(row.event_date, row.kind);
|
|
case "title":
|
|
return row.title;
|
|
case "project":
|
|
return row.project_title ?? "—";
|
|
case "actor":
|
|
return row.actor_name ?? "—";
|
|
case "status": {
|
|
const s = (row.detail.status as string | undefined) ?? "";
|
|
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
|
}
|
|
case "rule": {
|
|
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
|
|
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
|
|
const lang = getLang();
|
|
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
|
|
const name = (row.detail[nameKey] as string | undefined)
|
|
|| (row.detail.rule_name as string | undefined)
|
|
|| "";
|
|
const cite = (row.detail.rule_code as string | undefined) ?? "";
|
|
if (name && cite) return `${name} · ${cite}`;
|
|
if (name) return name;
|
|
if (cite) return cite;
|
|
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
|
|
if (custom.trim()) return `${custom} · Custom`;
|
|
return "—";
|
|
}
|
|
case "event_type":
|
|
return (row.detail.event_type as string | undefined) ?? "—";
|
|
case "location":
|
|
return (row.detail.location as string | undefined) ?? "—";
|
|
case "appointment_type":
|
|
return (row.detail.appointment_type as string | undefined) ?? "—";
|
|
case "approval_status":
|
|
return (row.detail.approval_status as string | undefined) ?? "—";
|
|
case "decided_by":
|
|
return (row.detail.decider_name as string | undefined) ?? "—";
|
|
case "kind":
|
|
return kindLabel(row.kind);
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function kindLabel(kind: string): string {
|
|
return t(("views.kind." + kind) as I18nKey);
|
|
}
|
|
|
|
function sortKey(iso: string): number {
|
|
const dateOnly = parseDateOnly(iso);
|
|
if (dateOnly) return dateOnly.getTime();
|
|
return Date.parse(iso);
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// row_action = "approve" — approval inbox layout
|
|
//
|
|
// Stamps the markup the /inbox surface needs (data attrs + classes);
|
|
// the surface (client/inbox.ts) wires the action handlers in onResult.
|
|
// This keeps shape-list independent of any specific surface's wiring.
|
|
// ----------------------------------------------------------------------
|
|
|
|
interface ApprovalDetail {
|
|
status?: string;
|
|
lifecycle_event?: string;
|
|
entity_type?: string;
|
|
entity_title?: string;
|
|
pre_image?: Record<string, unknown> | null;
|
|
payload?: Record<string, unknown> | null;
|
|
required_role?: string;
|
|
requester_name?: string;
|
|
requester_kind?: "user" | "agent";
|
|
decider_name?: string;
|
|
decision_note?: string;
|
|
// counter_payload + next_request_id — populated on the OLD row of a
|
|
// suggest-changes pair (t-paliad-216). The new row's id lets us
|
|
// render a back-link "→ Neuer Vorschlag von {decider}". Both stay
|
|
// unset on any non-changes_requested status.
|
|
counter_payload?: Record<string, unknown> | null;
|
|
next_request_id?: string;
|
|
// Per-viewer eligibility flags resolved server-side against the caller
|
|
// (t-paliad-202). Used to grey out actions the server would reject.
|
|
// Optional so an older payload still renders — falsy means "treat as
|
|
// disabled" for the safety side (no false enables).
|
|
viewer_can_approve?: boolean;
|
|
viewer_is_requester?: boolean;
|
|
}
|
|
|
|
// Pending-row action set. suggest_changes was added in t-paliad-216 as
|
|
// the fourth action — the approver authors a counter-proposal which
|
|
// becomes a NEW pending row authored by them.
|
|
type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes";
|
|
|
|
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
|
const ul = document.createElement("ul");
|
|
ul.className = "inbox-list views-approval-list";
|
|
for (const row of rows) {
|
|
ul.appendChild(renderApprovalRow(row));
|
|
}
|
|
return ul;
|
|
}
|
|
|
|
// renderApprovalRow stamps one <li> for an approval_request row.
|
|
// Factored out of renderApprovalList in t-paliad-249 so the unified
|
|
// inbox dispatch (renderInboxList) can reuse the exact same markup for
|
|
// approval rows interleaved with project_event rows.
|
|
export function renderApprovalRow(row: ViewRow): HTMLLIElement {
|
|
const detail = (row.detail || {}) as ApprovalDetail;
|
|
const li = document.createElement("li");
|
|
li.className = "inbox-row views-approval-row";
|
|
li.dataset.requestId = row.id;
|
|
li.dataset.status = detail.status ?? "";
|
|
|
|
// Header: entity / lifecycle
|
|
const head = document.createElement("div");
|
|
head.className = "inbox-row-head";
|
|
const title = document.createElement("div");
|
|
title.className = "inbox-row-title";
|
|
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
|
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
|
const entityTitle = detail.entity_title || row.title || "—";
|
|
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
|
head.appendChild(title);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "inbox-row-meta";
|
|
const reqByLabel = t("approvals.requested_by");
|
|
const roleLabel = detail.required_role
|
|
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
|
: "";
|
|
const requester = detail.requester_name || row.actor_name || "";
|
|
const requesterTag = detail.requester_kind === "agent"
|
|
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
|
: requester;
|
|
const projectTitle = row.project_title ?? "";
|
|
const parts = [
|
|
projectTitle,
|
|
`${reqByLabel} ${requesterTag}`,
|
|
];
|
|
if (roleLabel) parts.push(`${roleLabel}+`);
|
|
parts.push(formatRelativeTime(row.event_date));
|
|
meta.textContent = parts.filter(Boolean).join(" · ");
|
|
head.appendChild(meta);
|
|
li.appendChild(head);
|
|
|
|
// Diff for update / complete
|
|
const diff = renderDiff(detail);
|
|
if (diff) li.appendChild(diff);
|
|
|
|
if (detail.decision_note) {
|
|
const note = document.createElement("div");
|
|
note.className = "inbox-row-note";
|
|
note.textContent = detail.decision_note;
|
|
li.appendChild(note);
|
|
}
|
|
|
|
// Action row — surface attaches handlers via data-attrs.
|
|
const actions = document.createElement("div");
|
|
actions.className = "inbox-row-actions";
|
|
|
|
if (detail.status === "pending") {
|
|
// All four actions are stamped on every pending row; the per-viewer
|
|
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
|
|
// decide which are enabled vs. greyed out with a tooltip. m's ask
|
|
// (2026-05-17): show what's possible but disable what isn't, rather
|
|
// than alert-after-click. The server still enforces — disabled buttons
|
|
// are a UI hint, not a security gate.
|
|
//
|
|
// suggest_changes is hidden for non-update lifecycles (the backend
|
|
// returns ErrSuggestionLifecycleInvalid for create/complete/delete,
|
|
// so we don't even render the button for them).
|
|
actions.appendChild(approvalActionBtn("approve", detail));
|
|
if (detail.lifecycle_event === "update") {
|
|
actions.appendChild(approvalActionBtn("suggest_changes", detail));
|
|
}
|
|
actions.appendChild(approvalActionBtn("reject", detail));
|
|
actions.appendChild(approvalActionBtn("revoke", detail));
|
|
} else if (detail.status) {
|
|
const pill = document.createElement("span");
|
|
pill.className = "approval-pill approval-pill--historic";
|
|
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
|
if (detail.decider_name && detail.status !== "revoked") {
|
|
const decided = document.createElement("span");
|
|
decided.className = "inbox-row-decided";
|
|
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
|
pill.appendChild(decided);
|
|
}
|
|
actions.appendChild(pill);
|
|
}
|
|
li.appendChild(actions);
|
|
|
|
// Back-link from the OLD changes_requested row to the NEW pending
|
|
// counter row (t-paliad-216). Hydrated server-side as
|
|
// detail.next_request_id; the surface renders a link that scrolls
|
|
// / filters to the new row. Falsy next_request_id = no link (e.g.
|
|
// older rows pre-mig-103, or rows where the server hasn't joined the
|
|
// back-pointer).
|
|
if (detail.status === "changes_requested" && detail.next_request_id) {
|
|
const link = document.createElement("a");
|
|
link.className = "inbox-row-next-request";
|
|
link.href = `#request-${detail.next_request_id}`;
|
|
link.dataset.nextRequestId = detail.next_request_id;
|
|
const deciderName = detail.decider_name || "";
|
|
link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName);
|
|
li.appendChild(link);
|
|
}
|
|
|
|
return li;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// row_action = "inbox" — unified inbox layout (t-paliad-249)
|
|
//
|
|
// Dispatches per row.kind so approval_request rows reuse the existing
|
|
// approve/reject/revoke markup while project_event rows render as a
|
|
// compact stream row (timestamp + actor + title + project chip +
|
|
// Öffnen link to the underlying entity).
|
|
// ----------------------------------------------------------------------
|
|
|
|
function renderInboxList(rows: ViewRow[]): HTMLElement {
|
|
const ul = document.createElement("ul");
|
|
ul.className = "inbox-list inbox-list--unified";
|
|
for (const row of rows) {
|
|
if (row.kind === "approval_request") {
|
|
ul.appendChild(renderApprovalRow(row));
|
|
} else if (row.kind === "project_event") {
|
|
ul.appendChild(renderProjectEventInboxRow(row));
|
|
}
|
|
}
|
|
return ul;
|
|
}
|
|
|
|
interface ProjectEventDetail {
|
|
event_type?: string | null;
|
|
description?: string | null;
|
|
}
|
|
|
|
function renderProjectEventInboxRow(row: ViewRow): HTMLLIElement {
|
|
const detail = (row.detail || {}) as ProjectEventDetail;
|
|
const li = document.createElement("li");
|
|
li.className = "inbox-row inbox-row--project-event";
|
|
li.dataset.eventId = row.id;
|
|
if (detail.event_type) li.dataset.eventType = detail.event_type;
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "inbox-row-head";
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "inbox-row-title";
|
|
// Prefer the row.title (server-side authored, project-aware); fall
|
|
// back to a synthesised event-kind label so a malformed row never
|
|
// produces an empty <li>.
|
|
const kindLabelText = detail.event_type ? t(("event.title." + detail.event_type) as I18nKey) : "";
|
|
title.textContent = row.title || kindLabelText || "—";
|
|
head.appendChild(title);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "inbox-row-meta";
|
|
const parts: string[] = [];
|
|
if (row.project_title) parts.push(row.project_title);
|
|
if (row.actor_name) parts.push(row.actor_name);
|
|
parts.push(formatRelativeTime(row.event_date));
|
|
meta.textContent = parts.filter(Boolean).join(" · ");
|
|
head.appendChild(meta);
|
|
li.appendChild(head);
|
|
|
|
if (detail.description) {
|
|
const desc = document.createElement("div");
|
|
desc.className = "inbox-row-description";
|
|
desc.textContent = detail.description;
|
|
li.appendChild(desc);
|
|
}
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "inbox-row-actions";
|
|
const openLink = projectEventLink(row, detail);
|
|
if (openLink) actions.appendChild(openLink);
|
|
li.appendChild(actions);
|
|
|
|
return li;
|
|
}
|
|
|
|
// projectEventLink builds an "Öffnen" anchor that points to the most
|
|
// useful target for the event kind. Falls back to the project detail
|
|
// page when the kind doesn't carry a richer pointer.
|
|
//
|
|
// Slice B can deepen this (e.g. note_created → scroll to note anchor);
|
|
// keep it minimal for Slice A.
|
|
function projectEventLink(row: ViewRow, detail: ProjectEventDetail): HTMLAnchorElement | null {
|
|
if (!row.project_id) return null;
|
|
const kind = detail.event_type ?? "";
|
|
const a = document.createElement("a");
|
|
a.className = "inbox-row-open";
|
|
a.textContent = t("inbox.action.open");
|
|
if (kind.startsWith("deadline_")) {
|
|
a.href = `/projects/${row.project_id}#deadlines`;
|
|
} else if (kind.startsWith("appointment_")) {
|
|
a.href = `/projects/${row.project_id}#appointments`;
|
|
} else if (kind === "note_created") {
|
|
a.href = `/projects/${row.project_id}#notes`;
|
|
} else {
|
|
a.href = `/projects/${row.project_id}`;
|
|
}
|
|
return a;
|
|
}
|
|
|
|
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
|
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
|
const after = (detail.payload || {}) as Record<string, unknown>;
|
|
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
|
if (keys.length === 0) return null;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "inbox-row-diff";
|
|
for (const k of keys) {
|
|
const line = document.createElement("div");
|
|
line.className = "inbox-row-diff-line";
|
|
const label = document.createElement("span");
|
|
label.className = "inbox-row-diff-key";
|
|
label.textContent = k;
|
|
line.appendChild(label);
|
|
const span = document.createElement("span");
|
|
span.className = "inbox-row-diff-values";
|
|
const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v);
|
|
if (k in before && k in after) {
|
|
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
|
} else if (k in before) {
|
|
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
|
} else {
|
|
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
|
}
|
|
line.appendChild(span);
|
|
wrap.appendChild(line);
|
|
}
|
|
return wrap;
|
|
}
|
|
|
|
function approvalActionBtn(
|
|
action: ApprovalAction,
|
|
detail: ApprovalDetail,
|
|
): HTMLButtonElement {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.dataset.action = action;
|
|
// suggest_changes shares the secondary style with revoke; reject is
|
|
// danger (terminal "no"); approve is primary.
|
|
const cls = action === "approve"
|
|
? "btn-primary"
|
|
: action === "reject"
|
|
? "btn-danger"
|
|
: "btn-secondary";
|
|
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
|
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
|
|
|
// approve / reject / suggest_changes share the canApprove eligibility
|
|
// gate; revoke is requester-only.
|
|
const reason = disabledReasonFor(action, detail);
|
|
if (reason) {
|
|
btn.disabled = true;
|
|
btn.title = t(reason);
|
|
}
|
|
return btn;
|
|
}
|
|
|
|
function disabledReasonFor(
|
|
action: ApprovalAction,
|
|
detail: ApprovalDetail,
|
|
): I18nKey | null {
|
|
if (action === "revoke") {
|
|
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
|
|
}
|
|
// approve / reject / suggest_changes — same gate as the server's canApprove.
|
|
if (detail.viewer_can_approve) return null;
|
|
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
|
|
return "approvals.disabled.not_authorized";
|
|
}
|
|
|
|
function formatRelativeTime(iso: string): string {
|
|
const t0 = Date.parse(iso);
|
|
if (isNaN(t0)) return iso;
|
|
const diffMs = Date.now() - t0;
|
|
const sec = Math.floor(diffMs / 1000);
|
|
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
|
const min = Math.floor(sec / 60);
|
|
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
|
const day = Math.floor(hr / 24);
|
|
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
|
}
|
|
|
|
// Suppress unused warning for tDyn — kept available for future axes.
|
|
void tDyn;
|