diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 385448d..21cce00 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -15,7 +15,7 @@ import { } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { useDismiss } from "../lib/useDismiss"; -import { formatEta, formatViews, relativeTime } from "../lib/format"; +import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; @@ -28,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; -// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge). -function fmtTotalDuration(sec: number): string { - if (!sec) return "—"; - const h = Math.round(sec / 3600); - return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; -} - const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, @@ -287,7 +280,7 @@ export default function Channels({ nowrap: true, sortable: true, sortValue: (c) => c.total_duration_seconds, - render: (c) => {fmtTotalDuration(c.total_duration_seconds)}, + render: (c) => {formatTotalHours(c.total_duration_seconds)}, }, { key: "types", diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 044074d..8844da3 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -19,7 +19,7 @@ import { type VideoHiddenMeta, type VideoWatchedMeta, } from "../lib/notifications"; -import { channelYouTubeUrl } from "../lib/format"; +import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import type { Page } from "../lib/urlState"; import { LEVEL_STYLE } from "./Toaster"; @@ -29,22 +29,6 @@ import { LEVEL_STYLE } from "./Toaster"; // there's one indicator and one place to look. Transient toasts still pop via the Toaster. const POLL_MS = 8000; -function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { - if (!iso) return ""; - return relativeFromMs(new Date(iso).getTime(), t); -} - -function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string { - const secs = Math.max(0, Math.round((Date.now() - ms) / 1000)); - // Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes. - if (secs < 60) return t("notifications.time.justNow"); - const mins = Math.round(secs / 60); - if (mins < 60) return t("notifications.time.minutes", { count: mins }); - const hours = Math.round(mins / 60); - if (hours < 24) return t("notifications.time.hours", { count: hours }); - return t("notifications.time.days", { count: Math.round(hours / 24) }); -} - export default function NotificationsPanel({ filters, setFilters, @@ -253,7 +237,7 @@ function NotificationRow({
{title} - {relativeTime(n.created_at, t)} + {relativeTime(n.created_at)}
{body &&
{body}
} @@ -338,7 +322,7 @@ function ClientActivityRow({ {n.title &&
{n.title}
}
{n.message}
- {relativeFromMs(n.ts, t)} + {relativeFromMs(n.ts)} {needsAction && ( {t("notifications.actionNeeded")} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 5a2556d..394434a 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import i18n from "../i18n"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, @@ -31,7 +32,7 @@ function secsUntil(iso: string | null): number | null { } function fmtCountdown(s: number): string { - if (s <= 0) return "now"; + if (s <= 0) return i18n.t("time.eta.now"); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${s % 60}s`; diff --git a/frontend/src/i18n/locales/de/notifications.json b/frontend/src/i18n/locales/de/notifications.json index ec7f745..5c7fdb3 100644 --- a/frontend/src/i18n/locales/de/notifications.json +++ b/frontend/src/i18n/locales/de/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Kanalverwaltung", "openOnYouTube": "Auf YouTube öffnen", "unhidden": "Eingeblendet „{{title}}”", - "unwatched": "Als nicht angesehen markiert „{{title}}”", - "time": { - "justNow": "gerade eben", - "minutes": "vor {{count}} Min.", - "hours": "vor {{count}} Std.", - "days": "vor {{count}} T." - } + "unwatched": "Als nicht angesehen markiert „{{title}}”" } diff --git a/frontend/src/i18n/locales/de/time.json b/frontend/src/i18n/locales/de/time.json index 1f1424b..80a00f7 100644 --- a/frontend/src/i18n/locales/de/time.json +++ b/frontend/src/i18n/locales/de/time.json @@ -6,5 +6,14 @@ "daysAgo": "vor {{count}} T.", "weeksAgo": "vor {{count}} Wo.", "monthsAgo": "vor {{count}} Mon.", - "yearsAgo": "vor {{count}} J." + "yearsAgo": "vor {{count}} J.", + "eta": { + "now": "jetzt", + "done": "fertig", + "lessThanHour": "< 1 Stunde", + "hours_one": "~{{count}} Stunde", + "hours_other": "~{{count}} Stunden", + "days_one": "~{{count}} Tag", + "days_other": "~{{count}} Tage" + } } diff --git a/frontend/src/i18n/locales/en/notifications.json b/frontend/src/i18n/locales/en/notifications.json index 08a23dd..6a59040 100644 --- a/frontend/src/i18n/locales/en/notifications.json +++ b/frontend/src/i18n/locales/en/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Channel manager", "openOnYouTube": "Open on YouTube", "unhidden": "Unhidden “{{title}}”", - "unwatched": "Unwatched “{{title}}”", - "time": { - "justNow": "just now", - "minutes": "{{count}}m ago", - "hours": "{{count}}h ago", - "days": "{{count}}d ago" - } + "unwatched": "Unwatched “{{title}}”" } diff --git a/frontend/src/i18n/locales/en/time.json b/frontend/src/i18n/locales/en/time.json index c08ee22..f6d16ee 100644 --- a/frontend/src/i18n/locales/en/time.json +++ b/frontend/src/i18n/locales/en/time.json @@ -6,5 +6,14 @@ "daysAgo": "{{count}}d ago", "weeksAgo": "{{count}} wk ago", "monthsAgo": "{{count}} mo ago", - "yearsAgo": "{{count}} yr ago" + "yearsAgo": "{{count}} yr ago", + "eta": { + "now": "now", + "done": "done", + "lessThanHour": "< 1 hour", + "hours_one": "~{{count}} hour", + "hours_other": "~{{count}} hours", + "days_one": "~{{count}} day", + "days_other": "~{{count}} days" + } } diff --git a/frontend/src/i18n/locales/hu/notifications.json b/frontend/src/i18n/locales/hu/notifications.json index e305c4b..5c37dbf 100644 --- a/frontend/src/i18n/locales/hu/notifications.json +++ b/frontend/src/i18n/locales/hu/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Csatornakezelő", "openOnYouTube": "Megnyitás a YouTube-on", "unhidden": "Megjelenítve: „{{title}}”", - "unwatched": "Megtekintés visszavonva: „{{title}}”", - "time": { - "justNow": "az imént", - "minutes": "{{count}} perce", - "hours": "{{count}} órája", - "days": "{{count}} napja" - } + "unwatched": "Megtekintés visszavonva: „{{title}}”" } diff --git a/frontend/src/i18n/locales/hu/time.json b/frontend/src/i18n/locales/hu/time.json index f3413b9..71ce423 100644 --- a/frontend/src/i18n/locales/hu/time.json +++ b/frontend/src/i18n/locales/hu/time.json @@ -6,5 +6,14 @@ "daysAgo": "{{count}} napja", "weeksAgo": "{{count}} hete", "monthsAgo": "{{count}} hónapja", - "yearsAgo": "{{count}} éve" + "yearsAgo": "{{count}} éve", + "eta": { + "now": "most", + "done": "kész", + "lessThanHour": "< 1 óra", + "hours_one": "~{{count}} óra", + "hours_other": "~{{count}} óra", + "days_one": "~{{count}} nap", + "days_other": "~{{count}} nap" + } } diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index d7ebe17..e0bb0d4 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -2,8 +2,14 @@ import i18n from "../i18n"; export function relativeTime(iso: string | null): string { if (!iso) return ""; - const then = new Date(iso).getTime(); - const secs = Math.max(0, (Date.now() - then) / 1000); + return relativeFromMs(new Date(iso).getTime()); +} + +/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for + * client-side notifications whose timestamps are already numbers). One implementation, one + * set of `time.*` strings — shared instead of re-implemented per component. */ +export function relativeFromMs(ms: number): string { + const secs = Math.max(0, (Date.now() - ms) / 1000); const units: [number, string][] = [ [60, "time.secondsAgo"], [3600, "time.minutesAgo"], @@ -51,17 +57,21 @@ export function quotaActionLabel(action: string): string { return i18n.t(`quotaActions.${action}`, { defaultValue: action }); } -/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */ +/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated + * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */ export function formatEta(seconds: number): string { - if (seconds <= 0) return "done"; + if (seconds <= 0) return i18n.t("time.eta.done"); const hours = seconds / 3600; - if (hours < 1) return "< 1 hour"; - if (hours < 24) { - const h = Math.round(hours); - return `~${h} hour${h === 1 ? "" : "s"}`; - } - const d = Math.round(hours / 24); - return `~${d} day${d === 1 ? "" : "s"}`; + if (hours < 1) return i18n.t("time.eta.lessThanHour"); + if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) }); + return i18n.t("time.eta.days", { count: Math.round(hours / 24) }); +} + +/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */ +export function formatTotalHours(sec: number): string { + if (!sec) return "—"; + const h = Math.round(sec / 3600); + return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; } export function formatViews(n: number | null): string {