From 941dfd5f82a90c167a3dbbbb941f07b2bbbc4b16 Mon Sep 17 00:00:00 2001 From: lexmin0412 Date: Wed, 24 Jun 2026 12:53:59 +0800 Subject: [PATCH] feat(redis): optimize TTL display with human-readable format (#1689) --- .../src/components/redis/RedisValueViewer.vue | 3 +- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../src/lib/__tests__/ttlFormat.spec.ts | 163 ++++++++++++++++++ apps/desktop/src/lib/ttlFormat.ts | 26 +++ 10 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/__tests__/ttlFormat.spec.ts create mode 100644 apps/desktop/src/lib/ttlFormat.ts diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index 346a5bac1..4fa13a3d8 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -17,6 +17,7 @@ import { useTheme } from "@/composables/useTheme"; import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle"; import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redisJsonHighlighter"; import { copyToClipboard } from "@/lib/clipboard"; +import { formatTtl } from "@/lib/ttlFormat"; import { canEditRedisMemberDetail, clampRedisMemberDetailSheetWidth, formatRedisMemberDetail, getRedisMemberSelectionKey } from "@/lib/redisValuePresentation"; const { t } = useI18n(); @@ -783,7 +784,7 @@ onBeforeUnmount(() => { {{ data.key_type }} {{ t("redis.columnSize") }}: {{ metadataSizeLabel }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3667e3ac8..5de819b45 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1574,6 +1574,7 @@ export default { loadedMembers: "{loaded} of {total} members loaded", entries: "{count} entries", noExpiry: "no expiry", + ttlDay: "{count}d", columnType: "Type", columnKey: "Key", columnValue: "Value", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 75fc7fafc..3eefa0e8b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1308,6 +1308,7 @@ export default { loadedMembers: "{loaded} de {total} miembros cargados", entries: "{count} entradas", noExpiry: "sin expiración", + ttlDay: "{count}d", columnType: "Tipo", columnKey: "Clave", columnValue: "Valor", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 229be7cc3..dd8143c39 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1432,6 +1432,7 @@ export default { loadedMembers: "{loaded} di {total} membri caricati", entries: "{count} voci", noExpiry: "nessuna scadenza", + ttlDay: "{count}g", columnType: "Tipo", columnKey: "Chiave", columnValue: "Valore", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 57e70a81a..04ae31a0b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1561,6 +1561,7 @@ export default { loadedMembers: "{loaded}/{total}メンバー読み込み完了", entries: "{count}エントリ", noExpiry: "期限なし", + ttlDay: "{count}日", columnType: "型", columnKey: "キー", columnValue: "値", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 89cf97736..e5e7f0294 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1443,6 +1443,7 @@ export default { loadedMembers: "{loaded} de {total} membros carregados", entries: "{count} entradas", noExpiry: "sem expiração", + ttlDay: "{count}d", columnType: "Tipo", columnKey: "Chave", columnValue: "Valor", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index f7298f507..bc044e521 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1573,6 +1573,7 @@ export default { loadedMembers: "已加载 {loaded} / 共 {total} 个成员", entries: "{count} 条记录", noExpiry: "永不过期", + ttlDay: "{count}天", columnType: "类型", columnKey: "键", columnValue: "值", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e4ae04029..b5e44ca6b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1423,6 +1423,7 @@ export default { loadedMembers: "已載入 {loaded} / 共 {total} 個成員", entries: "{count} 筆項目", noExpiry: "永不過期", + ttlDay: "{count}天", columnType: "類型", columnKey: "鍵", columnValue: "值", diff --git a/apps/desktop/src/lib/__tests__/ttlFormat.spec.ts b/apps/desktop/src/lib/__tests__/ttlFormat.spec.ts new file mode 100644 index 000000000..7284accd7 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ttlFormat.spec.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { formatTtl } from "@/lib/ttlFormat"; + +// --------------------------------------------------------------------------- +// Mock `t()` helpers — only the day unit needs localization +// --------------------------------------------------------------------------- + +function enT(key: string, options?: Record): string { + const messages: Record = { + "redis.ttlDay": "{count}d", + }; + const msg = messages[key] ?? key; + const count = (options as { count?: number })?.count; + if (count != null) return msg.replace("{count}", String(count)); + return msg; +} + +function zhT(key: string, options?: Record): string { + const messages: Record = { + "redis.ttlDay": "{count}天", + }; + const msg = messages[key] ?? key; + const count = (options as { count?: number })?.count; + if (count != null) return msg.replace("{count}", String(count)); + return msg; +} + +function esT(key: string, options?: Record): string { + const messages: Record = { + "redis.ttlDay": "{count}d", + }; + const msg = messages[key] ?? key; + const count = (options as { count?: number })?.count; + if (count != null) return msg.replace("{count}", String(count)); + return msg; +} + +function itT(key: string, options?: Record): string { + const messages: Record = { + "redis.ttlDay": "{count}g", + }; + const msg = messages[key] ?? key; + const count = (options as { count?: number })?.count; + if (count != null) return msg.replace("{count}", String(count)); + return msg; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("formatTtl — special values", () => { + it("returns null for -1 (no expiry)", () => { + expect(formatTtl(-1, enT)).toBeNull(); + }); + + it("returns null for 0", () => { + expect(formatTtl(0, enT)).toBeNull(); + expect(formatTtl(0, zhT)).toBeNull(); + }); + + it("returns null for negative values other than -1", () => { + expect(formatTtl(-2, enT)).toBeNull(); + }); +}); + +describe("formatTtl — sub-day, HH:mm:ss (no i18n)", () => { + it("formats 45s as 00:00:45", () => { + expect(formatTtl(45, enT)).toBe("00:00:45"); + }); + + it("formats 1m as 00:01:00", () => { + expect(formatTtl(60, enT)).toBe("00:01:00"); + }); + + it("formats 1m 30s as 00:01:30", () => { + expect(formatTtl(90, enT)).toBe("00:01:30"); + }); + + it("formats 2m 5s as 00:02:05", () => { + expect(formatTtl(125, enT)).toBe("00:02:05"); + }); + + it("formats 1h as 01:00:00", () => { + expect(formatTtl(3600, enT)).toBe("01:00:00"); + }); + + it("formats 1h 1m 1s as 01:01:01", () => { + expect(formatTtl(3661, enT)).toBe("01:01:01"); + }); + + it("formats 23h 59m 59s as 23:59:59", () => { + expect(formatTtl(86399, enT)).toBe("23:59:59"); + }); +}); + +describe("formatTtl — multi-day, {n}d HH:mm:ss (day unit localized)", () => { + it("formats exactly 1 day as 1d 00:00:00", () => { + expect(formatTtl(86400, enT)).toBe("1d 00:00:00"); + expect(formatTtl(86400, zhT)).toBe("1天 00:00:00"); + }); + + it("formats 7 days as 7d 00:00:00", () => { + expect(formatTtl(604800, enT)).toBe("7d 00:00:00"); + expect(formatTtl(604800, zhT)).toBe("7天 00:00:00"); + }); + + it("formats 365 days as 365d 00:00:00", () => { + expect(formatTtl(31536000, enT)).toBe("365d 00:00:00"); + expect(formatTtl(31536000, zhT)).toBe("365天 00:00:00"); + }); + + it("formats 1d 1h 1m 1s", () => { + expect(formatTtl(90061, enT)).toBe("1d 01:01:01"); + expect(formatTtl(90061, zhT)).toBe("1天 01:01:01"); + }); + + it("formats 2d 3h 4m 5s", () => { + const ttl = 2 * 86400 + 3 * 3600 + 4 * 60 + 5; // 183845 + expect(formatTtl(ttl, enT)).toBe("2d 03:04:05"); + expect(formatTtl(ttl, zhT)).toBe("2天 03:04:05"); + }); +}); + +describe("formatTtl — Spanish locale", () => { + it("formats sub-day values with no i18n", () => { + expect(formatTtl(45, esT)).toBe("00:00:45"); + expect(formatTtl(3661, esT)).toBe("01:01:01"); + }); + + it("formats multi-day with localized d", () => { + expect(formatTtl(86400, esT)).toBe("1d 00:00:00"); + expect(formatTtl(604800, esT)).toBe("7d 00:00:00"); + }); + + it("returns null for -1", () => { + expect(formatTtl(-1, esT)).toBeNull(); + }); + + it("returns null for 0", () => { + expect(formatTtl(0, esT)).toBeNull(); + }); +}); + +describe("formatTtl — Italian locale", () => { + it("formats sub-day values with no i18n", () => { + expect(formatTtl(45, itT)).toBe("00:00:45"); + expect(formatTtl(3661, itT)).toBe("01:01:01"); + }); + + it("formats multi-day with localized g", () => { + expect(formatTtl(86400, itT)).toBe("1g 00:00:00"); + expect(formatTtl(604800, itT)).toBe("7g 00:00:00"); + }); + + it("returns null for -1", () => { + expect(formatTtl(-1, itT)).toBeNull(); + }); + + it("returns null for 0", () => { + expect(formatTtl(0, itT)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/ttlFormat.ts b/apps/desktop/src/lib/ttlFormat.ts new file mode 100644 index 000000000..1ddab936d --- /dev/null +++ b/apps/desktop/src/lib/ttlFormat.ts @@ -0,0 +1,26 @@ +/** + * Formats Redis TTL (seconds) as a compact string: + * < 1 day → HH:mm:ss + * >= 1 day → {n}d HH:mm:ss (day unit localized) + * + * Returns null for ttl === -1 (no expiry) or ttl <= 0. + */ + +type TranslateFn = (key: string, options?: Record) => string; + +export function formatTtl(ttl: number, t: TranslateFn): string | null { + if (ttl === -1) return null; + if (ttl <= 0) return null; + + const days = Math.floor(ttl / 86_400); + const hours = Math.floor((ttl % 86_400) / 3_600); + const minutes = Math.floor((ttl % 3_600) / 60); + const seconds = ttl % 60; + + const time = [String(hours).padStart(2, "0"), String(minutes).padStart(2, "0"), String(seconds).padStart(2, "0")].join(":"); + + if (days > 0) { + return `${t("redis.ttlDay", { count: days })} ${time}`; + } + return time; +}