Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dbdb5e8
fix: accept export CSV format in import
nazozokc Aug 16, 2026
4f90970
fix: convert API usage cost from cents to dollars
nazozokc Aug 16, 2026
64751e4
fix: add force flag to bulk tag commands
nazozokc Aug 16, 2026
66c874c
fix: exclude dist output from type check
nazozokc Aug 16, 2026
96466bf
Merge branch 'main' into AI-agent
nazozokc Aug 16, 2026
c08ad7f
fix: correct billing date calculations in upcoming and calendar
nazozokc Aug 16, 2026
09583ba
fix: exclude cancelled subscriptions from payment summaries and valid…
nazozokc Aug 16, 2026
58aab5b
feat: add list filters, forecast currency conversion, and analytics p…
nazozokc Aug 16, 2026
f2ba913
feat: enhance usage commands with edit, token totals, and paging
nazozokc Aug 16, 2026
ce1dafa
feat: extend MCP server with tag and usage tools, enum validation, an…
nazozokc Aug 16, 2026
ac2c325
docs: update command, development, and MCP documentation
nazozokc Aug 16, 2026
741b103
edit
nazozokc Aug 17, 2026
27658b7
edit
nazozokc Aug 17, 2026
04f3728
Merge branch 'main' into AI-agent
nazozokc Aug 17, 2026
3f5bfea
edit
nazozokc Aug 17, 2026
eedbd7a
fix: use local date and correct audit action
nazozokc Aug 18, 2026
518a640
refactor: consolidate duplicated helpers across modules
nazozokc Aug 18, 2026
c196992
docs: update architecture documentation
nazozokc Aug 18, 2026
f5ffc01
Merge branch 'main' into AI-agent
nazozokc Aug 18, 2026
53ff92b
feat: add user-configurable display themes
nazozokc Aug 18, 2026
da72662
Merge branch 'main' into AI-agent
nazozokc Aug 18, 2026
889a895
fix: normalize db dir separators for Windows compatibility
nazozokc Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions apps/subtrack/src/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }))
Expand Down Expand Up @@ -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 () => {
Expand Down
77 changes: 77 additions & 0 deletions apps/subtrack/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
203 changes: 203 additions & 0 deletions apps/subtrack/src/__tests__/display-constants.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
15 changes: 9 additions & 6 deletions apps/subtrack/src/__tests__/display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const logMessages: string[] = []
const infoMessages: string[] = []
const failMessages: string[] = []
const errorMessages: string[] = []
const warnMessages: string[] = []

let originalFetch: typeof globalThis.fetch

Expand All @@ -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, "")

Expand All @@ -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)
}
})

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions apps/subtrack/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function handleAnalytics(options: AnalyticsOptions = {}): Promise<v
try {
rates = await fetchFxRates()
} catch {
consola.warn("Failed to fetch exchange rates; reporting in original currencies")
consola.warn("Failed to fetch exchange rates; showing in original currencies")
}
const converted: Record<string, number> = {}
if (rates) {
Expand Down Expand Up @@ -69,7 +69,7 @@ export async function showAnalytics(options: AnalyticsOptions = {}): Promise<voi
try {
rates = await fetchFxRates()
} catch {
consola.warn("Failed to fetch exchange rates; showing original currencies")
consola.warn("Failed to fetch exchange rates; showing in original currencies")
}
}

Expand Down
Loading