@revenexx/studio-integrations 3.6.0 → 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.6.2
4
+
5
+ ### Patch Changes
6
+
7
+ - ef29256: The palette's version select offers the release channel as "Channel" / „Kanal“ instead of "Channel (follows release channel)", so the node name beside it is no longer cut off (IN-70).
8
+
9
+ ## 3.6.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 195bede: Every date and time the studio shows is now written in the studio's language — the one the host sets through `setStudioLocale` or `studioIntegrations.locale` — instead of the browser's, and is rewritten in place when that language changes. A German interface in an English browser used to show `7/27/2026, 9:14:02 AM` beside German labels; it now shows `27.7.2026, 09:14:02`. A timestamp that is missing or cannot be read is shown as `—` everywhere, where it used to read *Invalid Date*. Where the reader's browser names a region of that same language, its form is used: a British browser on an English studio shows `27/07/2026, 09:14:02`, a Swiss one on a German studio the Swiss form; a browser in another language changes nothing, and a region the host sets itself is kept. A host that sets no language reads English dates under its English interface.
14
+
3
15
  ## 3.6.0
4
16
 
5
17
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "3.6.0",
4
+ "version": "3.6.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -42,6 +42,7 @@ import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOpt
42
42
  import { keepOpenBeside } from "../../utils/dialog";
43
43
  import { testOutcomeView } from "../../utils/credentialTest";
44
44
  import { credentialNetworkPayload, fromCredentialNetwork, replacedFieldTexts, sameVpnRoute } from "../../utils/vpnRoute";
45
+ import { formatDateTime } from "../../utils/formatDateTime";
45
46
  const props = defineProps({
46
47
  open: { type: Boolean, required: true },
47
48
  credential: { type: [Object, null], required: true },
@@ -104,14 +105,6 @@ const state = computed(() => props.credential ? credentialState(props.credential
104
105
  function fieldsOf(t2) {
105
106
  return t2?.fields ?? [];
106
107
  }
107
- function formatDateTime(iso) {
108
- if (!iso) return "\u2014";
109
- try {
110
- return new Date(iso).toLocaleString();
111
- } catch {
112
- return String(iso);
113
- }
114
- }
115
108
  let viewOpening = 0;
116
109
  const storedConfig = computed(() => ({ ...defaultsFor(fieldsOf(props.type)), ...props.credential?.public_config ?? {} }));
117
110
  watch(
@@ -6,6 +6,7 @@ import { toast } from "vue-sonner";
6
6
  import LoadingAnnouncement from "./LoadingAnnouncement.vue";
7
7
  import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
8
8
  import { errorReason } from "../../utils/apiError";
9
+ import { formatDateTime } from "../../utils/formatDateTime";
9
10
  const props = defineProps({
10
11
  workflowId: { type: Number, required: true }
11
12
  });
@@ -65,14 +66,6 @@ async function onDiscard(dl) {
65
66
  busyId.value = null;
66
67
  }
67
68
  }
68
- function fmt(iso) {
69
- if (!iso) return "\u2014";
70
- try {
71
- return new Date(iso).toLocaleString();
72
- } catch {
73
- return iso;
74
- }
75
- }
76
69
  </script>
77
70
 
78
71
  <template>
@@ -131,7 +124,7 @@ function fmt(iso) {
131
124
  <span>#{{ dl.id }}</span>
132
125
  <span v-if="dl.source">{{ dl.source }}</span>
133
126
  <span v-if="dl.trigger_handle">↳ {{ dl.trigger_handle }}</span>
134
- <span>{{ fmt(dl.created_at) }}</span>
127
+ <span>{{ formatDateTime(dl.created_at) }}</span>
135
128
  </div>
136
129
  </div>
137
130
  <div v-if="dl.status === 'pending'" class="flex shrink-0 items-center gap-1">
@@ -414,7 +414,7 @@ const searching = computed(() => search.value.trim().length > 0);
414
414
  @focus="paletteDragEnabled = false"
415
415
  @blur="paletteDragEnabled = true"
416
416
  >
417
- <option v-if="offersChannel(hit.manifest.slug)" :value="CHANNEL_VERSION">{{ t("integration.common.nodeVersion.channel") }}</option>
417
+ <option v-if="offersChannel(hit.manifest.slug)" :value="CHANNEL_VERSION">{{ t("integration.common.nodeVersion.channelShort") }}</option>
418
418
  <option v-for="v in versionsOf(hit.manifest.slug)" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
419
419
  </select>
420
420
  <span v-else class="shrink-0 text-xs tabular-nums text-muted-foreground/60">{{ versionOptionLabel(hit.manifest) }}</span>
@@ -553,7 +553,7 @@ const searching = computed(() => search.value.trim().length > 0);
553
553
  @focus="paletteDragEnabled = false"
554
554
  @blur="paletteDragEnabled = true"
555
555
  >
556
- <option v-if="offersChannel(m.slug)" :value="CHANNEL_VERSION">{{ t("integration.common.nodeVersion.channel") }}</option>
556
+ <option v-if="offersChannel(m.slug)" :value="CHANNEL_VERSION">{{ t("integration.common.nodeVersion.channelShort") }}</option>
557
557
  <option v-for="v in versionsOf(m.slug)" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
558
558
  </select>
559
559
  <span v-else class="shrink-0 text-xs tabular-nums text-muted-foreground/60">{{ versionOptionLabel(m) }}</span>
@@ -11,6 +11,7 @@ import { formatDuration } from "../../utils/formatDuration";
11
11
  import { unwrapRun } from "../../utils/runEnvelope";
12
12
  import { acceptsResume } from "../../utils/runFeedQuery";
13
13
  import { splitRunOutputs } from "../../utils/runOutputs";
14
+ import { formatDateTime, formatTime } from "../../utils/formatDateTime";
14
15
  const props = defineProps({
15
16
  run: { type: Object, required: true },
16
17
  view: { type: String, required: false, default: "details" },
@@ -74,14 +75,6 @@ function pickStr(obj, keys) {
74
75
  function isNonEmptyObject(v) {
75
76
  return !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0;
76
77
  }
77
- function fmt(iso) {
78
- if (!iso) return "\u2014";
79
- try {
80
- return new Date(iso).toLocaleString();
81
- } catch {
82
- return String(iso);
83
- }
84
- }
85
78
  function json(x) {
86
79
  return JSON.stringify(x, null, 2);
87
80
  }
@@ -132,7 +125,7 @@ function eventType(ev) {
132
125
  return pickStr(ev, ["event_type", "eventType", "type"]) ?? "event";
133
126
  }
134
127
  function eventTime(ev) {
135
- return fmt(pickStr(ev, ["event_time", "eventTime"]));
128
+ return formatDateTime(pickStr(ev, ["event_time", "eventTime"]));
136
129
  }
137
130
  function eventAttributes(ev) {
138
131
  return pick(ev, ["attributes"]);
@@ -277,13 +270,6 @@ function timelineRowStatusWord(row) {
277
270
  function timelineRowStatus(row) {
278
271
  return runStatusLabel(timelineRowStatusWord(row));
279
272
  }
280
- function fmtClock(ms) {
281
- try {
282
- return new Date(ms).toLocaleTimeString();
283
- } catch {
284
- return "";
285
- }
286
- }
287
273
  async function loadSummary() {
288
274
  summaryBudget.spend();
289
275
  summaryLoading.value = true;
@@ -641,11 +627,11 @@ async function confirmAction() {
641
627
  </div>
642
628
  <div>
643
629
  <div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("integration.runs.detail.field.started") }}</div>
644
- <div class="tabular-nums">{{ fmt(startedAt) }}</div>
630
+ <div class="tabular-nums">{{ formatDateTime(startedAt) }}</div>
645
631
  </div>
646
632
  <div>
647
633
  <div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("integration.runs.detail.field.completed") }}</div>
648
- <div class="tabular-nums">{{ fmt(completedAt) }}</div>
634
+ <div class="tabular-nums">{{ formatDateTime(completedAt) }}</div>
649
635
  </div>
650
636
  <div>
651
637
  <div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("integration.runs.detail.field.historyLength") }}</div>
@@ -836,9 +822,9 @@ async function confirmAction() {
836
822
  </div>
837
823
  </div>
838
824
  <div class="mt-1 flex justify-between font-mono text-[10px] tabular-nums text-muted-foreground">
839
- <span>{{ fmtClock(timeline.t0) }}</span>
840
- <span>{{ fmtClock(timeline.t0 + timeline.span / 2) }}</span>
841
- <span>{{ fmtClock(timeline.tEnd) }}</span>
825
+ <span>{{ formatTime(timeline.t0) }}</span>
826
+ <span>{{ formatTime(timeline.t0 + timeline.span / 2) }}</span>
827
+ <span>{{ formatTime(timeline.tEnd) }}</span>
842
828
  </div>
843
829
  </div>
844
830
  </section>
@@ -54,11 +54,11 @@ type __VLS_PublicProps = __VLS_Props & {
54
54
  };
55
55
  declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
56
56
  search: (text: string) => any;
57
- changed: () => any;
58
57
  narrow: (patch: {
59
58
  status?: RunStatus[];
60
59
  triggerType?: TriggerType | "";
61
60
  }) => any;
61
+ changed: () => any;
62
62
  "page-size": (size: number) => any;
63
63
  reorder: (order: {
64
64
  sort: RunSort;
@@ -67,11 +67,11 @@ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {},
67
67
  "update:page": (value: number) => any;
68
68
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
69
69
  onSearch?: ((text: string) => any) | undefined;
70
- onChanged?: (() => any) | undefined;
71
70
  onNarrow?: ((patch: {
72
71
  status?: RunStatus[];
73
72
  triggerType?: TriggerType | "";
74
73
  }) => any) | undefined;
74
+ onChanged?: (() => any) | undefined;
75
75
  "onPage-size"?: ((size: number) => any) | undefined;
76
76
  onReorder?: ((order: {
77
77
  sort: RunSort;
@@ -54,11 +54,11 @@ type __VLS_PublicProps = __VLS_Props & {
54
54
  };
55
55
  declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
56
56
  search: (text: string) => any;
57
- changed: () => any;
58
57
  narrow: (patch: {
59
58
  status?: RunStatus[];
60
59
  triggerType?: TriggerType | "";
61
60
  }) => any;
61
+ changed: () => any;
62
62
  "page-size": (size: number) => any;
63
63
  reorder: (order: {
64
64
  sort: RunSort;
@@ -67,11 +67,11 @@ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {},
67
67
  "update:page": (value: number) => any;
68
68
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
69
69
  onSearch?: ((text: string) => any) | undefined;
70
- onChanged?: (() => any) | undefined;
71
70
  onNarrow?: ((patch: {
72
71
  status?: RunStatus[];
73
72
  triggerType?: TriggerType | "";
74
73
  }) => any) | undefined;
74
+ onChanged?: (() => any) | undefined;
75
75
  "onPage-size"?: ((size: number) => any) | undefined;
76
76
  onReorder?: ((order: {
77
77
  sort: RunSort;
@@ -26,6 +26,7 @@ import { canvasShapes } from "../../data/canvasShapes";
26
26
  import { useIntegrationsRevisions } from "../../composables/useIntegrationsRevisions";
27
27
  import { errorReason } from "../../utils/apiError";
28
28
  import { personLabel } from "../../utils/personLabel";
29
+ import { formatDateTime } from "../../utils/formatDateTime";
29
30
  const props = defineProps({
30
31
  workflow: { type: Object, required: true },
31
32
  draftPending: { type: Boolean, required: false }
@@ -284,14 +285,6 @@ watch(() => iteratorNodeIds(previewBlob.value.nodes).join("|"), () => {
284
285
  watch(useIntegrationsUiLocale(), () => {
285
286
  buildPreview(previewBlob.value);
286
287
  });
287
- function formatDateTime(iso) {
288
- if (!iso) return "\u2014";
289
- try {
290
- return new Date(iso).toLocaleString();
291
- } catch {
292
- return String(iso);
293
- }
294
- }
295
288
  const nodeTypes = {
296
289
  workflow: markRaw(WorkflowNodeView),
297
290
  trigger: markRaw(WorkflowTriggerView),
@@ -33,6 +33,7 @@ import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
33
33
  import { executedNodeIds, isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
34
34
  import { frameLabel, stepError } from "../../utils/stepError";
35
35
  import { errorReason } from "../../utils/apiError";
36
+ import { formatTime } from "../../utils/formatDateTime";
36
37
  const props = defineProps({
37
38
  workflow: { type: Object, required: true },
38
39
  initialRunId: { type: [String, null], required: false },
@@ -367,14 +368,6 @@ const edgeTypes = {
367
368
  preview.onNodeClick((evt) => {
368
369
  if (evt.node.type === "workflow") onNodeClick(evt.node.id);
369
370
  });
370
- function formatTime(iso) {
371
- if (!iso) return "\u2014";
372
- try {
373
- return new Date(iso).toLocaleTimeString();
374
- } catch {
375
- return String(iso);
376
- }
377
- }
378
371
  function pretty(v) {
379
372
  try {
380
373
  return JSON.stringify(v, null, 2);
@@ -20,6 +20,7 @@ import {
20
20
  import WorkflowIconPicker from "./WorkflowIconPicker.vue";
21
21
  import WorkflowTagsInput from "./WorkflowTagsInput.vue";
22
22
  import { keepOpenBeside } from "../../utils/dialog";
23
+ import { formatDateTime } from "../../utils/formatDateTime";
23
24
  const props = defineProps({
24
25
  open: { type: Boolean, required: true },
25
26
  workflow: { type: Object, required: true },
@@ -53,14 +54,6 @@ function applyAndClose() {
53
54
  if (JSON.stringify(edited.value) !== JSON.stringify(props.current)) emit("apply", edited.value);
54
55
  emit("update:open", false);
55
56
  }
56
- function formatDateTime(iso) {
57
- if (!iso) return "\u2014";
58
- try {
59
- return new Date(iso).toLocaleString();
60
- } catch {
61
- return String(iso);
62
- }
63
- }
64
57
  </script>
65
58
 
66
59
  <template>
@@ -18,6 +18,7 @@ import {
18
18
  Label,
19
19
  Switch
20
20
  } from "@revenexx/studio";
21
+ import { formatShortDateTime } from "../../utils/formatDateTime";
21
22
  defineProps({
22
23
  workflows: { type: Array, required: true },
23
24
  loading: { type: Boolean, required: false, default: false }
@@ -135,19 +136,6 @@ function modeLabel(m) {
135
136
  return "\u2014";
136
137
  }
137
138
  }
138
- function formatDateTime(iso) {
139
- if (!iso) return "\u2014";
140
- try {
141
- return new Date(iso).toLocaleString(void 0, {
142
- month: "short",
143
- day: "2-digit",
144
- hour: "2-digit",
145
- minute: "2-digit"
146
- });
147
- } catch {
148
- return iso;
149
- }
150
- }
151
139
  const LIFECYCLE = ["active", "draft", "paused"];
152
140
  const BUILD_STATES = ["pending", "building", "ready", "failed"];
153
141
  const MODES = ["async_only", "sync_only", "caller_decides"];
@@ -348,7 +336,7 @@ const columns = computed(() => [
348
336
  {{ runBadge(lastRunFor(wf.id).status).label }}
349
337
  </Badge>
350
338
  <span class="text-sm tabular-nums text-muted-foreground truncate">
351
- {{ formatDateTime(lastRunFor(wf.id).startedAt) }}
339
+ {{ formatShortDateTime(lastRunFor(wf.id).startedAt) }}
352
340
  </span>
353
341
  </div>
354
342
  <!-- Runs arrive after the rows do, so until they are in, "never run" is
@@ -363,7 +351,7 @@ const columns = computed(() => [
363
351
 
364
352
  <template #cell-updated="{ row: wf }">
365
353
  <span class="text-sm tabular-nums text-muted-foreground truncate">
366
- {{ formatDateTime(wf.updated_at) }}
354
+ {{ formatShortDateTime(wf.updated_at) }}
367
355
  </span>
368
356
  </template>
369
357
 
@@ -173,6 +173,7 @@
173
173
  "integration.common.triggerType.schedule": "Zeitplan",
174
174
  "integration.common.triggerType.webhook": "Webhook",
175
175
  "integration.common.nodeVersion.channel": "Kanal (folgt dem Release-Kanal)",
176
+ "integration.common.nodeVersion.channelShort": "Kanal",
176
177
  "integration.common.nodeVersion.candidate": "v{version} (Kandidat)",
177
178
  "integration.common.nodeVersion.channelCurrent": "Kanal · derzeit v{version}",
178
179
  "integration.credentials.createDialog.action.changeType": "Typ ändern",
@@ -173,6 +173,7 @@
173
173
  "integration.common.triggerType.schedule": "Schedule",
174
174
  "integration.common.triggerType.webhook": "Webhook",
175
175
  "integration.common.nodeVersion.channel": "Channel (follows release channel)",
176
+ "integration.common.nodeVersion.channelShort": "Channel",
176
177
  "integration.common.nodeVersion.candidate": "v{version} (candidate)",
177
178
  "integration.common.nodeVersion.channelCurrent": "Channel · currently v{version}",
178
179
  "integration.credentials.createDialog.action.changeType": "Change type",
@@ -16,6 +16,7 @@ import { Badge, Button } from "@revenexx/studio";
16
16
  import { copyToClipboard } from "../../utils/clipboard";
17
17
  import { isBareIdentifier, personLabel } from "../../utils/personLabel";
18
18
  import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
19
+ import { formatDateTime } from "../../utils/formatDateTime";
19
20
  const t = useIntegrationsT();
20
21
  useHead({ title: () => t("integration.audit.head.title") });
21
22
  const { entries, loading, error, hasMore, load, loadMore } = useIntegrationsAudit();
@@ -84,9 +85,6 @@ function resourceName(e) {
84
85
  if (e.resource_type === "state_entry" && text(m.key)) return [text(m.namespace), text(m.key)].filter(Boolean).join(" \xB7 ");
85
86
  return e.resource_id || "\u2014";
86
87
  }
87
- function fmtDate(iso) {
88
- return iso ? new Date(iso).toLocaleString() : "\u2014";
89
- }
90
88
  function actor(e) {
91
89
  return { sub: e.actor_sub, name: e.actor_name, email: e.actor_email };
92
90
  }
@@ -223,7 +221,7 @@ onUnmounted(() => document.removeEventListener("keydown", onKeydown));
223
221
  </div>
224
222
  </template>
225
223
  <template #cell-when="{ row }">
226
- <span class="text-sm text-muted-foreground">{{ fmtDate(row.created_at) }}</span>
224
+ <span class="text-sm text-muted-foreground">{{ formatDateTime(row.created_at) }}</span>
227
225
  </template>
228
226
  </DataTable>
229
227
 
@@ -282,7 +280,7 @@ onUnmounted(() => document.removeEventListener("keydown", onKeydown));
282
280
  <!-- The identifier stays for tracing — it is what the platform's own
283
281
  logs and the identity provider know the person by. -->
284
282
  <div v-if="selected.actor_sub && !actorIsId(selected)" class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.detail.actorId") }}</span><code class="font-mono text-xs">{{ selected.actor_sub }}</code></div>
285
- <div class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.table.column.when") }}</span><span>{{ fmtDate(selected.created_at) }}</span></div>
283
+ <div class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.table.column.when") }}</span><span>{{ formatDateTime(selected.created_at) }}</span></div>
286
284
  <div v-if="selected.tenant_id" class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.detail.tenant") }}</span><span>{{ selected.tenant_id }}</span></div>
287
285
  </div>
288
286
  <div v-if="selected.metadata && Object.keys(selected.metadata).length">
@@ -40,6 +40,7 @@ import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOpt
40
40
  import { keepOpenBeside } from "../../utils/dialog";
41
41
  import { testOutcomeView } from "../../utils/credentialTest";
42
42
  import { credentialNetworkPayload, credentialVpnBadge, replacedFieldTexts } from "../../utils/vpnRoute";
43
+ import { formatShortDateTime } from "../../utils/formatDateTime";
43
44
  const t = useIntegrationsT();
44
45
  useHead({ title: () => t("integration.credentials.list.head.title") });
45
46
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
@@ -74,14 +75,6 @@ function providerStep(c) {
74
75
  expired: state === "needs_reauth"
75
76
  };
76
77
  }
77
- function formatDateTime(iso) {
78
- if (!iso) return "\u2014";
79
- try {
80
- return new Date(iso).toLocaleString(void 0, { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
81
- } catch {
82
- return iso;
83
- }
84
- }
85
78
  const filterTabs = computed(() => {
86
79
  const systems = [...new Set(credentials.value.map((c) => c.credential_type_slug))].map((slug) => ({ value: slug, label: typeName(slug) })).sort((a, b) => a.label.localeCompare(b.label));
87
80
  return [
@@ -343,16 +336,16 @@ async function confirmDelete() {
343
336
 
344
337
  <template #cell-connected="{ row: c }">
345
338
  <span class="text-sm text-muted-foreground tabular-nums">
346
- {{ c.last_test_ok ? formatDateTime(c.last_test_at) : "\u2014" }}
339
+ {{ c.last_test_ok ? formatShortDateTime(c.last_test_at) : "\u2014" }}
347
340
  </span>
348
341
  </template>
349
342
 
350
343
  <template #cell-created="{ row: c }">
351
- <span class="text-sm text-muted-foreground tabular-nums">{{ formatDateTime(c.created_at) }}</span>
344
+ <span class="text-sm text-muted-foreground tabular-nums">{{ formatShortDateTime(c.created_at) }}</span>
352
345
  </template>
353
346
 
354
347
  <template #cell-updated="{ row: c }">
355
- <span class="text-sm text-muted-foreground tabular-nums">{{ formatDateTime(c.updated_at) }}</span>
348
+ <span class="text-sm text-muted-foreground tabular-nums">{{ formatShortDateTime(c.updated_at) }}</span>
356
349
  </template>
357
350
 
358
351
  <template #cell-actions="{ row: c }">
@@ -7,6 +7,7 @@ import RunsTable from "../../components/Integrations/RunsTable.vue";
7
7
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
8
8
  import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
9
9
  import { errorReason } from "../../utils/apiError";
10
+ import { formatDateTime } from "../../utils/formatDateTime";
10
11
  const t = useIntegrationsT();
11
12
  useHead({ title: () => t("integration.runs.list.head.title") });
12
13
  const route = useRoute();
@@ -127,14 +128,6 @@ function workflowNameOf(dl) {
127
128
  const name = dl.workflow_name;
128
129
  return typeof name === "string" && name.trim() !== "" ? name : t("integration.deadLetters.workflow.fallback", { id: dl.workflow_id });
129
130
  }
130
- function fmt(iso) {
131
- if (!iso) return "\u2014";
132
- try {
133
- return new Date(iso).toLocaleString();
134
- } catch {
135
- return iso;
136
- }
137
- }
138
131
  </script>
139
132
 
140
133
  <template>
@@ -234,7 +227,7 @@ function fmt(iso) {
234
227
  <span>#{{ dl.id }}</span>
235
228
  <span v-if="dl.source">{{ dl.source }}</span>
236
229
  <span v-if="dl.trigger_handle">↳ {{ dl.trigger_handle }}</span>
237
- <span>{{ fmt(dl.created_at) }}</span>
230
+ <span>{{ formatDateTime(dl.created_at) }}</span>
238
231
  </div>
239
232
  </div>
240
233
  <div v-if="dl.status === 'pending'" class="flex shrink-0 items-center gap-1">
@@ -3,6 +3,7 @@ import { ClockCircle as Clock, Globus as Globe } from "#solar-icons";
3
3
  import { Badge } from "@revenexx/studio";
4
4
  import { cadenceRank, describeCron } from "../../utils/schedule";
5
5
  import { runStatusLabel } from "../../utils/statusStyle";
6
+ import { formatShortDateTime } from "../../utils/formatDateTime";
6
7
  const t = useIntegrationsT();
7
8
  useHead({ title: () => t("integration.schedules.head.title") });
8
9
  const router = useRouter();
@@ -11,12 +12,7 @@ onMounted(() => {
11
12
  void load(true);
12
13
  });
13
14
  function inScheduleZone(iso, timezone) {
14
- const parts = { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" };
15
- try {
16
- return new Date(iso).toLocaleString(void 0, { ...parts, timeZone: timezone });
17
- } catch {
18
- return new Date(iso).toLocaleString(void 0, parts);
19
- }
15
+ return formatShortDateTime(iso, timezone);
20
16
  }
21
17
  function formatRelative(iso, timezone) {
22
18
  if (!iso) return "\u2014";
@@ -27,6 +27,7 @@ import {
27
27
  import { errorReason } from "../../utils/apiError";
28
28
  import { keepOpenBeside } from "../../utils/dialog";
29
29
  import { SECRET_KEY_MAX } from "../../composables/useIntegrationsSecrets";
30
+ import { formatDate } from "../../utils/formatDateTime";
30
31
  const t = useIntegrationsT();
31
32
  useHead({ title: () => t("integration.secrets.head.title") });
32
33
  const { secrets, loading, error, isValidKey, upsert, remove, load } = useIntegrationsSecrets();
@@ -75,13 +76,8 @@ async function confirmDelete() {
75
76
  deleting.value = false;
76
77
  pendingDelete.value = null;
77
78
  }
78
- function formatDate(iso) {
79
- if (!iso) return "\u2014";
80
- try {
81
- return new Date(iso).toLocaleDateString(void 0, { month: "short", day: "2-digit", year: "numeric" });
82
- } catch {
83
- return iso;
84
- }
79
+ function formatDay(iso) {
80
+ return formatDate(iso, { month: "short", day: "2-digit", year: "numeric" });
85
81
  }
86
82
  const columns = computed(() => [
87
83
  { key: "key", label: t("integration.secrets.table.column.key"), sortable: true, sortGetter: (s) => s.key.toLowerCase() },
@@ -208,18 +204,18 @@ const columns = computed(() => [
208
204
  <component
209
205
  :is="s.created_at ? 'time' : 'span'"
210
206
  :datetime="s.created_at || void 0"
211
- :aria-label="t('integration.secrets.cell.created.aria', { key: s.key, date: formatDate(s.created_at) })"
207
+ :aria-label="t('integration.secrets.cell.created.aria', { key: s.key, date: formatDay(s.created_at) })"
212
208
  class="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
213
- >{{ formatDate(s.created_at) }}</component>
209
+ >{{ formatDay(s.created_at) }}</component>
214
210
  </template>
215
211
 
216
212
  <template #cell-updated="{ row: s }">
217
213
  <component
218
214
  :is="s.updated_at ? 'time' : 'span'"
219
215
  :datetime="s.updated_at || void 0"
220
- :aria-label="t('integration.secrets.cell.updated.aria', { key: s.key, date: formatDate(s.updated_at) })"
216
+ :aria-label="t('integration.secrets.cell.updated.aria', { key: s.key, date: formatDay(s.updated_at) })"
221
217
  class="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
222
- >{{ formatDate(s.updated_at) }}</component>
218
+ >{{ formatDay(s.updated_at) }}</component>
223
219
  </template>
224
220
 
225
221
  <template #cell-actions="{ row: s }">
@@ -0,0 +1,61 @@
1
+ /** A moment as the API sends it (ISO 8601), as a clock gives it (milliseconds), or as a `Date`. */
2
+ export type DateInput = string | number | Date | null | undefined;
3
+ /**
4
+ * A moment as the reader reads it — in the studio's language, not the browser's,
5
+ * and in the browser's regional form of that language where it has one.
6
+ *
7
+ * The one place a date or a time of day is written (PO-389). Fourteen call sites
8
+ * used to ask `toLocaleString()` themselves, with no locale or with `undefined`,
9
+ * and both mean *the browser's* language: a host pinned to German in an English
10
+ * browser drew German labels around English dates. Reading {@link studioLocale}
11
+ * here is also what makes every date follow a language switch — each caller is
12
+ * inside a template or a `computed`, so it becomes a subscriber without changing
13
+ * a line, the way `resolveLocalized` does it.
14
+ *
15
+ * **The language is the studio's; the region is the reader's, when they agree.** A
16
+ * host sets a bare language (`en`, `de`), and a bare `en` is written the American
17
+ * way: a British reader would get `7/27/2026, 9:14 AM` and the day and month the
18
+ * wrong way round. So where the browser lists a tag of the same language
19
+ * (`en-GB` for `en`, `de-CH` for `de`), that tag is used; where it lists none, or
20
+ * where the host named a region itself, the studio's tag is. The browser's region never brings in its language: an `en-US`
21
+ * browser on a German studio still writes German.
22
+ *
23
+ * **Only the language is decided here, not the form.** Each caller passes the
24
+ * fields it wants, as `Intl.DateTimeFormatOptions`, and gets what `toLocaleString`,
25
+ * `toLocaleDateString` or `toLocaleTimeString` gives for them. A single house form
26
+ * for every date would be a product decision of its own, and PO-389 did not take it.
27
+ *
28
+ * **What will not parse is `—`, and so is nothing at all** — `0` included, which is
29
+ * how a millisecond field that was never set arrives, and which the private copies
30
+ * answered with `—` too. They had
31
+ * four answers for a broken timestamp — the raw string, `String(iso)`, `''` and
32
+ * `—` — and none of them ever ran: `new Date('junk')` does not throw, and its
33
+ * `toLocaleString()` is the words *Invalid Date*, which is what all fourteen
34
+ * printed. `—` is the answer `relativeTime` had already written down for the same
35
+ * case. A broken timestamp is a defect of the API, and the screen does not have to
36
+ * announce it in a cell an operator reads for something else.
37
+ *
38
+ * **What `Intl` refuses falls back, silently.** A language tag the host set that
39
+ * `Intl` cannot read (`not a locale`) is answered by the browser's language; a
40
+ * `timeZone` it refuses — a schedule's zone is what the platform resolved, and for a
41
+ * zone that is not a real one that is the unreadable string itself — by the reader's
42
+ * zone. Both used to be handled once, each in one place (`relativeTime`,
43
+ * `inScheduleZone` on the schedules page); here they hold for every caller.
44
+ *
45
+ * **Formatters are kept.** A table redraws on a clock, and each cell used to build
46
+ * its own `Intl.DateTimeFormat` on every redraw. One is built per language, kind
47
+ * and options, and kept for the rest of the session.
48
+ *
49
+ * Date and time, as `toLocaleString` writes them for these fields.
50
+ */
51
+ export declare function formatDateTime(value: DateInput, options?: Intl.DateTimeFormatOptions): string;
52
+ /** The date alone, as `toLocaleDateString` writes it for these fields. */
53
+ export declare function formatDate(value: DateInput, options?: Intl.DateTimeFormatOptions): string;
54
+ /** The time of day alone, as `toLocaleTimeString` writes it for these fields. */
55
+ export declare function formatTime(value: DateInput, options?: Intl.DateTimeFormatOptions): string;
56
+ /**
57
+ * The short form a list cell uses — day, month as a word, hour and minute, no year
58
+ * and no seconds — optionally in a zone of its own (a schedule's). One definition,
59
+ * so the lists that show it cannot come apart.
60
+ */
61
+ export declare function formatShortDateTime(value: DateInput, timeZone?: string): string;
@@ -0,0 +1,75 @@
1
+ import { studioLocale } from "./localize.js";
2
+ const NOTHING = "\u2014";
3
+ export function formatDateTime(value, options) {
4
+ return write(value, "any", options);
5
+ }
6
+ export function formatDate(value, options) {
7
+ return write(value, "date", options);
8
+ }
9
+ export function formatTime(value, options) {
10
+ return write(value, "time", options);
11
+ }
12
+ export function formatShortDateTime(value, timeZone) {
13
+ return formatDateTime(value, timeZone ? { ...SHORT, timeZone } : SHORT);
14
+ }
15
+ const SHORT = { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" };
16
+ const DATE_FIELDS = ["weekday", "year", "month", "day"];
17
+ const TIME_FIELDS = ["dayPeriod", "hour", "minute", "second", "fractionalSecondDigits"];
18
+ function write(value, kind, options) {
19
+ const at = toDate(value);
20
+ return at ? formatter(locale(), kind, options).format(at) : NOTHING;
21
+ }
22
+ function toDate(value) {
23
+ if (value === null || value === void 0 || value === "" || value === 0) return null;
24
+ const at = value instanceof Date ? value : new Date(value);
25
+ return Number.isNaN(at.getTime()) ? null : at;
26
+ }
27
+ const formatters = /* @__PURE__ */ new Map();
28
+ function formatter(tag, kind, options) {
29
+ const key = `${tag ?? ""}|${kind}|${options ? JSON.stringify(options) : ""}`;
30
+ let built = formatters.get(key);
31
+ if (!built) {
32
+ const full = withDefaults(kind, options);
33
+ try {
34
+ built = new Intl.DateTimeFormat(tag, full);
35
+ } catch {
36
+ const { timeZone: _refused, ...rest } = full;
37
+ built = new Intl.DateTimeFormat(tag, rest);
38
+ }
39
+ formatters.set(key, built);
40
+ }
41
+ return built;
42
+ }
43
+ function withDefaults(kind, options = {}) {
44
+ if (options.dateStyle || options.timeStyle) return options;
45
+ const has = (fields) => fields.some((f) => options[f] !== void 0);
46
+ const date = { year: "numeric", month: "numeric", day: "numeric" };
47
+ const time = { hour: "numeric", minute: "numeric", second: "numeric" };
48
+ if (kind === "any") return has(DATE_FIELDS) || has(TIME_FIELDS) ? options : { ...options, ...date, ...time };
49
+ if (kind === "date") return has(DATE_FIELDS) ? options : { ...options, ...date };
50
+ return has(TIME_FIELDS) ? options : { ...options, ...time };
51
+ }
52
+ const locales = /* @__PURE__ */ new Map();
53
+ function locale() {
54
+ const tag = studioLocale();
55
+ const browser = typeof navigator === "undefined" ? [] : navigator.languages ?? [navigator.language];
56
+ const key = `${tag}|${browser.join(",")}`;
57
+ if (locales.has(key)) return locales.get(key);
58
+ const answer = readable(regional(tag, browser)) ?? readable(tag);
59
+ locales.set(key, answer);
60
+ return answer;
61
+ }
62
+ function regional(tag, browser) {
63
+ if (tag.includes("-")) return void 0;
64
+ const language = tag.toLowerCase();
65
+ return browser.find((b) => b.includes("-") && b.split("-")[0]?.toLowerCase() === language);
66
+ }
67
+ function readable(tag) {
68
+ if (!tag) return void 0;
69
+ try {
70
+ Intl.DateTimeFormat.supportedLocalesOf(tag);
71
+ return tag;
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * **Beyond thirty days is a date, not a count of days.** "412d ago" makes the reader
22
22
  * do the arithmetic the page should have done. The date is written in the studio's
23
- * language rather than the browser's, like the words beside it.
23
+ * language rather than the browser's, like the words beside it (`formatDate`).
24
24
  *
25
25
  * **What was not computed is not printed.** A timestamp that will not parse returns
26
26
  * `—`, the same as none at all: `Date.now() - NaN` fails every threshold and would
@@ -1,5 +1,5 @@
1
1
  import { useIntegrationsT } from "../composables/useIntegrationsT.js";
2
- import { studioLocale } from "./localize.js";
2
+ import { formatDate } from "./formatDateTime.js";
3
3
  const JUST_NOW = 1e4;
4
4
  const MINUTE = 6e4;
5
5
  const HOUR = 60 * MINUTE;
@@ -15,9 +15,5 @@ export function relativeTime(iso, now = Date.now()) {
15
15
  if (ms < HOUR) return t("integration.common.time.minutesAgo", Math.floor(ms / MINUTE));
16
16
  if (ms < DAY) return t("integration.common.time.hoursAgo", Math.floor(ms / HOUR));
17
17
  if (ms < 30 * DAY) return t("integration.common.time.daysAgo", Math.floor(ms / DAY));
18
- try {
19
- return new Date(at).toLocaleDateString(studioLocale());
20
- } catch {
21
- return new Date(at).toLocaleDateString();
22
- }
18
+ return formatDate(at);
23
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "3.6.0",
3
+ "version": "3.6.2",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {