diff --git a/resources/js/components/posts/editor/PostEditorComposer.vue b/resources/js/components/posts/editor/PostEditorComposer.vue
index b6d4538f..094dfdac 100644
--- a/resources/js/components/posts/editor/PostEditorComposer.vue
+++ b/resources/js/components/posts/editor/PostEditorComposer.vue
@@ -23,6 +23,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatBytes } from '@/composables/useMedia';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
+import date from '@/date';
import { classify, isDocument, isVideo, MediaType } from '@/lib/mediaType';
import type { MediaItem } from '@/types/media';
@@ -86,13 +87,6 @@ const openPreview = (item: MediaItem) => {
);
};
-const formatDuration = (seconds: number): string => {
- const total = Math.round(seconds);
- const m = Math.floor(total / 60);
- const s = total % 60;
- return `${m}:${s.toString().padStart(2, '0')}`;
-};
-
const limitsWithUsage = computed(() =>
props.platformLimits.map((p) => {
const used = content.value.length;
@@ -267,7 +261,7 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
class="inline-flex items-center gap-0.5 rounded-md bg-black/65 px-1.5 py-0.5 backdrop-blur-sm"
>
- {{ formatDuration(item.meta.duration) }}
+ {{ date.formatClock(item.meta.duration) }}
{
return bytes + ' B';
};
-const formatDuration = (seconds: number): string => {
- const s = Math.round(seconds);
- if (s < 60) return `${s}s`;
- const m = Math.floor(s / 60);
- const rem = s % 60;
- return rem === 0 ? `${m}min` : `${m}min ${rem}s`;
-};
-
const formatAspect = (ratio: number): string => ratio.toFixed(2);
/**
@@ -104,7 +97,7 @@ export const getMediaValidationWarning = (
if (rules.maxVideoDurationSec && duration > rules.maxVideoDurationSec) {
return {
key: 'video_too_long',
- params: { max: formatDuration(rules.maxVideoDurationSec), current: formatDuration(duration) },
+ params: { max: date.formatDurationWords(rules.maxVideoDurationSec), current: date.formatDurationWords(duration) },
};
}
} else if (rules.maxImageBytes && size > rules.maxImageBytes) {
diff --git a/resources/js/date.ts b/resources/js/date.ts
index 09095988..fb91ad2c 100644
--- a/resources/js/date.ts
+++ b/resources/js/date.ts
@@ -214,4 +214,50 @@ export default {
}
return `${m}min`;
},
+
+ /**
+ * Format seconds as a clock (m:ss, or h:mm:ss past one hour).
+ * For media badges and stopwatches (e.g. "5:30", "1:05:30").
+ */
+ formatClock(seconds: number): string {
+ const d = dayjs.duration(Math.round(seconds), 'seconds');
+
+ return d.asHours() >= 1 ? d.format('H:mm:ss') : d.format('m:ss');
+ },
+
+ /**
+ * Format seconds as short words (e.g. "45s", "5min", "5min 30s").
+ */
+ formatDurationWords(seconds: number): string {
+ const s = Math.round(seconds);
+ if (s < 60) {
+ return `${s}s`;
+ }
+
+ const m = Math.floor(s / 60);
+ const rem = s % 60;
+
+ return rem === 0 ? `${m}min` : `${m}min ${rem}s`;
+ },
+
+ /**
+ * Format a duration in milliseconds (e.g. "—", "500ms", "1.5s", "2m 30s").
+ */
+ formatDurationMs(ms: number | null): string {
+ if (ms === null) {
+ return '—';
+ }
+ if (ms < 1000) {
+ return `${ms}ms`;
+ }
+
+ const seconds = ms / 1000;
+ if (seconds < 60) {
+ return `${seconds.toFixed(1)}s`;
+ }
+
+ const minutes = Math.floor(seconds / 60);
+
+ return `${minutes}m ${Math.round(seconds % 60)}s`;
+ },
};
diff --git a/resources/js/pages/automations/Invocations.vue b/resources/js/pages/automations/Invocations.vue
index 151fd6b8..d9a0eb8a 100644
--- a/resources/js/pages/automations/Invocations.vue
+++ b/resources/js/pages/automations/Invocations.vue
@@ -100,14 +100,6 @@ const summary = (invocation: Invocation): string => {
return trans('automations.invocations.summary.pending');
};
-const formatDuration = (ms: number | null): string => {
- if (ms === null) return '—';
- if (ms < 1000) return `${ms}ms`;
- const seconds = ms / 1000;
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
- const minutes = Math.floor(seconds / 60);
- return `${minutes}m ${Math.round(seconds % 60)}s`;
-};
const stepsLabel = (count: number): string =>
transChoice('automations.invocations.steps', count, {
@@ -362,7 +354,7 @@ const toggleExpand = async (invocation: Invocation) => {
{{
- formatDuration(
+ date.formatDurationMs(
invocation.duration_ms,
)
}} {
- if (ms === null) return '—';
- if (ms < 1000) return `${ms}ms`;
- const seconds = ms / 1000;
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
- const minutes = Math.floor(seconds / 60);
- return `${minutes}m ${Math.round(seconds % 60)}s`;
-};
-
const cards = computed(() => [
{ key: 'runs', label: 'automations.metrics.cards.runs', value: String(props.metrics.totals.runs) },
{ key: 'completed', label: 'automations.metrics.cards.completed', value: String(props.metrics.totals.completed) },
{ key: 'failed', label: 'automations.metrics.cards.failed', value: String(props.metrics.totals.failed) },
{ key: 'in_progress', label: 'automations.metrics.cards.in_progress', value: String(props.metrics.totals.in_progress) },
{ key: 'success_rate', label: 'automations.metrics.cards.success_rate', value: props.metrics.totals.success_rate === null ? '—' : `${props.metrics.totals.success_rate}%` },
- { key: 'avg_duration', label: 'automations.metrics.cards.avg_duration', value: formatDuration(props.metrics.totals.avg_duration_ms) },
+ { key: 'avg_duration', label: 'automations.metrics.cards.avg_duration', value: date.formatDurationMs(props.metrics.totals.avg_duration_ms) },
{ key: 'posts_created', label: 'automations.metrics.cards.posts_created', value: String(props.metrics.totals.posts_created) },
]);
diff --git a/resources/js/pages/posts/ai/Loading.vue b/resources/js/pages/posts/ai/Loading.vue
index ca3c9b02..40bc0245 100644
--- a/resources/js/pages/posts/ai/Loading.vue
+++ b/resources/js/pages/posts/ai/Loading.vue
@@ -6,6 +6,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Button } from '@/components/ui/button';
+import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar as calendarRoute } from '@/routes/app';
import { create as createPostRoute, edit as editPostRoute } from '@/routes/app/posts';
@@ -53,11 +54,7 @@ const currentTip = computed(() => trans(tipKeys[tipIndex.value % tipKeys.length]
const elapsed = ref(0);
let elapsedTimer: ReturnType | null = null;
-const elapsedLabel = computed(() => {
- const minutes = Math.floor(elapsed.value / 60);
- const seconds = elapsed.value % 60;
- return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
-});
+const elapsedLabel = computed(() => date.formatClock(elapsed.value));
const progress = computed(() => {
const ratio = elapsed.value / estimatedSeconds.value;