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 }}
- TTL: {{ data.ttl }}s
+ TTL: {{ formatTtl(data.ttl, t) }}
{{ t("redis.noExpiry") }}
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;
+}