Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136// Time, identifiers, and the one-line summary a row shows.//// Everything here is a pure function of a record's own fields and the instant the view is drawn at// ("now" is passed in, never read from the clock): the fixture draws itself at `FIXTURE_NOW`, so// "6m ago" means six minutes in the comp and six minutes against a live space, from the same code.
import { plain, prose } from './prose.js'
const MINUTE = 60_000const HOUR = 60 * MINUTEconst DAY = 24 * HOUR
/** "now" · "40s" · "6m" · "3h" · "4d" · "Jun 12" — the age a row shows in its tail. */export function relativeTime(iso: string, now: string): string { const then = Date.parse(iso) const at = Date.parse(now) if (Number.isNaN(then) || Number.isNaN(at)) return '' const delta = at - then if (delta < 0) return 'now' if (delta < 45_000) return 'now' if (delta < HOUR) return `${Math.round(delta / MINUTE)}m` if (delta < DAY) return `${Math.round(delta / HOUR)}h` if (delta < 7 * DAY) return `${Math.round(delta / DAY)}d` return shortDate(iso)}
/** "Jul 21" — a date a human reads at a glance, in their own zone. */export function shortDate(iso: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return '' return date.toLocaleDateString(undefined, { month: 'short', day: '2-digit' })}
/** "Jul 21, 09:14" — provenance lines, where the exact moment matters. */export function stamp(iso: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return '' return `${date.toLocaleDateString(undefined, { month: 'short', day: '2-digit' })}, ${date.toLocaleTimeString( undefined, { hour: '2-digit', minute: '2-digit', hour12: false }, )}`}
/** "09:14" — the time alone, for a log whose rows already sit under the day they happened on. */export function clock(iso: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return '' return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
/** Stable per-calendar-day key, so a goal's units group into the days they happened on. */export function dayKey(iso: string): string { const date = new Date(iso) if (Number.isNaN(date.getTime())) return '' return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`}
/** "Today" · "Yesterday" · "Jul 21" — the heading over a day's units. */export function dayLabel(iso: string, now: string): string { const key = dayKey(iso) if (!key) return '' if (key === dayKey(now)) return 'Today' const yesterday = new Date(Date.parse(now) - DAY).toISOString() if (key === dayKey(yesterday)) return 'Yesterday' return shortDate(iso)}
/** at://did:plc:abc/com.disnetdev.radial.goal/3lbq → 3lbq. The rkey is what a chip shows. */export function rkeyOf(uri: string): string { return uri.slice(uri.lastIndexOf('/') + 1)}
/** at://did:plc:abc/… → did:plc:abc */export function didOf(uri: string): string { return uri.slice('at://'.length, uri.indexOf('/', 'at://'.length))}
/** The collection segment of an AT URI, or '' when there is none. */export function collectionOf(uri: string): string { const rest = uri.slice('at://'.length) const parts = rest.split('/') return parts[1] ?? ''}
export const shortCommit = (commit: string, length = 10): string => commit.slice(0, length)
/** * What to CALL a landed artifact anywhere it is named rather than read: its own short title. * * An artifact carries an explicit `title` now, and it is the label wherever there is one. Records * signed before the field existed cannot be given one — they are immutable, and nothing may rewrite * them — so those fall back to their body's first Markdown heading, then `summarize(body)` when * there is no heading. That fallback is display-only: it is derived on the way to the screen, it is * never written back, and nothing treats it as protocol data. * * One helper for every such place, so one screen cannot show the explicit title while the next * quietly goes on extracting prose from the same record. */export function artifactLabel(artifact: { title?: string; body: string }, limit = 140): string { const title = artifact.title?.trim() if (title) return title.length > limit ? `${title.slice(0, limit - 1).trimEnd()}…` : title const heading = prose(artifact.body).find((block) => block.type === 'heading') if (heading) { const text = plain([heading]).replace(/\s+/g, ' ').trim() if (text) return text.length > limit ? `${text.slice(0, limit - 1).trimEnd()}…` : text } return summarize(artifact.body, limit)}
/** * The line a row shows for an artifact that has no title of its own — the legacy fallback behind * `artifactLabel`, and the shape of every row before the field existed. * * The row's one line has to come out of the prose. Take the first block of running text: not a * heading (the body's own title is not a summary of it), not a fence or a rule, and not the `Status:` * convention an ADR opens with (phase6-ui-plan §3.4 keeps that prose rather than making it a field). * Then cut it at the first sentence. */export function summarize(body: string, limit = 140): string { let paragraph = '' for (const block of prose(body)) { if (block.type === 'heading' || block.type === 'code' || block.type === 'hr') continue // A list stands for its first item, which is the closest thing it has to a line. const first = block.type === 'list' ? block.items[0] : block const text = first ? plain([first]) : '' if (!text.trim() || /^status:/i.test(text.trim())) continue paragraph = text break } if (!paragraph) return '' const line = paragraph.replace(/\s+/g, ' ') const sentence = /^(.+?[.?!])(\s|$)/.exec(line) const text = sentence?.[1] ?? line return text.length > limit ? `${text.slice(0, limit - 1).trimEnd()}…` : text}