Files
paseo/packages/app/src/utils/time.ts
T
Mohamed Boudra ab8151d33f feat(app): sidebar display preferences, a shared menu engine, and a colour pass
Sidebar polish round.

Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).

Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.

Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.

Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.

The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.

File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
2026-08-03 18:58:06 +02:00

171 lines
6.0 KiB
TypeScript

/**
* How long ago something was, before it's worded. The two formatters below share this so
* their thresholds can't drift apart; they differ only in how much room they have to say it.
*
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
*/
type Elapsed =
| { kind: "now" }
| { kind: "elapsed"; value: string }
| { kind: "date"; value: string };
function describeElapsed(date: Date, now: Date): Elapsed {
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 10) return { kind: "now" };
if (diffMin < 1) return { kind: "elapsed", value: `${diffSec}s` };
if (diffHour < 1) return { kind: "elapsed", value: `${diffMin}m` };
if (diffDay < 1) return { kind: "elapsed", value: `${diffHour}h` };
if (diffDay < 7) return { kind: "elapsed", value: `${diffDay}d` };
// For older dates, show abbreviated month and day
const month = date.toLocaleDateString("en-US", { month: "short" });
return { kind: "date", value: `${month} ${date.getDate()}` };
}
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
*/
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
const elapsed = describeElapsed(date, now);
if (elapsed.kind === "now") return "just now";
if (elapsed.kind === "elapsed") return `${elapsed.value} ago`;
return elapsed.value;
}
/**
* How often a compact label can change, which is all a caller needs to know to keep it honest.
* `static` means it never will again.
*/
export type RelativeTimeResolution = "minute" | "hour" | "day" | "static";
export interface CompactTimeAgo {
label: string;
resolution: RelativeTimeResolution;
}
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** Past a week the elapsed count stops meaning anything; the date itself is more use. */
const ABSOLUTE_AFTER_MS = 7 * DAY_MS;
/**
* The same instant with the prose removed, for somewhere with no room for it — a dense list
* where the column is understood to be a timestamp and "ago" is the only word on the line.
* Examples: "now", "5m", "2h", "3d", "Jan 15".
*
* Deliberately never sub-minute. A seconds label is only correct for the second it was rendered,
* so it either lies or forces a once-a-second re-render of a list that has nothing new to say.
* Everything under a minute is "now", which is both true and stable.
*
* The resolution comes back with the label so a caller can wake at the rate the label actually
* changes instead of guessing — see `useCompactTimeAgo`.
*/
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo {
const elapsedMs = now.getTime() - date.getTime();
if (elapsedMs < MINUTE_MS) {
return { label: "now", resolution: "minute" };
}
if (elapsedMs < HOUR_MS) {
return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" };
}
if (elapsedMs < DAY_MS) {
return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
}
if (elapsedMs < ABSOLUTE_AFTER_MS) {
return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
}
const month = date.toLocaleDateString("en-US", { month: "short" });
return { label: `${month} ${date.getDate()}`, resolution: "static" };
}
export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string {
return describeCompactTimeAgo(date, now).label;
}
function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
// options is what makes the runtime respect the user's OS-level 12h/24h
// preference rather than the locale's default cycle.
let cachedTimeFormatter: Intl.DateTimeFormat | null = null;
function getTimeFormatter(): Intl.DateTimeFormat {
if (cachedTimeFormatter) return cachedTimeFormatter;
const resolved = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).resolvedOptions();
cachedTimeFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
hourCycle: resolved.hourCycle,
});
return cachedTimeFormatter;
}
/**
* Format a chat-message timestamp for hover-revealed UI.
* - Same day: "10:11 PM" or "22:11" depending on user preference
* - Within ~6 days: "Wednesday 10:11 PM"
* - Older: "14 May 2026, 10:11 PM"
*/
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = getTimeFormatter().format(date);
if (isSameLocalDay(date, now)) {
return time;
}
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays >= 0 && diffDays < 7) {
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
return `${weekday} ${time}`;
}
const dateLabel = date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
return `${dateLabel}, ${time}`;
}
/**
* Format a duration as a compact human-readable string.
* - 0-60s: whole seconds ("47s")
* - Minutes/hours: integers only ("2m 12s", "1h 5m")
*/
export function formatDuration(durationMs: number): string {
if (!Number.isFinite(durationMs) || durationMs < 0) {
return "0s";
}
const totalSeconds = durationMs / 1000;
if (totalSeconds < 60) {
return `${Math.floor(totalSeconds)}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
const seconds = Math.floor(totalSeconds) % 60;
return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${seconds}s`;
}
const hours = Math.floor(totalMinutes / 60);
const remMinutes = totalMinutes % 60;
return remMinutes === 0 ? `${hours}h` : `${hours}h ${remMinutes}m`;
}