@octabits-io/nuxt-ui-kit 0.2.0
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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/ai/index.d.ts +255 -0
- package/dist/ai/index.js +389 -0
- package/dist/dates/index.d.ts +42 -0
- package/dist/dates/index.js +108 -0
- package/dist/index.d.ts +453 -0
- package/dist/index.js +526 -0
- package/dist/zod/index.d.ts +24 -0
- package/dist/zod/index.js +17 -0
- package/package.json +102 -0
- package/src/components/AiResultReviewCard.vue +67 -0
- package/src/components/ConfirmDialog.vue +60 -0
- package/src/components/DateInput.vue +66 -0
- package/src/components/DateRangeInput.vue +651 -0
- package/src/components/PeriodDisplay.vue +76 -0
- package/src/components/SubSidebar.vue +85 -0
package/dist/ai/index.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { computed, onMounted, onScopeDispose, ref, shallowRef, toValue, watch } from "vue";
|
|
2
|
+
//#region src/ai/types.ts
|
|
3
|
+
const TERMINAL_STATUSES = [
|
|
4
|
+
"completed",
|
|
5
|
+
"failed",
|
|
6
|
+
"cancelled"
|
|
7
|
+
];
|
|
8
|
+
function isTerminalStatus(status) {
|
|
9
|
+
return TERMINAL_STATUSES.includes(status);
|
|
10
|
+
}
|
|
11
|
+
function isActiveStatus(status) {
|
|
12
|
+
return status === "pending" || status === "running";
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/ai/interval.ts
|
|
16
|
+
/**
|
|
17
|
+
* Minimal pausable interval — avoids a @vueuse/core peer for one helper.
|
|
18
|
+
* Not auto-disposed; callers pause it in their own teardown.
|
|
19
|
+
*/
|
|
20
|
+
function createPausableInterval(fn, ms) {
|
|
21
|
+
let timer;
|
|
22
|
+
const isActive = ref(false);
|
|
23
|
+
function resume() {
|
|
24
|
+
if (timer) return;
|
|
25
|
+
isActive.value = true;
|
|
26
|
+
timer = setInterval(() => void fn(), ms);
|
|
27
|
+
}
|
|
28
|
+
function pause() {
|
|
29
|
+
if (timer) {
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
timer = void 0;
|
|
32
|
+
}
|
|
33
|
+
isActive.value = false;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
pause,
|
|
37
|
+
resume,
|
|
38
|
+
isActive
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/ai/useAiWorkflow.ts
|
|
43
|
+
/**
|
|
44
|
+
* Poll-driven AI-workflow state: `start(pollFn)` fetches immediately and then
|
|
45
|
+
* polls until the workflow reaches a terminal status, firing the matching
|
|
46
|
+
* callback. The poll function is injected — the engine is transport-agnostic.
|
|
47
|
+
*/
|
|
48
|
+
function useAiWorkflow(options = {}) {
|
|
49
|
+
const { interval = 2e3, onCompleted, onFailed, onCancelled } = options;
|
|
50
|
+
const workflow = shallowRef(null);
|
|
51
|
+
const isLoading = ref(false);
|
|
52
|
+
let activePollFn = null;
|
|
53
|
+
const status = computed(() => workflow.value?.status ?? null);
|
|
54
|
+
const isCompleted = computed(() => status.value === "completed");
|
|
55
|
+
const isFailed = computed(() => status.value === "failed");
|
|
56
|
+
const isCancelled = computed(() => status.value === "cancelled");
|
|
57
|
+
const isTerminal = computed(() => status.value != null && isTerminalStatus(status.value));
|
|
58
|
+
const isActive = computed(() => status.value != null && isActiveStatus(status.value));
|
|
59
|
+
const output = computed(() => workflow.value?.output ?? null);
|
|
60
|
+
const error = computed(() => workflow.value?.error ?? null);
|
|
61
|
+
const progress = computed(() => {
|
|
62
|
+
if (!workflow.value || workflow.value.totalSteps === 0) return 0;
|
|
63
|
+
return workflow.value.completedSteps / workflow.value.totalSteps;
|
|
64
|
+
});
|
|
65
|
+
async function poll() {
|
|
66
|
+
if (!activePollFn) return;
|
|
67
|
+
try {
|
|
68
|
+
const data = await activePollFn();
|
|
69
|
+
if (!data) return;
|
|
70
|
+
workflow.value = data;
|
|
71
|
+
if (isTerminalStatus(data.status)) {
|
|
72
|
+
stopPolling();
|
|
73
|
+
if (data.status === "completed") onCompleted?.(data);
|
|
74
|
+
else if (data.status === "failed") onFailed?.(data);
|
|
75
|
+
else if (data.status === "cancelled") onCancelled?.(data);
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
const { pause: stopPolling, resume: resumePolling, isActive: isPolling } = createPausableInterval(poll, interval);
|
|
80
|
+
function start(pollFn) {
|
|
81
|
+
activePollFn = pollFn;
|
|
82
|
+
isLoading.value = true;
|
|
83
|
+
pollFn().then((data) => {
|
|
84
|
+
if (data) workflow.value = data;
|
|
85
|
+
isLoading.value = false;
|
|
86
|
+
if (!data || !isTerminalStatus(data.status)) resumePolling();
|
|
87
|
+
else if (data.status === "completed") onCompleted?.(data);
|
|
88
|
+
else if (data.status === "failed") onFailed?.(data);
|
|
89
|
+
else if (data.status === "cancelled") onCancelled?.(data);
|
|
90
|
+
}).catch(() => {
|
|
91
|
+
isLoading.value = false;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function stop() {
|
|
95
|
+
stopPolling();
|
|
96
|
+
activePollFn = null;
|
|
97
|
+
}
|
|
98
|
+
async function cancel(cancelFn) {
|
|
99
|
+
try {
|
|
100
|
+
await cancelFn();
|
|
101
|
+
await poll();
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
async function refresh() {
|
|
105
|
+
if (activePollFn) {
|
|
106
|
+
isLoading.value = true;
|
|
107
|
+
try {
|
|
108
|
+
await poll();
|
|
109
|
+
} finally {
|
|
110
|
+
isLoading.value = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function setWorkflow(data) {
|
|
115
|
+
workflow.value = data;
|
|
116
|
+
}
|
|
117
|
+
onScopeDispose(() => {
|
|
118
|
+
stop();
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
workflow,
|
|
122
|
+
isLoading,
|
|
123
|
+
isPolling,
|
|
124
|
+
status,
|
|
125
|
+
progress,
|
|
126
|
+
isCompleted,
|
|
127
|
+
isFailed,
|
|
128
|
+
isCancelled,
|
|
129
|
+
isTerminal,
|
|
130
|
+
isActive,
|
|
131
|
+
output,
|
|
132
|
+
error,
|
|
133
|
+
start,
|
|
134
|
+
stop,
|
|
135
|
+
cancel,
|
|
136
|
+
refresh,
|
|
137
|
+
setWorkflow
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/ai/useAiWorkflowGuard.ts
|
|
142
|
+
/**
|
|
143
|
+
* useAiWorkflow plus a mount-time re-hydration check (resume polling a
|
|
144
|
+
* workflow that is already running) and a `trigger` that refuses to start a
|
|
145
|
+
* duplicate while one is active. All transport is injected.
|
|
146
|
+
*/
|
|
147
|
+
function useAiWorkflowGuard(options) {
|
|
148
|
+
const { checkFn, pollFn, ...workflowOptions } = options;
|
|
149
|
+
const ai = useAiWorkflow(workflowOptions);
|
|
150
|
+
const isChecking = ref(true);
|
|
151
|
+
onMounted(async () => {
|
|
152
|
+
try {
|
|
153
|
+
const existing = await checkFn();
|
|
154
|
+
if (existing && isActiveStatus(existing.status)) {
|
|
155
|
+
ai.setWorkflow(existing);
|
|
156
|
+
ai.start(pollFn);
|
|
157
|
+
} else if (existing) ai.setWorkflow(existing);
|
|
158
|
+
} catch {} finally {
|
|
159
|
+
isChecking.value = false;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* Trigger a new workflow. Calls the provided trigger function,
|
|
164
|
+
* then starts polling with the configured pollFn.
|
|
165
|
+
* Returns false if a workflow is already active.
|
|
166
|
+
*/
|
|
167
|
+
async function trigger(triggerFn) {
|
|
168
|
+
if (ai.isActive.value) return false;
|
|
169
|
+
try {
|
|
170
|
+
await triggerFn();
|
|
171
|
+
ai.start(pollFn);
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
...ai,
|
|
179
|
+
isChecking,
|
|
180
|
+
trigger
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/ai/progressCore.ts
|
|
185
|
+
/**
|
|
186
|
+
* Cross-page AI-workflow progress tracking — the setup body of an app's
|
|
187
|
+
* progress store (`defineStore('ai-progress', () => createAiProgressCore(…))`).
|
|
188
|
+
* Tracks triggered workflows, polls the active ones through the injected
|
|
189
|
+
* fetch, and exposes `completionSignal` / `appliedSignal` counters pages watch
|
|
190
|
+
* to refresh their data. The dialog-request channel is generic over the app's
|
|
191
|
+
* request shape (typically `{ definition, entityId?, entityRef?, workflowId? }`).
|
|
192
|
+
*/
|
|
193
|
+
function createAiProgressCore(options) {
|
|
194
|
+
const intervalMs = options.intervalMs ?? 3e3;
|
|
195
|
+
const trackedWorkflows = ref([]);
|
|
196
|
+
const dialogRequest = ref(null);
|
|
197
|
+
/** Bumps whenever a tracked workflow transitions to terminal status. Watch this to refresh history. */
|
|
198
|
+
const completionSignal = ref(0);
|
|
199
|
+
/** Bumps whenever workflow results are applied (from float or sidebar). Watch this to reload page data. */
|
|
200
|
+
const appliedSignal = ref(0);
|
|
201
|
+
const activeWorkflows = computed(() => trackedWorkflows.value.filter((w) => !w.dismissed));
|
|
202
|
+
const hasActive = computed(() => trackedWorkflows.value.some((w) => !isTerminalStatus(w.status)));
|
|
203
|
+
function track(workflowId, workflowType, entityRef, entityId) {
|
|
204
|
+
if (trackedWorkflows.value.some((w) => w.workflowId === workflowId)) return;
|
|
205
|
+
trackedWorkflows.value.push({
|
|
206
|
+
workflowId,
|
|
207
|
+
workflowType,
|
|
208
|
+
entityRef,
|
|
209
|
+
entityId,
|
|
210
|
+
status: "pending",
|
|
211
|
+
progress: 0,
|
|
212
|
+
totalSteps: 0,
|
|
213
|
+
completedSteps: 0,
|
|
214
|
+
dismissed: false
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function dismiss(workflowId) {
|
|
218
|
+
const w = trackedWorkflows.value.find((w) => w.workflowId === workflowId);
|
|
219
|
+
if (w) w.dismissed = true;
|
|
220
|
+
}
|
|
221
|
+
/** Mark a workflow as applied and signal listeners to reload data. */
|
|
222
|
+
function markApplied(workflowId) {
|
|
223
|
+
dismiss(workflowId);
|
|
224
|
+
appliedSignal.value++;
|
|
225
|
+
}
|
|
226
|
+
function untrack(workflowId) {
|
|
227
|
+
trackedWorkflows.value = trackedWorkflows.value.filter((w) => w.workflowId !== workflowId);
|
|
228
|
+
}
|
|
229
|
+
/** Find a tracked workflow by entityRef (for inline status display) */
|
|
230
|
+
function getByEntityRef(entityRef) {
|
|
231
|
+
return trackedWorkflows.value.find((w) => w.entityRef === entityRef && !w.dismissed);
|
|
232
|
+
}
|
|
233
|
+
async function pollActive() {
|
|
234
|
+
const active = trackedWorkflows.value.filter((w) => !isTerminalStatus(w.status));
|
|
235
|
+
if (active.length === 0) return;
|
|
236
|
+
for (const tracked of active) try {
|
|
237
|
+
const workflow = await options.fetchWorkflowStatus(tracked.workflowId);
|
|
238
|
+
if (!workflow) continue;
|
|
239
|
+
const wasActive = !isTerminalStatus(tracked.status);
|
|
240
|
+
tracked.status = workflow.status;
|
|
241
|
+
tracked.totalSteps = workflow.totalSteps;
|
|
242
|
+
tracked.completedSteps = workflow.completedSteps;
|
|
243
|
+
tracked.progress = workflow.totalSteps > 0 ? workflow.completedSteps / workflow.totalSteps * 100 : 0;
|
|
244
|
+
if (wasActive && isTerminalStatus(workflow.status)) completionSignal.value++;
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
const { pause, resume } = createPausableInterval(pollActive, intervalMs);
|
|
248
|
+
watch(hasActive, (active) => {
|
|
249
|
+
if (active) resume();
|
|
250
|
+
else pause();
|
|
251
|
+
});
|
|
252
|
+
watch(trackedWorkflows, () => {
|
|
253
|
+
if (hasActive.value) resume();
|
|
254
|
+
}, { deep: true });
|
|
255
|
+
function openDialog(request) {
|
|
256
|
+
dialogRequest.value = request;
|
|
257
|
+
}
|
|
258
|
+
function closeDialog() {
|
|
259
|
+
dialogRequest.value = null;
|
|
260
|
+
}
|
|
261
|
+
function reset() {
|
|
262
|
+
trackedWorkflows.value = [];
|
|
263
|
+
completionSignal.value = 0;
|
|
264
|
+
appliedSignal.value = 0;
|
|
265
|
+
dialogRequest.value = null;
|
|
266
|
+
pause();
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
trackedWorkflows,
|
|
270
|
+
activeWorkflows,
|
|
271
|
+
hasActive,
|
|
272
|
+
completionSignal,
|
|
273
|
+
appliedSignal,
|
|
274
|
+
dialogRequest,
|
|
275
|
+
track,
|
|
276
|
+
dismiss,
|
|
277
|
+
markApplied,
|
|
278
|
+
untrack,
|
|
279
|
+
getByEntityRef,
|
|
280
|
+
openDialog,
|
|
281
|
+
closeDialog,
|
|
282
|
+
pollActive,
|
|
283
|
+
reset
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/ai/useAiCardState.ts
|
|
288
|
+
/**
|
|
289
|
+
* Shared state machine for AI trigger/suggestion cards. Derives the card
|
|
290
|
+
* phase from the workflow tracked in the (injected) progress store for the
|
|
291
|
+
* given entityRef.
|
|
292
|
+
*/
|
|
293
|
+
function useAiCardState(store, entityRef, hasActiveWorkflow) {
|
|
294
|
+
const trackedWorkflow = computed(() => store.getByEntityRef(toValue(entityRef)));
|
|
295
|
+
const cardState = computed(() => {
|
|
296
|
+
const tracked = trackedWorkflow.value;
|
|
297
|
+
if (tracked) {
|
|
298
|
+
if (isActiveStatus(tracked.status)) return "active";
|
|
299
|
+
if (tracked.status === "failed") return "failed";
|
|
300
|
+
}
|
|
301
|
+
if (toValue(hasActiveWorkflow)) return "active";
|
|
302
|
+
return "idle";
|
|
303
|
+
});
|
|
304
|
+
const failedWorkflow = computed(() => {
|
|
305
|
+
const tracked = trackedWorkflow.value;
|
|
306
|
+
return tracked?.status === "failed" ? tracked : null;
|
|
307
|
+
});
|
|
308
|
+
function dismissFailure() {
|
|
309
|
+
if (failedWorkflow.value) store.dismiss(failedWorkflow.value.workflowId);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
trackedWorkflow,
|
|
313
|
+
cardState,
|
|
314
|
+
failedWorkflow,
|
|
315
|
+
dismissFailure
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/ai/useActiveAiWorkflowProbe.ts
|
|
320
|
+
/**
|
|
321
|
+
* "Is something already running for this entity?" probe: checks on mount and
|
|
322
|
+
* whenever the entity changes, then polls while active so trigger buttons can
|
|
323
|
+
* disable themselves. Transport is injected.
|
|
324
|
+
*/
|
|
325
|
+
function useActiveAiWorkflowProbe(options) {
|
|
326
|
+
const hasActive = ref(false);
|
|
327
|
+
const isChecking = ref(false);
|
|
328
|
+
async function refresh() {
|
|
329
|
+
const ref_ = toValue(options.entityRef);
|
|
330
|
+
if (!ref_) return;
|
|
331
|
+
isChecking.value = true;
|
|
332
|
+
try {
|
|
333
|
+
const result = await options.fetchHasActive(ref_);
|
|
334
|
+
if (result !== null) hasActive.value = result;
|
|
335
|
+
} catch {} finally {
|
|
336
|
+
isChecking.value = false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const { pause, resume } = createPausableInterval(refresh, options.intervalMs ?? 3e3);
|
|
340
|
+
watch(hasActive, (active) => {
|
|
341
|
+
if (active) resume();
|
|
342
|
+
else pause();
|
|
343
|
+
});
|
|
344
|
+
watch(() => toValue(options.entityRef), () => {
|
|
345
|
+
hasActive.value = false;
|
|
346
|
+
refresh();
|
|
347
|
+
});
|
|
348
|
+
onMounted(() => {
|
|
349
|
+
refresh();
|
|
350
|
+
});
|
|
351
|
+
onScopeDispose(() => {
|
|
352
|
+
pause();
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
hasActive,
|
|
356
|
+
isChecking,
|
|
357
|
+
refresh
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/ai/registry.ts
|
|
362
|
+
function createWorkflowRegistry(options = {}) {
|
|
363
|
+
const registry = /* @__PURE__ */ new Map();
|
|
364
|
+
function register(definition) {
|
|
365
|
+
registry.set(definition.type, definition);
|
|
366
|
+
}
|
|
367
|
+
function get(type) {
|
|
368
|
+
return registry.get(type);
|
|
369
|
+
}
|
|
370
|
+
function getAll() {
|
|
371
|
+
return Array.from(registry.values());
|
|
372
|
+
}
|
|
373
|
+
/** Human label for a type: definition labelKey → extra fallback key → raw type. */
|
|
374
|
+
function getLabel(type, t) {
|
|
375
|
+
const def = registry.get(type);
|
|
376
|
+
if (def) return t(def.labelKey);
|
|
377
|
+
const extraKey = options.extraLabelKeys?.[type];
|
|
378
|
+
if (extraKey) return t(extraKey);
|
|
379
|
+
return type;
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
register,
|
|
383
|
+
get,
|
|
384
|
+
getAll,
|
|
385
|
+
getLabel
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
//#endregion
|
|
389
|
+
export { createAiProgressCore, createWorkflowRegistry, isActiveStatus, isTerminalStatus, useActiveAiWorkflowProbe, useAiCardState, useAiWorkflow, useAiWorkflowGuard };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Ref } from "vue";
|
|
2
|
+
//#region src/dates/index.d.ts
|
|
3
|
+
/** ISO (`YYYY-MM-DD`) date range. Whether `end` is inclusive is the caller's convention. */
|
|
4
|
+
interface Period {
|
|
5
|
+
start: string;
|
|
6
|
+
end: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Number of days in a period, **inclusive** of both endpoints.
|
|
10
|
+
* Example: `{ start: "2025-01-01", end: "2025-01-03" }` → 3 days.
|
|
11
|
+
*/
|
|
12
|
+
declare function calculateDays(period: Period): number;
|
|
13
|
+
/** Shift an ISO date string by n days; `''` stays `''`. */
|
|
14
|
+
declare function shiftIso(iso: string, days: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Converts between ISO date strings (start/end) and Date arrays.
|
|
17
|
+
* Useful for bridging separate start/end date inputs with range picker components.
|
|
18
|
+
*/
|
|
19
|
+
declare function useDateRangeInput(startRef: Ref<string | undefined>, endRef: Ref<string | undefined>): {
|
|
20
|
+
dateRange: import("vue").WritableComputedRef<Date[], Date[]>;
|
|
21
|
+
dayCount: import("vue").ComputedRef<number>;
|
|
22
|
+
};
|
|
23
|
+
interface DateFormatterOptions {
|
|
24
|
+
/** Read the active locale code (e.g. from vue-i18n's `locale.value`). */
|
|
25
|
+
getLocale: () => string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Locale-aware date/time/currency formatting — the engine of an app-side
|
|
29
|
+
* `useDateFormat()` composable (`createDateFormatter({ getLocale: () =>
|
|
30
|
+
* locale.value })`). Inject the locale getter; no i18n dependency here.
|
|
31
|
+
*/
|
|
32
|
+
declare function createDateFormatter(options: DateFormatterOptions): {
|
|
33
|
+
formatDate: (dateStr: string) => string;
|
|
34
|
+
formatDateMedium: (dateStr: string) => string;
|
|
35
|
+
formatDateTime: (dateStr: string) => string;
|
|
36
|
+
formatCheckoutDate: (endDate: string, checkOutNextDay?: boolean) => string;
|
|
37
|
+
formatTimeFromString: (timeStr: string) => string;
|
|
38
|
+
formatCurrency: (amount: number, currencyCode: string) => string;
|
|
39
|
+
};
|
|
40
|
+
type DateFormatter = ReturnType<typeof createDateFormatter>;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { DateFormatter, DateFormatterOptions, Period, calculateDays, createDateFormatter, shiftIso, useDateRangeInput };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { addDays, differenceInDays, eachDayOfInterval, format, parseISO } from "date-fns";
|
|
3
|
+
//#region src/dates/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Number of days in a period, **inclusive** of both endpoints.
|
|
6
|
+
* Example: `{ start: "2025-01-01", end: "2025-01-03" }` → 3 days.
|
|
7
|
+
*/
|
|
8
|
+
function calculateDays(period) {
|
|
9
|
+
return differenceInDays(new Date(period.end), new Date(period.start)) + 1;
|
|
10
|
+
}
|
|
11
|
+
/** Shift an ISO date string by n days; `''` stays `''`. */
|
|
12
|
+
function shiftIso(iso, days) {
|
|
13
|
+
if (!iso || days === 0) return iso;
|
|
14
|
+
try {
|
|
15
|
+
return format(addDays(parseISO(iso), days), "yyyy-MM-dd");
|
|
16
|
+
} catch {
|
|
17
|
+
return iso;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Converts between ISO date strings (start/end) and Date arrays.
|
|
22
|
+
* Useful for bridging separate start/end date inputs with range picker components.
|
|
23
|
+
*/
|
|
24
|
+
function useDateRangeInput(startRef, endRef) {
|
|
25
|
+
const dateRange = computed({
|
|
26
|
+
get() {
|
|
27
|
+
if (!startRef.value || !endRef.value) return [];
|
|
28
|
+
try {
|
|
29
|
+
return eachDayOfInterval({
|
|
30
|
+
start: parseISO(startRef.value),
|
|
31
|
+
end: parseISO(endRef.value)
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
set(dates) {
|
|
38
|
+
if (!dates || dates.length === 0) {
|
|
39
|
+
startRef.value = void 0;
|
|
40
|
+
endRef.value = void 0;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
|
|
44
|
+
startRef.value = format(sorted[0], "yyyy-MM-dd");
|
|
45
|
+
endRef.value = format(sorted[sorted.length - 1], "yyyy-MM-dd");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
dateRange,
|
|
50
|
+
dayCount: computed(() => dateRange.value.length)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Locale-aware date/time/currency formatting — the engine of an app-side
|
|
55
|
+
* `useDateFormat()` composable (`createDateFormatter({ getLocale: () =>
|
|
56
|
+
* locale.value })`). Inject the locale getter; no i18n dependency here.
|
|
57
|
+
*/
|
|
58
|
+
function createDateFormatter(options) {
|
|
59
|
+
const locale = () => options.getLocale();
|
|
60
|
+
function formatDate(dateStr) {
|
|
61
|
+
return new Date(dateStr).toLocaleDateString(locale());
|
|
62
|
+
}
|
|
63
|
+
function formatDateMedium(dateStr) {
|
|
64
|
+
const date = new Date(dateStr);
|
|
65
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
66
|
+
return date.toLocaleDateString(locale(), {
|
|
67
|
+
month: "short",
|
|
68
|
+
day: "numeric",
|
|
69
|
+
year: "numeric"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Customer-facing checkout date: the day after the (inclusive) last booked day. */
|
|
73
|
+
function formatCheckoutDate(endDate, checkOutNextDay = true) {
|
|
74
|
+
const daysToAdd = checkOutNextDay ? 1 : 0;
|
|
75
|
+
return addDays(new Date(endDate), daysToAdd).toLocaleDateString(locale());
|
|
76
|
+
}
|
|
77
|
+
function formatTimeFromString(timeStr) {
|
|
78
|
+
const [hours, minutes] = timeStr.split(":").map(Number);
|
|
79
|
+
const date = /* @__PURE__ */ new Date();
|
|
80
|
+
date.setHours(hours, minutes, 0, 0);
|
|
81
|
+
return date.toLocaleTimeString(locale(), {
|
|
82
|
+
hour: "numeric",
|
|
83
|
+
minute: "2-digit"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function formatCurrency(amount, currencyCode) {
|
|
87
|
+
return new Intl.NumberFormat(locale(), {
|
|
88
|
+
style: "currency",
|
|
89
|
+
currency: currencyCode
|
|
90
|
+
}).format(amount);
|
|
91
|
+
}
|
|
92
|
+
function formatDateTime(dateStr) {
|
|
93
|
+
return new Date(dateStr).toLocaleString(locale(), {
|
|
94
|
+
dateStyle: "medium",
|
|
95
|
+
timeStyle: "short"
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
formatDate,
|
|
100
|
+
formatDateMedium,
|
|
101
|
+
formatDateTime,
|
|
102
|
+
formatCheckoutDate,
|
|
103
|
+
formatTimeFromString,
|
|
104
|
+
formatCurrency
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { calculateDays, createDateFormatter, shiftIso, useDateRangeInput };
|