Spaces:
Sleeping
Sleeping
File size: 5,856 Bytes
034e664 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | 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);
}
}
|