diff --git a/apps/subtrack/src/__tests__/commands.test.ts b/apps/subtrack/src/__tests__/commands.test.ts index e17a745..dac9fc0 100644 --- a/apps/subtrack/src/__tests__/commands.test.ts +++ b/apps/subtrack/src/__tests__/commands.test.ts @@ -397,8 +397,8 @@ test("handleExport with currency falls back when fetch fails", async () => { const { handleExport } = await import("../commands.ts") await handleExport("csv", { currency: "USD" }) - expect(failMessages.length).toBeGreaterThan(0) - expect(failMessages[0]).toContain("Failed to fetch exchange rates") + expect(warnMessages.length).toBeGreaterThan(0) + expect(warnMessages[0]).toContain("Failed to fetch exchange rates") globalThis.fetch = async () => new Response(JSON.stringify({ base: "USD", rates: { JPY: 160, USD: 1 } })) @@ -1346,11 +1346,12 @@ test("handleUsageTotal display shows tokens and by model", async () => { const { handleUsageTotal } = await import("../usage-total.ts") handleUsageTotal({ from: "2026-06-01", to: "2026-06-30" }) const combined = logMessages.join("\n") - expect(combined).toContain("By provider") - expect(combined).toContain("By model") - expect(combined).toContain("gpt-4o") - expect(combined).toContain("Tokens: 100 in / 50 out") - expect(combined).toContain("Total: $0.01") // cost 1.0 = 1 cent + const plain = combined.replace(/\x1b\[[0-9;]*m/g, "") + expect(plain).toContain("By provider") + expect(plain).toContain("By model") + expect(plain).toContain("gpt-4o") + expect(plain).toContain("Tokens: 100 in / 50 out") + expect(plain).toContain("Total: $0.01") // cost 1.0 = 1 cent }) test("handleUsageTotal shows info when no usage in range", async () => { diff --git a/apps/subtrack/src/__tests__/config.test.ts b/apps/subtrack/src/__tests__/config.test.ts index d162670..da1949c 100644 --- a/apps/subtrack/src/__tests__/config.test.ts +++ b/apps/subtrack/src/__tests__/config.test.ts @@ -174,3 +174,80 @@ test("webhook secrets are masked in list, get, and set output", async () => { // The real value is still stored expect(loadConfig().slackWebhook).toBe(SECRET) }) + +// ── Display theme keys ─────────────────────────────── + +test("theme accepts preset names and rejects unknown", async () => { + const { handleConfigSet, handleConfigGet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("theme", "light") + expect(loadConfig().theme).toBe("light") + expect(successMessages.some((m) => m.includes("theme = light"))).toBe(true) + + resetConfig() + handleConfigSet("theme", "vaporwave") + expect(errorMessages.some((m) => m.includes("theme must be one of"))).toBe(true) + expect(loadConfig().theme).not.toBe("vaporwave") +}) + +test("color keys validate color names", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("tableBorderColor", "brightMagenta") + expect(loadConfig().tableBorderColor).toBe("brightMagenta") + + resetConfig() + handleConfigSet("accentColor", "notacolor") + expect(errorMessages.some((m) => m.includes("must be a color name"))).toBe(true) + expect(loadConfig().accentColor).toBeUndefined() + + resetConfig() + handleConfigSet("tableHeaderColor", "cyan") + expect(loadConfig().tableHeaderColor).toBe("cyan") +}) + +test("tableZebra and tableMinWidth validate values", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("tableZebra", "off") + expect(loadConfig().tableZebra).toBe("off") + + resetConfig() + handleConfigSet("tableZebra", "maybe") + expect(errorMessages.some((m) => m.includes("tableZebra must be"))).toBe(true) + + resetConfig() + handleConfigSet("tableMinWidth", "120") + expect(loadConfig().tableMinWidth).toBe(120) + + resetConfig() + handleConfigSet("tableMinWidth", "10") + expect(errorMessages.some((m) => m.includes("tableMinWidth must be"))).toBe(true) +}) + +test("dateFormat and listShow keys validate values", async () => { + const { handleConfigSet } = await import("../commands.ts") + const { loadConfig, resetConfig } = await import("../config.ts") + + resetConfig() + handleConfigSet("dateFormat", "short") + expect(loadConfig().dateFormat).toBe("short") + + resetConfig() + handleConfigSet("dateFormat", "long") + expect(errorMessages.some((m) => m.includes("dateFormat must be"))).toBe(true) + + resetConfig() + handleConfigSet("listShowNotes", "on") + expect(loadConfig().listShowNotes).toBe("on") + + resetConfig() + handleConfigSet("listShowMethod", "yes") + expect(errorMessages.some((m) => m.includes("must be 'on' or 'off'"))).toBe(true) +}) diff --git a/apps/subtrack/src/__tests__/display-constants.test.ts b/apps/subtrack/src/__tests__/display-constants.test.ts new file mode 100644 index 0000000..0657470 --- /dev/null +++ b/apps/subtrack/src/__tests__/display-constants.test.ts @@ -0,0 +1,203 @@ +import { test, expect, describe, beforeEach, afterEach } from "vitest" +import { unlinkSync } from "node:fs" +import { + statusColor, + sectionTitle, + divider, + zebraRow, + calcColumnWidths, + getDisplayTheme, + isPlainTheme, + getTableStyle, +} from "../display-constants.ts" +import { loadConfig, saveConfig, resetConfig, getConfigPath } from "../config.ts" + +const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + +beforeEach(() => { + resetConfig() + try { unlinkSync(getConfigPath()) } catch { /* no config yet */ } +}) + +afterEach(() => { + resetConfig() + try { unlinkSync(getConfigPath()) } catch { /* no config yet */ } +}) + +describe("statusColor", () => { + test("colors active green", () => { + expect(stripAnsi(statusColor("active"))).toBe("active") + expect(statusColor("active")).toContain("\x1b[32m") + }) + + test("colors paused yellow", () => { + expect(statusColor("paused")).toContain("\x1b[33m") + }) + + test("colors cancelled red", () => { + expect(statusColor("cancelled")).toContain("\x1b[31m") + }) + + test("colors archived gray", () => { + expect(statusColor("archived")).toContain("\x1b[90m") + }) +}) + +describe("sectionTitle", () => { + test("wraps text in bold cyan ── markers", () => { + const t = sectionTitle("Database Statistics") + expect(stripAnsi(t)).toBe("── Database Statistics ──") + expect(t).toContain("\x1b[1m") // bold + expect(t).toContain("\x1b[36m") // cyan + }) +}) + +describe("divider", () => { + test("renders a dim line with the given length", () => { + const d = divider(20) + expect(stripAnsi(d)).toBe("─".repeat(20)) + expect(d).toContain("\x1b[2m") // dim + }) + + test("defaults to 40 chars", () => { + expect(stripAnsi(divider())).toBe("─".repeat(40)) + }) +}) + +describe("zebraRow", () => { + test("wraps every cell with the zebra background", () => { + const cells = ["a", "b"] + const out = zebraRow(cells) + expect(out).toEqual([ + "\x1b[100ma\x1b[0m", + "\x1b[100mb\x1b[0m", + ]) + }) +}) + +describe("calcColumnWidths", () => { + const config = { + headers: ["name", "status", "cycle", "tags", "price"] as const, + minWidths: [10, 8, 6, 8, 8] as const, + maxWidths: [40, 12, 20, 60, 20] as const, + } + + test("respects min and max widths", () => { + const widths = calcColumnWidths( + [["a", "active", "monthly", "-", "$10.00"]], + config, + ) + expect(widths.length).toBe(5) + expect(widths[0]).toBeGreaterThanOrEqual(10) + expect(widths[0]).toBeLessThanOrEqual(40) + }) + + test("allocates more width to longer content", () => { + const rows = [ + ["a very long subscription name here", "active", "monthly", "-", "$10.00"], + ["x", "paused", "yearly", "tag1, tag2, tag3", "$999.99"], + ] + const widths = calcColumnWidths(rows, config) + // name column gets more room than the tiny status column + expect(widths[0]).toBeGreaterThan(widths[1]) + }) + + test("sum of widths stays within the terminal width", () => { + const rows = [ + ["name", "active", "monthly", "-", "$10.00"], + ["another subscription", "paused", "yearly", "a, b, c", "$99.00"], + ] + const widths = calcColumnWidths(rows, config) + const sum = widths.reduce((a, b) => a + b, 0) + const termWidth = process.stdout.columns ?? 80 + expect(sum).toBeLessThanOrEqual(termWidth) + }) + + test("honors tableMinWidth from config", () => { + saveConfig({ ...loadConfig(), tableMinWidth: 100 }) + const widths = calcColumnWidths( + [["a", "active", "monthly", "-", "$10.00"]], + config, + ) + expect(widths.reduce((a, b) => a + b, 0)).toBeGreaterThanOrEqual(80) + resetConfig() + }) +}) + +describe("getDisplayTheme", () => { + test("uses default preset when no config", () => { + const t = getDisplayTheme() + expect(t.border).toBe("gray") + expect(t.header).toBe("brightBlue") + expect(t.zebra).toBe("gray") + expect(t.accent).toBe("cyan") + expect(t.statusActive).toBe("green") + expect(t.statusArchived).toBe("gray") + expect(isPlainTheme()).toBe(false) + }) + + test("none theme resolves to plain output", () => { + saveConfig({ ...loadConfig(), theme: "none" }) + const t = getDisplayTheme() + expect(t.border).toBeNull() + expect(t.header).toBeNull() + expect(t.zebra).toBeNull() + expect(t.accent).toBeNull() + expect(t.statusActive).toBeNull() + expect(isPlainTheme()).toBe(true) + // Emitted helpers are uncolored + expect(zebraRow(["a"])).toEqual(["a"]) + expect(sectionTitle("X")).not.toContain("\x1b[") + expect(statusColor("active")).toBe("active") + resetConfig() + }) + + test("individual keys override the preset", () => { + saveConfig({ + ...loadConfig(), + theme: "light", + tableBorderColor: "brightMagenta", + accentColor: "yellow", + }) + const t = getDisplayTheme() + expect(t.border).toBe("brightMagenta") + expect(t.header).toBe("blue") // from light preset + expect(t.accent).toBe("yellow") + resetConfig() + }) + + test("tableZebra off disables striping", () => { + saveConfig({ ...loadConfig(), tableZebra: "off" }) + expect(getDisplayTheme().zebra).toBeNull() + expect(zebraRow(["a"])).toEqual(["a"]) + resetConfig() + }) + + test("invalid color names fall back to the preset", () => { + saveConfig({ ...loadConfig(), tableBorderColor: "notacolor" }) + expect(getDisplayTheme().border).toBe("gray") + resetConfig() + }) + + test("unknown theme falls back to default preset", () => { + saveConfig({ ...loadConfig(), theme: "vaporwave" }) + expect(getDisplayTheme().header).toBe("brightBlue") + resetConfig() + }) +}) + +describe("getTableStyle", () => { + test("emits border and head colors for the default theme", () => { + const style = getTableStyle() + expect(style.border).toEqual(["\x1b[90m", "\x1b[0m"]) + expect(style.head).toEqual(["\x1b[1m\x1b[94m", "\x1b[0m"]) + }) + + test("emits empty codes for the none theme", () => { + saveConfig({ ...loadConfig(), theme: "none" }) + const style = getTableStyle() + expect(style.border).toEqual(["", ""]) + expect(style.head).toEqual(["", ""]) + resetConfig() + }) +}) diff --git a/apps/subtrack/src/__tests__/display.test.ts b/apps/subtrack/src/__tests__/display.test.ts index 900d984..904314f 100644 --- a/apps/subtrack/src/__tests__/display.test.ts +++ b/apps/subtrack/src/__tests__/display.test.ts @@ -9,6 +9,7 @@ const logMessages: string[] = [] const infoMessages: string[] = [] const failMessages: string[] = [] const errorMessages: string[] = [] +const warnMessages: string[] = [] let originalFetch: typeof globalThis.fetch @@ -17,6 +18,7 @@ beforeEach(() => { infoMessages.length = 0 failMessages.length = 0 errorMessages.length = 0 + warnMessages.length = 0 const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") @@ -28,6 +30,7 @@ beforeEach(() => { if (_type === "info") infoMessages.push(clean) if (_type === "fail") failMessages.push(clean) if (_type === "error") errorMessages.push(clean) + if (_type === "warn") warnMessages.push(clean) } }) @@ -247,9 +250,9 @@ test("currency falls back when fetch fails", async () => { "JPY", ) - // Should log fail message - expect(failMessages.length).toBeGreaterThan(0) - expect(failMessages[0]).toContain("Failed to fetch exchange rates") + // Should log warn message + expect(warnMessages.length).toBeGreaterThan(0) + expect(warnMessages[0]).toContain("Failed to fetch exchange rates") // Should fall back to grouped-by-currency display const table = logMessages.filter(Boolean).join("\n") @@ -481,9 +484,9 @@ test("showPayment --currency falls back when fetch fails", async () => { makeSub({ name: "US", price: 10, currency: "USD" }), ]) - // Should log fail message - expect(failMessages.length).toBeGreaterThan(0) - expect(failMessages[0]).toContain("Failed to fetch exchange rates") + // Should log warn message + expect(warnMessages.length).toBeGreaterThan(0) + expect(warnMessages[0]).toContain("Failed to fetch exchange rates") // Should fall back to per-currency display const combined = logMessages.join("\n") diff --git a/apps/subtrack/src/analytics.ts b/apps/subtrack/src/analytics.ts index 800fd6b..32ccdf2 100644 --- a/apps/subtrack/src/analytics.ts +++ b/apps/subtrack/src/analytics.ts @@ -24,7 +24,7 @@ export async function handleAnalytics(options: AnalyticsOptions = {}): Promise = {} if (rates) { @@ -69,7 +69,7 @@ export async function showAnalytics(options: AnalyticsOptions = {}): Promise [String(e.id), formatAction(e.action), e.target_type ?? "", e.details ?? ""]), AUDIT_COLS) const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: colWidths, - head: headers, + head: [...headers], colAligns: ["right", "left", "left", "left"], }) @@ -112,7 +112,7 @@ export function handleAuditList(options: { const ts = formatTimestamp(e.created_at) const row = [String(e.id), action, target, `${ts} ${details}`] if (i % 2 === 0) { - table.push(row.map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } diff --git a/apps/subtrack/src/budget.ts b/apps/subtrack/src/budget.ts index cdd358b..3b8789b 100644 --- a/apps/subtrack/src/budget.ts +++ b/apps/subtrack/src/budget.ts @@ -134,7 +134,7 @@ export async function handleBudget(options: BudgetOptions = {}): Promise { try { rates = await fetchFxRates() } catch { - consola.warn("Failed to fetch exchange rates; comparing in original currencies") + consola.warn("Failed to fetch exchange rates; showing in original currencies") } } @@ -225,10 +225,10 @@ export async function handleBudget(options: BudgetOptions = {}): Promise { const budgetLabel = budget.name ? `Budget (${budget.name})` : "Budget" consola.log( - `${periodName} spending: ${pc.bold(formatPrice(Math.round(spending), currency))}/${periodLabel}`, + `${periodName} spending: ${pc.bold(pc.yellow(formatPrice(Math.round(spending), currency)))}/${periodLabel}`, ) consola.log( - `${budgetLabel}: ${pc.bold(formatPrice(Math.round(budgetDisplay), currency))}/${periodLabel}` + + `${budgetLabel}: ${pc.bold(pc.yellow(formatPrice(Math.round(budgetDisplay), currency)))}/${periodLabel}` + (budget.currency !== currency ? ` (${budget.currency})` : ""), ) if (over) { diff --git a/apps/subtrack/src/calendar.ts b/apps/subtrack/src/calendar.ts index be8a62d..645db0b 100644 --- a/apps/subtrack/src/calendar.ts +++ b/apps/subtrack/src/calendar.ts @@ -2,10 +2,11 @@ import { consola } from "consola" import pc from "picocolors" import { getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" -import type { SharedArgs, Currency } from "./types.ts" +import type { SharedArgs, Currency, Status } from "./types.ts" import { fetchFxRates, tryConvert } from "./fx.ts" import type { FxRates } from "./fx.ts" import { toDate, clampDay, daysInMonth } from "./date-utils.ts" +import { statusColor } from "./display-constants.ts" /** Options for the calendar command */ export type CalendarOptions = { @@ -24,7 +25,7 @@ export type CalendarEntry = { /** Day of month (1-31) */ day: number /** Subscriptions billing on this day */ - subs: { name: string; price: number; currency: string; status: string; id: number }[] + subs: { name: string; price: number; currency: string; status: Status; id: number }[] } /** @@ -201,10 +202,8 @@ export async function showCalendar(options: CalendarOptions): Promise { for (const entry of entries) { for (const sub of entry.subs) { - const statusStyle = - sub.status === "active" ? pc.green : sub.status === "paused" ? pc.yellow : pc.dim consola.log( - ` ${pc.cyan(`Day ${String(entry.day).padStart(2)}`)} ${pc.bold(sub.name)} ${formatPrice(sub.price, sub.currency)} ${statusStyle(sub.status)}`, + ` ${pc.cyan(`Day ${String(entry.day).padStart(2)}`)} ${pc.bold(sub.name)} ${formatPrice(sub.price, sub.currency)} ${statusColor(sub.status)}`, ) currencyTotals[sub.currency] = (currencyTotals[sub.currency] ?? 0) + sub.price totalSubs++ diff --git a/apps/subtrack/src/cancel.ts b/apps/subtrack/src/cancel.ts index ac38adc..73e0bc9 100644 --- a/apps/subtrack/src/cancel.ts +++ b/apps/subtrack/src/cancel.ts @@ -7,9 +7,10 @@ import path from "node:path" import { getSubscription, updateSubscription } from "./db.ts" import { logAudit } from "./audit.ts" import { fail } from "./error.ts" +import { loadConfig } from "./config.ts" import { formatPrice } from "./price.ts" import { calculateNextBilling } from "./upcoming.ts" -import { today, formatDate } from "./date-utils.ts" +import { today, formatDate, formatShortDate } from "./date-utils.ts" import { exportCsv } from "./export.ts" import { safeOutputPath } from "./path-utils.ts" import type { AddSharedArgs } from "./types.ts" @@ -67,7 +68,8 @@ export async function handleCancel(id: number, options: CancelOptions = {}): Pro consola.log(pc.bold(`Cancelling: ${sub.name}`)) consola.log(` Price: ${formatPrice(sub.price, sub.currency)}/${sub.cycle}`) - consola.log(` Next billing: ${formatDate(nextBilling)}`) + const fmt = loadConfig().dateFormat === "short" ? formatShortDate : formatDate + consola.log(` Next billing: ${fmt(nextBilling)}`) consola.log("") let noteCancellationDate = false diff --git a/apps/subtrack/src/color.ts b/apps/subtrack/src/color.ts new file mode 100644 index 0000000..2f8dfe3 --- /dev/null +++ b/apps/subtrack/src/color.ts @@ -0,0 +1,34 @@ +/** + * Named ANSI color system for display theming. + * Shared by display-constants (theme resolution) and config (validation). + */ + +/** Named ANSI colors configurable by the user. */ +export type ColorName = + | "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" + | "gray" | "brightRed" | "brightGreen" | "brightYellow" | "brightBlue" + | "brightMagenta" | "brightCyan" | "brightWhite" + +const FG_CODES: Record = { + black: 30, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, + gray: 90, brightRed: 91, brightGreen: 92, brightYellow: 93, brightBlue: 94, + brightMagenta: 95, brightCyan: 96, brightWhite: 97, +} + +const BG_CODES: Record = Object.fromEntries( + Object.entries(FG_CODES).map(([name, code]) => [name, code + 10]), +) as Record + +export function isColorName(value: string): value is ColorName { + return value in FG_CODES +} + +/** Foreground ANSI prefix for a color name, or "" when null. */ +export function fgCode(name: ColorName | null): string { + return name ? `\x1b[${FG_CODES[name]}m` : "" +} + +/** Background ANSI prefix for a color name, or "" when null. */ +export function bgCode(name: ColorName | null): string { + return name ? `\x1b[${BG_CODES[name]}m` : "" +} \ No newline at end of file diff --git a/apps/subtrack/src/compare.ts b/apps/subtrack/src/compare.ts index 8bf966b..abf8a3b 100644 --- a/apps/subtrack/src/compare.ts +++ b/apps/subtrack/src/compare.ts @@ -8,7 +8,8 @@ import { formatPrice } from "./price.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" import { calcSubTotal } from "./payment.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" +import { TABLE_CHARS, getTableStyle, calcColumnWidths } from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" type PeriodLabel = string @@ -60,10 +61,22 @@ function renderCompareTable( currentLabel: string, previousLabel: string, ): void { + const headers = ["", currentLabel, previousLabel, "Change"] as const + const COMPARE_COLS: ColumnConfig = { + headers, + minWidths: [10, 12, 12, 16] as const, + maxWidths: [40, 20, 20, 30] as const, + } + const colWidths = calcColumnWidths( + rows.map((r) => [r.label, r.current, r.previous, r.change]), + COMPARE_COLS, + ) + const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, - head: ["", currentLabel, previousLabel, "Change"], + style: getTableStyle(), + colWidths, + head: [...headers], colAligns: ["left", "right", "right", "right"], }) @@ -112,7 +125,7 @@ export async function showCompare( try { rates = await fetchFxRates() } catch { - consola.fail("Failed to fetch exchange rates; showing per-currency totals") + consola.warn("Failed to fetch exchange rates; showing in original currencies") } } diff --git a/apps/subtrack/src/config.ts b/apps/subtrack/src/config.ts index ee91967..7bee105 100644 --- a/apps/subtrack/src/config.ts +++ b/apps/subtrack/src/config.ts @@ -6,6 +6,7 @@ import { safeJsonParse } from "./safe-json.ts" import { encryptBuffer, decryptBuffer, hasEncryptionKey } from "./crypto.ts" import { logAudit } from "./audit.ts" import { fail } from "./error.ts" +import { isColorName } from "./color.ts" import type { SubtrackConfig } from "./types.ts" export const CONFIG_KEYS = [ @@ -17,6 +18,15 @@ export const CONFIG_KEYS = [ "notifyChannels", "slackWebhook", "webhookUrl", + "tableBorderColor", + "tableHeaderColor", + "tableZebraColor", + "accentColor", + "tableZebra", + "tableMinWidth", + "dateFormat", + "listShowNotes", + "listShowMethod", ] as const /** IMAP-related config keys (not stored directly on SubtrackConfig). */ @@ -131,9 +141,57 @@ export function setConfig(key: string, value: string): boolean { config.budgets = parsed break } - case "theme": + case "theme": { + const validThemes = ["default", "light", "high-contrast", "none"] + if (!validThemes.includes(value)) { + fail(`theme must be one of: ${validThemes.join(", ")}`) + return false + } config.theme = value break + } + case "tableBorderColor": + case "tableHeaderColor": + case "tableZebraColor": + case "accentColor": { + if (!isColorName(value)) { + fail(`"${key}" must be a color name: black, red, green, yellow, blue, magenta, cyan, white, gray, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite`) + return false + } + config[key] = value + break + } + case "tableZebra": + if (value !== "on" && value !== "off") { + fail("tableZebra must be 'on' or 'off'") + return false + } + config.tableZebra = value + break + case "tableMinWidth": { + const num = Number(value) + if (isNaN(num) || num < 20 || num > 200 || !Number.isInteger(num)) { + fail("tableMinWidth must be an integer between 20 and 200") + return false + } + config.tableMinWidth = num + break + } + case "dateFormat": + if (value !== "iso" && value !== "short") { + fail("dateFormat must be 'iso' or 'short'") + return false + } + config.dateFormat = value + break + case "listShowNotes": + case "listShowMethod": + if (value !== "on" && value !== "off") { + fail(`"${key}" must be 'on' or 'off'`) + return false + } + config[key] = value + break case "notifyDays": { const num = Number(value) if (isNaN(num) || num < 0 || !Number.isInteger(num)) { @@ -339,7 +397,10 @@ function getConfigDisplayValue(key: string, config: SubtrackConfig): string { case "notifyChannels": return config.notifyChannels?.length ? config.notifyChannels.join(",") : "(not set)" case "slackWebhook": return maskSecret(config.slackWebhook) case "webhookUrl": return maskSecret(config.webhookUrl) - default: return String((config as Record)[key] ?? "") + default: { + const v = (config as Record)[key] + return v === undefined || v === null || v === "" ? "(not set)" : String(v) + } } } diff --git a/apps/subtrack/src/currency.ts b/apps/subtrack/src/currency.ts index 67ca530..efdf66b 100644 --- a/apps/subtrack/src/currency.ts +++ b/apps/subtrack/src/currency.ts @@ -3,6 +3,7 @@ */ import { consola } from "consola" +import { sectionTitle } from "./display-constants.ts" import { CURRENCY_CHOICES } from "./prompts.ts" export type CurrencyListOptions = { @@ -15,7 +16,7 @@ export function handleCurrencyList(options: CurrencyListOptions = {}): void { return } - consola.log("── Supported Currencies ──") + consola.log(sectionTitle("Supported Currencies")) for (const c of CURRENCY_CHOICES) { consola.log(` ${c.value.padEnd(5)} ${c.name}`) } diff --git a/apps/subtrack/src/db/connection.ts b/apps/subtrack/src/db/connection.ts index c80d41f..e9c72f7 100644 --- a/apps/subtrack/src/db/connection.ts +++ b/apps/subtrack/src/db/connection.ts @@ -155,7 +155,9 @@ process.on("exit", releaseLock) export function getDbDir(): string { const dir = process.env.SUBSC_CLI_DB_DIR ?? path.join(homedir(), ".config", "subtrack") validateDbDir(dir) - return dir + // Normalize separators so downstream path.join() produces consistent + // paths on all platforms (e.g. "/tmp/x" on Windows becomes "\tmp\x") + return path.normalize(dir) } export function getDefaultBackupDir(): string { diff --git a/apps/subtrack/src/display-constants.ts b/apps/subtrack/src/display-constants.ts index d711b48..0a9390f 100644 --- a/apps/subtrack/src/display-constants.ts +++ b/apps/subtrack/src/display-constants.ts @@ -1,8 +1,17 @@ /** * Shared table rendering constants for cli-table3. * Extracted to avoid duplication across display, trial, forecast, compare, etc. + * Colors are theme-driven: resolved from config with preset fallbacks. */ +import type { Status } from "./types.ts" +import { loadConfig } from "./config.ts" +import type { ColorName } from "./color.ts" +import { isColorName, fgCode, bgCode } from "./color.ts" + +export type { ColorName } from "./color.ts" +export { isColorName, fgCode, bgCode } from "./color.ts" + export const TABLE_CHARS = { top: "─", "top-mid": "┬", @@ -21,10 +30,223 @@ export const TABLE_CHARS = { middle: "│", } as const -export const TABLE_STYLE = { - border: ["\x1b[90m", "\x1b[0m"], - head: ["\x1b[1m\x1b[38;5;75m", "\x1b[0m"], - "padding-left": 1, - "padding-right": 1, - compact: false, -} satisfies Record +/** Resolved display theme (all colors resolved, ready to emit). */ +export type ResolvedTheme = { + border: ColorName | null + header: ColorName | null + zebra: ColorName | null + accent: ColorName | null + statusActive: ColorName | null + statusPaused: ColorName | null + statusCancelled: ColorName | null + statusArchived: ColorName | null +} + +const PRESET_THEMES: Record = { + default: { + border: "gray", + header: "brightBlue", + zebra: "gray", + accent: "cyan", + statusActive: "green", + statusPaused: "yellow", + statusCancelled: "red", + statusArchived: "gray", + }, + light: { + border: "gray", + header: "blue", + zebra: "brightWhite", + accent: "blue", + statusActive: "green", + statusPaused: "yellow", + statusCancelled: "red", + statusArchived: "gray", + }, + "high-contrast": { + border: "white", + header: "white", + zebra: null, + accent: "white", + statusActive: "green", + statusPaused: "yellow", + statusCancelled: "red", + statusArchived: "white", + }, + none: { + border: null, + header: null, + zebra: null, + accent: null, + statusActive: null, + statusPaused: null, + statusCancelled: null, + statusArchived: null, + }, +} + +/** + * Resolve the display theme from config. + * Priority: individual color keys > preset (`theme`) > default preset. + * `tableZebra: "off"` disables zebra striping entirely. + */ +export function getDisplayTheme(): ResolvedTheme { + const config = loadConfig() + const preset = PRESET_THEMES[config.theme] ?? PRESET_THEMES.default + return { + border: config.tableBorderColor && isColorName(config.tableBorderColor) + ? config.tableBorderColor + : preset.border, + header: config.tableHeaderColor && isColorName(config.tableHeaderColor) + ? config.tableHeaderColor + : preset.header, + zebra: config.tableZebra === "off" + ? null + : config.tableZebraColor && isColorName(config.tableZebraColor) + ? config.tableZebraColor + : preset.zebra, + accent: config.accentColor && isColorName(config.accentColor) + ? config.accentColor + : preset.accent, + statusActive: preset.statusActive, + statusPaused: preset.statusPaused, + statusCancelled: preset.statusCancelled, + statusArchived: preset.statusArchived, + } +} + +/** True when the resolved theme emits no colors at all. */ +export function isPlainTheme(): boolean { + const t = getDisplayTheme() + return !t.border && !t.header && !t.zebra && !t.accent +} + +/** cli-table3 style object for the resolved theme. */ +export function getTableStyle(): Record { + const t = getDisplayTheme() + return { + border: t.border ? [fgCode(t.border), "\x1b[0m"] : ["", ""], + head: t.header ? [`\x1b[1m${fgCode(t.header)}`, "\x1b[0m"] : ["", ""], + "padding-left": 1, + "padding-right": 1, + compact: false, + } +} + +/** + * Wrap table cells with the zebra background for striping. + * Apply to every even-indexed row (i % 2 === 0). + * Returns cells unchanged when zebra is disabled by the theme. + */ +export function zebraRow(cells: string[]): string[] { + const zebra = getDisplayTheme().zebra + if (!zebra) return cells + return cells.map((cell) => `${bgCode(zebra)}${cell}\x1b[0m`) +} + +/** + * Colorize a subscription status consistently across all views. + * Canonical definition — do not redefine per module. + */ +export function statusColor(status: Status): string { + const t = getDisplayTheme() + const name = status === "active" ? t.statusActive + : status === "paused" ? t.statusPaused + : status === "cancelled" ? t.statusCancelled + : status === "archived" ? t.statusArchived + : null + if (!name) return status + return `${fgCode(name)}${status}\x1b[0m` +} + +/** + * Section heading used to separate report sections. + * e.g. `── Database Statistics ──` + */ +export function sectionTitle(text: string): string { + const t = getDisplayTheme() + if (!t.accent) return `── ${text} ──` + return `\x1b[1m${fgCode(t.accent)}── ${text} ──\x1b[0m` +} + +/** + * Horizontal divider line with a consistent width. + * Dimmed, except in plain (none) themes where it stays uncolored. + */ +export function divider(length = 40): string { + if (isPlainTheme()) return "─".repeat(length) + return `\x1b[2m${"─".repeat(length)}\x1b[0m` +} + +export type ColumnConfig = { + headers: readonly string[] + minWidths: readonly number[] + maxWidths: readonly number[] + /** Minimum available width (default 40) */ + minAvail?: number +} + +/** Border + left/right padding overhead of a cli-table3 table. */ +export const BORDER_AND_PADDING = 16 + +/** + * Calculate column widths that fit the terminal width. + * Allocates available width proportionally to content weight, clamped to + * min/max widths per column, then adjusts to exactly fit. + */ +export function calcColumnWidths(rows: string[][], config: ColumnConfig): number[] { + const termWidth = process.stdout.columns ?? 80 + const avail = Math.max( + config.minAvail ?? loadConfig().tableMinWidth ?? 40, + termWidth - BORDER_AND_PADDING, + ) + + const weights = config.headers.map((hdr, i) => { + let max = hdr.length + for (const row of rows) { + const len = row[i].length + if (len > max) max = len + } + return Math.min(max, config.maxWidths[i]) + }) + + const totalWeight = weights.reduce((a, b) => a + b, 0) + const widths = weights.map((w, i) => + Math.max( + config.minWidths[i], + Math.min(config.maxWidths[i], Math.round((avail * w) / totalWeight)), + ), + ) + + // Adjust to exactly fit avail + let sum = widths.reduce((a, b) => a + b, 0) + let diff = sum - avail + let iterations = 0 + + while (diff > 0 && iterations < 100) { + let idx = -1 + for (let i = 0; i < widths.length; i++) { + if (widths[i] > config.minWidths[i] && (idx === -1 || widths[i] > widths[idx])) + idx = i + } + if (idx === -1) break + widths[idx]-- + diff-- + iterations++ + } + + iterations = 0 + while (diff < 0 && iterations < 100) { + let idx = -1 + for (let i = 0; i < widths.length; i++) { + if (widths[i] < config.maxWidths[i] && (idx === -1 || weights[i] > weights[idx])) + idx = i + } + if (idx === -1) break + widths[idx]++ + diff++ + iterations++ + } + + return widths +} \ No newline at end of file diff --git a/apps/subtrack/src/display.ts b/apps/subtrack/src/display.ts index d11e89e..c8a5162 100644 --- a/apps/subtrack/src/display.ts +++ b/apps/subtrack/src/display.ts @@ -1,23 +1,21 @@ import { consola } from "consola" import pc from "picocolors" import CliTable3 from "cli-table3" -import type { SharedArgs, Currency, LlmUsageEntry, Status } from "./types.ts" +import type { SharedArgs, Currency, LlmUsageEntry } from "./types.ts" import { getSubscriptions } from "./db.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" -import { formatPrice } from "./price.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" - -function statusColor(status: Status): string { - switch (status) { - case "active": return pc.green("active") - case "paused": return pc.yellow("paused") - case "cancelled": return pc.red("cancelled") - case "archived": return pc.dim("archived") - default: return status - } -} +import { formatPrice, formatUsdCost } from "./price.ts" +import { + TABLE_CHARS, + getTableStyle, + statusColor, + zebraRow, + sectionTitle, + calcColumnWidths, +} from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" function buildRow(sub: SharedArgs, price: string, showNotes: boolean, showMethod: boolean, showContract?: boolean, showVendor?: boolean): string[] { const row = [ @@ -62,14 +60,6 @@ function buildRow(sub: SharedArgs, price: string, showNotes: boolean, showMethod return row } -type ColumnConfig = { - headers: readonly string[] - minWidths: readonly number[] - maxWidths: readonly number[] - /** Minimum available width (default 40) */ - minAvail?: number -} - const BASE_COLS: ColumnConfig = { headers: ["name", "status", "cycle", "tags", "price"] as const, minWidths: [10, 8, 6, 8, 8] as const, @@ -118,62 +108,6 @@ const ALL_EXTRA_COLS: ColumnConfig = { maxWidths: [30, 12, 20, 50, 30, 30, 10, 30, 20, 50, 20] as const, } -const BORDER_AND_PADDING = 16 - -function calcColumnWidths(rows: string[][], config: ColumnConfig): number[] { - const termWidth = process.stdout.columns ?? 80 - const avail = Math.max(config.minAvail ?? 40, termWidth - BORDER_AND_PADDING) - - const weights = config.headers.map((hdr, i) => { - let max = hdr.length - for (const row of rows) { - const len = row[i].length - if (len > max) max = len - } - return Math.min(max, config.maxWidths[i]) - }) - - const totalWeight = weights.reduce((a, b) => a + b, 0) - const widths = weights.map((w, i) => - Math.max( - config.minWidths[i], - Math.min(config.maxWidths[i], Math.round((avail * w) / totalWeight)), - ), - ) - - // Adjust to exactly fit avail - let sum = widths.reduce((a, b) => a + b, 0) - let diff = sum - avail - let iterations = 0 - - while (diff > 0 && iterations < 100) { - let idx = -1 - for (let i = 0; i < widths.length; i++) { - if (widths[i] > config.minWidths[i] && (idx === -1 || widths[i] > widths[idx])) - idx = i - } - if (idx === -1) break - widths[idx]-- - diff-- - iterations++ - } - - iterations = 0 - while (diff < 0 && iterations < 100) { - let idx = -1 - for (let i = 0; i < widths.length; i++) { - if (widths[i] < config.maxWidths[i] && (idx === -1 || weights[i] > weights[idx])) - idx = i - } - if (idx === -1) break - widths[idx]++ - diff++ - iterations++ - } - - return widths -} - function renderTable(rows: string[][], config: ColumnConfig): string { const widths = calcColumnWidths(rows, config) const colAligns = config.headers.map((h, i) => @@ -182,7 +116,7 @@ function renderTable(rows: string[][], config: ColumnConfig): string { const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, head: [...config.headers], wordWrap: true, @@ -205,7 +139,7 @@ function renderTable(rows: string[][], config: ColumnConfig): string { })) } else { if (i % 2 === 0) { - table.push(row.map(cell => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } @@ -255,8 +189,8 @@ export const spreadSubscription = async ( consola.info("Fetching the latest exchange rates...") rates = await fetchFxRates() consola.success("Exchange rates updated") - } catch (e) { - consola.fail(`Failed to fetch exchange rates: ${e}`) + } catch { + consola.warn("Failed to fetch exchange rates; showing in original currencies") } if (rates) { @@ -345,7 +279,7 @@ function renderUsageTableBody( ): string { const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, head: [...USAGE_HEADERS], wordWrap: true, @@ -360,12 +294,12 @@ function renderUsageTableBody( e.model, e.input_tokens.toLocaleString(), e.output_tokens.toLocaleString(), - `$${(e.cost / 100).toFixed(4)}`, + formatUsdCost(e.cost), e.date, e.description ?? "", ] if (i % 2 === 0) { - table.push(row.map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } @@ -393,7 +327,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { e.model, e.input_tokens.toLocaleString(), e.output_tokens.toLocaleString(), - `$${(e.cost / 100).toFixed(4)}`, + formatUsdCost(e.cost), e.date, e.description ?? "", ]) @@ -407,7 +341,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { "", "", "", - `$${(totalCost / 100).toFixed(2)}`, + formatUsdCost(totalCost, 2), "", `(${entries.length} entr${entries.length === 1 ? "y" : "ies"})`, ] as UsageRow, @@ -419,7 +353,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { // Render TOTAL footer row const table = new CliTable3({ chars: { ...TABLE_CHARS_FOOTER }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, colAligns: ["left", "left", "right", "right", "right", "left", "left"], }) @@ -428,7 +362,7 @@ export function renderUsageTable(entries: LlmUsageEntry[]): void { "", "", "", - pc.bold(pc.yellow(`$${(totalCost / 100).toFixed(2)}`)), + pc.bold(pc.yellow(formatUsdCost(totalCost, 2))), "", pc.dim(`(${entries.length} entr${entries.length === 1 ? "y" : "ies"})`), ]) @@ -443,7 +377,7 @@ export function showApiUsage( periodLabel: string, ): void { consola.log("") - consola.log(pc.bold(pc.cyan(`── API Usage (${periodLabel}) ──`))) + consola.log(sectionTitle(`API Usage (${periodLabel})`)) if (total <= 0) { consola.info("No API usage found for this month") @@ -452,17 +386,17 @@ export function showApiUsage( const apiTable = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), head: ["Provider", "Cost"], colAligns: ["left", "right"], }) for (const p of byProvider) { - apiTable.push([p.provider, `$${(p.total / 100).toFixed(2)}`]) + apiTable.push([p.provider, formatUsdCost(p.total, 2)]) } apiTable.push([ pc.bold(pc.yellow("Total")), - pc.bold(pc.yellow(`$${(total / 100).toFixed(2)}`)), + pc.bold(pc.yellow(formatUsdCost(total, 2))), ]) consola.log(apiTable.toString()) diff --git a/apps/subtrack/src/export.ts b/apps/subtrack/src/export.ts index 6601d5e..df35ce9 100644 --- a/apps/subtrack/src/export.ts +++ b/apps/subtrack/src/export.ts @@ -277,7 +277,7 @@ export async function handleExport( const targetCurrency = options.currency as Currency list = convertSubsWithRates(list, targetCurrency, rates) } catch (e) { - consola.fail(`Failed to fetch exchange rates; exporting in original currencies: ${String(e)}`) + consola.warn("Failed to fetch exchange rates; showing in original currencies") } } diff --git a/apps/subtrack/src/forecast.ts b/apps/subtrack/src/forecast.ts index 00912bf..ce6a387 100644 --- a/apps/subtrack/src/forecast.ts +++ b/apps/subtrack/src/forecast.ts @@ -3,7 +3,8 @@ import { consola } from "consola" import pc from "picocolors" import CliTable3 from "cli-table3" import type { SharedArgs, Currency, Cycle } from "./types.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" +import { TABLE_CHARS, getTableStyle, sectionTitle, calcColumnWidths, zebraRow } from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" import { periodFactor } from "./date-utils.ts" import { getSubscriptions, getNonCancelledSubscriptions } from "./db.ts" import { formatPrice } from "./price.ts" @@ -132,7 +133,7 @@ export async function handleForecast( try { rates = await fetchFxRates() } catch { - consola.fail("Failed to fetch exchange rates; showing original currencies") + consola.warn("Failed to fetch exchange rates; showing in original currencies") targetCurrency = undefined } } @@ -194,7 +195,7 @@ export async function handleForecast( for (const [ccy, group] of Object.entries(currencyGroups).sort()) { if (Object.keys(currencyGroups).length > 1) { consola.log("") - consola.log(pc.bold(pc.cyan(`── ${ccy} ──`))) + consola.log(sectionTitle(ccy)) } const entriesForTable = group.entries @@ -205,20 +206,21 @@ export async function handleForecast( const displayEntries = entriesForTable.slice(0, maxRows) const overflow = entriesForTable.length - displayEntries.length - const headers = ["Subscription", `Monthly`, periodLabel] + const headers = ["Subscription", `Monthly`, periodLabel] as const const colAligns: ("left" | "right")[] = ["left", "right", "right"] - const width = process.stdout.columns ?? 80 - const nameWidth = Math.max(16, Math.round(width * 0.35)) - const priceWidth = Math.max(10, Math.round(width * 0.2)) - const totalWidth = Math.max(10, Math.round(width * 0.2)) - const colWidths: number[] = [nameWidth, priceWidth, totalWidth] + const FORECAST_COLS: ColumnConfig = { + headers, + minWidths: [16, 10, 10] as const, + maxWidths: [60, 20, 20] as const, + } + const colWidths = calcColumnWidths(displayEntries.map((e) => [e.name, formatPrice(e.monthly, e.currency), formatPrice(Math.round(e.monthly * months), e.currency)]), FORECAST_COLS) const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: colWidths, - head: headers, + head: [...headers], colAligns, }) @@ -231,7 +233,7 @@ export async function handleForecast( formatPrice(monthlyTotal, e.currency), ] if (i % 2 === 0) { - table.push(row.map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(row)) } else { table.push(row) } @@ -247,7 +249,7 @@ export async function handleForecast( // Divider table.push([ - pc.dim("─".repeat(nameWidth - 2)), + pc.dim("─".repeat(colWidths[0] - 2)), pc.dim("─"), pc.dim("─"), ]) diff --git a/apps/subtrack/src/menu.ts b/apps/subtrack/src/menu.ts index ec1c02f..a8a416f 100644 --- a/apps/subtrack/src/menu.ts +++ b/apps/subtrack/src/menu.ts @@ -8,7 +8,8 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts" import { consola } from "consola" -import { getAllTags, getSubscriptions } from "./db.ts" +import pc from "picocolors" +import { getAllTags, getSubscriptions, getDbPath } from "./db.ts" import { handleList, handleDelete, handleTags, handleClone, handleArchive, handleUnarchive } from "./subscription/core.ts" import { handleAdd } from "./subscription/add.ts" import { handleEdit } from "./subscription/edit.ts" @@ -46,11 +47,27 @@ import { handleCurrencyList } from "./currency.ts" import { handleMcp } from "./commands.ts" import { CYCLE_CHOICES } from "./prompts.ts" import { formatPrice } from "./price.ts" +import { divider } from "./display-constants.ts" import type { Cycle, Status } from "./types.ts" type MainChoice = "view" | "add" | "manage" | "report" | "data" | "config" | "system" | "quit" +/** Show the subtrack header (title, version, subscription count, DB path). */ +function showMenuHeader(): void { + const pkg = require("../package.json") as { version: string } + const count = getSubscriptions().length + console.log(pc.bold(pc.cyan(`subtrack v${pkg.version}`))) + console.log( + pc.dim( + ` ${count} subscription${count === 1 ? "" : "s"} · ${getDbPath()}`, + ), + ) + console.log(divider(52)) + console.log("") +} + export async function handleMenu(): Promise { + showMenuHeader() while (true) { const choice = await select({ message: "subtrack — choose a category", diff --git a/apps/subtrack/src/notify.ts b/apps/subtrack/src/notify.ts index e1bde54..3bfe657 100644 --- a/apps/subtrack/src/notify.ts +++ b/apps/subtrack/src/notify.ts @@ -3,7 +3,7 @@ import { calcUpcoming } from "./upcoming.ts" import { formatPrice } from "./price.ts" import { loadConfig } from "./config.ts" import type { NotifyChannel } from "./types.ts" -import { formatDate } from "./date-utils.ts" +import { formatDate, formatShortDate } from "./date-utils.ts" export type NotifyOptions = { days?: number @@ -57,8 +57,9 @@ export async function handleNotify(options: NotifyOptions = {}): Promise { if (options.dryRun) { consola.info(`Upcoming bills (next ${days} day${days > 1 ? "s" : ""}):`) + const fmt = loadConfig().dateFormat === "short" ? formatShortDate : formatDate for (const e of entries) { - const date = formatDate(e.nextDate) + const date = fmt(e.nextDate) consola.log(` ${date} ${e.sub.name} ${formatPrice(e.sub.price, e.sub.currency)}/${e.sub.cycle}`) } return diff --git a/apps/subtrack/src/payment.ts b/apps/subtrack/src/payment.ts index 05fa90d..7afa19a 100644 --- a/apps/subtrack/src/payment.ts +++ b/apps/subtrack/src/payment.ts @@ -3,7 +3,7 @@ import pc from "picocolors" import type { SharedArgs, Currency, Cycle } from "./types.ts" import { periodFactor, getPeriodDateRange } from "./date-utils.ts" import { getSubscriptions, getNonCancelledSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider, getAllPriceChanges } from "./db.ts" -import { formatPrice } from "./price.ts" +import { formatPrice, formatUsdCost } from "./price.ts" import { fetchFxRates, convertPrice } from "./fx.ts" import type { FxRates } from "./fx.ts" import { runPreCommandHooks } from "./pre-command.ts" @@ -50,7 +50,7 @@ export const showPayment = async ( try { rates = await fetchFxRates() } catch { - consola.fail("Failed to fetch exchange rates; falling back to per-currency display") + consola.warn("Failed to fetch exchange rates; showing in original currencies") } if (rates) { @@ -88,7 +88,7 @@ export const showPayment = async ( } const grandTotal = subTotal + apiConverted consola.log( - `${formatPrice(Math.round(subTotal), currency)}/${fmtPeriod} ${pc.dim(`+ API ${formatPrice(Math.round(apiConverted), currency)} = ${pc.bold(formatPrice(Math.round(grandTotal), currency))}/${fmtPeriod}`)}`, + `${formatPrice(Math.round(subTotal), currency)}/${fmtPeriod} ${pc.dim(`+ API ${formatPrice(Math.round(apiConverted), currency)} = ${pc.bold(pc.yellow(formatPrice(Math.round(grandTotal), currency)))}/${fmtPeriod}`)}`, ) } else { consola.log(`${formatPrice(Math.round(subTotal), currency)}/${fmtPeriod}`) @@ -139,11 +139,11 @@ export const showPayment = async ( } else { // Show API usage in USD with provider breakdown const providerDetails = apiByProvider - .map((p) => `${p.provider}: $${(p.total / 100).toFixed(2)}`) + .map((p) => `${p.provider}: ${formatUsdCost(p.total, 2)}`) .join(", ") consola.log( pc.dim( - `${pc.bold("API usage:")} $${(apiTotal / 100).toFixed(2)}/${fmtPeriod} ${pc.dim(`(${providerDetails})`)}`, + `${pc.bold("API usage:")} ${formatUsdCost(apiTotal, 2)}/${fmtPeriod} ${pc.dim(`(${providerDetails})`)}`, ), ) } @@ -345,7 +345,7 @@ export async function handlePayment( let subTotal = 0 if (targetCurrency) { - try { rates = await fetchFxRates() } catch { consola.fail("Failed to fetch exchange rates; reporting in original currencies") } + try { rates = await fetchFxRates() } catch { consola.warn("Failed to fetch exchange rates; showing in original currencies") } if (rates) { for (const entry of entries) { try { subTotal += convertPrice(entry.convertedPrice, entry.currency, targetCurrency, rates.rates) } diff --git a/apps/subtrack/src/price.ts b/apps/subtrack/src/price.ts index b85cc11..fb84461 100644 --- a/apps/subtrack/src/price.ts +++ b/apps/subtrack/src/price.ts @@ -17,3 +17,11 @@ export function formatPrice(price: number, currency: string): string { return `${currency} ${price}` } } + +/** + * Format an API usage cost (stored in cents) as a USD string. + * Defaults to 4 decimal places because LLM API costs are small. + */ +export function formatUsdCost(cents: number, digits = 4): string { + return `$${(cents / 100).toFixed(digits)}` +} diff --git a/apps/subtrack/src/report.ts b/apps/subtrack/src/report.ts index f9e6fff..47a7dac 100644 --- a/apps/subtrack/src/report.ts +++ b/apps/subtrack/src/report.ts @@ -149,7 +149,7 @@ export async function handleReport(options: ReportOptions = {}): Promise { displayCurrency = target displaySubs = convertSubsWithRates(subs, target as Currency, rates!) } catch { - consola.warn("Failed to fetch exchange rates; reporting in original currencies") + consola.warn("Failed to fetch exchange rates; showing in original currencies") displayCurrency = null } } @@ -236,20 +236,20 @@ export async function handleReport(options: ReportOptions = {}): Promise { return } - consola.log(pc.bold(`📊 Subscription Report — ${year}`)) + consola.log(pc.bold(pc.cyan(`📊 Subscription Report — ${year}`))) consola.log("") // Total spending consola.log(pc.bold("Total spending:")) if (displayCurrency) { - consola.log(` ${formatPrice(Math.round(total), displayCurrency)}`) + consola.log(` ${pc.bold(pc.yellow(formatPrice(Math.round(total), displayCurrency)))}`) } else if (Object.keys(byCurrency).length === 1) { const [ccy, amount] = Object.entries(byCurrency)[0]! - consola.log(` ${formatPrice(amount, ccy)}`) + consola.log(` ${pc.bold(pc.yellow(formatPrice(amount, ccy)))}`) } else { const parts = Object.entries(byCurrency) .sort(([a], [b]) => a.localeCompare(b)) - .map(([ccy, amount]) => `${formatPrice(amount, ccy)}`) + .map(([ccy, amount]) => `${pc.bold(pc.yellow(formatPrice(amount, ccy)))}`) consola.log(` ${parts.join(" + ")}`) } diff --git a/apps/subtrack/src/stats.ts b/apps/subtrack/src/stats.ts index c81a791..ce04072 100644 --- a/apps/subtrack/src/stats.ts +++ b/apps/subtrack/src/stats.ts @@ -3,6 +3,7 @@ */ import { consola } from "consola" +import { sectionTitle } from "./display-constants.ts" import { getDb, getDbPath } from "./db.ts" import type { Status } from "./types.ts" import { execObjs } from "./db/connection.ts" @@ -72,7 +73,7 @@ export function handleStats(options: { json?: boolean } = {}): void { return } - consola.log("── Database Statistics ──") + consola.log(sectionTitle("Database Statistics")) consola.log(` Subscriptions: ${total}`) consola.log(` Active: ${active}`) consola.log(` Paused: ${paused}`) diff --git a/apps/subtrack/src/subscription/core.ts b/apps/subtrack/src/subscription/core.ts index d0dc905..dc26d85 100644 --- a/apps/subtrack/src/subscription/core.ts +++ b/apps/subtrack/src/subscription/core.ts @@ -6,6 +6,7 @@ import { checkbox, confirm, select } from "@inquirer/prompts" import { consola } from "consola" import { fail } from "../error.ts" +import { loadConfig } from "../config.ts" import type { Currency, SharedArgs, AddFlags } from "../types.ts" import { getSubscriptions, @@ -61,7 +62,10 @@ export async function handleList(options: { process.stdout.write(JSON.stringify(list, null, 2) + "\n") return } - await spreadSubscription(list, options.currency as Currency | undefined, options.notes, options.method, options.showContract, options.showVendor) + // Flag > config > default (off) + const showNotes = options.notes ?? loadConfig().listShowNotes === "on" + const showMethod = options.method ?? loadConfig().listShowMethod === "on" + await spreadSubscription(list, options.currency as Currency | undefined, showNotes, showMethod, options.showContract, options.showVendor) if (options.api) { const now = new Date() diff --git a/apps/subtrack/src/trial.ts b/apps/subtrack/src/trial.ts index 25f8aff..6a95aea 100644 --- a/apps/subtrack/src/trial.ts +++ b/apps/subtrack/src/trial.ts @@ -6,7 +6,13 @@ import CliTable3 from "cli-table3" import type { TrialEntry, AddTrialArgs, TrialAddFlags } from "./types.ts" import { writeTrial, getTrials, getTrial, deleteTrial, getTrialsExpiringSoon } from "./db.ts" import { formatPrice } from "./price.ts" -import { TABLE_CHARS, TABLE_STYLE } from "./display-constants.ts" +import { + TABLE_CHARS, + getTableStyle, + calcColumnWidths, + zebraRow, +} from "./display-constants.ts" +import type { ColumnConfig } from "./display-constants.ts" import { CURRENCY_CHOICES, CYCLE_CHOICES, @@ -240,7 +246,7 @@ export async function handleTrialDelete(ids?: number[]): Promise { // ── Table rendering ──────────────────────────────────── function renderTrialTable(trials: TrialEntry[]): void { - const headers = ["ID", "Name", "Expires", "Days", "Price", "Notes"] + const headers = ["ID", "Name", "Expires", "Days", "Price", "Notes"] as const const rows: string[][] = trials.map((t) => { const days = daysUntil(t.expiresAt) const statusLabel = trialStatusLabel(days) @@ -255,14 +261,17 @@ function renderTrialTable(trials: TrialEntry[]): void { ] }) - const termWidth = process.stdout.columns ?? 80 - const avail = Math.max(50, termWidth - 14) - const weights = [4, 20, 12, 6, 16, 20] - const widths = weights.map((w) => Math.min(w, Math.round((avail * w) / weights.reduce((a, b) => a + b, 0)))) + const TRIAL_COLS: ColumnConfig = { + headers, + minWidths: [4, 16, 10, 6, 10, 8] as const, + maxWidths: [10, 40, 14, 10, 20, 40] as const, + minAvail: 50, + } + const widths = calcColumnWidths(rows, TRIAL_COLS) const table = new CliTable3({ chars: { ...TABLE_CHARS }, - style: { ...TABLE_STYLE }, + style: getTableStyle(), colWidths: widths, head: [...headers], colAligns: ["right", "left", "left", "left", "right", "left"], @@ -270,7 +279,7 @@ function renderTrialTable(trials: TrialEntry[]): void { for (let i = 0; i < rows.length; i++) { if (i % 2 === 0) { - table.push(rows[i].map((cell) => `\x1b[48;5;236m${cell}\x1b[0m`)) + table.push(zebraRow(rows[i])) } else { table.push(rows[i]) } diff --git a/apps/subtrack/src/types.ts b/apps/subtrack/src/types.ts index a906ca7..bf4d7b4 100644 --- a/apps/subtrack/src/types.ts +++ b/apps/subtrack/src/types.ts @@ -241,4 +241,22 @@ export type SubtrackConfig = { slackWebhook?: string /** Generic webhook URL for notifications */ webhookUrl?: string + /** Display theme preset name (default | light | high-contrast | none) */ + tableBorderColor?: string + /** Override border color (ColorName) */ + tableHeaderColor?: string + /** Override table header color (ColorName) */ + tableZebraColor?: string + /** Override zebra stripe background color (ColorName) */ + accentColor?: string + /** Override accent color used for headings (ColorName) */ + tableZebra?: "on" | "off" + /** Enable/disable zebra striping */ + tableMinWidth?: number + /** Minimum table width in columns (default 40) */ + dateFormat?: "iso" | "short" + /** Show notes column in `subtrack list` by default */ + listShowNotes?: "on" | "off" + /** Show payment method column in `subtrack list` by default */ + listShowMethod?: "on" | "off" } diff --git a/apps/subtrack/src/usage-add.ts b/apps/subtrack/src/usage-add.ts index f48a946..f473cfc 100644 --- a/apps/subtrack/src/usage-add.ts +++ b/apps/subtrack/src/usage-add.ts @@ -10,6 +10,7 @@ import { validateDate, } from "./prompts.ts" import { today } from "./date-utils.ts" +import { formatUsdCost } from "./price.ts" import { ensurePricingCache, searchPricingModels, @@ -179,8 +180,8 @@ async function resolveUsageAddOptions(flags: UsageAddFlags) { // Confirm (only when interactive) if (prompted) { const costDisplay = manualCost - ? `$${((costCents ?? 0) / 100).toFixed(2)} (manual)` - : `$${((costCents ?? 0) / 100).toFixed(4)}` + ? `${formatUsdCost(costCents ?? 0, 2)} (manual)` + : `${formatUsdCost(costCents ?? 0)}` consola.info( `Cost: ${costDisplay} (${(inputTokens ?? 0).toLocaleString()} in / ${(outputTokens ?? 0).toLocaleString()} out)`, ) @@ -214,10 +215,10 @@ export async function handleUsageAdd(flags: UsageAddFlags) { addLlmUsage(result) logAudit("usage.add", { targetType: "usage", - details: `${result.provider}/${result.model}: $${(result.cost / 100).toFixed(4)} on ${result.date}`, + details: `${result.provider}/${result.model}: ${formatUsdCost(result.cost)} on ${result.date}`, }) consola.success( - `Added usage: ${result.provider}/${result.model} — $${(result.cost / 100).toFixed(4)} on ${result.date}`, + `Added usage: ${result.provider}/${result.model} — ${formatUsdCost(result.cost)} on ${result.date}`, ) } catch (error) { fail(`Failed to add usage entry: ${error instanceof Error ? error.message : String(error)}`) diff --git a/apps/subtrack/src/usage-total.ts b/apps/subtrack/src/usage-total.ts index 85c7867..e6dc32d 100644 --- a/apps/subtrack/src/usage-total.ts +++ b/apps/subtrack/src/usage-total.ts @@ -4,6 +4,7 @@ import { consola } from "consola" import pc from "picocolors" +import { sectionTitle, divider } from "./display-constants.ts" import { getLlmUsageTotal, getLlmUsageTokenTotal, @@ -12,6 +13,7 @@ import { } from "./db.ts" import { getPeriodDateRange } from "./date-utils.ts" import type { Cycle } from "./types.ts" +import { formatUsdCost } from "./price.ts" export type UsageTotalOptions = { from?: string @@ -55,23 +57,23 @@ export function handleUsageTotal(options: UsageTotalOptions = {}): void { return } - consola.log(`── LLM API Usage (${from} → ${to}) ──`) + consola.log(sectionTitle(`LLM API Usage (${from} → ${to})`)) consola.log(pc.bold(" By provider:")) for (const p of byProvider) { - consola.log(` ${p.provider}: $${(p.total / 100).toFixed(2)}`) + consola.log(` ${p.provider}: ${formatUsdCost(p.total, 2)}`) } if (byModel.length > 0) { consola.log(pc.bold(" By model:")) for (const m of byModel) { consola.log( - ` ${m.model}: $${(m.total / 100).toFixed(2)} ` + + ` ${m.model}: ${formatUsdCost(m.total, 2)} ` + `(${m.inputTokens.toLocaleString()} in / ${m.outputTokens.toLocaleString()} out)`, ) } } - consola.log(` ${"─".repeat(20)}`) + consola.log(` ${divider(20)}`) consola.log( ` Tokens: ${tokens.inputTokens.toLocaleString()} in / ${tokens.outputTokens.toLocaleString()} out`, ) - consola.log(` Total: $${(total / 100).toFixed(2)}`) + consola.log(` Total: ${pc.bold(pc.yellow(formatUsdCost(total, 2)))}`) } \ No newline at end of file diff --git a/apps/subtrack/src/usage.ts b/apps/subtrack/src/usage.ts index 765291c..a9d0968 100644 --- a/apps/subtrack/src/usage.ts +++ b/apps/subtrack/src/usage.ts @@ -5,6 +5,7 @@ import type { LlmUsageEntry } from "./types.ts" import { getLlmUsage, deleteLlmUsage } from "./db.ts" import { logAudit } from "./audit.ts" import { renderUsageTable } from "./display.ts" +import { formatUsdCost } from "./price.ts" export { handleUsageAdd } from "./usage-add.ts" export { handleUsageImport } from "./usage-import.ts" @@ -58,7 +59,7 @@ export async function handleUsageDelete(ids?: number[]) { const selected = await checkbox({ message: "Select usage entries to delete", choices: all.map((e: LlmUsageEntry) => ({ - name: `${e.date} ${e.provider}/${e.model} ${e.input_tokens.toLocaleString()} in / ${e.output_tokens.toLocaleString()} out $${(e.cost / 100).toFixed(4)}${e.description ? ` — ${e.description}` : ""}`, + name: `${e.date} ${e.provider}/${e.model} ${e.input_tokens.toLocaleString()} in / ${e.output_tokens.toLocaleString()} out ${formatUsdCost(e.cost)}${e.description ? ` — ${e.description}` : ""}`, value: e, })), loop: false, diff --git a/apps/subtrack/vitest.config.ts b/apps/subtrack/vitest.config.ts index 3f89d26..c1af15b 100644 --- a/apps/subtrack/vitest.config.ts +++ b/apps/subtrack/vitest.config.ts @@ -4,6 +4,12 @@ export default defineConfig({ test: { // Scanner tests need time for sql.js WASM initialization at module level testTimeout: 15_000, + // Force ANSI colors so display helpers can be tested for styling + env: { + FORCE_COLOR: "1", + // Isolate config.json so tests never read the user's real config + SUBSC_CLI_DB_DIR: "/tmp/subtrack-vitest-config", + }, coverage: { provider: "v8", reporter: ["text", "lcov"], diff --git a/docs/configuration.md b/docs/configuration.md index ebb9342..92336f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,7 +77,7 @@ See the [Commands reference](/commands#config) for full details. |-----|-------------|---------| | `defaultCurrency` | Default currency for display and analytics | `USD` | | `monthlyBudget` | Monthly spending budget in USD (0 = disabled) | `0` | -| `theme` | Display theme | `default` | +| `theme` | Display theme preset (`default`, `light`, `high-contrast`, `none`) | `default` | | `notifyDays` | Default look-ahead days for `subtrack notify` | `7` | | `notifyChannels` | Notification channels (comma-separated: `os`, `slack`, `webhook`) | `os` | | `slackWebhook` | Slack webhook URL for Slack notifications | — | @@ -86,6 +86,15 @@ See the [Commands reference](/commands#config) for full details. | `profiles` | Saved filter profiles (stored as JSON object) | `{}` | | `activeProfile` | Currently active filter profile name | — | | `budgets` | Multiple named budgets for budget-vs-actual tracking | — | +| `tableBorderColor` | Table border color override (color name) | theme default | +| `tableHeaderColor` | Table header color override (color name) | theme default | +| `tableZebraColor` | Zebra stripe background color override (color name) | theme default | +| `accentColor` | Accent color override for headings (color name) | theme default | +| `tableZebra` | Enable/disable zebra striping (`on`/`off`) | `on` | +| `tableMinWidth` | Minimum table width in columns (20–200) | `40` | +| `dateFormat` | Date display format (`iso` or `short`) | `iso` | +| `listShowNotes` | Show notes column in `subtrack list` by default (`on`/`off`) | `off` | +| `listShowMethod` | Show payment method column in `subtrack list` by default (`on`/`off`) | `off` | Set values with: @@ -96,6 +105,31 @@ subtrack config set defaultCurrency JPY The `config set` command validates input (e.g., currency codes must be ISO 4217, budget must be non-negative). +### Display themes + +The `theme` key switches between color presets: + +| Preset | Best for | +|--------|----------| +| `default` | Dark terminal backgrounds (default) | +| `light` | Light terminal backgrounds | +| `high-contrast` | Accessibility / high ambient light | +| `none` | Plain monochrome output (piping, screen readers) | + +Individual color keys (`tableBorderColor`, `tableHeaderColor`, `tableZebraColor`, `accentColor`) override the preset. Valid color names: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `gray`, and the `bright*` variants. + +```bash +# Switch to the light theme +subtrack config set theme light + +# Or fine-tune a preset +subtrack config set theme high-contrast +subtrack config set accentColor yellow + +# Disable zebra striping +subtrack config set tableZebra off +``` + ## No config file subtrack does not use configuration files (`.subtrackrc`, `subtrack.json`, etc.). All settings are controlled via the `config` command, environment variables, or CLI flags. This keeps the tool simple and predictable.