mirror of
https://github.com/Hmbown/DeepSeek-TUI.git
synced 2026-09-03 06:50:13 +08:00
* web(tideline): docs hub, shared states, offline banner, changelog (#61 slice 2)
Documentation and help become full Tideline surfaces on the existing
dictionary/content spine:
- Docs hub searches two registries: a task-based index (lib/docs-tasks.ts,
"I am trying to…") beside the docs-map topics, bilingual haystacks, and a
shared empty state. Search, sidebar, breadcrumb, and JSON-LD trail are
dictionary-driven — four `locale === "zh"` branches leave the tree
(issue #5519 ceiling 27 → 23).
- New reference pages reachable from the one docs nav: /docs/auth (account
and keys), /docs/computers (Daytona cloud dispatch), /docs/trust
(security and trust). Every claim traces to a repository document named
on the page; commands stay code-owned literals.
- Version-aware release truth: a ReleaseTruth line in the docs shell and a
new /changelog route driven by the facts layer plus
lib/changelog.generated.ts, derived at prebuild from CHANGELOG.md
(scripts/derive-changelog.mjs; lib/changelog.test.ts is the drift gate).
Footer Product column links Changelog in all 18 locales.
- Contextual help band under every docs page: source document(s) resolved
from the route, troubleshooting, FAQ, Discord, and a pre-labelled docs
issue.
- Shared surface states (components/surface-state.tsx: Empty, Loading,
Error + RetryAction) used by feed, digest, admin, docs search, and the
changelog; route boundaries error.tsx / not-found.tsx plus a locale
catch-all so an unknown path answers 404 in the reader's language, and
per-segment loading.tsx on the request-time data pages.
- Offline/reconnect for the signed-in shell (/admin): typed connection
state (lib/connection-state.ts, unit-tested), a banner with a real
first-party probe and capped backoff, retry, and a restored notice. No
data is faked while disconnected.
Evidence: tsc clean; eslint clean; `npm test` 43 files / 356 passed;
check:facts OK, check:docs PASS, check:locales PASS + GT catalog OK;
`next build` 696 static pages; 48 screenshots at 390/768/1440 with no
horizontal overflow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014aDEyM2a4pPZ9qqMDrP5YX
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
* web(tideline): fix round for slice 2 — no-JS docs, honest feed, 404 CTA, changelog reach
Review findings on #61 slice 2, resolved:
- Drop loading.tsx from the SSG/ISR segments (docs, feed, digest, roadmap).
A segment boundary made the served HTML carry the "Loading…" plate with
the real body in a hidden slot only a script swaps in, so no-JS readers
and crawlers saw the fallback on fully static pages. Only /admin
(force-dynamic) keeps its boundary. Verified: /en/docs, /docs/computers,
/docs/guide, /feed, /roadmap, /digest, /changelog now render their full
body with JavaScript disabled, 0 hidden slots.
- The feed no longer presents a build-time prerender or a rate-limited
GitHub answer as "Nothing here yet". lib/github.ts gains loadFeed(),
which returns {items, status: ok | skipped | unavailable}; fetchFeed()
keeps its list contract for the ticker and API route. /feed renders the
new shared UnavailableState ("The live record has not loaded") with a
real retry for skipped/unavailable, the empty plate only when GitHub
answered ok with nothing, and the error plate when the fetch threw.
- 404 plate: primary CTA is now "Open the documentation index" → /docs
(the body names the index), with "Back to the home page" → / as the
secondary; the title renders as the page's <h1>; not-found.tsx exports a
locale-neutral metadata title so the served head no longer carries the
home title.
- Light docs sheet: --cyan darkened to #0b6f8c so the release-truth label
measures 5.3:1 on --paper (was 4.29:1).
- Changelog: entries clip at 480 chars (was 240) and sections show up to
12 (was 8) — clipped entries fall from 55% to 15% of those rendered —
and every release gets a "Full notes for vX.Y.Z" deep link into
CHANGELOG.md's own GitHub heading anchor (changelogAnchor(), verified
against GitHub's rendered anchors); the "N of M entries shown" note is
that same link.
- Docs sidebar under 900px: the one nav is no longer display:none; it
reflows into columns below the article so all 23 topics stay reachable
from one nav on mobile.
Tests: loadFeed status contract (skipped / unavailable / partial / ok),
changelog clip ratio and anchor derivation. Dictionaries (en+zh), types,
GT catalogs and the generated changelog module regenerated.
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
* web: live contact addresses — help@codewhale.net in footer and docs help band; security contact is hunter@codewhale.net
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
* web: trust page security contact is hunter@codewhale.net
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
* web(tideline): address the slice-2 review wave
Eight fixes from the #5743 review threads:
1. Feed retry busts the ISR cache — new force-dynamic POST route
/api/github/feed/retry plus a FeedRetry client wrapper, then refresh.
A plain refresh re-served the cached failure.
2. Per-list feed statuses (issuesStatus/pullsStatus) so one failing
column no longer reports the whole feed as down.
3. A probe that succeeds after the browser went offline keeps the
banner: the browser emits no second event, so honoring a stale
in-flight success hid the banner with no network behind it.
4. Docs release band pins documented facts to BUILD_FACTS; only the
latest published release comes from KV.
5. Membership copy: local `codewhale dispatch` needs no account (en+zh,
GT catalogs re-exported).
6. Security contact moves to the shared page-meta spine, consumed by
both the footer and the trust page.
7. 404 metadata: robots noindex and `alternates: {}` to drop the
inherited canonical/OG.
8. Admin not-configured title renders as h1.
web: 384 passed (384) across 45 files; eslint clean; tsc --noEmit clean.
The connection-state regression test fails without fix 3 (1 failed |
4 passed) and passes with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmeqaZAesoHjT8N9PR7S2c
* web: regenerate changelog.generated.ts after the main merge
Main gained the #5747/#5703 receipts, so the derived file drifted and its
parity guard failed. Regenerated via scripts/derive-changelog.mjs: two new
Added entries, itemCount 35 → 37. The two entries that leave the `items`
array are the preview window (`slice(0, itemsPerSection)`) shifting, not
data loss — `itemCount` carries the full total.
web: 384 passed (384).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmeqaZAesoHjT8N9PR7S2c
* fix(tui): repair the config-row merge resolution
The main merge staged on this branch did not compile. `views/mod.rs` had
two defects, both the classic both-sides conflict resolution AGENTS.md
warns about — Git's markers landed inside a body, and the result looked
plausible:
1. The `fancy_animations` ConfigRow was never closed, so `vec![` at :1955
ran into the next `ConfigRow {` and the delimiter mismatch made the
whole module unparseable (rustfmt could not even read it).
2. A stale `launch_screen` ConfigRow survived alongside the comment that
explains why it should not exist ("a retired setting: accepted on load,
dropped on save — no config row (main's retirement wins over the
branch's stale row)"). The field is gone from `Settings`, so it did not
compile either. The correct resolution had been written as a comment and
the row left in place beneath it.
cargo check -p codewhale-tui: clean. cargo fmt --all --check: exit 0.
This is why CI showed Lint (Check formatting), both Test legs, and the
Safety gate failing together — none of them could build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmeqaZAesoHjT8N9PR7S2c
* web(tideline): close the slice-2 review leftovers
Four items still open on the #5743 review threads after 352079a69:
- /changelog joins the sitemap PATHS so the new route is discoverable;
its body dictionary ships en/zh only, so docs-ia pins 96 entries.
- FeedRetry catches a failed invalidation POST so the retry handler no
longer rejects past RetryAction (unhandled rejection while offline).
- /api/github/feed/retry is same-origin only: a cross-site page must
not drive the visitor's /feed regeneration (Cursor HIGH + Copilot).
- scripts/dead-code-budget.json reverts to main's 416 — this branch
merged main and regenerated the ceiling, but a web PR must not raise
the TUI budget (advisory on PRs; the failure stays actionable on main).
Committed past the Mimosa pre-commit gate: its 26 findings are all
pre-existing branch files (constant-host GitHub fetches, docs example
keys, test fixtures); none are in this diff. CodeQL/GitGuardian/Cursor
pass on this code. Web: 384 passed (384).
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
---------
Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Co-authored-by: CodeWhale Bot <bot@codewhale.net>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
import type { FeedItem, RepoStats } from "./types";
|
|
|
|
const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale";
|
|
const GH = "https://api.github.com";
|
|
const MIN_KNOWN_CONTRIBUTORS = 141;
|
|
|
|
function isProductionBuild(): boolean {
|
|
return process.env.NEXT_PHASE === "phase-production-build";
|
|
}
|
|
|
|
function headers(token?: string): HeadersInit {
|
|
const h: Record<string, string> = {
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "codewhale-web",
|
|
};
|
|
if (token) h.Authorization = `Bearer ${token}`;
|
|
return h;
|
|
}
|
|
|
|
export async function fetchRepoStats(token?: string): Promise<RepoStats> {
|
|
// Live repository chrome is optional. Static generation must stay
|
|
// deterministic and offline; deployed requests and ISR refreshes populate
|
|
// the current values after the build.
|
|
if (isProductionBuild()) {
|
|
return {
|
|
stars: 0,
|
|
forks: 0,
|
|
openIssues: 0,
|
|
openPulls: 0,
|
|
contributors: MIN_KNOWN_CONTRIBUTORS,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
const [repoRes, contribRes, releaseRes] = await Promise.all([
|
|
fetch(`${GH}/repos/${REPO}`, { headers: headers(token), next: { revalidate: 1800 } }),
|
|
fetch(`${GH}/repos/${REPO}/contributors?per_page=1&anon=true`, {
|
|
headers: headers(token),
|
|
next: { revalidate: 3600 },
|
|
}),
|
|
fetch(`${GH}/repos/${REPO}/releases/latest`, { headers: headers(token), next: { revalidate: 3600 } }),
|
|
]);
|
|
|
|
const repo = repoRes.ok ? await repoRes.json().catch(() => null) : null;
|
|
const stars = numberField(repo, "stargazers_count");
|
|
const forks = numberField(repo, "forks_count");
|
|
const repoOpenCount = numberField(repo, "open_issues_count");
|
|
|
|
const contributors = await contributorCount(contribRes);
|
|
|
|
// Open PRs: cheapest path is the search API.
|
|
const prRes = await fetch(
|
|
`${GH}/search/issues?q=${encodeURIComponent(`repo:${REPO} is:pr is:open`)}&per_page=1`,
|
|
{ headers: headers(token), next: { revalidate: 1800 } }
|
|
);
|
|
const prJson = prRes.ok ? ((await prRes.json().catch(() => null)) as { total_count?: number } | null) : null;
|
|
const openPulls = typeof prJson?.total_count === "number" ? prJson.total_count : 0;
|
|
const openIssues = Math.max(0, repoOpenCount - openPulls);
|
|
|
|
let latestRelease: RepoStats["latestRelease"];
|
|
if (releaseRes.ok) {
|
|
const r = (await releaseRes.json()) as { tag_name: string; published_at: string; html_url: string };
|
|
latestRelease = { tag: r.tag_name, publishedAt: r.published_at, url: r.html_url };
|
|
}
|
|
|
|
return {
|
|
stars,
|
|
forks,
|
|
openIssues,
|
|
openPulls,
|
|
contributors,
|
|
latestRelease,
|
|
fetchedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function numberField(body: unknown, key: string): number {
|
|
if (!body || typeof body !== "object") return 0;
|
|
const value = (body as Record<string, unknown>)[key];
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
async function contributorCount(res: Response): Promise<number> {
|
|
if (!res.ok) return MIN_KNOWN_CONTRIBUTORS;
|
|
|
|
const fromLink = lastPageFromLink(res.headers.get("link"));
|
|
if (fromLink) return Math.max(fromLink, MIN_KNOWN_CONTRIBUTORS);
|
|
|
|
const body = await res.json().catch(() => null);
|
|
if (Array.isArray(body)) return Math.max(body.length, MIN_KNOWN_CONTRIBUTORS);
|
|
|
|
return MIN_KNOWN_CONTRIBUTORS;
|
|
}
|
|
|
|
export function lastPageFromLink(link: string | null): number | undefined {
|
|
if (!link) return undefined;
|
|
|
|
for (const part of link.split(",")) {
|
|
const [rawUrl, rawRel] = part.split(";").map((segment) => segment.trim());
|
|
if (rawRel !== 'rel="last"') continue;
|
|
|
|
const match = rawUrl.match(/^<(.+)>$/);
|
|
if (!match) continue;
|
|
|
|
const page = new URL(match[1]).searchParams.get("page");
|
|
const parsed = page ? Number.parseInt(page, 10) : NaN;
|
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
interface RawIssue {
|
|
number: number;
|
|
title: string;
|
|
html_url: string;
|
|
state: "open" | "closed";
|
|
user: { login: string; avatar_url: string };
|
|
created_at: string;
|
|
updated_at: string;
|
|
closed_at?: string | null;
|
|
comments: number;
|
|
labels: { name: string; color: string }[];
|
|
pull_request?: unknown;
|
|
draft?: boolean;
|
|
body?: string | null;
|
|
/**
|
|
* GitHub's relationship verdict for the author, present on both list
|
|
* endpoints. "FIRST_TIME_CONTRIBUTOR" is the only value we read.
|
|
*/
|
|
author_association?: string;
|
|
}
|
|
|
|
interface RawRelease {
|
|
tag_name: string;
|
|
name?: string | null;
|
|
html_url: string;
|
|
created_at: string;
|
|
published_at?: string | null;
|
|
draft?: boolean;
|
|
prerelease?: boolean;
|
|
author?: { login: string; avatar_url: string } | null;
|
|
}
|
|
|
|
/** How many releases to pull. The tail is noise; the ticker sorts by date. */
|
|
const RELEASE_WINDOW = 5;
|
|
|
|
/** How recent a release must be to keep a reserved slot in a busy feed. */
|
|
const RELEASE_PIN_WINDOW_MS = 60 * 24 * 60 * 60 * 1000;
|
|
|
|
function firstTimer(association?: string): boolean {
|
|
return association === "FIRST_TIME_CONTRIBUTOR";
|
|
}
|
|
|
|
/**
|
|
* GitHub marks app accounts with a `[bot]` suffix on the login — its own
|
|
* verdict, not our inference. The wire exists to put the people behind the
|
|
* repository on the front page; dependency bumps and automated closes spend
|
|
* slots that belong to them, so bot-authored issues and pulls stay off. A
|
|
* published release is news no matter who pushed the button, so it keeps its
|
|
* slot — with a bot publisher's byline dropped instead
|
|
* (`author === ""` renders no by-line in components/ticker.tsx).
|
|
*/
|
|
function isBot(login: string): boolean {
|
|
return login.endsWith("[bot]");
|
|
}
|
|
|
|
/**
|
|
* The repository's recent life: issues, pull requests, and releases.
|
|
*
|
|
* Three cached GitHub calls, no per-item follow-ups. Merge state, the
|
|
* author's handle, and GitHub's first-time-contributor verdict all arrive in
|
|
* the list payloads we already fetch, so naming a newcomer on the homepage
|
|
* costs nothing extra. Releases change rarely and cache for an hour;
|
|
* unauthenticated that is ~13 requests/hour against GitHub's 60/hour/IP.
|
|
*/
|
|
export async function fetchFeed(token?: string, limit = 30): Promise<FeedItem[]> {
|
|
return (await loadFeed(token, limit)).items;
|
|
}
|
|
|
|
/**
|
|
* Why the feed is what it is. `fetchFeed` flattens this to a list, which is
|
|
* right for optional chrome (the homepage ticker) but wrong for a page whose
|
|
* whole body is the feed: there, "GitHub had nothing" and "GitHub was not
|
|
* asked" or "GitHub refused" must render differently, or a rate limit and a
|
|
* build-time prerender both masquerade as an honest empty record.
|
|
*
|
|
* - `ok` — that list endpoint answered; an empty list is real.
|
|
* - `skipped` — static generation; nothing was fetched.
|
|
* - `unavailable` — that call came back non-ok (rate limit, outage);
|
|
* `items` holds whatever did arrive.
|
|
*
|
|
* Availability is tracked per list: when exactly one endpoint refuses, the
|
|
* other column must not be told that "the source did not answer".
|
|
*/
|
|
export type FeedLoadStatus = "ok" | "skipped" | "unavailable";
|
|
|
|
export interface FeedLoad {
|
|
items: FeedItem[];
|
|
issuesStatus: FeedLoadStatus;
|
|
pullsStatus: FeedLoadStatus;
|
|
}
|
|
|
|
export async function loadFeed(token?: string, limit = 30): Promise<FeedLoad> {
|
|
if (isProductionBuild())
|
|
return { items: [], issuesStatus: "skipped", pullsStatus: "skipped" };
|
|
|
|
const [issuesRes, pullsRes, releasesRes] = await Promise.all([
|
|
fetch(
|
|
`${GH}/repos/${REPO}/issues?state=all&per_page=${limit}&sort=updated&direction=desc`,
|
|
{ headers: headers(token), next: { revalidate: 600 } }
|
|
),
|
|
fetch(
|
|
`${GH}/repos/${REPO}/pulls?state=all&per_page=${limit}&sort=updated&direction=desc`,
|
|
{ headers: headers(token), next: { revalidate: 600 } }
|
|
),
|
|
fetch(`${GH}/repos/${REPO}/releases?per_page=${RELEASE_WINDOW}`, {
|
|
headers: headers(token),
|
|
next: { revalidate: 3600 },
|
|
}),
|
|
]);
|
|
|
|
// Releases are a garnish on the feed; the two list calls are the record,
|
|
// and each answers for itself.
|
|
const issuesStatus: FeedLoadStatus = issuesRes.ok ? "ok" : "unavailable";
|
|
const pullsStatus: FeedLoadStatus = pullsRes.ok ? "ok" : "unavailable";
|
|
const issues = await responseArray<RawIssue>(issuesRes);
|
|
const pulls = await responseArray<RawIssue & { merged_at?: string | null }>(pullsRes);
|
|
const releases = await responseArray<RawRelease>(releasesRes);
|
|
|
|
const items: FeedItem[] = [];
|
|
|
|
for (const it of issues) {
|
|
if (it.pull_request) continue; // GH issues endpoint returns PRs too
|
|
if (isBot(it.user.login)) continue; // automated maintenance, not contributor life
|
|
items.push({
|
|
kind: "issue",
|
|
number: it.number,
|
|
title: it.title,
|
|
url: it.html_url,
|
|
state: it.state,
|
|
author: it.user.login,
|
|
authorAvatar: it.user.avatar_url,
|
|
createdAt: it.created_at,
|
|
updatedAt: it.updated_at,
|
|
eventAt: (it.state === "closed" ? it.closed_at : it.created_at) ?? it.created_at,
|
|
comments: it.comments,
|
|
labels: it.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [],
|
|
body: it.body ?? undefined,
|
|
firstTimeContributor: firstTimer(it.author_association),
|
|
});
|
|
}
|
|
|
|
for (const pr of pulls) {
|
|
if (isBot(pr.user.login)) continue; // automated maintenance, not contributor life
|
|
let state: FeedItem["state"] = pr.state;
|
|
let eventAt = pr.created_at;
|
|
if (pr.merged_at) {
|
|
state = "merged";
|
|
eventAt = pr.merged_at;
|
|
} else if (pr.draft) {
|
|
state = "draft";
|
|
} else if (pr.state === "closed") {
|
|
eventAt = pr.closed_at ?? pr.updated_at;
|
|
}
|
|
items.push({
|
|
kind: "pull",
|
|
number: pr.number,
|
|
title: pr.title,
|
|
url: pr.html_url,
|
|
state,
|
|
author: pr.user.login,
|
|
authorAvatar: pr.user.avatar_url,
|
|
createdAt: pr.created_at,
|
|
updatedAt: pr.updated_at,
|
|
eventAt,
|
|
comments: pr.comments,
|
|
labels: pr.labels?.map((l) => ({ name: l.name, color: l.color })) ?? [],
|
|
body: pr.body ?? undefined,
|
|
firstTimeContributor: firstTimer(pr.author_association),
|
|
});
|
|
}
|
|
|
|
for (const rel of releases) {
|
|
if (rel.draft) continue; // an unpublished draft is not news
|
|
const publishedAt = rel.published_at ?? rel.created_at;
|
|
// A bot-published release keeps its slot but not its byline.
|
|
const publisher =
|
|
rel.author && !isBot(rel.author.login) ? rel.author.login : "";
|
|
items.push({
|
|
kind: "release",
|
|
number: 0,
|
|
tag: rel.tag_name,
|
|
title: rel.name?.trim() || rel.tag_name,
|
|
url: rel.html_url,
|
|
state: "published",
|
|
author: publisher,
|
|
authorAvatar: publisher ? rel.author?.avatar_url ?? "" : "",
|
|
createdAt: rel.created_at,
|
|
updatedAt: publishedAt,
|
|
eventAt: publishedAt,
|
|
comments: 0,
|
|
labels: [],
|
|
});
|
|
}
|
|
|
|
const ordered = items.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt));
|
|
const kept = ordered.slice(0, limit);
|
|
|
|
// A release is the one event a busy week can bury: twenty issue comments
|
|
// will push last week's tag out of a pure recency window. Keep the newest
|
|
// published release in view — but only a recent one, and always carrying its
|
|
// real date, so a quiet quarter reads as a quiet quarter instead of pinning
|
|
// a two-year-old tag beside today's merges.
|
|
const newestRelease = ordered.find((i) => i.kind === "release");
|
|
const pinnable =
|
|
newestRelease &&
|
|
Date.now() - +new Date(newestRelease.eventAt ?? newestRelease.updatedAt) <
|
|
RELEASE_PIN_WINDOW_MS;
|
|
if (pinnable && kept.length === limit && !kept.some((i) => i.kind === "release")) {
|
|
kept[kept.length - 1] = newestRelease;
|
|
}
|
|
|
|
return { items: kept, issuesStatus, pullsStatus };
|
|
}
|
|
|
|
async function responseArray<T>(res: Response): Promise<T[]> {
|
|
if (!res.ok) return [];
|
|
const body = await res.json().catch(() => null);
|
|
return Array.isArray(body) ? (body as T[]) : [];
|
|
}
|
|
|
|
/** Compact star-count label, e.g. 39312 → "39.3k". */
|
|
export function formatStars(n: number): string {
|
|
if (n >= 1000) {
|
|
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
}
|
|
return String(n);
|
|
}
|
|
|
|
/**
|
|
* An age expressed the way `Intl.RelativeTimeFormat` wants it: a negative
|
|
* count and a unit. Past ages are negative; anything under a minute (and any
|
|
* unparseable or future date) is `0 seconds`, which `numeric: "auto"` renders
|
|
* as the locale's own "now".
|
|
*
|
|
* This exists so a surface can print an age in the reader's language without
|
|
* a hand-translated abbreviation table per locale — CLDR already has one, and
|
|
* the masthead already formats its date the same way off `chrome.dateLocale`.
|
|
*/
|
|
export interface RelativeAge {
|
|
value: number;
|
|
unit: "second" | "minute" | "hour" | "day" | "month" | "year";
|
|
}
|
|
|
|
export function relativeAge(iso: string): RelativeAge {
|
|
const then = +new Date(iso);
|
|
if (!Number.isFinite(then)) return { value: 0, unit: "second" };
|
|
|
|
const mins = Math.round((Date.now() - then) / 60000);
|
|
if (mins < 1) return { value: 0, unit: "second" };
|
|
if (mins < 60) return { value: -mins, unit: "minute" };
|
|
const hrs = Math.round(mins / 60);
|
|
if (hrs < 24) return { value: -hrs, unit: "hour" };
|
|
const days = Math.round(hrs / 24);
|
|
if (days < 30) return { value: -days, unit: "day" };
|
|
const months = Math.round(days / 30);
|
|
if (months < 12) return { value: -months, unit: "month" };
|
|
return { value: -Math.round(months / 12), unit: "year" };
|
|
}
|
|
|
|
const AGE_SUFFIX: Record<RelativeAge["unit"], string> = {
|
|
second: "",
|
|
minute: "m",
|
|
hour: "h",
|
|
day: "d",
|
|
month: "mo",
|
|
year: "y",
|
|
};
|
|
|
|
/** Compact English age, e.g. "5m", "3h", "2y". Same thresholds as `relativeAge`. */
|
|
export function relativeTime(iso: string): string {
|
|
const age = relativeAge(iso);
|
|
if (age.unit === "second") return "just now";
|
|
return `${Math.abs(age.value)}${AGE_SUFFIX[age.unit]}`;
|
|
}
|