Files
transmission/web/format.ts
T
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

159 lines
6.4 KiB
TypeScript

import type { Torrent } from './shared';
import { STATUS } from './shared';
// Display formatting for the Transmission panels. One implementation each, because a torrent list shows the
// same quantity in a column, a tooltip and a detail row, and three near-identical formatters drift.
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
/** Binary (1024-based) sizes, matching what Transmission's own clients show. */
export function formatSize(bytes: number | undefined | null): string {
if (bytes == null || !Number.isFinite(bytes)) return '—';
if (bytes <= 0) return '0 B';
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), SIZE_UNITS.length - 1);
const value = bytes / 1024 ** i;
return `${value.toFixed(i === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${SIZE_UNITS[i]}`;
}
/** Zero renders blank, not "0 B/s" — a list where every idle row shouts zero is unreadable. */
export function formatSpeed(bytesPerSecond: number | undefined | null): string {
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
return `${formatSize(bytesPerSecond)}/s`;
}
/**
* Transmission's ETA sentinels: -1 means "not available", -2 means "unknown". Both are ordinary states for a
* stopped or seeding torrent, so neither is an error worth surfacing.
*/
export function formatEta(seconds: number | undefined | null): string {
if (seconds == null || seconds < 0) return '∞';
return formatDuration(seconds);
}
export function formatDuration(seconds: number | undefined | null): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
if (seconds < 60) return `${Math.round(seconds)}s`;
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
return `${m}m`;
}
/**
* Ratio, with the two conventions every torrent client shares: nothing uploaded yet reads as a dash, and
* anything uploaded against a zero download is infinite rather than a division blow-up.
*/
export function formatRatio(ratio: number | undefined | null): string {
if (ratio == null) return '—';
if (ratio < 0) return '∞';
return ratio.toFixed(2);
}
/** Transmission timestamps are unix seconds, and 0 means "never" rather than 1970. */
export function formatDate(unixSeconds: number | undefined | null): string {
if (!unixSeconds || unixSeconds <= 0) return '—';
const d = new Date(unixSeconds * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* Progress, floored — never rounded. `toFixed` rounds, so 99.97% printed as "100.0%" next to a bar that
* is still blue, which reads as a stuck torrent rather than a nearly-finished one. Only an actually
* complete fraction may show a hundred, and it shows it as a flat "100%" so the two can't be confused.
*/
export function formatPercent(fraction: number | undefined | null): string {
if (fraction == null) return '—';
if (fraction >= 1) return '100%';
return `${(Math.floor(fraction * 1000) / 10).toFixed(1)}%`;
}
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
export type StatusTone = 'stopped' | 'downloading' | 'seeding' | 'checking' | 'queued' | 'error';
export function statusTone(torrent: Torrent): StatusTone {
if (torrent.error !== 0) return 'error';
switch (torrent.status) {
case STATUS.Download:
return 'downloading';
case STATUS.Seed:
return 'seeding';
case STATUS.Check:
case STATUS.CheckWait:
return 'checking';
case STATUS.DownloadWait:
case STATUS.SeedWait:
return 'queued';
default:
return 'stopped';
}
}
export function statusLabel(torrent: Torrent): string {
if (torrent.error !== 0) return 'Error';
switch (torrent.status) {
case STATUS.Stopped:
// A stopped torrent that never finished is paused; one that did is simply done and not seeding.
return torrent.percentDone >= 1 ? 'Finished' : 'Paused';
case STATUS.CheckWait:
return 'Queued to verify';
case STATUS.Check:
return 'Verifying';
case STATUS.DownloadWait:
return 'Queued to download';
case STATUS.Download:
// Transmission reports "downloading" while it is still fetching the metadata of a magnet link, which
// is a materially different state — there is nothing to download yet.
return torrent.metadataPercentComplete < 1 ? 'Fetching metadata' : 'Downloading';
case STATUS.SeedWait:
return 'Queued to seed';
case STATUS.Seed:
return 'Seeding';
default:
return 'Unknown';
}
}
/** Tailwind classes per tone, kept together so the list, the pill and the detail header cannot disagree. */
export const TONE_CLASSES: Record<StatusTone, { text: string; bg: string; dot: string }> = {
stopped: { text: 'text-muted-foreground', bg: 'bg-muted', dot: 'bg-muted-foreground' },
downloading: { text: 'text-info', bg: 'bg-info/15', dot: 'bg-info' },
seeding: { text: 'text-success', bg: 'bg-success/15', dot: 'bg-success' },
checking: { text: 'text-busy-ink', bg: 'bg-busy/15', dot: 'bg-busy' },
queued: { text: 'text-warning', bg: 'bg-warning/15', dot: 'bg-warning' },
error: { text: 'text-destructive', bg: 'bg-destructive/10', dot: 'bg-destructive' },
};
export const PRIORITY_LABELS: Record<number, string> = { [-1]: 'Low', 0: 'Normal', 1: 'High' };
/** Peer flag characters, spelled out for the peers table tooltip (rpc-spec.md §3.2). */
export const PEER_FLAGS: Record<string, string> = {
O: 'Optimistic unchoke',
D: 'Downloading from peer',
d: 'We would download but peer is choking us',
U: 'Uploading to peer',
u: 'Peer would download but we are choking',
K: 'Peer unchoked us but we are not interested',
'?': 'We unchoked peer but it is not interested',
E: 'Encrypted connection',
H: 'Peer discovered through DHT',
X: 'Peer discovered through PEX',
I: 'Peer is incoming',
T: 'Peer is over uTP',
};
export function describePeerFlags(flagStr: string): string {
return (
flagStr
.split('')
.map((c) => PEER_FLAGS[c])
.filter(Boolean)
.join('\n') || flagStr
);
}