refactor(date): consolidate duration formatting into three date helpers

Five hand-rolled formatDuration variants (two of them byte-identical
across the automations pages) collapse into formatClock, formatDurationWords
and formatDurationMs on the date helper, all backed by dayjs.duration.

Migrates the video badge, AI loading timer, media validation params, and
the automation metrics/invocations tables onto them. Output is unchanged
except the loading timer's minutes are no longer zero-padded (5:30, not 05:30).
This commit is contained in:
Paulo Castellano 2026-06-25 18:42:05 -03:00
parent 1206448f17
commit 91b353c569
6 changed files with 55 additions and 41 deletions

View file

@ -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"
>
<IconVideo class="size-2.5" />
{{ formatDuration(item.meta.duration) }}
{{ date.formatClock(item.meta.duration) }}
</span>
<span
v-if="item.size"

View file

@ -1,4 +1,5 @@
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import date from '@/date';
import { isDocument, isGif, isImage, isVideo } from '@/lib/mediaType';
import type { MediaItem } from '@/types/media';
@ -22,14 +23,6 @@ export const formatBytes = (bytes: number): string => {
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) {

View file

@ -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`;
},
};

View file

@ -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) => {
<TableCell
class="text-right text-sm text-foreground/70 tabular-nums"
>{{
formatDuration(
date.formatDurationMs(
invocation.duration_ms,
)
}}</TableCell

View file

@ -5,6 +5,7 @@ import { computed, ref, watch } from 'vue';
import AutomationDetailLayout from '@/components/automations/AutomationDetailLayout.vue';
import AutomationRunsChart from '@/components/automations/AutomationRunsChart.vue';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import date from '@/date';
import dayjs from '@/dayjs';
import type { Automation } from '@/types/automation/automation';
@ -47,22 +48,13 @@ watch(
{ deep: true },
);
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 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) },
]);

View file

@ -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<typeof setInterval> | 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;