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);
  }
}