api-gateway / client /src /lib /utils.ts
recursiverall's picture
Deploy api-gateway with HF Space support
034e664 verified
Raw History Blame Contribute Delete
5.86 kB
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// SQLite stores timestamps as `YYYY-MM-DD HH:MM:SS` with no timezone marker, so
// passing them straight to `new Date(...)` makes the browser read them as LOCAL
// time when they are actually UTC — shifting every displayed time by the
// viewer's offset. These helpers tag the value as UTC before parsing. (#170)
/** Convert a SQLite UTC datetime string into an ISO-8601 UTC string. */
export function sqliteUtcToIso(value: string): string {
// Already ISO (has 'T' and a zone/offset)? Leave it alone.
if (value.includes('T')) return value;
return value.replace(' ', 'T') + 'Z';
}
/** Format a SQLite UTC datetime string as the viewer's local time-of-day. */
export function formatSqliteUtcToLocalTime(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', second: '2-digit' },
): string {
if (!value) return '—';
const date = new Date(sqliteUtcToIso(value));
if (isNaN(date.getTime())) return '—';
return date.toLocaleTimeString([], options);
}
/**
* Parse an ISO-8601 UTC timestamp and format it in the user's local timezone
* for chart axis labels. Hourly gets abbreviated time; daily gets short date.
*/
export function formatIsoUtcToLocalChart(value: string, interval: 'hour' | 'day'): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
if (interval === 'hour') {
return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
/**
* Build a polished, sortable backup filename for a config export.
*
* Format:
* - No label: `API_Gateway-Backup-YYYY-MM-DD-HH-mm-ss.json`
* - With label: `API_Gateway-Backup-<slug>-YYYY-MM-DD-HH-mm-ss.json`
*
* The timestamp is rendered in the *user's local timezone* (the browser
* already knows it via `Intl.DateTimeFormat`), not UTC. Two-digit fields
* are zero-padded so the resulting filename sorts lexicographically.
*
* The optional `label` ("Laptop staging", "Production") is slugified so
* the operator can tell multiple exports apart at a glance. Slug rules:
* - lowercase, ASCII letters / digits / hyphens only
* - max 32 chars (truncated on a word boundary if possible)
* - leading/trailing hyphens stripped
* - non-Latin letters (e.g. Cyrillic) collapse to a dash so the
* filename stays portable across filesystems
*/
export function makeConfigBackupFilename(now: Date = new Date(), label?: string): string {
const pad = (n: number): string => n.toString().padStart(2, '0');
const stamp =
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
`-${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const slug = labelSlugify(label);
return slug
? `API_Gateway-Backup-${slug}-${stamp}.json`
: `API_Gateway-Backup-${stamp}.json`;
}
/**
* Convert a free-form label into a filename-safe slug.
* Returns an empty string when there's nothing useful to keep.
*/
function labelSlugify(label: string | undefined | null): string {
if (!label) return '';
// Replace runs of non-[a-z0-9] with a single hyphen; lowercase.
const ascii = label
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '') // strip combining marks (e.g. é → e)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!ascii) return '';
// Truncate at a word boundary when possible to keep the slug readable.
if (ascii.length <= 32) return ascii;
const cut = ascii.slice(0, 32);
const lastHyphen = cut.lastIndexOf('-');
// Only use the cut if the boundary isn't too aggressive (< 50% loss).
if (lastHyphen > 16) return cut.slice(0, lastHyphen);
return cut.replace(/-+$/, '');
}
/**
* Return the user's IANA timezone (e.g. `Europe/London`), falling back to
* `UTC` if the browser doesn't expose the resolved name. Useful for
* showing the operator which timezone a filename stamp represents.
*/
export function getLocalTimezoneName(): string {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || 'UTC';
} catch {
return 'UTC';
}
}
/**
* Trigger a browser file download for an in-memory payload.
*
* The standard pattern of `URL.createObjectURL(...) → anchor.click() →
* URL.revokeObjectURL(url)` is racy in Firefox: revoking the URL on the
* same tick as the click causes the browser to drop the download with
* a "could not load XPCOM" error, and the file never reaches the
* user's machine. We instead schedule the revoke on a `setTimeout(0)`
* microtask plus a 1s safety net so the download has plenty of time to
* read from the blob before the URL is invalidated.
*/
export function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.rel = 'noopener';
anchor.style.display = 'none';
document.body.appendChild(anchor);
try {
anchor.click();
} finally {
document.body.removeChild(anchor);
// Defer the revoke so the browser can read the blob first. Two
// timers cover both fast and slow pipelines:
// 1. `setTimeout(0)` is enough for Chromium / WebKit.
// 2. The 1s safety net catches Firefox and anything that
// dispatches the download asynchronously.
setTimeout(() => URL.revokeObjectURL(url), 0);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}