This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3152d7af90c More human readable duration strings (#72350)
3152d7af90c is described below

commit 3152d7af90cf6559f4755f30a15c4af93aa52314
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Sep 9 15:42:29 2026 -0400

    More human readable duration strings (#72350)
    
    * UI: Show durations in a readable, localized format
    
    Zero-padded clock strings buried the one number that mattered: a task
    that took 83ms read as 00:00:00.083, and anything under a second
    rendered as 00:00:00 in chart tooltips and Gantt axis labels, which is
    indistinguishable from no time at all. Someone scanning a column of
    durations wants the magnitude at a glance, and roughly three
    significant digits gives it to them at every scale from milliseconds
    to days.
    
    Unit names, decimal separators and plural forms now come from CLDR
    through Intl rather than being hardcoded English, so durations follow
    the language the rest of the UI is already using. This needs no new
    translation keys, and it reproduces Intl.DurationFormat's narrow style
    without requiring it, since that API is above this package's Node
    floor.
    
    Formatting goes through a hook bound to the active language because
    reading the language straight off the i18next singleton put it outside
    anything React tracks: a component with no reason of its own to
    subscribe to a language change kept rendering the previous locale
    indefinitely.
    
    * UI: Address duration formatting review feedback
    
    Relative times picked their unit from the unrounded gap and then rounded
    inside it, so anything just short of the next unit printed that unit's
    own ceiling: "60 minutes ago" where "an hour ago" was meant, and the
    same at 24 hours and 12 months. Promoting after the rounding matches
    what the duration path already does a few hundred lines up.
    
    Two layout budgets were sized when every duration was the English
    "1h 2m" and are too tight now that CLDR decides the width: the bar-end
    labels on the slowest-task chart reserved a fixed 64px, and the Gantt
    axis derived its tick spacing from an eight-character "HH:MM:SS"
    estimate. German narrow needs roughly half again as much room.
    
    The Gantt axis also placed ticks at evenly divided raw values, which
    the old truncating format hid; at this precision a seven-second span
    read 0s | 1.17s | 2.33s. Snapping to the same counted units the other
    duration axes use keeps short spans legible.
    
    Rounding to two units leaves "1h 2m" covering a full minute, which is
    too coarse to tell two similar runs apart in a list. The exact value now
    rides along in a title on the duration columns, so the compact form
    stays scannable without giving up the precise number.
    
    * Address PR Feedback
    
    * UI: Export useDurationFormat from the utils barrel
    
    The hook was the only util under src/utils that callers had to reach by
    its own path, so consumers imported it differently from every other util
    sitting beside it. Routing it through the barrel leaves one way in.
    
    The Dag overview test replaces the barrel wholesale rather than patching
    it, so it has to name the hook for the components under test to keep
    resolving.
---
 .../ClearTaskInstanceConfirmationDialog.tsx        |   5 +-
 .../src/airflow/ui/src/components/DagRunInfo.tsx   |   8 +-
 .../DurationCell.tsx}                              |  25 +-
 .../airflow/ui/src/components/DurationChart.tsx    |  32 +-
 .../HITLReview/HITLReviewDetailSummary.tsx         |   5 +-
 .../src/components/SlowestTaskInstancesChart.tsx   |  42 ++-
 .../ui/src/components/TaskInstanceTooltip.test.tsx |   7 +-
 .../ui/src/components/TaskInstanceTooltip.tsx      |   7 +-
 .../ui/src/layouts/Details/Gantt/GanttTimeline.tsx |  11 +-
 .../ui/src/layouts/Details/Gantt/utils.test.ts     |  25 +-
 .../airflow/ui/src/layouts/Details/Gantt/utils.ts  |  23 +-
 .../ui/src/layouts/Details/Grid/DurationTick.tsx   |  16 +-
 .../src/layouts/Details/Grid/GridButton.test.tsx   |   2 +-
 .../ui/src/layouts/Details/Grid/GridButton.tsx     |   3 +-
 .../ui/src/pages/Dag/Backfills/Backfills.tsx       |  21 +-
 .../src/airflow/ui/src/pages/Dag/Code/Code.tsx     |   3 +-
 .../ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx  |   4 +-
 .../ui/src/pages/Dag/DeadlineAlertsBadge.tsx       |   4 +-
 .../src/airflow/ui/src/pages/Dag/Details.tsx       |   3 +-
 .../ui/src/pages/Dag/Overview/DeadlineRow.tsx      |   4 +-
 .../ui/src/pages/Dag/Overview/Overview.test.tsx    |   6 +-
 .../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx   |   5 +-
 .../airflow/ui/src/pages/DagsList/RecentRuns.tsx   |   3 +-
 .../ui/src/pages/GroupTaskInstance/Header.tsx      |   5 +-
 .../ui/src/pages/MappedTaskInstance/Details.tsx    |   5 +-
 .../ui/src/pages/MappedTaskInstance/Header.tsx     |   5 +-
 .../airflow/ui/src/pages/Run/DeadlineStatus.tsx    |   9 +-
 .../ui/src/pages/Run/DeadlineStatusModal.tsx       |   7 +-
 .../src/airflow/ui/src/pages/Run/Details.tsx       |   5 +-
 .../src/airflow/ui/src/pages/Run/Header.tsx        |   5 +-
 .../airflow/ui/src/pages/TaskInstance/Details.tsx  |   3 +-
 .../airflow/ui/src/pages/TaskInstance/Header.tsx   |   5 +-
 .../ui/src/pages/TaskInstances/TaskInstances.tsx   |   5 +-
 .../src/airflow/ui/src/utils/datetimeUtils.test.ts | 372 +++++++++++++++++----
 .../src/airflow/ui/src/utils/datetimeUtils.ts      | 309 +++++++++++++----
 .../src/airflow/ui/src/utils/deadlines.test.ts     |   2 +-
 airflow-core/src/airflow/ui/src/utils/deadlines.ts |   3 +-
 airflow-core/src/airflow/ui/src/utils/index.ts     |   2 +-
 .../ui/src/utils/useDurationFormat.test.tsx        |  80 +++++
 .../src/airflow/ui/src/utils/useDurationFormat.ts  |  64 ++++
 40 files changed, 896 insertions(+), 254 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
index e6c673825c0..53a4d7955b9 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
@@ -27,7 +27,7 @@ import type { ClearTaskInstancesBody } from 
"openapi/requests/types.gen";
 import { Dialog } from "src/system-components";
 
 import { useClearTaskInstancesDryRun } from 
"src/queries/useClearTaskInstancesDryRun";
-import { getRelativeTime } from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
 
 type Props = {
   readonly dagDetails?: {
@@ -56,6 +56,7 @@ const ClearTaskInstanceConfirmationDialog = ({
   preventRunningTask,
 }: Props) => {
   const { t: translate } = useTranslation();
+  const { formatRelative } = useDurationFormat();
   const useExplicitTaskIds = dagDetails?.taskIds !== undefined;
   const { data, isFetching } = useClearTaskInstancesDryRun({
     dagId: dagDetails?.dagId ?? "",
@@ -127,7 +128,7 @@ const ClearTaskInstanceConfirmationDialog = ({
                         state: taskCurrentState,
                         time:
                           firstInstance?.start_date !== null && 
firstInstance?.start_date !== undefined
-                            ? getRelativeTime(firstInstance.start_date)
+                            ? formatRelative(firstInstance.start_date)
                             : undefined,
                         user:
                           (firstInstance?.unixname?.trim().length ?? 0) > 0
diff --git a/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx 
b/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx
index 4e5515f5b86..59afac55a89 100644
--- a/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagRunInfo.tsx
@@ -26,8 +26,7 @@ import { Tooltip } from "src/system-components";
 import { StateBadge } from "src/components/StateBadge";
 import Time from "src/components/Time";
 
-import { getDuration } from "src/utils";
-import { getRelativeTime } from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
 
 type Props = {
   readonly endDate?: string | null;
@@ -39,6 +38,7 @@ type Props = {
 
 const DagRunInfo = ({ endDate, logicalDate, runAfter, startDate, state }: 
Props) => {
   const { t: translate } = useTranslation();
+  const { formatElapsed, formatRelative } = useDurationFormat();
 
   return (
     <Tooltip
@@ -46,7 +46,7 @@ const DagRunInfo = ({ endDate, logicalDate, runAfter, 
startDate, state }: Props)
         <VStack align="left" gap={0}>
           {state === undefined ? (
             <Text>
-              {translate("dagDetails.nextRun")}: {getRelativeTime(runAfter)}
+              {translate("dagDetails.nextRun")}: {formatRelative(runAfter)}
             </Text>
           ) : (
             <>
@@ -70,7 +70,7 @@ const DagRunInfo = ({ endDate, logicalDate, runAfter, 
startDate, state }: Props)
               )}
               {Boolean(startDate) && (
                 <Text>
-                  {translate("duration")}: {getDuration(startDate, endDate)}
+                  {translate("duration")}: {formatElapsed(startDate, endDate)}
                 </Text>
               )}
             </>
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx 
b/airflow-core/src/airflow/ui/src/components/DurationCell.tsx
similarity index 54%
copy from airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx
copy to airflow-core/src/airflow/ui/src/components/DurationCell.tsx
index 7b766264a60..881275989ad 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DurationCell.tsx
@@ -16,16 +16,21 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text, type TextProps } from "@chakra-ui/react";
-
-import { renderDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 type Props = {
-  readonly duration: number;
-} & TextProps;
+  readonly duration: number | null | undefined;
+};
+
+/**
+ * A duration in a list column: rounded for scanning, exact on hover.
+ *
+ * The rounded form loses up to half a unit ("1h 2m" spans a minute), which is 
too coarse when the
+ * point is comparing two similar runs, so the unrounded value rides along in 
the title. Columns
+ * should use this rather than formatting inline, so the pair stays consistent 
everywhere.
+ */
+export const DurationCell = ({ duration }: Props) => {
+  const { renderDuration, renderExactDuration } = useDurationFormat();
 
-export const DurationTick = ({ duration, ...rest }: Props) => (
-  <Text color="border.emphasized" fontSize="xs" position="absolute" right={1} 
whiteSpace="nowrap" {...rest}>
-    {renderDuration(duration)}
-  </Text>
-);
+  return <span 
title={renderExactDuration(duration)}>{renderDuration(duration)}</span>;
+};
diff --git a/airflow-core/src/airflow/ui/src/components/DurationChart.tsx 
b/airflow-core/src/airflow/ui/src/components/DurationChart.tsx
index 1fbc701dc2f..77053be4c6c 100644
--- a/airflow-core/src/airflow/ui/src/components/DurationChart.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DurationChart.tsx
@@ -38,12 +38,8 @@ import type { TaskInstanceResponse, GridRunsResponse } from 
"openapi/requests/ty
 
 import { useTimezone } from "src/context/timezone";
 import { getComputedCSSVariableValue } from "src/theme";
-import {
-  formatDate,
-  getDurationTickStep,
-  renderCompactDuration,
-  renderDuration,
-} from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
+import { formatDate, getDurationTickStep, getElapsedSeconds } from 
"src/utils/datetimeUtils";
 import { buildTaskInstanceUrl } from "src/utils/links";
 import { median } from "src/utils/median";
 
@@ -63,23 +59,12 @@ const CHART_HEIGHT = "280px";
 
 type RunResponse = GridRunsResponse | TaskInstanceResponse;
 
-const getDuration = (start: string, end: string | null) => {
-  const startDate = dayjs(start);
-  const endDate = end === null ? dayjs() : dayjs(end);
-
-  if (!startDate.isValid() || !endDate.isValid()) {
-    return 0;
-  }
-
-  return dayjs.duration(endDate.diff(startDate)).asSeconds();
-};
-
 const getQueuedDuration = (entry: RunResponse, kind: "Dag Run" | "Task 
Instance") => {
   if (kind === "Dag Run") {
     const run = entry as GridRunsResponse;
 
     return run.queued_at !== null && run.start_date !== null && run.queued_at 
< run.start_date
-      ? getDuration(run.queued_at, run.start_date)
+      ? (getElapsedSeconds(run.queued_at, run.start_date) ?? 0)
       : 0;
   }
 
@@ -88,7 +73,7 @@ const getQueuedDuration = (entry: RunResponse, kind: "Dag 
Run" | "Task Instance"
   return taskInstance.queued_when !== null &&
     taskInstance.start_date !== null &&
     taskInstance.queued_when < taskInstance.start_date
-    ? getDuration(taskInstance.queued_when, taskInstance.start_date)
+    ? (getElapsedSeconds(taskInstance.queued_when, taskInstance.start_date) ?? 
0)
     : 0;
 };
 
@@ -119,6 +104,7 @@ export const DurationChart = ({
   readonly kind: "Dag Run" | "Task Instance";
 }) => {
   const { t: translate } = useTranslation(["components", "common"]);
+  const { renderDuration } = useDurationFormat();
   const navigate = useNavigate();
   const { selectedTimezone } = useTimezone();
   const [queuedColorToken] = useToken("colors", ["queued.solid"]);
@@ -145,7 +131,7 @@ export const DurationChart = ({
 
   const queuedDurations = entries.map((entry) => getQueuedDuration(entry, 
kind));
   const runDurations = entries.map((entry) =>
-    entry.start_date === null ? 0 : getDuration(entry.start_date, 
entry.end_date),
+    entry.start_date === null ? 0 : (getElapsedSeconds(entry.start_date, 
entry.end_date) ?? 0),
   );
   // Bars stack queued under run, so the reference line tracks the same total 
the
   // reader sees at the top of each bar.
@@ -158,7 +144,7 @@ export const DurationChart = ({
     borderWidth: 1,
     label: {
       content: translate("durationChart.medianTotalDuration", {
-        duration: renderCompactDuration(medianTotal),
+        duration: renderDuration(medianTotal) ?? "0s",
       }),
       display: true,
       position: "start",
@@ -256,7 +242,7 @@ export const DurationChart = ({
                   label: (context) => {
                     const datasetLabel = context.dataset.label ?? "";
 
-                    const formatted = renderDuration(context.parsed.y, false) 
?? "0";
+                    const formatted = renderDuration(context.parsed.y) ?? "0s";
 
                     return datasetLabel ? `${datasetLabel}: ${formatted}` : 
formatted;
                   },
@@ -279,7 +265,7 @@ export const DurationChart = ({
                 stacked: true,
                 ticks: {
                   callback: (value) =>
-                    renderCompactDuration(typeof value === "number" ? value : 
Number(value)),
+                    renderDuration(typeof value === "number" ? value : 
Number(value)) ?? "0s",
                   stepSize: getDurationTickStep(Math.max(...totalDurations, 
0)),
                 },
                 title: { align: "end", display: true, text: 
translate("common:duration") },
diff --git 
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
 
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
index 2a9eeb6763b..1ffe940cbe0 100644
--- 
a/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/HITLReview/HITLReviewDetailSummary.tsx
@@ -27,7 +27,7 @@ import { RouterLink } from "src/system-components";
 
 import Time from "src/components/Time.tsx";
 
-import { getRelativeTime } from "src/utils/datetimeUtils.ts";
+import { useDurationFormat } from "src/utils";
 import { getTaskInstanceLink } from "src/utils/links.ts";
 
 const HITLReviewRow = ({ label, value }: { readonly label: string; readonly 
value: ReactNode }) => (
@@ -45,6 +45,7 @@ export const HITLReviewDetailSummary = ({
   readonly onOpenTask: () => void;
 }) => {
   const { t: translate } = useTranslation(["hitl", "common"]);
+  const { formatRelative } = useDurationFormat();
   const ti = detail.task_instance;
   const mappedIndex = ti.rendered_map_index ?? (ti.map_index >= 0 ? 
ti.map_index : undefined);
 
@@ -67,7 +68,7 @@ export const HITLReviewDetailSummary = ({
           value={
             <Text>
               <Time datetime={detail.created_at} />
-              {` (${getRelativeTime(detail.created_at)})`}
+              {` (${formatRelative(detail.created_at)})`}
             </Text>
           }
         />
diff --git 
a/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx 
b/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx
index 3c734beb4e0..79ca4d9e695 100644
--- a/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SlowestTaskInstancesChart.tsx
@@ -25,16 +25,31 @@ import type { TaskInstanceResponse } from 
"openapi/requests/types.gen";
 
 import { useTimezone } from "src/context/timezone";
 import { getComputedCSSVariableValue } from "src/theme";
-import {
-  formatDate,
-  getDurationTickStep,
-  renderCompactDuration,
-  renderDuration,
-} from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
+import { formatDate, getDurationTickStep } from "src/utils/datetimeUtils";
 
 ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip);
 
 const CHART_HEIGHT = "340px";
+const BAR_END_LABEL_FONT = "11px system-ui, sans-serif";
+const BAR_END_LABEL_GUTTER_PX = 16;
+const BAR_END_LABEL_FALLBACK_RESERVE_PX = 96;
+
+const measureContext = document.createElement("canvas").getContext("2d");
+
+// The old fixed 64px reserve was sized for the always-English "1h 2m". CLDR 
narrow is far wider in
+// several shipped locales (German "1 Std., 2 Min." is roughly 80px), so 
measure what is drawn.
+const measureBarEndLabelReserve = (labels: Array<string>): number => {
+  if (measureContext === null) {
+    return BAR_END_LABEL_FALLBACK_RESERVE_PX;
+  }
+
+  measureContext.font = BAR_END_LABEL_FONT;
+
+  const widest = labels.reduce((max, label) => Math.max(max, 
measureContext.measureText(label).width), 0);
+
+  return Math.ceil(widest) + BAR_END_LABEL_GUTTER_PX;
+};
 const RUN_LABEL_FORMAT = "MMM DD HH:mm";
 
 export const SlowestTaskInstancesChart = ({
@@ -43,6 +58,7 @@ export const SlowestTaskInstancesChart = ({
   readonly taskInstances: Array<TaskInstanceResponse>;
 }) => {
   const { t: translate } = useTranslation(["components", "common"]);
+  const { renderDuration } = useDurationFormat();
   const { selectedTimezone } = useTimezone();
   const [labelColorToken, fallbackColorToken] = useToken("colors", 
["fg.muted", "gray.solid"]);
 
@@ -77,20 +93,24 @@ export const SlowestTaskInstancesChart = ({
   const durations = taskInstances.map((taskInstance) => taskInstance.duration 
?? 0);
   const maxDuration = Math.max(...durations, 0);
 
+  const barEndLabelReserve = measureBarEndLabelReserve(
+    durations.map((duration) => renderDuration(duration) ?? "0s"),
+  );
+
   const barEndLabels: Plugin<"bar"> = {
     afterDatasetsDraw: (chart) => {
       const { ctx } = chart;
       const meta = chart.getDatasetMeta(0);
 
       ctx.save();
-      ctx.font = "11px system-ui, sans-serif";
+      ctx.font = BAR_END_LABEL_FONT;
       ctx.fillStyle = getComputedCSSVariableValue(labelColorToken ?? 
"oklch(0.5 0 0)");
       ctx.textBaseline = "middle";
       meta.data.forEach((bar, index) => {
         const duration = durations[index];
 
         if (duration !== undefined) {
-          ctx.fillText(renderCompactDuration(duration), bar.x + 6, bar.y);
+          ctx.fillText(renderDuration(duration) ?? "0s", bar.x + 6, bar.y);
         }
       });
       ctx.restore();
@@ -123,13 +143,13 @@ export const SlowestTaskInstancesChart = ({
           }}
           options={{
             indexAxis: "y",
-            layout: { padding: { right: 64 } },
+            layout: { padding: { right: barEndLabelReserve } },
             maintainAspectRatio: false,
             plugins: {
               legend: { display: false },
               tooltip: {
                 callbacks: {
-                  label: (context) => renderDuration(context.parsed.x, false) 
?? "0",
+                  label: (context) => renderDuration(context.parsed.x) ?? "0s",
                   title: ([context]) => {
                     const taskInstance = context === undefined ? undefined : 
taskInstances[context.dataIndex];
 
@@ -146,7 +166,7 @@ export const SlowestTaskInstancesChart = ({
                 beginAtZero: true,
                 ticks: {
                   callback: (value) =>
-                    renderCompactDuration(typeof value === "number" ? value : 
Number(value)),
+                    renderDuration(typeof value === "number" ? value : 
Number(value)) ?? "0s",
                   stepSize: getDurationTickStep(maxDuration),
                 },
                 title: { align: "end", display: true, text: 
translate("common:duration") },
diff --git 
a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx 
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
index ac473837454..423af4809f8 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.test.tsx
@@ -112,10 +112,9 @@ describe("TaskInstanceTooltip", () => {
 
     const durationText = 
screen.getByText(/duration/iu).parentElement?.textContent;
 
-    // The calculated duration should be around 2 hours (e.g., "02:00:00" or 
similar)
-    // It should definitely NOT be "00:00:50.000" which is what 
`renderDuration(50)` gives
-    expect(durationText).not.toContain("00:00:50");
-    expect(durationText).toContain("02:00:");
+    // The live start_date is two hours old; the stale `duration: 50` field 
must not win.
+    expect(durationText).not.toContain("50s");
+    expect(durationText).toContain("2h");
   });
 
   it("shows only start date when max_end_date is null", () => {
diff --git a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx 
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
index 81a9ad43e76..059f168ae39 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -29,7 +29,7 @@ import { Tooltip, type TooltipProps } from 
"src/system-components";
 
 import Time from "src/components/Time";
 
-import { getDuration, renderDuration, sortStateEntries } from "src/utils";
+import { sortStateEntries, useDurationFormat } from "src/utils";
 
 /** Grid summary plus optional schedule/queue hints (e.g. Gantt segment 
tooltips). */
 type LightGridTaskInstanceSummaryWithWhen = {
@@ -46,6 +46,7 @@ type Props = {
 
 const TaskInstanceTooltip = ({ children, positioning, runId, taskInstance, 
tooltip, ...rest }: Props) => {
   const { t: translate } = useTranslation();
+  const { formatElapsed, renderDuration } = useDurationFormat();
 
   const hasTooltip = tooltip !== undefined && tooltip !== null;
 
@@ -108,7 +109,7 @@ const TaskInstanceTooltip = ({ children, positioning, 
runId, taskInstance, toolt
                   <Text>
                     {translate("duration")}:{" "}
                     {taskInstance.state === "running" || taskInstance.state 
=== "deferred"
-                      ? getDuration(
+                      ? formatElapsed(
                           taskInstance.start_date,
                           taskInstance.end_date ?? new Date().toISOString(),
                         )
@@ -128,7 +129,7 @@ const TaskInstanceTooltip = ({ children, positioning, 
runId, taskInstance, toolt
                       </Text>
                       <Text>
                         {translate("duration")}:{" "}
-                        {getDuration(taskInstance.min_start_date, 
taskInstance.max_end_date, false)}
+                        {formatElapsed(taskInstance.min_start_date, 
taskInstance.max_end_date)}
                       </Text>
                     </>
                   )}
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
index 5008f825457..977322eeb38 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
@@ -37,6 +37,8 @@ import type { GridTask } from 
"src/layouts/Details/Grid/utils";
 import { StateIcon } from "src/components/StateIcon";
 import TaskInstanceTooltip from "src/components/TaskInstanceTooltip";
 
+import { useDurationFormat } from "src/utils";
+
 import {
   type GanttDataItem,
   GANTT_TIME_AXIS_TICK_COUNT,
@@ -56,7 +58,9 @@ const MIN_BAR_WIDTH_PX = GANTT_STATE_ICON_SIZE_PX;
 const MIN_SEGMENT_RENDER_PX = 5;
 
 /** Minimum horizontal gap (px) between time-axis labels before one is 
dropped. */
-const MIN_TICK_SPACING_PX = 80;
+// Sized for the widest CLDR narrow label, not the old "HH:MM:SS": German runs 
~14 chars
+// ("1 Std., 2 Min."), roughly 84px at font-size xs, so 80px let ticks collide 
in wider locales.
+const MIN_TICK_SPACING_PX = 110;
 
 /** Short mark above the axis bottom border, aligned with each timestamp. */
 const GANTT_AXIS_TICK_HEIGHT_PX = 6;
@@ -115,6 +119,7 @@ export const GanttTimeline = ({
   scrollContainerRef,
   virtualizerScrollPaddingStart,
 }: Props) => {
+  const { locale } = useDurationFormat();
   const location = useLocation();
   const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
   const [bodyWidthPx, setBodyWidthPx] = useState(0);
@@ -148,10 +153,10 @@ export const GanttTimeline = ({
   const spanMs = Math.max(1, maxMs - minMs);
 
   // Derive tick count from available width so labels never overlap.
-  // Each "HH:MM:SS" label is ~8 chars at font-size xs; allow 
MIN_TICK_SPACING_PX per tick.
+  // Allow MIN_TICK_SPACING_PX per tick so labels never overlap.
   const tickCount =
     bodyWidthPx > 0 ? Math.max(2, Math.floor(bodyWidthPx / 
MIN_TICK_SPACING_PX)) : GANTT_TIME_AXIS_TICK_COUNT;
-  const timeTicks = buildGanttTimeAxisTicks(minMs, maxMs, tickCount);
+  const timeTicks = buildGanttTimeAxisTicks(minMs, maxMs, { locale, tickCount 
});
 
   const rowVirtualizer = useVirtualizer({
     count: flatNodes.length,
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
index 0f8fc131819..c4cc2a38e7e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
@@ -41,22 +41,39 @@ describe("buildGanttTimeAxisTicks", () => {
 
     expect(ticks).toHaveLength(GANTT_TIME_AXIS_TICK_COUNT);
     expect(ticks[0]?.leftPct).toBe(0);
-    expect(ticks[0]?.label).toBe("00:00:00");
+    expect(ticks[0]?.label).toBe("0s");
     expect(ticks[0]?.labelAlign).toBe("left");
     expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.leftPct).toBe(100);
     expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.labelAlign).toBe("right");
-    expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.label).toBe("00:01:00");
+    expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.label).toBe("1m");
     expect(ticks[1]?.labelAlign).toBe("center");
     expect(ticks.every((tick) => typeof tick.label === "string" && 
tick.label.length > 0)).toBe(true);
   });
 
+  it("localizes tick labels with the given locale", () => {
+    const ticks = buildGanttTimeAxisTicks(0, 60_000, { locale: "de", 
tickCount: 2 });
+    const inEnglish = buildGanttTimeAxisTicks(0, 60_000, { locale: "en", 
tickCount: 2 });
+
+    expect(ticks.at(-1)?.label).toBe(
+      new Intl.NumberFormat("de", { style: "unit", unit: "minute", 
unitDisplay: "narrow" }).format(1),
+    );
+    expect(ticks.at(-1)?.label).not.toBe(inEnglish.at(-1)?.label);
+  });
+
+  it("snaps ticks to counted units rather than dividing the raw span", () => {
+    // A 7s span divided evenly used to read 0s | 1.17s | 2.33s | 3.5s ...
+    const labels = buildGanttTimeAxisTicks(0, 7000, { locale: "en", tickCount: 
8 }).map((tick) => tick.label);
+
+    expect(labels).toStrictEqual(["0s", "1s", "2s", "3s", "4s", "5s", "6s", 
"7s"]);
+  });
+
   it("supports a single tick", () => {
-    const ticks = buildGanttTimeAxisTicks(1000, 1000, 1);
+    const ticks = buildGanttTimeAxisTicks(1000, 1000, { tickCount: 1 });
 
     expect(ticks).toHaveLength(1);
     expect(ticks[0]?.leftPct).toBe(0);
     expect(ticks[0]?.labelAlign).toBe("left");
-    expect(ticks[0]?.label).toBe("00:00:00");
+    expect(ticks[0]?.label).toBe("0s");
   });
 });
 
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
index e090c498c12..8db8511368e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
@@ -26,7 +26,7 @@ import type { GridTask } from 
"src/layouts/Details/Grid/utils";
 
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { isStatePending } from "src/utils";
-import { renderDuration } from "src/utils/datetimeUtils";
+import { getDurationTickStep, renderDuration } from "src/utils/datetimeUtils";
 import { buildTaskInstanceUrl } from "src/utils/links";
 
 export type GanttDataItem = {
@@ -352,35 +352,36 @@ export type GanttAxisTick = {
 };
 
 /** Elapsed time from the chart origin (`minMs`), formatted like grid duration 
labels (no wall-clock). */
-const formatElapsedMsForGanttAxis = (elapsedMs: number): string => {
+const formatElapsedMsForGanttAxis = (elapsedMs: number, locale?: string): 
string => {
   const seconds = Math.max(0, elapsedMs / 1000);
 
-  if (seconds <= 0.01) {
-    return "00:00:00";
-  }
-
-  return renderDuration(seconds, false) ?? "00:00:00";
+  return renderDuration(seconds, locale) ?? "0s";
 };
 
 export const buildGanttTimeAxisTicks = (
   minMs: number,
   maxMs: number,
-  tickCount: number = GANTT_TIME_AXIS_TICK_COUNT,
+  { locale, tickCount = GANTT_TIME_AXIS_TICK_COUNT }: { locale?: string; 
tickCount?: number } = {},
 ): Array<GanttAxisTick> => {
   const spanMs = Math.max(1, maxMs - minMs);
   const denominator = Math.max(1, tickCount - 1);
   const lastIndex = tickCount - 1;
   const ticks: Array<GanttAxisTick> = [];
+  // Snap to the units people count in, the way the Chart.js duration axes do. 
Evenly dividing the
+  // raw span reads fine at truncated whole seconds but not at this precision: 
a 7s span became
+  // "0s | 1.17s | 2.33s | 3.5s". The last tick keeps the true span so the 
axis still ends at it.
+  const stepMs = getDurationTickStep(spanMs / 1000, denominator) * 1000;
 
   for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
-    const elapsedMs = (tickIndex / denominator) * spanMs;
+    const elapsedMs =
+      tickCount > 1 && tickIndex === lastIndex ? spanMs : Math.min(tickIndex * 
stepMs, spanMs);
     const labelAlign: GanttAxisTickLabelAlign =
       tickCount === 1 ? "left" : tickIndex === 0 ? "left" : tickIndex === 
lastIndex ? "right" : "center";
 
     ticks.push({
-      label: formatElapsedMsForGanttAxis(elapsedMs),
+      label: formatElapsedMsForGanttAxis(elapsedMs, locale),
       labelAlign,
-      leftPct: (tickIndex / denominator) * 100,
+      leftPct: (elapsedMs / spanMs) * 100,
     });
   }
 
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx
index 7b766264a60..9061539ef95 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationTick.tsx
@@ -18,14 +18,18 @@
  */
 import { Text, type TextProps } from "@chakra-ui/react";
 
-import { renderDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 type Props = {
   readonly duration: number;
 } & TextProps;
 
-export const DurationTick = ({ duration, ...rest }: Props) => (
-  <Text color="border.emphasized" fontSize="xs" position="absolute" right={1} 
whiteSpace="nowrap" {...rest}>
-    {renderDuration(duration)}
-  </Text>
-);
+export const DurationTick = ({ duration, ...rest }: Props) => {
+  const { renderDuration } = useDurationFormat();
+
+  return (
+    <Text color="border.emphasized" fontSize="xs" position="absolute" 
right={1} whiteSpace="nowrap" {...rest}>
+      {renderDuration(duration)}
+    </Text>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
index e5103df00c3..f4bdfacd609 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
@@ -54,7 +54,7 @@ describe("GridButton", () => {
     expect(screen.getByTestId("basic-tooltip")).toHaveTextContent(
       "common:runId: manual__2026-04-21T00:00:00+00:00",
     );
-    expect(screen.getByTestId("basic-tooltip")).toHaveTextContent("duration: 
01:01:01");
+    expect(screen.getByTestId("basic-tooltip")).toHaveTextContent("duration: 
1h 1m");
 
     vi.useRealTimers();
   });
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
index b55681905d4..0a15583dd36 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
@@ -25,7 +25,7 @@ import type { DagRunState, TaskInstanceState } from 
"openapi/requests/types.gen"
 import { BasicTooltip } from "src/components/BasicTooltip";
 import Time from "src/components/Time";
 
-import { renderDuration } from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
 
 type Props = {
   readonly dagId: string;
@@ -51,6 +51,7 @@ export const GridButton = ({
   ...rest
 }: Props) => {
   const { t: translate } = useTranslation();
+  const { renderDuration } = useDurationFormat();
 
   const tooltipContent = (
     <>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
index 66964abd02e..ef8ab29cbf4 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
@@ -31,7 +31,7 @@ import { ErrorAlert } from "src/components/ErrorAlert";
 import Time from "src/components/Time";
 
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
-import { getDuration } from "src/utils";
+import { type DurationFormat, useDurationFormat } from "src/utils";
 
 import { BackfillDagRunsModal } from "./BackfillDagRunsModal";
 import { BackfillsFilters } from "./BackfillsFilters";
@@ -59,10 +59,16 @@ const REPROCESS_BEHAVIOR_VALUES = [
 const isReprocessBehavior = (value: string | null): value is ReprocessBehavior 
=>
   (REPROCESS_BEHAVIOR_VALUES as ReadonlyArray<string | null>).includes(value);
 
-const getColumns = (
-  onSelectBackfill: (backfillId: number) => void,
-  translate: TFunction,
-): Array<ColumnDef<BackfillResponse>> => [
+type ColumnProps = {
+  readonly onSelectBackfill: (backfillId: number) => void;
+  readonly translate: TFunction;
+} & Pick<DurationFormat, "formatElapsed">;
+
+const getColumns = ({
+  formatElapsed,
+  onSelectBackfill,
+  translate,
+}: ColumnProps): Array<ColumnDef<BackfillResponse>> => [
   {
     accessorKey: "date_from",
     cell: ({ row }) => (
@@ -129,7 +135,7 @@ const getColumns = (
       <Text>
         {row.original.completed_at === null
           ? ""
-          : getDuration(row.original.created_at, row.original.completed_at)}
+          : formatElapsed(row.original.created_at, row.original.completed_at)}
       </Text>
     ),
     enableSorting: false,
@@ -144,6 +150,7 @@ const getColumns = (
 
 export const Backfills = () => {
   const { t: translate } = useTranslation();
+  const { formatElapsed } = useDurationFormat();
   const { setTableURLState, tableURLState } = useTableURLState();
   const location = useLocation();
   const navigate = useNavigate();
@@ -207,7 +214,7 @@ export const Backfills = () => {
       ),
     );
   };
-  const columns = getColumns(onSelectBackfill, translate);
+  const columns = getColumns({ formatElapsed, onSelectBackfill, translate });
 
   return (
     <>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
index 9691405698d..ca274462779 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
@@ -44,7 +44,7 @@ import { SHORTCUTS } from "src/context/keyboardShortcuts";
 import useSelectedVersion from "src/hooks/useSelectedVersion";
 import { useShortcut } from "src/hooks/useShortcut";
 import { useConfig } from "src/queries/useConfig";
-import { renderDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 import { CodeDiffViewer } from "./CodeDiffViewer";
 import { FileLocation } from "./FileLocation";
@@ -52,6 +52,7 @@ import { VersionCompareSelect } from "./VersionCompareSelect";
 
 export const Code = () => {
   const { t: translate } = useTranslation(["dag", "common", "components"]);
+  const { renderDuration } = useDurationFormat();
   const { dagId } = useParams();
 
   const selectedVersion = useSelectedVersion();
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
index 76999a2201e..d787b26c145 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.test.tsx
@@ -55,7 +55,7 @@ vi.mock("openapi/queries", async (importOriginal) => {
 const { useDeadlinesServiceGetDagDeadlineAlerts } = await 
import("openapi/queries");
 
 // Defaults to a VariableInterval alert, whose interval only the scheduler 
resolves at evaluation
-// time. Without a rule of its own, dayjs humanizes that null interval as "a 
few seconds" and the
+// time. Without a rule of its own, a null interval would be named as some 
concrete length and the
 // popover claims the run must complete within a few seconds of its logical 
date.
 const baseAlert: DeadlineAlertResponse = {
   created_at: "2025-01-01T00:00:00Z",
@@ -67,7 +67,7 @@ const baseAlert: DeadlineAlertResponse = {
 
 const REFERENCE = "deadlineAlerts.referenceType.DagRunLogicalDateDeadline";
 const DYNAMIC_RULE = `deadlineAlerts.completionRuleDynamic:${REFERENCE}`;
-const FIXED_RULE = `deadlineAlerts.completionRule:an hour:${REFERENCE}`;
+const FIXED_RULE = `deadlineAlerts.completionRule:1 hour:${REFERENCE}`;
 
 describe("DeadlineAlertsBadge", () => {
   it.each([
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx
index 9cbb8533d9e..b58df33f13b 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/DeadlineAlertsBadge.tsx
@@ -25,15 +25,17 @@ import type { DeadlineAlertResponse } from 
"openapi/requests/types.gen";
 
 import { Popover } from "src/system-components";
 
+import { useDurationFormat } from "src/utils";
 import { translateCompletionRule } from "src/utils/deadlines";
 
 const AlertRow = ({ alert }: { readonly alert: DeadlineAlertResponse }) => {
   const { t: translate } = useTranslation("dag");
+  const { locale } = useDurationFormat();
 
   return (
     <Box py={2} width="100%">
       <Text color="fg.muted" fontSize="xs">
-        {translateCompletionRule(translate, alert)}
+        {translateCompletionRule(translate, alert, locale)}
         {Boolean(alert.name) && (
           <Text as="span" color="fg.subtle" fontSize="xs">
             {" "}
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
index ef47bae842f..3c7ac58c0bb 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Details.tsx
@@ -30,10 +30,11 @@ import { TeamName } from "src/components/TeamName";
 import Time from "src/components/Time";
 
 import { useShowTeam } from "src/hooks/useShowTeam";
-import { renderDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 export const Details = () => {
   const { t: translate } = useTranslation(["common", "dag"]);
+  const { renderDuration } = useDurationFormat();
   const { dagId = "" } = useParams();
 
   const { data: dag } = useDagServiceGetDagDetails({
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx
index 7014d4a2224..e5eb70c647b 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/DeadlineRow.tsx
@@ -26,6 +26,7 @@ import { RouterLink } from "src/system-components";
 
 import Time from "src/components/Time";
 
+import { useDurationFormat } from "src/utils";
 import { translateCompletionRule } from "src/utils/deadlines";
 
 type DeadlineRowProps = {
@@ -35,8 +36,9 @@ type DeadlineRowProps = {
 
 export const DeadlineRow = ({ alert, deadline }: DeadlineRowProps) => {
   const { t: translate } = useTranslation("dag");
+  const { locale } = useDurationFormat();
 
-  const completionRule = translateCompletionRule(translate, alert);
+  const completionRule = translateCompletionRule(translate, alert, locale);
 
   return (
     <HStack justifyContent="space-between" px={2} py={1.5} width="100%">
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx
index 297bc73ad23..72593e873a9 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx
@@ -67,7 +67,11 @@ vi.mock("src/pages/ReactPlugin", () => ({
   ReactPlugin: ({ reactApp }: { readonly reactApp: ReactAppResponse }) => 
<div>{reactApp.name}</div>,
 }));
 vi.mock("src/queries/useGridRuns.ts", () => ({ useGridRuns: () => ({ data: [], 
isLoading: false }) }));
-vi.mock("src/utils", () => ({ isStatePending: () => false, useAutoRefresh: () 
=> false }));
+vi.mock("src/utils", () => ({
+  isStatePending: () => false,
+  useAutoRefresh: () => false,
+  useDurationFormat: () => ({ locale: "en" }),
+}));
 vi.mock("./DagDeadlines", () => ({ DagDeadlines: () => null }));
 vi.mock("./FailedLogs", () => ({ default: () => null }));
 
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index c745f24c496..b7fb5c347fe 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -38,6 +38,7 @@ import {
   type GetColumnsParams,
 } from "src/components/DataTable/useRowSelection";
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { DurationCell } from "src/components/DurationCell";
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ExpandCollapseButtons } from "src/components/ExpandCollapseButtons";
 import { LimitedItemsList } from "src/components/LimitedItemsList";
@@ -52,7 +53,7 @@ import { TruncatedText } from "src/components/TruncatedText";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
 import { useConfig } from "src/queries/useConfig";
-import { renderDuration, useAutoRefresh, isStatePending, useDocumentTitle } 
from "src/utils";
+import { useAutoRefresh, isStatePending, useDocumentTitle } from "src/utils";
 
 import BulkClearDagRunsButton from "./BulkClearDagRunsButton";
 import BulkDeleteDagRunsButton from "./BulkDeleteDagRunsButton";
@@ -187,7 +188,7 @@ const runColumns = ({ dagId, multiTeam, open, translate }: 
ColumnProps): Array<C
   },
   {
     accessorKey: "duration",
-    cell: ({ row: { original } }) => renderDuration(original.duration),
+    cell: ({ row: { original } }) => <DurationCell 
duration={original.duration} />,
     header: translate("duration"),
   },
   {
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
index 954c7e1b684..1da938fb28c 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
@@ -27,7 +27,7 @@ import type { DAGWithLatestDagRunsResponse } from 
"openapi/requests/types.gen";
 import { StateIcon } from "src/components/StateIcon";
 import Time from "src/components/Time";
 
-import { renderDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 dayjs.extend(duration);
 
@@ -37,6 +37,7 @@ type LatestRun = 
DAGWithLatestDagRunsResponse["latest_dag_runs"][number];
 
 const RecentRunTooltipContent = ({ run }: { readonly run: LatestRun }) => {
   const { t: translate } = useTranslation();
+  const { renderDuration } = useDurationFormat();
 
   return (
     <Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
index 8eb644fbccd..8f2c7d8fac2 100644
--- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
@@ -28,10 +28,11 @@ import { HeaderCard } from "src/components/HeaderCard";
 import { MarkTaskGroupAsButton } from "src/components/MarkAs";
 import Time from "src/components/Time";
 
-import { getDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 export const Header = ({ taskInstance }: { readonly taskInstance: 
LightGridTaskInstanceSummary }) => {
   const { t: translate } = useTranslation();
+  const { formatElapsed } = useDurationFormat();
   const entries: Array<{ label: string; value: number | ReactNode | string }> 
= [];
 
   Object.entries(taskInstance.child_states ?? {}).forEach(([state, count]) => {
@@ -48,7 +49,7 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
       ? [
           {
             label: translate("duration"),
-            value: getDuration(taskInstance.min_start_date, 
taskInstance.max_end_date),
+            value: formatElapsed(taskInstance.min_start_date, 
taskInstance.max_end_date),
           },
         ]
       : []),
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx
index 94bdb8f1a9b..783179291f5 100644
--- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx
@@ -26,11 +26,12 @@ import type { LightGridTaskInstanceSummary } from 
"openapi/requests/types.gen";
 import { StateBadge } from "src/components/StateBadge";
 import Time from "src/components/Time";
 
-import { getDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 export const Details = () => {
   const { dagId = "", taskId = "" } = useParams();
   const { t: translate } = useTranslation();
+  const { formatElapsed } = useDurationFormat();
 
   // The aggregate summary (per-state counts, dates) is streamed once by the 
parent page and
   // shared through the router outlet, so this tab does not re-open the TI 
summaries stream.
@@ -129,7 +130,7 @@ export const Details = () => {
           </Table.Row>
           <Table.Row>
             <Table.Cell>{translate("duration")}</Table.Cell>
-            <Table.Cell>{getDuration(taskInstance?.min_start_date, 
taskInstance?.max_end_date)}</Table.Cell>
+            <Table.Cell>{formatElapsed(taskInstance?.min_start_date, 
taskInstance?.max_end_date)}</Table.Cell>
           </Table.Row>
           <Table.Row>
             <Table.Cell>{translate("taskInstance.dagVersion")}</Table.Cell>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
index f755f3a51eb..7de01299c8a 100644
--- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
@@ -29,11 +29,12 @@ import { ClearTaskInstanceButton } from 
"src/components/Clear";
 import { HeaderCard } from "src/components/HeaderCard";
 import Time from "src/components/Time";
 
-import { getDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 export const Header = ({ taskInstance }: { readonly taskInstance: 
LightGridTaskInstanceSummary }) => {
   const { dagId = "", runId = "" } = useParams();
   const { t: translate } = useTranslation();
+  const { formatElapsed } = useDurationFormat();
   const entries: Array<{ key?: string; label: string; value: number | 
ReactNode | string }> = [];
   let taskCount: number = 0;
 
@@ -65,7 +66,7 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
       ? [
           {
             label: translate("duration"),
-            value: getDuration(taskInstance.min_start_date, 
taskInstance.max_end_date),
+            value: formatElapsed(taskInstance.min_start_date, 
taskInstance.max_end_date),
           },
         ]
       : []),
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx
index eecfcd3cfae..f136910c1fa 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatus.tsx
@@ -30,7 +30,7 @@ import { Tooltip } from "src/system-components";
 
 import Time from "src/components/Time";
 
-import { renderDuration } from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
 import { translateCompletionRule } from "src/utils/deadlines";
 
 import { DeadlineStatusModal } from "./DeadlineStatusModal";
@@ -43,6 +43,7 @@ type DeadlineStatusProps = {
 
 export const DeadlineStatus = ({ dagId, dagRunId, endDate }: 
DeadlineStatusProps) => {
   const { t: translate } = useTranslation("dag");
+  const { locale, renderDuration } = useDurationFormat();
   const [isModalOpen, setIsModalOpen] = useState(false);
 
   const { data: deadlineData, isLoading: isLoadingDeadlines } = 
useDeadlinesServiceGetDeadlines({
@@ -84,7 +85,7 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate }: 
DeadlineStatusProps
       <VStack alignItems="flex-start" gap={0.5}>
         {(alertData?.deadline_alerts ?? []).map((deadlineAlert) => (
           <Text fontSize="xs" key={deadlineAlert.id}>
-            {translateCompletionRule(translate, deadlineAlert)}
+            {translateCompletionRule(translate, deadlineAlert, locale)}
           </Text>
         ))}
       </VStack>
@@ -151,14 +152,14 @@ export const DeadlineStatus = ({ dagId, dagRunId, endDate 
}: DeadlineStatusProps
   }
 
   const alert = dl.alert_id !== undefined && dl.alert_id !== null ? 
alertMap.get(dl.alert_id) : undefined;
-  const completionRule = translateCompletionRule(translate, alert);
+  const completionRule = translateCompletionRule(translate, alert, locale);
   const deadlineTime = dayjs(dl.deadline_time);
 
   let actualDurationLabel: string | undefined;
 
   if (dl.missed && runEndDate !== undefined) {
     const diff = dayjs(runEndDate).diff(deadlineTime);
-    const dur = renderDuration(Math.abs(diff) / 1000, false);
+    const dur = renderDuration(Math.abs(diff) / 1000);
 
     if (dur !== undefined) {
       actualDurationLabel =
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx
index cc811b1db84..559aed7a634 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/DeadlineStatusModal.tsx
@@ -31,7 +31,7 @@ import { Modal, Pagination } from "src/system-components";
 import { ErrorAlert } from "src/components/ErrorAlert";
 import Time from "src/components/Time";
 
-import { renderDuration } from "src/utils/datetimeUtils";
+import { useDurationFormat } from "src/utils";
 import { translateCompletionRule } from "src/utils/deadlines";
 
 const PAGE_LIMIT = 10;
@@ -54,6 +54,7 @@ export const DeadlineStatusModal = ({
   runEndDate,
 }: DeadlineStatusModalProps) => {
   const { t: translate } = useTranslation("dag");
+  const { locale, renderDuration } = useDurationFormat();
   const [page, setPage] = useState(1);
   const offset = (page - 1) * PAGE_LIMIT;
 
@@ -117,14 +118,14 @@ export const DeadlineStatusModal = ({
           {deadlines.map((dl) => {
             const alert =
               dl.alert_id !== undefined && dl.alert_id !== null ? 
alertMap.get(dl.alert_id) : undefined;
-            const completionRule = translateCompletionRule(translate, alert);
+            const completionRule = translateCompletionRule(translate, alert, 
locale);
             const deadlineTime = dayjs(dl.deadline_time);
 
             let actualDurationLabel: string | undefined;
 
             if (dl.missed && runEndDate !== undefined) {
               const diff = dayjs(runEndDate).diff(deadlineTime);
-              const dur = renderDuration(Math.abs(diff) / 1000, false);
+              const dur = renderDuration(Math.abs(diff) / 1000);
 
               if (dur !== undefined) {
                 actualDurationLabel =
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
index 49136036500..4151d543c1d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Details.tsx
@@ -32,10 +32,11 @@ import { TeamName } from "src/components/TeamName";
 import Time from "src/components/Time";
 
 import { useShowTeam } from "src/hooks/useShowTeam";
-import { getDuration, isStatePending, renderDuration, useAutoRefresh } from 
"src/utils";
+import { isStatePending, useAutoRefresh, useDurationFormat } from "src/utils";
 
 export const Details = () => {
   const { t: translate } = useTranslation(["common", "components"]);
+  const { formatElapsed, renderDuration } = useDurationFormat();
   const { dagId = "", runId = "" } = useParams();
 
   const refetchInterval = useAutoRefresh({ dagId });
@@ -99,7 +100,7 @@ export const Details = () => {
         ) : undefined}
         <Table.Row>
           <Table.Cell>{translate("duration")}</Table.Cell>
-          <Table.Cell>{getDuration(dagRun.start_date, 
dagRun.end_date)}</Table.Cell>
+          <Table.Cell>{formatElapsed(dagRun.start_date, 
dagRun.end_date)}</Table.Cell>
         </Table.Row>
         {dagRunStats?.duration ? (
           <Table.Row>
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index 8fce25a6432..6930f556cce 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -41,12 +41,13 @@ import Time from "src/components/Time";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { useShowTeam } from "src/hooks/useShowTeam";
 import { useDagRunNote } from "src/queries/useDagRunNote";
-import { getDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 import { DeadlineStatus } from "./DeadlineStatus";
 
 export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
   const { t: translate } = useTranslation();
+  const { formatElapsed } = useDurationFormat();
   const { isPending, note, onOpen, onSave, setNote } = useDagRunNote(dagRun);
   const showTeam = useShowTeam(dagRun.team_name);
 
@@ -97,7 +98,7 @@ export const Header = ({ dagRun }: { readonly dagRun: 
DAGRunResponse }) => {
           },
           { label: translate("startDate"), value: <Time 
datetime={dagRun.start_date} /> },
           { label: translate("endDate"), value: <Time 
datetime={dagRun.end_date} /> },
-          { label: translate("duration"), value: 
getDuration(dagRun.start_date, dagRun.end_date) },
+          { label: translate("duration"), value: 
formatElapsed(dagRun.start_date, dagRun.end_date) },
           ...(dagRun.triggering_user_name === null
             ? []
             : [
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
index 5b1ccc5eafa..017e24c54c7 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
@@ -38,7 +38,7 @@ import Time from "src/components/Time";
 
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { useShowTeam } from "src/hooks/useShowTeam";
-import { useAutoRefresh, isStatePending, renderDuration } from "src/utils";
+import { isStatePending, useAutoRefresh, useDurationFormat } from "src/utils";
 
 import { BlockingDeps } from "./BlockingDeps";
 import { ExtraLinks } from "./ExtraLinks";
@@ -46,6 +46,7 @@ import { TriggererInfo } from "./TriggererInfo";
 
 export const Details = () => {
   const { t: translate } = useTranslation();
+  const { renderDuration } = useDurationFormat();
   const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
   const [searchParams, setSearchParams] = useSearchParams();
 
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
index d217db2e9a5..850c3b42e30 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -35,10 +35,11 @@ import Time from "src/components/Time";
 
 import { useShowTeam } from "src/hooks/useShowTeam";
 import { useTaskInstanceNote } from "src/queries/useTaskInstanceNote";
-import { getDuration, renderDuration } from "src/utils";
+import { useDurationFormat } from "src/utils";
 
 export const Header = ({ taskInstance }: { readonly taskInstance: 
TaskInstanceResponse }) => {
   const { t: translate } = useTranslation();
+  const { formatElapsed, renderDuration } = useDurationFormat();
   const { isPending, note, onOpen, onSave, setNote } = 
useTaskInstanceNote(taskInstance);
   const showTeam = useShowTeam(taskInstance.team_name);
 
@@ -58,7 +59,7 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
             label: translate("duration"),
             value: Boolean(taskInstance.duration)
               ? renderDuration(taskInstance.duration)
-              : getDuration(taskInstance.start_date, taskInstance.end_date),
+              : formatElapsed(taskInstance.start_date, taskInstance.end_date),
           },
         ]
       : []),
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index ed09a4da37c..626d7a7611a 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -38,6 +38,7 @@ import {
   type GetColumnsParams,
 } from "src/components/DataTable/useRowSelection";
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { DurationCell } from "src/components/DurationCell";
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { MarkTaskInstanceAsButton } from "src/components/MarkAs";
 import { StateBadge } from "src/components/StateBadge";
@@ -48,7 +49,7 @@ import { TruncatedText } from "src/components/TruncatedText";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
 import { useConfig } from "src/queries/useConfig";
-import { useAutoRefresh, isStatePending, renderDuration, useDocumentTitle } 
from "src/utils";
+import { useAutoRefresh, isStatePending, useDocumentTitle } from "src/utils";
 import { getTaskInstanceLink } from "src/utils/links";
 
 import BulkClearTaskInstancesButton from "./BulkClearTaskInstancesButton";
@@ -239,7 +240,7 @@ const taskInstanceColumns = ({
   },
   {
     accessorKey: "duration",
-    cell: ({ row: { original } }) => renderDuration(original.duration),
+    cell: ({ row: { original } }) => <DurationCell 
duration={original.duration} />,
     header: translate("duration"),
   },
   {
diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts 
b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts
index c5c766af95a..15191678cb8 100644
--- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.test.ts
@@ -23,55 +23,204 @@ import { describe, it, expect, vi, beforeAll, afterAll } 
from "vitest";
 import {
   getDuration,
   getDurationTickStep,
+  getElapsedSeconds,
   humanizeSeconds,
-  renderCompactDuration,
   renderDuration,
+  renderExactDuration,
   getRelativeTime,
 } from "./datetimeUtils";
 
 dayjs.extend(dayjsDuration);
 
-describe("getDuration & formatDuration", () => {
-  it("handles durations less than 60 seconds", () => {
-    const start = "2024-03-14T10:00:00.000Z";
-    const end = "2024-03-14T10:00:05.5111111Z";
+// CLDR's own strings shift between ICU releases — de narrow "1 Std." became 
"1h" in ICU 78 — and the
+// runtime ICU differs across CI, contributor machines and browsers. So 
localized cases assert the
+// composition we control (which units, what precision, which style, joined in 
order) and leave the
+// wording to the platform. Only the English cases pin literals, as those 
encode our band policy.
+const expectDuration = (
+  locale: string,
+  style: "long" | "narrow",
+  parts: Array<[Intl.NumberFormatOptions["unit"], number, number?]>,
+) => {
+  const formatted = parts.map(([unit, value, fractionDigits = 0]) =>
+    new Intl.NumberFormat(locale, {
+      maximumFractionDigits: fractionDigits,
+      style: "unit",
+      unit,
+      unitDisplay: style,
+    }).format(value),
+  );
+
+  return formatted.length > 1
+    ? new Intl.ListFormat(locale, { style, type: "unit" }).format(formatted)
+    : formatted[0];
+};
 
-    expect(getDuration(start, end)).toBe("00:00:05.511");
+describe("renderDuration", () => {
+  it.each([
+    [0, "0s"],
+    [0.0000004, "<1ms"],
+    [0.0009, "<1ms"],
+    [0.001, "1ms"],
+    [0.083, "83ms"],
+    [0.9994, "999ms"],
+    // Rounding up out of the millisecond band must promote to seconds, not 
print "1000ms".
+    [0.9996, "1s"],
+    [1, "1s"],
+    [1.5, "1.5s"],
+    [9.87456, "9.87s"],
+    // Three significant digits means one decimal from 10s up, two below it.
+    [14.846, "14.8s"],
+    [15, "15s"],
+    [45, "45s"],
+    [59.9, "59.9s"],
+    // Rounding at the band's precision spills into the next band.
+    [59.96, "1m"],
+    [60, "1m"],
+    [65.25, "1m 5s"],
+    [545, "9m 5s"],
+    [540, "9m"],
+    [3599.6, "1h"],
+    [3600, "1h"],
+    [3725.4, "1h 2m"],
+    [5400, "1h 30m"],
+    [86_399.6, "1d"],
+    [86_400, "1d"],
+    [90_061.2, "1d 1h"],
+    // Rounds rather than truncates: 1d 4h 30m is nearer 1d 5h.
+    [102_600, "1d 5h"],
+    [281_445, "3d 6h"],
+  ])("formats %s seconds as %s", (seconds, expected) => {
+    expect(renderDuration(seconds, "en")).toBe(expected);
   });
 
-  it("handles durations spanning multiple days", () => {
-    const start = "2024-03-14T10:00:00.000Z";
-    const end = "2024-03-17T15:30:45.000Z";
+  it.each([[null], [undefined], [Number.NaN], [Number.POSITIVE_INFINITY], 
[-5]])(
+    "returns undefined without a usable duration (%s)",
+    (seconds) => {
+      expect(renderDuration(seconds, "en")).toBeUndefined();
+    },
+  );
 
-    expect(getDuration(start, end)).toBe("3d05:30:45");
+  it("accepts dayjs durations as well as numbers", () => {
+    expect(renderDuration(dayjs.duration(10, "seconds"), "en")).toBe("10s");
+    expect(renderDuration(dayjs.duration(0.083, "seconds"), 
"en")).toBe("83ms");
+    expect(renderDuration(dayjs.duration(3725.4, "seconds"), "en")).toBe("1h 
2m");
   });
 
-  it("handles exactly 24 hours", () => {
-    const start = "2024-03-14T10:00:00.000Z";
-    const end = "2024-03-15T10:00:00.000Z";
+  it.each([
+    ["de", 0.083, [["millisecond", 83]]],
+    ["de", 14.846, [["second", 14.8, 1]]],
+    [
+      "de",
+      3725.4,
+      [
+        ["hour", 1],
+        ["minute", 2],
+      ],
+    ],
+    [
+      "fr",
+      281_445,
+      [
+        ["day", 3],
+        ["hour", 6],
+      ],
+    ],
+    [
+      "ru",
+      3725.4,
+      [
+        ["hour", 1],
+        ["minute", 2],
+      ],
+    ],
+    [
+      "ja",
+      65.25,
+      [
+        ["minute", 1],
+        ["second", 5],
+      ],
+    ],
+    [
+      "ar",
+      3725.4,
+      [
+        ["hour", 1],
+        ["minute", 2],
+      ],
+    ],
+    [
+      "pl",
+      545,
+      [
+        ["minute", 9],
+        ["second", 5],
+      ],
+    ],
+    [
+      "zh-CN",
+      545,
+      [
+        ["minute", 9],
+        ["second", 5],
+      ],
+    ],
+    ["pt", 604_800, [["day", 7]]],
+    ["it", 604_800, [["day", 7]]],
+  ] as Array<[string, number, Array<[Intl.NumberFormatOptions["unit"], number, 
number?]>]>)(
+    "localizes %s duration of %s seconds",
+    (locale, seconds, parts) => {
+      expect(renderDuration(seconds, locale)).toBe(expectDuration(locale, 
"narrow", parts));
+    },
+  );
 
-    expect(getDuration(start, end)).toBe("1d00:00:00");
+  // Properties CLDR has held stable for decades, unlike the unit 
abbreviations themselves.
+  it("uses the locale's decimal separator and script", () => {
+    expect(renderDuration(14.846, "fr")).toContain("14,8");
+    expect(renderDuration(14.846, "en")).toContain("14.8");
+    expect(renderDuration(3725.4, "ru")).toMatch(/\p{Script=Cyrillic}/u);
+    expect(renderDuration(3725.4, "de")).toBe(
+      expectDuration("de", "narrow", [
+        ["hour", 1],
+        ["minute", 2],
+      ]),
+    );
   });
 
-  it("handles hours and minutes without days", () => {
-    const start = "2024-03-14T10:00:00.000Z";
-    const end = "2024-03-14T12:30:00.000Z";
+  it.each([["en"], ["de"], ["ru"]])("marks sub-millisecond durations as under 
1ms in %s", (locale) => {
+    expect(renderDuration(0.0004, locale)).toBe(`<${expectDuration(locale, 
"narrow", [["millisecond", 1]])}`);
+  });
 
-    expect(getDuration(start, end)).toBe("02:30:00");
+  it("falls back to English rather than throwing on a language Intl rejects", 
() => {
+    expect(renderDuration(3725.4, "not a locale!")).toBe("1h 2m");
   });
+});
 
-  it("omits milliseconds when withMilliseconds is false", () => {
-    const start = "2024-03-14T10:00:00.000Z";
-    const end = "2024-03-14T10:00:05.511Z";
+describe("getDuration", () => {
+  it.each([
+    ["2024-03-14T10:00:00.000Z", "2024-03-14T10:00:00.083Z", "83ms"],
+    ["2024-03-14T10:00:00.000Z", "2024-03-14T10:00:05.5111111Z", "5.51s"],
+    ["2024-03-14T10:00:00.000Z", "2024-03-14T10:00:14.846Z", "14.8s"],
+    ["2024-03-14T10:00:00.000Z", "2024-03-14T12:30:00.000Z", "2h 30m"],
+    ["2024-03-14T10:00:00.000Z", "2024-03-15T10:00:00.000Z", "1d"],
+    ["2024-03-14T10:00:00.000Z", "2024-03-17T15:30:45.000Z", "3d 6h"],
+  ])("renders %s to %s as %s", (start, end, expected) => {
+    expect(getDuration(start, end, "en")).toBe(expected);
+  });
 
-    expect(getDuration(start, end, false)).toBe("00:00:05");
+  it("forwards the locale to the formatter", () => {
+    expect(getDuration("2024-03-14T10:00:00.000Z", "2024-03-14T12:30:00.000Z", 
"de")).toBe(
+      expectDuration("de", "narrow", [
+        ["hour", 2],
+        ["minute", 30],
+      ]),
+    );
   });
 
-  it("handles small, null or undefined values", () => {
+  it("handles null or undefined values", () => {
     expect(getDuration(null, null)).toBe(undefined);
     expect(getDuration(undefined, undefined)).toBe(undefined);
     expect(getDuration(null, "2024-03-14T10:00:10.000Z")).toBe(undefined);
-    expect(renderDuration(0.00001)).toBe(undefined);
   });
 
   it("falls back to current time when endDate is null (running task)", () => {
@@ -80,24 +229,70 @@ describe("getDuration & formatDuration", () => {
 
     const start = "2024-03-14T10:00:00.000Z";
 
-    expect(getDuration(start, null)).toBe("00:00:10");
-    expect(getDuration(start, undefined)).toBe("00:00:10");
+    expect(getDuration(start, null, "en")).toBe("10s");
+    expect(getDuration(start, undefined, "en")).toBe("10s");
 
     vi.useRealTimers();
   });
+});
+
+describe("renderExactDuration", () => {
+  // renderDuration rounds to two units, so "1h 2m" spans a full minute. The 
exact form backs the
+  // `title` on the duration columns, where two similar runs have to be told 
apart.
+  it.each([
+    [3725.412, "1h 2m 5.412s"],
+    [102_600, "1d 4h 30m"],
+    [281_445, "3d 6h 10m 45s"],
+    [3600, "1h"],
+    [45.5, "45.5s"],
+    [0.083, "83ms"],
+  ])("renders %s seconds in full as %s", (seconds, expected) => {
+    expect(renderExactDuration(seconds, "en")).toBe(expected);
+  });
 
-  it("handles both numbers and duration objects", () => {
-    expect(renderDuration(dayjs.duration(10, "seconds"))).toBe("00:00:10");
-    expect(renderDuration(10)).toBe("00:00:10");
+  it("keeps precision the rounded form drops", () => {
+    expect(renderDuration(3725.412, "en")).toBe("1h 2m");
+    expect(renderExactDuration(3725.412, "en")).toBe("1h 2m 5.412s");
   });
 
-  it("handles floating point milliseconds", () => {
-    expect(renderDuration(dayjs.duration(10.000499738, 
"seconds"))).toBe("00:00:10");
-    expect(renderDuration(10.000499738)).toBe("00:00:10");
-    expect(renderDuration(dayjs.duration(10.0005, 
"seconds"))).toBe("00:00:10.001");
-    expect(renderDuration(10.0005)).toBe("00:00:10.001");
-    expect(renderDuration(dayjs.duration(10.838999738, 
"seconds"))).toBe("00:00:10.839");
-    expect(renderDuration(10.838999738)).toBe("00:00:10.839");
+  it.each([[null], [undefined], [Number.NaN], [-5]])("returns undefined for 
%s", (seconds) => {
+    expect(renderExactDuration(seconds, "en")).toBeUndefined();
+  });
+
+  it("localizes like the rounded form", () => {
+    expect(renderExactDuration(3725.412, "de")).toBe(
+      expectDuration("de", "narrow", [
+        ["hour", 1],
+        ["minute", 2],
+        ["second", 5.412, 3],
+      ]),
+    );
+  });
+});
+
+describe("getElapsedSeconds", () => {
+  it.each([
+    ["2024-03-14T10:00:00.000Z", "2024-03-14T10:00:14.846Z", 14.846],
+    ["2024-03-14T10:00:00.000Z", "2024-03-14T12:30:00.000Z", 9000],
+  ])("measures %s to %s as %s seconds", (start, end, expected) => {
+    expect(getElapsedSeconds(start, end)).toBe(expected);
+  });
+
+  it.each([[null], [undefined], ["not a date"]])("returns undefined without a 
usable start (%s)", (start) => {
+    expect(getElapsedSeconds(start, 
"2024-03-14T10:00:10.000Z")).toBeUndefined();
+  });
+
+  it("returns undefined when the end date is unparsable", () => {
+    expect(getElapsedSeconds("2024-03-14T10:00:00.000Z", "not a 
date")).toBeUndefined();
+  });
+
+  it("measures against now when the end date is absent (running task)", () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date("2024-03-14T10:00:10.000Z"));
+
+    expect(getElapsedSeconds("2024-03-14T10:00:00.000Z", null)).toBe(10);
+
+    vi.useRealTimers();
   });
 });
 
@@ -113,40 +308,48 @@ describe("getRelativeTime", () => {
     vi.useRealTimers();
   });
 
-  it("returns relative time for a valid date", () => {
-    const date = "2024-03-14T10:00:00.000Z";
-
-    expect(getRelativeTime(date)).toBe("a few seconds ago");
+  it.each([
+    ["2024-03-14T10:00:00.000Z", "10 seconds ago"],
+    ["2024-03-14T10:00:20.000Z", "in 10 seconds"],
+    // The largest unit the gap reaches wins, rather than "2700 seconds ago".
+    ["2024-03-14T09:15:10.000Z", "45 minutes ago"],
+    ["2024-03-14T07:00:10.000Z", "3 hours ago"],
+    ["2024-03-11T10:00:10.000Z", "3 days ago"],
+    ["2024-02-14T10:00:10.000Z", "4 weeks ago"],
+    ["2024-01-14T10:00:10.000Z", "2 months ago"],
+    ["2022-03-14T10:00:10.000Z", "2 years ago"],
+  ])("describes %s as %s", (date, expected) => {
+    expect(getRelativeTime(date, "en")).toBe(expected);
   });
 
-  it("returns an empty string for undefined dates", () => {
-    expect(getRelativeTime(undefined)).toBe("");
+  // Rounding used to saturate the unit the magnitude was picked from, 
printing the unit's own
+  // ceiling instead of promoting: "60 minutes ago" where dayjs's fromNow said 
"an hour ago".
+  it.each([
+    // 59.7 minutes: rounds to 60, which used to print "60 minutes ago".
+    ["2024-03-14T09:00:28.000Z", "1 hour ago"],
+    // 23.9 hours -> "24 hours ago" before.
+    ["2024-03-13T10:06:50.000Z", "yesterday"],
+    // 365.2 days -> "12 months ago" before.
+    ["2023-03-15T04:10:10.000Z", "last year"],
+  ])("promotes %s to the next unit up rather than saturating", (date, 
expected) => {
+    expect(getRelativeTime(date, "en")).toBe(expected);
   });
 
-  it("handles future dates", () => {
-    const futureDate = "2024-03-14T10:00:20.000Z";
-
-    expect(getRelativeTime(futureDate)).toBe("in a few seconds");
+  it.each([["de"], ["fr"], ["ru"], ["ja"]])("localizes relative time for %s", 
(locale) => {
+    expect(getRelativeTime("2024-03-14T10:00:00.000Z", locale)).toBe(
+      new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-10, 
"second"),
+    );
+    expect(getRelativeTime("2024-03-14T10:00:00.000Z", locale)).not.toBe(
+      getRelativeTime("2024-03-14T10:00:00.000Z", "en"),
+    );
   });
-});
 
-describe("renderCompactDuration", () => {
-  it.each([
-    [0, "0s"],
-    [-5, "0s"],
-    [Number.NaN, "0s"],
-    [Number.POSITIVE_INFINITY, "0s"],
-    [0.25, "250ms"],
-    [45, "45s"],
-    [540, "9m"],
-    [545, "9m 5s"],
-    [3600, "1h"],
-    [5400, "1h 30m"],
-    [86_400, "1d"],
-    [102_600, "1d 4h"],
-  ])("formats %s seconds as %s", (seconds, expected) => {
-    expect(renderCompactDuration(seconds)).toBe(expected);
-  });
+  it.each([[undefined], [null], [""], ["not a date"]])(
+    "returns an empty string without a usable date (%s)",
+    (date) => {
+      expect(getRelativeTime(date, "en")).toBe("");
+    },
+  );
 });
 
 describe("getDurationTickStep", () => {
@@ -174,16 +377,43 @@ describe("getDurationTickStep", () => {
 
 describe("humanizeSeconds", () => {
   it.each([
-    [3600, "an hour"],
-    [86_400, "a day"],
-  ])("humanizes %s seconds as %s", (seconds, expected) => {
-    expect(humanizeSeconds(seconds)).toBe(expected);
+    [3600, "1 hour"],
+    [86_400, "1 day"],
+    [3725.4, "1 hour, 2 minutes"],
+    [0.083, "83 milliseconds"],
+  ])("spells out %s seconds as %s in English", (seconds, expected) => {
+    expect(humanizeSeconds(seconds, "en")).toBe(expected);
+  });
+
+  // The prose form is localized by the same CLDR data as the compact one.
+  it.each([
+    ["de", 3600, [["hour", 1]]],
+    ["de", 86_400, [["day", 1]]],
+    ["ru", 3600, [["hour", 1]]],
+    [
+      "fr",
+      3725.4,
+      [
+        ["hour", 1],
+        ["minute", 2],
+      ],
+    ],
+  ] as Array<[string, number, Array<[Intl.NumberFormatOptions["unit"], number, 
number?]>]>)(
+    "spells out %s duration of %s seconds",
+    (locale, seconds, parts) => {
+      expect(humanizeSeconds(seconds, locale)).toBe(expectDuration(locale, 
"long", parts));
+    },
+  );
+
+  it("differs from the compact form and from English", () => {
+    expect(humanizeSeconds(3600, "en")).not.toBe(renderDuration(3600, "en"));
+    expect(humanizeSeconds(3600, "de")).not.toBe(humanizeSeconds(3600, "en"));
   });
 
-  it.each([[null], [undefined], [Number.NaN], [Number.POSITIVE_INFINITY]])(
-    "returns undefined without a finite interval (%s)",
+  it.each([[null], [undefined], [Number.NaN], [Number.POSITIVE_INFINITY], 
[-5]])(
+    "returns undefined without a usable interval (%s)",
     (seconds) => {
-      expect(humanizeSeconds(seconds)).toBeUndefined();
+      expect(humanizeSeconds(seconds, "en")).toBeUndefined();
     },
   );
 });
diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts 
b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts
index 20e129d0645..1ed1f8f3b6f 100644
--- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts
+++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts
@@ -18,82 +18,212 @@
  */
 import dayjs from "dayjs";
 import dayjsDuration from "dayjs/plugin/duration";
-import relativeTime from "dayjs/plugin/relativeTime";
 import tz from "dayjs/plugin/timezone";
+import i18n from "i18next";
 
 dayjs.extend(dayjsDuration);
-dayjs.extend(relativeTime);
 dayjs.extend(tz);
 
 export const DATE_FORMAT = "YYYY-MM-DD";
 export const DEFAULT_DATETIME_FORMAT = `${DATE_FORMAT} HH:mm:ss`;
 export const DEFAULT_DATETIME_FORMAT_WITH_TZ = `${DEFAULT_DATETIME_FORMAT} z`;
 
-export const renderDuration = (
-  durationSeconds: dayjsDuration.Duration | number | null | undefined,
-  withMilliseconds: boolean = true,
-): string | undefined => {
-  if (durationSeconds === null || durationSeconds === undefined) {
-    return undefined;
+const DEFAULT_LOCALE = "en";
+const SECONDS_PER_MINUTE = 60;
+const SECONDS_PER_HOUR = 3600;
+const SECONDS_PER_DAY = 86_400;
+const MINUTES_PER_HOUR = 60;
+const HOURS_PER_DAY = 24;
+
+type DurationUnit = "day" | "hour" | "millisecond" | "minute" | "second";
+
+type DurationPart = { fractionDigits?: number; unit: DurationUnit; value: 
number };
+
+/** `narrow` ("1h 2m") suits dense tables and charts; `long` ("1 hour, 2 
minutes") suits prose. */
+type DurationStyle = "long" | "narrow";
+
+// Intl constructors are costly and durations render in every table row and 
chart tick callback, so
+// instances are reused. A stored language Intl rejects must not blank out 
every duration in the UI,
+// hence the fallback to DEFAULT_LOCALE rather than letting the RangeError 
escape.
+const createIntlCache = <T>() => {
+  const cache = new Map<string, T>();
+
+  return (variant: string, locale: string, construct: (forLocale: string) => 
T): T => {
+    const key = `${locale}|${variant}`;
+    const cached = cache.get(key);
+
+    if (cached !== undefined) {
+      return cached;
+    }
+
+    let formatter: T;
+
+    try {
+      formatter = construct(locale);
+    } catch {
+      formatter = construct(DEFAULT_LOCALE);
+    }
+
+    cache.set(key, formatter);
+
+    return formatter;
+  };
+};
+
+const unitFormatter = createIntlCache<Intl.NumberFormat>();
+const listFormatter = createIntlCache<Intl.ListFormat>();
+const relativeTimeFormatter = createIntlCache<Intl.RelativeTimeFormat>();
+
+const getUnitFormatter = (locale: string, style: DurationStyle, part: 
DurationPart): Intl.NumberFormat => {
+  const { fractionDigits = 0, unit } = part;
+
+  return unitFormatter(
+    `${unit}|${fractionDigits}|${style}`,
+    locale,
+    (forLocale) =>
+      new Intl.NumberFormat(forLocale, {
+        maximumFractionDigits: fractionDigits,
+        style: "unit",
+        unit,
+        unitDisplay: style,
+      }),
+  );
+};
+
+const getListFormatter = (locale: string, style: DurationStyle): 
Intl.ListFormat =>
+  listFormatter(style, locale, (forLocale) => new Intl.ListFormat(forLocale, { 
style, type: "unit" }));
+
+const getRelativeTimeFormatter = (locale: string): Intl.RelativeTimeFormat =>
+  relativeTimeFormatter(
+    "auto",
+    locale,
+    (forLocale) => new Intl.RelativeTimeFormat(forLocale, { numeric: "auto" }),
+  );
+
+// Unit names, decimal separators, plural forms and the joiner all come from 
CLDR, so "1h 2m" is
+// "1 ч 2 мин" in ru. This reproduces Intl.DurationFormat's narrow style 
exactly (verified across
+// every locale we ship) without requiring it: that API needs Node 23+, above 
this package's
+// engines floor, and Node 23 was never an LTS line. Exact wording also varies 
by the runtime's ICU
+// version, so nothing may depend on a specific CLDR string.
+const formatParts = (parts: Array<DurationPart>, locale: string, style: 
DurationStyle): string => {
+  const formatted = parts.map((part) => getUnitFormatter(locale, style, 
part).format(part.value));
+
+  return formatted.length > 1 ? getListFormatter(locale, 
style).format(formatted) : (formatted[0] ?? "");
+};
+
+// Durations carry roughly three significant digits at every magnitude, so a 
83ms task and a
+// three-day backfill are both legible without decoding zero-padded clock 
groups. Rounding at a
+// band's precision can spill into the next band (59.96s is a minute, not 
"60.0s"), hence the
+// recursion on the promoted value. Callers needing the unrounded number 
should surface it separately.
+const getDurationParts = (seconds: number): Array<DurationPart> => {
+  if (seconds === 0) {
+    return [{ unit: "second", value: 0 }];
   }
 
-  // Handle floating point milliseconds
-  const duration = dayjs.isDuration(durationSeconds)
-    ? dayjs.duration(Math.round(durationSeconds.asMilliseconds()))
-    : dayjs.duration(Number(durationSeconds.toFixed(3)), "seconds");
+  if (seconds < 1) {
+    const milliseconds = Math.round(seconds * 1000);
 
-  if (duration.asMilliseconds() < 1) {
-    return undefined;
+    return milliseconds < 1000 ? [{ unit: "millisecond", value: milliseconds 
}] : getDurationParts(1);
   }
 
-  // If under 60 seconds, render milliseconds
-  if (duration.asSeconds() < 60 && duration.milliseconds() > 0 && 
withMilliseconds) {
-    return duration.format("HH:mm:ss.SSS");
+  if (seconds < SECONDS_PER_MINUTE) {
+    // Two decimals under 10s, one above, keeps three significant digits 
either way.
+    const fractionDigits = seconds < 10 ? 2 : 1;
+    const rounded = Number(seconds.toFixed(fractionDigits));
+
+    return rounded < SECONDS_PER_MINUTE
+      ? [{ fractionDigits, unit: "second", value: rounded }]
+      : getDurationParts(SECONDS_PER_MINUTE);
   }
 
-  // If under 1 day, render as HH:mm:ss otherwise include the number of days
-  return duration.asSeconds() < 86_400 ? duration.format("HH:mm:ss") : 
duration.format("D[d]HH:mm:ss");
-};
+  if (seconds < SECONDS_PER_HOUR) {
+    const minutes = Math.floor(seconds / SECONDS_PER_MINUTE);
+    const remainingSeconds = Math.round(seconds - minutes * 
SECONDS_PER_MINUTE);
 
-// dayjs humanizes a missing or non-finite input as "a few seconds", so 
callers with no duration
-// to name get undefined instead of a made-up one.
-export const humanizeSeconds = (seconds: number | null | undefined): string | 
undefined =>
-  typeof seconds === "number" && Number.isFinite(seconds)
-    ? dayjs.duration(seconds, "seconds").humanize()
-    : undefined;
+    if (remainingSeconds === SECONDS_PER_MINUTE) {
+      return getDurationParts((minutes + 1) * SECONDS_PER_MINUTE);
+    }
 
-// Chart axes need whole units at a glance; HH:mm:ss forces the reader to 
decode
-// every tick to work out the magnitude.
-export const renderCompactDuration = (durationSeconds: number): string => {
-  if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
-    return "0s";
+    return remainingSeconds > 0
+      ? [
+          { unit: "minute", value: minutes },
+          { unit: "second", value: remainingSeconds },
+        ]
+      : [{ unit: "minute", value: minutes }];
   }
 
-  if (durationSeconds < 1) {
-    return `${Math.round(durationSeconds * 1000)}ms`;
+  if (seconds < SECONDS_PER_DAY) {
+    const hours = Math.floor(seconds / SECONDS_PER_HOUR);
+    const remainingMinutes = Math.round((seconds - hours * SECONDS_PER_HOUR) / 
SECONDS_PER_MINUTE);
+
+    if (remainingMinutes === MINUTES_PER_HOUR) {
+      return getDurationParts((hours + 1) * SECONDS_PER_HOUR);
+    }
+
+    return remainingMinutes > 0
+      ? [
+          { unit: "hour", value: hours },
+          { unit: "minute", value: remainingMinutes },
+        ]
+      : [{ unit: "hour", value: hours }];
   }
 
-  const duration = dayjs.duration(Math.round(durationSeconds), "seconds");
-  const days = Math.floor(duration.asDays());
-  const hours = duration.hours();
-  const minutes = duration.minutes();
-  const seconds = duration.seconds();
+  const days = Math.floor(seconds / SECONDS_PER_DAY);
+  const remainingHours = Math.round((seconds - days * SECONDS_PER_DAY) / 
SECONDS_PER_HOUR);
 
-  if (days > 0) {
-    return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
+  if (remainingHours === HOURS_PER_DAY) {
+    return getDurationParts((days + 1) * SECONDS_PER_DAY);
   }
 
-  if (hours > 0) {
-    return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+  return remainingHours > 0
+    ? [
+        { unit: "day", value: days },
+        { unit: "hour", value: remainingHours },
+      ]
+    : [{ unit: "day", value: days }];
+};
+
+const formatDuration = (
+  duration: dayjsDuration.Duration | number | null | undefined,
+  locale: string,
+  style: DurationStyle,
+): string | undefined => {
+  if (duration === null || duration === undefined) {
+    return undefined;
+  }
+
+  const seconds = dayjs.isDuration(duration) ? duration.asSeconds() : 
Number(duration);
+
+  if (!Number.isFinite(seconds) || seconds < 0) {
+    return undefined;
   }
 
-  if (minutes > 0) {
-    return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
+  // Below a millisecond the digits are timestamp resolution and clock skew 
rather than signal. "<"
+  // is mathematical notation, not prose, so CLDR has no pattern for it and 
none is needed.
+  if (seconds > 0 && seconds < 0.001) {
+    return `<${formatParts([{ unit: "millisecond", value: 1 }], locale, 
style)}`;
   }
 
-  return `${seconds}s`;
+  return formatParts(getDurationParts(seconds), locale, style);
 };
 
+/**
+ * Formats a duration for display, localized to the active UI language.
+ *
+ * `locale` defaults to the current i18next language and exists so tests and 
callers outside React
+ * can pin it; pass it rather than reaching for the raw seconds.
+ */
+export const renderDuration = (
+  duration: dayjsDuration.Duration | number | null | undefined,
+  locale: string = i18n.language || DEFAULT_LOCALE,
+): string | undefined => formatDuration(duration, locale, "narrow");
+
+/** Spelled-out duration for prose, where "1 hour" reads better than the table 
form "1h". */
+export const humanizeSeconds = (
+  seconds: number | null | undefined,
+  locale: string = i18n.language || DEFAULT_LOCALE,
+): string | undefined => formatDuration(seconds, locale, "long");
+
 // Chart.js picks decimal steps, which on a time axis reads as 26m 40s / 33m 
20s.
 // Snapping to units people actually count in keeps the ticks legible.
 const DURATION_TICK_STEPS_SECONDS = [
@@ -112,21 +242,21 @@ export const getDurationTickStep = (maxSeconds: number, 
maxTicks = 8): number =>
   );
 };
 
-export const getDuration = (
-  startDate?: string | null,
-  endDate?: string | null,
-  withMilliseconds: boolean = true,
-) => {
+/** Elapsed seconds between two timestamps, counting an absent `endDate` as 
still running. */
+export const getElapsedSeconds = (startDate?: string | null, endDate?: string 
| null): number | undefined => {
   if (startDate === undefined || startDate === null) {
     return undefined;
   }
 
-  const end = endDate ?? dayjs().toISOString();
-  const milliseconds = dayjs.duration(dayjs(end).diff(startDate));
+  const start = dayjs(startDate);
+  const end = endDate === undefined || endDate === null ? dayjs() : 
dayjs(endDate);
 
-  return renderDuration(milliseconds, withMilliseconds);
+  return start.isValid() && end.isValid() ? 
dayjs.duration(end.diff(start)).asSeconds() : undefined;
 };
 
+export const getDuration = (startDate?: string | null, endDate?: string | 
null, locale?: string) =>
+  renderDuration(getElapsedSeconds(startDate, endDate), locale);
+
 export const formatDate = (
   date: number | string | null | undefined,
   timezone: string,
@@ -139,12 +269,79 @@ export const formatDate = (
   return dayjs(date).tz(timezone).format(format);
 };
 
-export const getRelativeTime = (date: string | null | undefined): string => {
-  if (date === null || date === "" || date === undefined) {
+// Ordered largest first so the first unit the difference reaches wins: "45 
minutes ago" rather than
+// "2700 seconds ago". Months and years use the mean Gregorian lengths CLDR 
assumes for relative
+// phrasing. Anything under a minute falls through to seconds.
+const RELATIVE_TIME_UNITS: Array<{ seconds: number; unit: 
Intl.RelativeTimeFormatUnit }> = [
+  { seconds: 31_557_600, unit: "year" },
+  { seconds: 2_629_800, unit: "month" },
+  { seconds: SECONDS_PER_DAY * 7, unit: "week" },
+  { seconds: SECONDS_PER_DAY, unit: "day" },
+  { seconds: SECONDS_PER_HOUR, unit: "hour" },
+  { seconds: SECONDS_PER_MINUTE, unit: "minute" },
+  { seconds: 1, unit: "second" },
+];
+
+export const getRelativeTime = (
+  date: string | null | undefined,
+  locale: string = i18n.language || DEFAULT_LOCALE,
+): string => {
+  if (date === null || date === "" || date === undefined || 
!dayjs(date).isValid()) {
     return "";
   }
 
-  return dayjs(date).fromNow();
+  const elapsed = dayjs(date).diff(dayjs(), "second", true);
+  const magnitude = Math.abs(elapsed);
+  const index = RELATIVE_TIME_UNITS.findIndex((entry) => magnitude >= 
entry.seconds);
+  // Anything under a minute falls through to the smallest unit in the table.
+  const candidate = RELATIVE_TIME_UNITS[index] ?? RELATIVE_TIME_UNITS.at(-1)!;
+  const rounded = Math.round(elapsed / candidate.seconds);
+
+  // Rounding can saturate the unit the magnitude was picked from -- 59.7 
minutes rounds to 60 -- so
+  // promote to the next unit up rather than printing "60 minutes ago" where 
"an hour ago" is meant.
+  const larger = index > 0 ? RELATIVE_TIME_UNITS[index - 1] : undefined;
+  const { seconds, unit } =
+    larger !== undefined && Math.abs(rounded) * candidate.seconds >= 
larger.seconds ? larger : candidate;
+
+  return getRelativeTimeFormatter(locale).format(Math.round(elapsed / 
seconds), unit);
+};
+
+/**
+ * Every non-zero unit down to fractional seconds, for a `title` alongside the 
rounded form.
+ *
+ * `renderDuration` deliberately rounds to two units, which makes "1h 2m" 
cover a 60-second band --
+ * too coarse to compare two similar runs. This keeps the exact number one 
hover away.
+ */
+export const renderExactDuration = (
+  duration: number | null | undefined,
+  locale: string = i18n.language || DEFAULT_LOCALE,
+): string | undefined => {
+  if (duration === null || duration === undefined) {
+    return undefined;
+  }
+
+  const total = Number(duration);
+
+  if (!Number.isFinite(total) || total < 0) {
+    return undefined;
+  }
+
+  if (total < 1) {
+    return formatParts(getDurationParts(total), locale, "narrow");
+  }
+
+  const days = Math.floor(total / SECONDS_PER_DAY);
+  const hours = Math.floor((total % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
+  const minutes = Math.floor((total % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
+  const seconds = Number((total % SECONDS_PER_MINUTE).toFixed(3));
+  const parts: Array<DurationPart> = [
+    { unit: "day" as const, value: days },
+    { unit: "hour" as const, value: hours },
+    { unit: "minute" as const, value: minutes },
+    { fractionDigits: 3, unit: "second" as const, value: seconds },
+  ].filter((part) => part.value > 0);
+
+  return formatParts(parts, locale, "narrow");
 };
 
 export const getTimezoneOffsetString = (timezone: string): string => 
dayjs().tz(timezone).format("Z");
diff --git a/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts 
b/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts
index fdd65885bb3..1470d09fb92 100644
--- a/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/deadlines.test.ts
@@ -40,7 +40,7 @@ const REFERENCE = 
"deadlineAlerts.referenceType.DagRunLogicalDateDeadline";
 
 describe("translateCompletionRule", () => {
   it.each([
-    [3600, `deadlineAlerts.completionRule:an hour:${REFERENCE}`],
+    [3600, `deadlineAlerts.completionRule:1 hour:${REFERENCE}`],
     [null, `deadlineAlerts.completionRuleDynamic::${REFERENCE}`],
   ])("names the rule for an interval of %s seconds", (interval, expected) => {
     expect(translateCompletionRule(translate, { ...baseAlert, interval 
})).toBe(expected);
diff --git a/airflow-core/src/airflow/ui/src/utils/deadlines.ts 
b/airflow-core/src/airflow/ui/src/utils/deadlines.ts
index 6c20831357e..d426232fb6f 100644
--- a/airflow-core/src/airflow/ui/src/utils/deadlines.ts
+++ b/airflow-core/src/airflow/ui/src/utils/deadlines.ts
@@ -29,6 +29,7 @@ import { humanizeSeconds } from "src/utils/datetimeUtils";
 export const translateCompletionRule = (
   translate: TFunction,
   alert: DeadlineAlertResponse | undefined,
+  locale?: string,
 ): string | undefined => {
   if (alert === undefined) {
     return undefined;
@@ -37,7 +38,7 @@ export const translateCompletionRule = (
   const reference = 
translate(`deadlineAlerts.referenceType.${alert.reference_type}`, {
     defaultValue: alert.reference_type,
   });
-  const interval = humanizeSeconds(alert.interval);
+  const interval = humanizeSeconds(alert.interval, locale);
 
   return interval === undefined
     ? translate("deadlineAlerts.completionRuleDynamic", { reference })
diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts 
b/airflow-core/src/airflow/ui/src/utils/index.ts
index 292a091d38a..210e25586c8 100644
--- a/airflow-core/src/airflow/ui/src/utils/index.ts
+++ b/airflow-core/src/airflow/ui/src/utils/index.ts
@@ -18,12 +18,12 @@
  */
 
 export { capitalize } from "./capitalize";
-export { getDuration, renderDuration } from "./datetimeUtils";
 export { createErrorToaster, getErrorStatus } from "./errorHandling";
 export { getMetaKey } from "./getMetaKey";
 export { toNullablePartitionKey } from "./partitionKey";
 export { useContainerWidth } from "./useContainerWidth";
 export { useDocumentTitle } from "./useDocumentTitle";
+export { type DurationFormat, useDurationFormat } from "./useDurationFormat";
 export { DocumentTitleProvider } from "./useDocumentTitleProvider";
 export { useFiltersHandler, type FilterableSearchParamsKeys } from 
"./useFiltersHandler";
 export * from "./query";
diff --git a/airflow-core/src/airflow/ui/src/utils/useDurationFormat.test.tsx 
b/airflow-core/src/airflow/ui/src/utils/useDurationFormat.test.tsx
new file mode 100644
index 00000000000..5b990aaa88d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/useDurationFormat.test.tsx
@@ -0,0 +1,80 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, screen, act } from "@testing-library/react";
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import { describe, it, expect, beforeAll, afterEach } from "vitest";
+
+import { useDurationFormat } from "./useDurationFormat";
+
+// A component that shows a duration and nothing else. Before this hook 
existed the formatters read
+// the language straight off the i18next singleton, so a component like this — 
with no reason of its
+// own to subscribe to `languageChanged` — never re-rendered and kept the 
previous locale forever.
+const DurationOnly = () => {
+  const { renderDuration } = useDurationFormat();
+
+  return <span data-testid="duration">{renderDuration(3725.4)}</span>;
+};
+
+const LocaleProbe = () => <span 
data-testid="locale">{useDurationFormat().locale}</span>;
+
+const expected = (locale: string) => {
+  const unit = (unitName: Intl.NumberFormatOptions["unit"], value: number) =>
+    new Intl.NumberFormat(locale, { style: "unit", unit: unitName, 
unitDisplay: "narrow" }).format(value);
+
+  return new Intl.ListFormat(locale, { style: "narrow", type: "unit" 
}).format([
+    unit("hour", 1),
+    unit("minute", 2),
+  ]);
+};
+
+describe("useDurationFormat", () => {
+  beforeAll(async () => {
+    await i18n.use(initReactI18next).init({ fallbackLng: "en", lng: "en", 
resources: { de: {}, en: {} } });
+  });
+
+  afterEach(async () => {
+    await act(async () => {
+      await i18n.changeLanguage("en");
+    });
+  });
+
+  it("re-formats a duration when the language changes, without any other 
subscription", async () => {
+    render(<DurationOnly />);
+    expect(screen.getByTestId("duration")).toHaveTextContent(expected("en"));
+
+    await act(async () => {
+      await i18n.changeLanguage("de");
+    });
+
+    expect(screen.getByTestId("duration")).toHaveTextContent(expected("de"));
+    expect(expected("de")).not.toBe(expected("en"));
+  });
+
+  it("exposes the active locale so callers can key their own memos on it", 
async () => {
+    render(<LocaleProbe />);
+    expect(screen.getByTestId("locale")).toHaveTextContent("en");
+
+    await act(async () => {
+      await i18n.changeLanguage("de");
+    });
+
+    expect(screen.getByTestId("locale")).toHaveTextContent("de");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/useDurationFormat.ts 
b/airflow-core/src/airflow/ui/src/utils/useDurationFormat.ts
new file mode 100644
index 00000000000..12477406828
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/useDurationFormat.ts
@@ -0,0 +1,64 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useMemo } from "react";
+
+import type dayjsDuration from "dayjs/plugin/duration";
+import { useTranslation } from "react-i18next";
+
+import { getDuration, getRelativeTime, renderDuration, renderExactDuration } 
from "./datetimeUtils";
+
+/**
+ * Duration formatters bound to the language currently on screen.
+ *
+ * The plain formatters read the language from the i18next singleton, which 
React does not track, so
+ * a component that shows a duration but never subscribes to `languageChanged` 
keeps rendering the
+ * previous locale. Reading the language through `useTranslation` here makes 
it an ordinary render
+ * input: switching language re-renders every consumer, and `locale` can be 
added to a caller's memo
+ * dependencies so derived columns, chart options and tick callbacks rebuild 
with it.
+ *
+ * Components should always format durations through this hook. Reach for the 
raw functions in
+ * `datetimeUtils` only outside React, and pass a locale explicitly there.
+ */
+/**
+ * The formatters this hook returns. Column builders and other helpers that 
receive them should
+ * `Pick` from this rather than restating the signatures, so they cannot drift 
from the hook.
+ */
+export type DurationFormat = ReturnType<typeof useDurationFormat>;
+
+export const useDurationFormat = () => {
+  const { i18n } = useTranslation();
+  const locale = i18n.language;
+
+  return useMemo(
+    () => ({
+      /** Elapsed time between two timestamps, counting an absent end as still 
running. */
+      formatElapsed: (startDate?: string | null, endDate?: string | null) =>
+        getDuration(startDate, endDate, locale),
+      /** Relative wall-clock time, e.g. "2 hours ago". */
+      formatRelative: (date: string | null | undefined) => 
getRelativeTime(date, locale),
+      locale,
+      /** Compact duration for tables, charts and tooltips, e.g. "1h 2m". */
+      renderDuration: (duration: dayjsDuration.Duration | number | null | 
undefined) =>
+        renderDuration(duration, locale),
+      /** Every unit down to fractional seconds, for a `title` beside the 
rounded form. */
+      renderExactDuration: (duration: number | null | undefined) => 
renderExactDuration(duration, locale),
+    }),
+    [locale],
+  );
+};

Reply via email to