From 34bee2a2fd018a410b1af73cb748b0d169a985d1 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 30 Jun 2026 22:59:03 +0900 Subject: [PATCH 01/12] edit --- pnpm-lock.yaml | 50 ++++ subtrack/package.json | 2 + subtrack/src/__tests__/commands.test.ts | 11 + subtrack/src/commands.ts | 4 + subtrack/src/config.ts | 11 + subtrack/src/db.ts | 98 ++++++++ subtrack/src/history.ts | 97 ++++++++ subtrack/src/index.ts | 83 +++++++ subtrack/src/notify.ts | 79 +++++++ subtrack/src/subscription.ts | 3 + subtrack/src/tui/components/command-bar.tsx | 9 +- subtrack/src/tui/context/app-context.tsx | 19 ++ subtrack/src/tui/screen-router.tsx | 3 + subtrack/src/tui/screens/detail.tsx | 10 +- subtrack/src/tui/screens/history-screen.tsx | 112 +++++++++ subtrack/src/tui/screens/list.tsx | 247 ++++++++++++-------- subtrack/src/tui/types.ts | 2 + subtrack/src/types.ts | 1 + subtrack/v8-plan.md | 167 +++++++++++++ 19 files changed, 907 insertions(+), 101 deletions(-) create mode 100644 subtrack/src/history.ts create mode 100644 subtrack/src/notify.ts create mode 100644 subtrack/src/tui/screens/history-screen.tsx create mode 100644 subtrack/v8-plan.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c50a946..5d0d376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: ink-spinner: specifier: ^5.0.0 version: 5.0.0(ink@7.1.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + node-notifier: + specifier: ^10.0.1 + version: 10.0.1 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -78,6 +81,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 + '@types/node-notifier': + specifier: ^8.0.5 + version: 8.0.5 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -739,6 +745,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node-notifier@8.0.5': + resolution: {integrity: sha512-LX7+8MtTsv6szumAp6WOy87nqMEdGhhry/Qfprjm1Ma6REjVzeF7SCyvPtp5RaF6IkXCS9V4ra8g5fwvf2ZAYg==} + '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} @@ -1360,6 +1369,9 @@ packages: resolution: {integrity: sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==} engines: {node: '>=14'} + growly@1.3.0: + resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==} + gunshi@0.35.1: resolution: {integrity: sha512-Fn+IZReFITpU1lfjLdQG9WXk5+NqolwR1Q6gnSeEicstlZZfVKqnANCo4YbR4ml3/4JkyJSi3vYmxb9XdCeN3g==} engines: {node: '>= 22'} @@ -1468,6 +1480,11 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1511,6 +1528,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1792,6 +1813,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-notifier@10.0.1: + resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==} + normalize-package-data@6.0.2: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2075,6 +2099,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shellwords@0.1.1: + resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -3120,6 +3147,10 @@ snapshots: dependencies: '@types/unist': 2.0.11 + '@types/node-notifier@8.0.5': + dependencies: + '@types/node': 26.0.0 + '@types/node@14.18.63': {} '@types/node@26.0.0': @@ -3771,6 +3802,8 @@ snapshots: chalk: 5.6.2 tinygradient: 1.1.5 + growly@1.3.0: {} + gunshi@0.35.1: {} has-flag@4.0.0: {} @@ -3876,6 +3909,8 @@ snapshots: ipaddr.js@1.9.1: {} + is-docker@2.2.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3904,6 +3939,10 @@ snapshots: is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@1.0.0: {} isexe@2.0.0: {} @@ -4131,6 +4170,15 @@ snapshots: negotiator@1.0.0: {} + node-notifier@10.0.1: + dependencies: + growly: 1.3.0 + is-wsl: 2.2.0 + semver: 7.8.4 + shellwords: 0.1.1 + uuid: 8.3.2 + which: 2.0.2 + normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 @@ -4424,6 +4472,8 @@ snapshots: shebang-regex@3.0.0: {} + shellwords@0.1.1: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 diff --git a/subtrack/package.json b/subtrack/package.json index ec27db9..64bdca2 100644 --- a/subtrack/package.json +++ b/subtrack/package.json @@ -65,6 +65,7 @@ "ink": "^7.1.0", "ink-gradient": "^4.0.1", "ink-spinner": "^5.0.0", + "node-notifier": "^10.0.1", "picocolors": "^1.1.1", "react": "^19.2.7", "sql.js": "^1.14.1" @@ -72,6 +73,7 @@ "devDependencies": { "@secretlint/secretlint-rule-preset-recommend": "^13.0.2", "@types/node": "^26.0.0", + "@types/node-notifier": "^8.0.5", "@types/react": "^19.2.17", "@types/sql.js": "^1.4.11", "husky": "^9.1.7", diff --git a/subtrack/src/__tests__/commands.test.ts b/subtrack/src/__tests__/commands.test.ts index def7cd6..6d8631c 100644 --- a/subtrack/src/__tests__/commands.test.ts +++ b/subtrack/src/__tests__/commands.test.ts @@ -133,6 +133,16 @@ beforeAll(async () => { notes TEXT, created_at TEXT NOT NULL DEFAULT (date('now')) )`) + testDb.run(`CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER, + new_price INTEGER NOT NULL, + old_currency TEXT, + new_currency TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + )`) const db = await import("../db.ts") db.__setDb(testDb) @@ -149,6 +159,7 @@ beforeAll(async () => { }) beforeEach(() => { + testDb.run("DELETE FROM price_history") testDb.run("DELETE FROM subscription_tags") testDb.run("DELETE FROM tags") testDb.run("DELETE FROM subscriptions") diff --git a/subtrack/src/commands.ts b/subtrack/src/commands.ts index 340d21f..664d437 100644 --- a/subtrack/src/commands.ts +++ b/subtrack/src/commands.ts @@ -5,6 +5,8 @@ export { handleSearch } from "./search.ts" export { handleTrialAdd, handleTrialList, handleTrialExpiring, handleTrialDelete } from "./trial.ts" export { handleBulkStatus, handleBulkDelete, handleBulkTagAdd, handleBulkTagRemove } from "./bulk.ts" export { handleForecast } from "./forecast.ts" +export { handleHistory } from "./history.ts" +export { handleNotify } from "./notify.ts" export { handleBackup, handleRestore } from "./backup.ts" export { handleTagList, handleTagRename, handleTagDelete, handleTagPrune } from "./tag.ts" @@ -27,6 +29,8 @@ import { showAnalytics } from "./analytics.ts" import { showCompare } from "./compare.ts" import { exportCsv, exportMd, exportJson, exportExcel, exportIcs } from "./export.ts" import { showCalendar, calcCalendarEntries } from "./calendar.ts" +import { handleHistory } from "./history.ts" +import { handleNotify } from "./notify.ts" import { fetchFxRates, convertPrice, type FxRates } from "./fx.ts" import { loadConfig, setConfig, resetConfig, CONFIG_KEYS, getConfigPath } from "./config.ts" import { unlinkSync, existsSync } from "node:fs" diff --git a/subtrack/src/config.ts b/subtrack/src/config.ts index af533f3..de4d5a8 100644 --- a/subtrack/src/config.ts +++ b/subtrack/src/config.ts @@ -9,6 +9,7 @@ export const CONFIG_KEYS = [ "defaultCurrency", "monthlyBudget", "theme", + "notifyDays", ] as const export type ConfigKey = (typeof CONFIG_KEYS)[number] @@ -17,6 +18,7 @@ const DEFAULT_CONFIG: SubtrackConfig = { defaultCurrency: "USD", monthlyBudget: 0, theme: "default", + notifyDays: 7, } function getConfigDir(): string { @@ -76,6 +78,15 @@ export function setConfig(key: ConfigKey, value: string): boolean { case "theme": config.theme = value break + case "notifyDays": { + const num = Number(value) + if (isNaN(num) || num < 0 || !Number.isInteger(num)) { + consola.error("notifyDays must be a non-negative integer") + return false + } + config.notifyDays = num + break + } default: consola.error(`Unknown config key: "${key}"`) return false diff --git a/subtrack/src/db.ts b/subtrack/src/db.ts index b0ee809..ab75837 100644 --- a/subtrack/src/db.ts +++ b/subtrack/src/db.ts @@ -232,6 +232,16 @@ function runMigrations(db: Database): void { notes TEXT, created_at TEXT NOT NULL DEFAULT (date('now')) )`) + db.run(`CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER, + new_price INTEGER NOT NULL, + old_currency TEXT, + new_currency TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + )`) // Migration: add generation_id column if missing (pre-4.1.0 databases) const llmCols = db.exec("PRAGMA table_info(llm_usage)") @@ -949,3 +959,91 @@ export const getTrialsExpiringSoon = (days: number): TrialEntry[] => { [days], ) } + +// ── Price History ───────────────────────────────────────── + +export type PriceHistoryEntry = { + id: number + subscriptionId: number + subscriptionName: string + oldPrice: number | null + newPrice: number + oldCurrency: string | null + newCurrency: string + changedAt: string +} + +type RawPriceHistory = { + id: number + subscription_id: number + old_price: number | null + new_price: number + old_currency: string | null + new_currency: string + changed_at: string + name: string +} + +export const writePriceHistory = ( + subscriptionId: number, + oldPrice: number | null, + newPrice: number, + oldCurrency: string | null, + newCurrency: string, +): void => { + const db = getDb() + // Only record if price or currency actually changed + if (oldPrice === newPrice && oldCurrency === newCurrency) return + db.run( + `INSERT INTO price_history (subscription_id, old_price, new_price, old_currency, new_currency) + VALUES (?, ?, ?, ?, ?)`, + [subscriptionId, oldPrice, newPrice, oldCurrency, newCurrency], + ) + saveDb() +} + +/** Get price history for a specific subscription (newest first). */ +export const getPriceHistory = (subscriptionId: number): PriceHistoryEntry[] => { + const db = getDb() + const rows = execObjs( + db, + `SELECT ph.id, ph.subscription_id, ph.old_price, ph.new_price, + ph.old_currency, ph.new_currency, ph.changed_at, s.name + FROM price_history ph + JOIN subscriptions s ON s.id = ph.subscription_id + WHERE ph.subscription_id = ? + ORDER BY ph.changed_at DESC`, + [subscriptionId], + ) + return rows.map(toPriceHistoryEntry) +} + +/** Get all price changes across subscriptions, optionally filtered to recent days. */ +export const getAllPriceChanges = (days?: number): PriceHistoryEntry[] => { + const db = getDb() + let sql = `SELECT ph.id, ph.subscription_id, ph.old_price, ph.new_price, + ph.old_currency, ph.new_currency, ph.changed_at, s.name + FROM price_history ph + JOIN subscriptions s ON s.id = ph.subscription_id` + const params: SqlValue[] = [] + if (days !== undefined && days > 0) { + sql += ` WHERE ph.changed_at >= datetime('now', '-' || ? || ' days')` + params.push(days) + } + sql += ` ORDER BY ph.changed_at DESC` + const rows = execObjs(db, sql, params.length > 0 ? params : undefined) + return rows.map(toPriceHistoryEntry) +} + +function toPriceHistoryEntry(r: RawPriceHistory): PriceHistoryEntry { + return { + id: r.id, + subscriptionId: r.subscription_id, + subscriptionName: r.name, + oldPrice: r.old_price, + newPrice: r.new_price, + oldCurrency: r.old_currency, + newCurrency: r.new_currency, + changedAt: r.changed_at, + } +} diff --git a/subtrack/src/history.ts b/subtrack/src/history.ts new file mode 100644 index 0000000..0691c7b --- /dev/null +++ b/subtrack/src/history.ts @@ -0,0 +1,97 @@ +import { consola } from "consola" +import pc from "picocolors" +import { getPriceHistory, getAllPriceChanges, getSubscription } from "./db.ts" +import type { PriceHistoryEntry } from "./db.ts" +import { formatPrice } from "./price.ts" + +export type HistoryOptions = { + json?: boolean + all?: boolean + days?: number +} + +function formatDiff( + oldPrice: number | null, + newPrice: number, + oldCurrency: string | null, + newCurrency: string, +): string { + const oldStr = oldPrice !== null ? formatPrice(oldPrice, oldCurrency ?? newCurrency) : "—" + const newStr = formatPrice(newPrice, newCurrency) + if (oldCurrency && newCurrency && oldCurrency !== newCurrency) { + return `${oldStr} (${oldCurrency}) → ${newStr} (${newCurrency})` + } + if (oldPrice !== null && oldPrice !== newPrice) { + const diff = newPrice - oldPrice + const sign = diff > 0 ? "+" : "" + return `${oldStr} → ${newStr} (${sign}${formatPrice(diff, newCurrency)})` + } + return `→ ${newStr}` +} + +function displayHistory(entries: PriceHistoryEntry[]): void { + if (entries.length === 0) { + consola.info("No price history found") + return + } + + // Group by subscription if showing all + const groups = new Map() + for (const e of entries) { + const key = `${e.subscriptionId}::${e.subscriptionName}` + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(e) + } + + for (const [key, groupEntries] of groups) { + const [, name] = key.split("::") + consola.log(pc.bold(`${name}:`)) + for (const e of groupEntries) { + const date = e.changedAt.slice(0, 10) + consola.log( + ` ${pc.dim(date)} ${formatDiff(e.oldPrice, e.newPrice, e.oldCurrency, e.newCurrency)}`, + ) + } + consola.log("") + } +} + +export function handleHistory(id?: number, options: HistoryOptions = {}): void { + if (options.json) { + let entries: PriceHistoryEntry[] + if (id !== undefined) { + entries = getPriceHistory(id) + } else { + entries = getAllPriceChanges(options.days) + } + process.stdout.write(JSON.stringify(entries, null, 2) + "\n") + return + } + + if (id !== undefined) { + const sub = getSubscription(id) + if (!sub) { + consola.error(`Subscription with id ${id} not found`) + return + } + consola.log(pc.bold(`Price history for: ${sub.name}`)) + consola.log("") + const entries = getPriceHistory(id) + displayHistory(entries) + return + } + + if (options.all) { + const entries = getAllPriceChanges(options.days) + if (entries.length === 0) { + consola.info("No price changes recorded") + return + } + consola.log(pc.bold("All price changes:")) + consola.log("") + displayHistory(entries) + return + } + + consola.info("Usage: subtrack history | subtrack history --all") +} diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index 8363819..a9fc432 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -37,6 +37,8 @@ import { handleTui, handleCalendar, handleMcp, + handleHistory, + handleNotify, } from "./commands.ts"; import { handleUsageAdd, @@ -853,6 +855,85 @@ const usageCommand = define({ consola.info("Usage: subtrack usage add|list|delete|import|refresh"), }); +// ── History ─────────────────────────────────────────────── + +const historyCommand = define({ + name: "history", + description: "Show price change history for a subscription", + args: { + id: { + type: "positional", + description: "Subscription ID", + required: false, + }, + all: { + type: "boolean", + description: "Show all price changes across all subscriptions", + }, + days: { + type: "string", + description: "Filter to recent N days (used with --all)", + }, + json: { + type: "boolean", + short: "j", + description: "Output as JSON", + }, + }, + run: (ctx) => { + const positionals = ctx.positionals as string[] + const id = ctx.values.id !== undefined ? Number(ctx.values.id) : positionals[1] ? Number(positionals[1]) : undefined + if (id !== undefined && (isNaN(id) || !Number.isInteger(id) || id < 1)) { + consola.error("id must be a positive integer") + return + } + const days = ctx.values.days !== undefined ? Number(ctx.values.days) : undefined + if (days !== undefined && (isNaN(days) || days < 1 || !Number.isInteger(days))) { + consola.error("days must be a positive integer") + return + } + handleHistory(id, { + all: ctx.values.all, + json: ctx.values.json, + days, + }) + }, +}) + +// ── Notify ──────────────────────────────────────────────── + +const notifyCommand = define({ + name: "notify", + description: "Send desktop notification for upcoming bills", + args: { + days: { + type: "string", + description: "Number of days (default: config notifyDays or 7)", + }, + dryRun: { + type: "boolean", + description: "Show upcoming bills without sending notification", + }, + json: { + type: "boolean", + short: "j", + description: "Output as JSON", + }, + }, + run: (ctx) => { + const days = ctx.values.days !== undefined ? Number(ctx.values.days) : undefined + if (days !== undefined && (isNaN(days) || days < 0 || !Number.isInteger(days))) { + consola.error("days must be a non-negative integer") + return + } + handleNotify({ + days, + dryRun: ctx.values.dryRun, + json: ctx.values.json, + }) + }, +}) + // ── MCP ────────────────────────────────────────────────── const mcpCommand = define({ @@ -910,6 +991,8 @@ try { payment: paymentCommand, upcoming: upcomingCommand, calendar: calendarCommand, + history: historyCommand, + notify: notifyCommand, mcp: mcpCommand, analytics: analyticsCommand, compare: compareCommand, diff --git a/subtrack/src/notify.ts b/subtrack/src/notify.ts new file mode 100644 index 0000000..281295f --- /dev/null +++ b/subtrack/src/notify.ts @@ -0,0 +1,79 @@ +import { consola } from "consola" +import { calcUpcoming } from "./upcoming.ts" +import { formatPrice } from "./price.ts" +import { loadConfig } from "./config.ts" +import type { Currency } from "./types.ts" + +export type NotifyOptions = { + days?: number + dryRun?: boolean + json?: boolean +} + +export function handleNotify(options: NotifyOptions = {}): void { + const config = loadConfig() + const days = options.days ?? config.notifyDays ?? 7 + + const entries = calcUpcoming(days) + + if (options.json) { + const data = entries.map((e) => ({ + name: e.sub.name, + price: e.sub.price, + currency: e.sub.currency, + cycle: e.sub.cycle, + nextDate: `${e.nextDate.getFullYear()}-${String(e.nextDate.getMonth() + 1).padStart(2, "0")}-${String(e.nextDate.getDate()).padStart(2, "0")}`, + tags: e.sub.tags, + })) + process.stdout.write(JSON.stringify({ days, count: entries.length, entries: data }, null, 2) + "\n") + return + } + + if (entries.length === 0) { + if (!options.dryRun) return // no notification needed + consola.info(`No upcoming bills in the next ${days} day${days > 1 ? "s" : ""}`) + return + } + + if (options.dryRun) { + consola.info(`Upcoming bills (next ${days} day${days > 1 ? "s" : ""}):`) + for (const e of entries) { + const date = `${e.nextDate.getFullYear()}-${String(e.nextDate.getMonth() + 1).padStart(2, "0")}-${String(e.nextDate.getDate()).padStart(2, "0")}` + consola.log(` ${date} ${e.sub.name} ${formatPrice(e.sub.price, e.sub.currency)}/${e.sub.cycle}`) + } + return + } + + // ── Send OS notification ── + sendNotification(entries, days) +} + +function sendNotification( + entries: { sub: { name: string; price: number; currency: string; cycle: string } }[], + days: number, +): void { + // Lazy-import to avoid loading the notifier when not needed + const notifier = require("node-notifier") as typeof import("node-notifier") + + const count = entries.length + let message: string + + if (count <= 5) { + message = entries + .map((e) => `${e.sub.name}: ${formatPrice(e.sub.price, e.sub.currency)}/${e.sub.cycle}`) + .join("\n") + } else { + const shown = entries.slice(0, 5) + message = + shown + .map((e) => `${e.sub.name}: ${formatPrice(e.sub.price, e.sub.currency)}/${e.sub.cycle}`) + .join("\n") + `\n... and ${count - 5} more` + } + + notifier.notify({ + title: `subtrack: ${count} upcoming bill${count > 1 ? "s" : ""} in ${days} day${days > 1 ? "s" : ""}`, + message, + sound: true, + timeout: 10, + }) +} diff --git a/subtrack/src/subscription.ts b/subtrack/src/subscription.ts index 9f9ec8c..9c1318f 100644 --- a/subtrack/src/subscription.ts +++ b/subtrack/src/subscription.ts @@ -11,6 +11,7 @@ import { tagsSubscription, getLlmUsageTotal, getLlmUsageTotalByProvider, + writePriceHistory, } from "./db.ts" import { formatPrice, @@ -335,6 +336,7 @@ export async function handleEdit( newData.paymentMethod = trimmed || null } updateSubscription(sub.id, newData) + writePriceHistory(sub.id, sub.price, newData.price ?? sub.price, sub.currency, newData.currency ?? sub.currency) const updated = getSubscription(sub.id)! consola.success( `Updated: ${updated.name} — ${formatPrice(updated.price, updated.currency)}/${updated.cycle}`, @@ -441,6 +443,7 @@ export async function handleEdit( } updateSubscription(sub.id, newData) + writePriceHistory(sub.id, sub.price, newData.price ?? sub.price, sub.currency, newData.currency ?? sub.currency) const updated = getSubscription(sub.id) if (!updated) { consola.error("Failed to retrieve updated subscription") diff --git a/subtrack/src/tui/components/command-bar.tsx b/subtrack/src/tui/components/command-bar.tsx index 75abba8..eef35a8 100644 --- a/subtrack/src/tui/components/command-bar.tsx +++ b/subtrack/src/tui/components/command-bar.tsx @@ -27,9 +27,12 @@ const HINTS: Record = { { label: "Confirm", color: colors.danger, keys: "y delete · n cancel" }, ], detail: [ - { label: "Action", color: colors.success, keys: "e edit · d delete" }, - { label: "View", color: colors.primary, keys: "r raw" }, - { label: "Back", color: colors.textDim, keys: "q/Esc back" }, + { label: "Edit", color: colors.success, keys: "e edit" }, + { label: "History", color: colors.info, keys: "h view" }, + { label: "Back", color: colors.textDim, keys: "Esc back" }, + ], + history: [ + { label: "Back", color: colors.textDim, keys: "Esc back" }, ], reports: [ { label: "Tab", color: colors.primary, keys: "← → · h/l" }, diff --git a/subtrack/src/tui/context/app-context.tsx b/subtrack/src/tui/context/app-context.tsx index cc6f9d6..c8cdeb5 100644 --- a/subtrack/src/tui/context/app-context.tsx +++ b/subtrack/src/tui/context/app-context.tsx @@ -53,6 +53,10 @@ export type AppState = { showSidebar: boolean /** Split ratio between list and detail (0.5 = 50/50) */ splitRatio: number + /** Column visibility in list screen */ + showTagsCol: boolean + showNotesCol: boolean + showMethodCol: boolean } export type AppAction = @@ -82,6 +86,7 @@ export type AppAction = | { type: "TOGGLE_SIDEBAR" } | { type: "SET_SPLIT_RATIO"; ratio: number } | { type: "SET_SPLIT_RATIO_STEP"; delta: number } + | { type: "TOGGLE_COLUMN"; column: "tags" | "notes" | "method" } const initialState: AppState = { screen: "list", @@ -106,6 +111,9 @@ const initialState: AppState = { showDetail: false, showSidebar: true, splitRatio: 0.6, + showTagsCol: false, + showNotesCol: false, + showMethodCol: false, } function appReducer(state: AppState, action: AppAction): AppState { @@ -210,6 +218,17 @@ function appReducer(state: AppState, action: AppAction): AppState { const next = state.splitRatio + action.delta return { ...state, splitRatio: Math.max(0.3, Math.min(0.8, next)) } } + case "TOGGLE_COLUMN": { + switch (action.column) { + case "tags": + return { ...state, showTagsCol: !state.showTagsCol } + case "notes": + return { ...state, showNotesCol: !state.showNotesCol } + case "method": + return { ...state, showMethodCol: !state.showMethodCol } + } + return state + } default: return state } diff --git a/subtrack/src/tui/screen-router.tsx b/subtrack/src/tui/screen-router.tsx index d492152..6b693c5 100644 --- a/subtrack/src/tui/screen-router.tsx +++ b/subtrack/src/tui/screen-router.tsx @@ -10,6 +10,7 @@ import { CalendarScreen } from "./screens/calendar-screen.tsx" import { ConfigScreen } from "./screens/config.tsx" import { ToolsScreen } from "./screens/tools/index.tsx" import { HelpScreen } from "./screens/help.tsx" +import { HistoryScreen } from "./screens/history-screen.tsx" export function CurrentScreen() { const { state } = useTui() @@ -25,6 +26,8 @@ export function CurrentScreen() { return case "detail": return + case "history": + return case "reports": return case "calendar": diff --git a/subtrack/src/tui/screens/detail.tsx b/subtrack/src/tui/screens/detail.tsx index e9e70db..a08666f 100644 --- a/subtrack/src/tui/screens/detail.tsx +++ b/subtrack/src/tui/screens/detail.tsx @@ -33,6 +33,12 @@ export function DetailScreen() { setShowRaw((p) => !p) return } + if (input === "h") { + if (state.selectedId !== null) { + dispatch({ type: "SET_SCREEN", screen: "history" }) + } + return + } }, { isActive: true }, ) @@ -174,7 +180,7 @@ export function DetailScreen() { paddingY={0} > - {" "}e: edit d: delete q/ Esc: back + {" "}e: edit d: delete h: history q/ Esc: back {showRaw && ( @@ -275,7 +281,7 @@ export function DetailPreview() { {"─".repeat(24)} - e:edit d:del |:close Enter:full + e:edit d:del h:hist |:close Enter:full ) diff --git a/subtrack/src/tui/screens/history-screen.tsx b/subtrack/src/tui/screens/history-screen.tsx new file mode 100644 index 0000000..be70dd2 --- /dev/null +++ b/subtrack/src/tui/screens/history-screen.tsx @@ -0,0 +1,112 @@ +import { Box, Text, useInput } from "ink" +import { useMemo } from "react" +import { getPriceHistory, getSubscription } from "../../db.ts" +import { useTui } from "../context/app-context.tsx" +import { colors } from "../theme.ts" +import { formatPrice } from "../../price.ts" + +export function HistoryScreen() { + const { state, dispatch } = useTui() + + const selectedId = state.selectedId + + const sub = useMemo( + () => (selectedId !== null ? getSubscription(selectedId) : undefined), + [selectedId, state.refreshKey], + ) + + const entries = useMemo( + () => (selectedId !== null ? getPriceHistory(selectedId) : []), + [selectedId, state.refreshKey], + ) + + useInput( + (input, key) => { + if (input === "q" || key.escape) { + dispatch({ type: "GO_BACK" }) + return + } + }, + { isActive: true }, + ) + + if (!sub) { + return ( + + No subscription selected + + ) + } + + return ( + + + + Price History:{" "} + + + {sub.name} + + + + {entries.length === 0 ? ( + + No price changes recorded yet + + ) : ( + + + + Date + + + Change + + + {entries.map((entry) => { + const date = entry.changedAt.slice(0, 10) + const oldStr = entry.oldPrice !== null + ? formatPrice(entry.oldPrice, entry.oldCurrency ?? entry.newCurrency) + : "—" + const newStr = formatPrice(entry.newPrice, entry.newCurrency) + const diff = entry.oldPrice !== null ? entry.newPrice - entry.oldPrice : 0 + const diffColor = diff > 0 ? "red" : diff < 0 ? "green" : "white" + const diffStr = entry.oldPrice !== null && diff !== 0 + ? ` (${diff > 0 ? "+" : ""}${formatPrice(Math.abs(diff), entry.newCurrency)})` + : "" + + return ( + + + {date} + + {entry.oldCurrency !== null && entry.oldCurrency !== entry.newCurrency ? ( + + + {oldStr} ({entry.oldCurrency}) → {newStr} ({entry.newCurrency}) + + + ) : ( + + + {oldStr} →{" "} + + {newStr}{diffStr} + + + + )} + + ) + })} + + )} + + + + q/Esc back + + + + ) +} diff --git a/subtrack/src/tui/screens/list.tsx b/subtrack/src/tui/screens/list.tsx index 281913a..68bfc15 100644 --- a/subtrack/src/tui/screens/list.tsx +++ b/subtrack/src/tui/screens/list.tsx @@ -19,28 +19,39 @@ type Column = { sortField?: SortField } -const COLUMNS: Column[] = [ - { key: "name", label: "Name", minWidth: 8, flex: 4, align: "left", sortField: "name" }, - { key: "status", label: "Status", minWidth: 8, flex: 2, align: "left", sortField: "status" }, - { key: "cycle", label: "Cycle", minWidth: 6, flex: 2, align: "left", sortField: "cycle" }, - { key: "billingDay", label: "Bill", minWidth: 4, flex: 1, align: "right" }, - { key: "tags", label: "Tags", minWidth: 4, flex: 3, align: "left" }, - { key: "price", label: "Price", minWidth: 10, flex: 3, align: "right", sortField: "price" }, -] - -function calcWidths(availableWidth: number): number[] { - const totalFlex = COLUMNS.reduce((s, c) => s + c.flex, 0) - const widths = COLUMNS.map((c) => c.minWidth) +function getColumns(state: { showTagsCol: boolean; showNotesCol: boolean; showMethodCol: boolean }): Column[] { + const cols: Column[] = [ + { key: "name", label: "Name", minWidth: 8, flex: 4, align: "left", sortField: "name" }, + { key: "status", label: "Status", minWidth: 8, flex: 2, align: "left", sortField: "status" }, + { key: "cycle", label: "Cycle", minWidth: 6, flex: 2, align: "left", sortField: "cycle" }, + ] + if (state.showMethodCol) { + cols.push({ key: "method", label: "Method", minWidth: 6, flex: 2, align: "left" }) + } + cols.push({ key: "billingDay", label: "Bill", minWidth: 4, flex: 1, align: "right" }) + if (state.showTagsCol) { + cols.push({ key: "tags", label: "Tags", minWidth: 4, flex: 3, align: "left" }) + } + if (state.showNotesCol) { + cols.push({ key: "notes", label: "Notes", minWidth: 6, flex: 3, align: "left" }) + } + cols.push({ key: "price", label: "Price", minWidth: 10, flex: 3, align: "right", sortField: "price" }) + return cols +} + +function calcWidths(columns: Column[], availableWidth: number): number[] { + const totalFlex = columns.reduce((s, c) => s + c.flex, 0) + const widths = columns.map((c) => c.minWidth) const remaining = availableWidth - widths.reduce((s, w) => s + w, 0) if (remaining > 0) { let allocated = 0 - for (let i = 0; i < COLUMNS.length - 1; i++) { - const extra = Math.floor((remaining * COLUMNS[i].flex) / totalFlex) + for (let i = 0; i < columns.length - 1; i++) { + const extra = Math.floor((remaining * columns[i].flex) / totalFlex) widths[i] += extra allocated += extra } - widths[COLUMNS.length - 1] += remaining - allocated + widths[columns.length - 1] += remaining - allocated } return widths @@ -67,7 +78,8 @@ export function ListScreen() { const LAYOUT_OVERHEAD = 3 // StatusBar + CommandBar + border top/bottom const availableHeight = Math.max(5, termRows - LAYOUT_OVERHEAD - 1) // -1 for header - const widths = calcWidths(availableWidth) + const columns = useMemo(() => getColumns(state), [state.showTagsCol, state.showNotesCol, state.showMethodCol]) + const widths = useMemo(() => calcWidths(columns, availableWidth), [columns, availableWidth]) // ── Data ── @@ -224,6 +236,18 @@ export function ListScreen() { dispatch({ type: "SET_SORT" }) return } + if (input === "_") { + dispatch({ type: "TOGGLE_COLUMN", column: "tags" }) + return + } + if (input === "<") { + dispatch({ type: "TOGGLE_COLUMN", column: "notes" }) + return + } + if (input === ">") { + dispatch({ type: "TOGGLE_COLUMN", column: "method" }) + return + } if (input === "S" && state.selectedId !== null) { const sub = getSubscription(state.selectedId) if (sub) { @@ -294,6 +318,13 @@ export function ListScreen() { {" ["}{state.multiSelect.size}{"]"} )} + {state.filterText && subs.length > 0 && ( + + {" — "}{state.filterText.length > 15 + ? state.filterText.slice(0, 15) + "…" + : state.filterText}{" "}({subs.length}) + + )} {barshow && ( @@ -302,11 +333,21 @@ export function ListScreen() { )} - {/* ── Row 2: Column headers ── */} + {/* ── Column toggle indicators ── */} + + + {" "} + {state.showTagsCol ? "[T]" : "[·]"} + {state.showNotesCol ? "[N]" : "[·]"} + {state.showMethodCol ? "[M]" : "[·]"} + + + + {/* ── Row 3: Column headers ── */} {/* Multi-select spacer */} - {COLUMNS.map((col, i) => ( + {columns.map((col, i) => ( + + {sub.name.padEnd(w).slice(0, w)} + + + ) + case "status": + return ( + + + {statusLabel(sub.status).padEnd(w).slice(0, w)} + + + ) + case "cycle": + return ( + + + {sub.cycle.padEnd(w).slice(0, w)} + + + ) + case "method": + return ( + + + {(sub.paymentMethod ?? "—").padEnd(w).slice(0, w)} + + + ) + case "billingDay": + return ( + + + {sub.billingDay + ? String(sub.billingDay).padStart(w).slice(0, w) + : "—".padStart(w)} + + + ) + case "tags": + return ( + + + {(sub.tags.length > 0 ? sub.tags.join(", ") : "—").padEnd(w).slice(0, w)} + + + ) + case "notes": + return ( + + + {(sub.notes ?? "—").padEnd(w).slice(0, w)} + + + ) + case "price": + return ( + + + {formatPrice(sub.price, sub.currency).padStart(w).slice(0, w)} + + + ) + default: + return null + } + } + return ( {/* Multi-select / selection marker */} @@ -379,84 +511,7 @@ export function ListScreen() { )} - - {/* Name */} - - {selStyle ? ( - - {sub.name.padEnd(widths[0]).slice(0, widths[0])} - - ) : ( - - {sub.name.padEnd(widths[0]).slice(0, widths[0])} - - )} - - - {/* Status */} - - - {statusLabel(sub.status).padEnd(widths[1]).slice(0, widths[1])} - - - - {/* Cycle */} - - - {sub.cycle.padEnd(widths[2]).slice(0, widths[2])} - - - - {/* Billing Day */} - - - {sub.billingDay - ? String(sub.billingDay).padStart(widths[3]).slice(0, widths[3]) - : "—".padStart(widths[3])} - - - - {/* Tags */} - - - {(sub.tags.length > 0 - ? sub.tags.join(", ") - : "—" - ).padEnd(widths[4]).slice(0, widths[4])} - - - - {/* Price */} - - - {formatPrice(sub.price, sub.currency).padStart(widths[5]).slice(0, widths[5])} - - + {columns.map((col, ci) => renderCell(col, ci))} ) }) diff --git a/subtrack/src/tui/types.ts b/subtrack/src/tui/types.ts index 9c6a1d6..a6a3e35 100644 --- a/subtrack/src/tui/types.ts +++ b/subtrack/src/tui/types.ts @@ -4,6 +4,7 @@ export type Screen = | "edit" | "delete" | "detail" + | "history" | "reports" | "calendar" | "config" @@ -60,6 +61,7 @@ export const SCREEN_TITLES: Record = { edit: "Edit Subscription", delete: "Delete Subscription", detail: "Subscription Detail", + history: "Price History", reports: "Reports", calendar: "Calendar", config: "Configuration", diff --git a/subtrack/src/types.ts b/subtrack/src/types.ts index a5b4e7a..4cbd483 100644 --- a/subtrack/src/types.ts +++ b/subtrack/src/types.ts @@ -150,4 +150,5 @@ export type SubtrackConfig = { defaultCurrency: string monthlyBudget: number theme: string + notifyDays: number } diff --git a/subtrack/v8-plan.md b/subtrack/v8-plan.md new file mode 100644 index 0000000..d294f7d --- /dev/null +++ b/subtrack/v8-plan.md @@ -0,0 +1,167 @@ +# v8.0.0 Implementation Plan + +## Feature 1: `subtrack notify` — Desktop notification for upcoming bills + +### Files +| File | Action | +|---|---| +| `src/notify.ts` | New — CLI handler + notification logic | +| `src/config.ts` | Modify — add `notifyDays` to CONFIG_KEYS | +| `src/types.ts` | Modify — add `notifyDays` to SubtrackConfig | +| `src/index.ts` | Modify — register `notify` subCommands | +| `src/commands.ts` | Modify — add `handleNotify` export | +| `package.json` | Modify — add `node-notifier` dependency | + +### API +``` +subtrack notify # 7日以内の請求を通知 (default) +subtrack notify --days 3 # 指定日数以内 +subtrack notify --dry-run # 内容だけ表示、通知は送らない +subtrack notify --json # JSON出力(通知なし) +``` + +### Implementation +1. `src/notify.ts`: + - `handleNotify(options: { days?: number; dryRun?: boolean; json?: boolean })` + - `calcUpcoming()` を流用して近々の請求を取得 + - `--json` → `process.stdout.write`(notify不要) + - `--dry-run` → `consola.log` で一覧表示(notify不要) + - デフォルト → `node-notifier` でOS通知を送信 + - 通知内容: 件名「subtrack: N upcoming bills」 + 各サブ名と金額(最大5件まで) +2. `config.ts`: + - `notifyDays` キー追加(デフォルト7) + - `CONFIG_KEYS` に追加 +3. `types.ts`: + - `SubtrackConfig.notifyDays: number` 追加 +4. `index.ts`: + - `notifyCommand` 定義。サブコマンドなしの単一コマンド +5. `commands.ts`: + - `handleNotify` export → `import { handleNotify } from "./notify.ts"` + +### Key decisions +- `node-notifier` は optional dependency? → No, 使うなら必須。ただしnpmサイズは小さい。 +- Linux: `notify-send`, macOS: `terminal-notifier`, Windows: native toast +- ランタイムがNode.jsなので `node-notifier` のGrowl/Notification Center対応をそのまま使う +- `--cron` フラグは省く — exit codeで判定するより `--dry-run` で自分でcronジョブを組むのがsubtrackらしい + +--- + +## Feature 2: Price change history (`subtrack history`) + +### Files +| File | Action | +|---|---| +| `src/db.ts` | Modify — add `price_history` table, `writePriceHistory`, `getPriceHistory` | +| `src/history.ts` | New — CLI handler | +| `src/subscription.ts` | Modify — hook into `handleEdit` to detect price/currency changes | +| `src/index.ts` | Modify — register `history` command | +| `src/commands.ts` | Modify — re-export `handleHistory` | +| `src/tui/types.ts` | Modify — add `"history"` to Screen type, sidebar items | +| `src/tui/screens/history-screen.tsx` | New — TUI screen for price history | +| `src/tui/screen-router.tsx` | Modify — route `history` screen | +| `src/tui/components/command-bar.tsx` | Modify — add history hints | +| `src/tui/screens/detail.tsx` | Modify — add price history section | + +### DB Schema +```sql +CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER, + new_price INTEGER NOT NULL, + old_currency TEXT, + new_currency TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE +) +``` + +### API +``` +subtrack history # 特定サブの履歴 +subtrack history --json # JSON出力 +subtrack history --all # 全サブの価格変更一覧 +subtrack history --all --days 30 # 直近30日の変更のみ +``` + +### Implementation + +**db.ts:** +- `runMigrations` に `price_history` テーブル作成を追加 +- `writePriceHistory(id, oldFields, newFields)` — `updateSubscription` から呼ばれる + - priceかcurrencyが変わったときだけINSERT + - `old_price`, `old_currency` は現在の値を保存 / `new_*` は新しい値 +- `getPriceHistory(subId): PriceHistoryEntry[]` — id降順で取得 +- `getAllPriceChanges(days?: number): PriceHistoryEntry[]` — 全サブの変更 +- 型 `PriceHistoryEntry { id, subscriptionId, name, oldPrice, newPrice, oldCurrency, newCurrency, changedAt }` + +**subscription.ts (`handleEdit`):** +- `updateSubscription` を呼ぶ前に現在の `sub.price` と `sub.currency` を保持 +- `updateSubscription` の後、変更があったら `writePriceHistory` を呼ぶ +- 注意: `updateSubscription` はDBのトランザクション内で動くので、履歴書き込みはその後に行う + +**history.ts:** +- `handleHistory(id?, options: { json?, all?, days? })` +- `--all` モード: `getAllPriceChanges(days?)` → テーブル表示 or JSON +- ID指定: `getPriceHistory(id)` → 詳細表示 or JSON +- JSON: `process.stdout.write` + +**TUI:** +- Screen type に `"history"` 追加 +- サイドバーは追加しない(detail画面から辿る) +- `detail.tsx`: 既存の詳細ペイン下部に「Price History」セクション追加 + - 最新3件程度をインライン表示 + 「View all」リンク → history screenへ +- `history-screen.tsx`: price history専用画面(リスト + 詳細行) +- `Screen` 追加に伴う型の変更: `SCREEN_LABEL`, `SCREEN_TITLES`, `Screen` type + +--- + +## Feature 3: TUI sort/filter/display enhancements + +### Files +| File | Action | +|---|---| +| `src/tui/screens/list.tsx` | Modify — column visibility, inline filter UX, sort UX | +| `src/tui/context/app-context.tsx` | Modify — add `showTags`, `showNotes`, `showMethod` to state | +| `src/tui/keyboard.tsx` | Modify — add keybinds for column toggles | + +### Changes + +**1. Column visibility toggle** +- 新しい状態: `showTags: boolean`, `showNotes: boolean`, `showMethod: boolean` (AppStateに追加) +- デフォルト: 全部false(現状維持) +- キー: + - `_` (underscore) → tags列の表示/非表示トグル + - `<` → notes列の表示/非表示トグル + - `>` → method列の表示/非表示トグル +- アクション: `TOGGLE_COLUMN { column: "tags" | "notes" | "method" }` +- レンダリング: `list.tsx` のCOLUMNS配列を動的に構築 + - tags列: 非表示時もCOLUMNSから削除する(幅計算に含めない) + - notes列: nameとtagsの間に挿入 + - method列: cycleとbillの間に挿入 +- ヘッダー行に表示状態を示す薄いインジケータ(`[T]`, `[N]`, `[M]`) + +**2. Sort UX improvements** +- `s` は今まで通りsort fieldをcyclic (name → price → cycle → status → id) +- `S` (shift+s) → sort direction toggle (今のSTATUS_TOGGLE → shift+Sは空いてる) + - wait, `S` は今status cycle toggle (`subtrack status cycle`) に使われてる + - 衝突を避ける: `s` でsort fieldを進めるのはそのまま。方向を変えたいときは `s` を押し続けて目的のfieldで止める。 + - あるいは: `Ctrl+s` で方向トグル(でもCtrl+sはターミナルでよく使われる) + - → 結論: 今のままで良い。`s` が5 fieldを循環する → name → price → cycle → status → id → (ディレクション反転) name... 今はこの挙動。これで十分。 + - 細かい改善: 現在のsort fieldで2回目 `s` を押したら方向反転(今は1周してから反転) + - `SET_SORT` アクション変更: 現在のfieldでもう一度押したら `sortDesc` を反転、異なるfieldならそっちに変更(今の挙動) + +**3. Filter display** +- 現在filter中なら `state.filterText` の内容をタイトル行に表示(済み) +- 追加: filterがマッチしなかったとき「Esc or Ctrl+L to clear」のヒント(済み) +- 追加: filterヒット件数を表示 `"▶ query (3/10)"` (現在はヒット件数なし) + +**4. 行の色分け改善** +- キャンセル済み: `dimColor` + 取り消し線 → 今は `pc.dim` 相当がない +- `status === "cancelled"` の行を `dimColor` で表示 + +### Order of changes (smallest → largest) +1. Column visibility state + reducers + keybinds ← **small** +2. Sort UX improvement (double-tap reverse) ← **trivial** +3. Filter hit count display ← **trivial** +4. Cancelled row dimming ← **trivial** From 3ac3fbb8bc98f4b70b68b84c8f4cb428d8d2f544 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Tue, 30 Jun 2026 23:25:17 +0900 Subject: [PATCH 02/12] fix: use kebab-case arg name for --dry-run in notify command --- subtrack/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index a9fc432..6544fe6 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -910,7 +910,7 @@ const notifyCommand = define({ type: "string", description: "Number of days (default: config notifyDays or 7)", }, - dryRun: { + "dry-run": { type: "boolean", description: "Show upcoming bills without sending notification", }, @@ -928,7 +928,7 @@ const notifyCommand = define({ } handleNotify({ days, - dryRun: ctx.values.dryRun, + dryRun: ctx.values["dry-run"], json: ctx.values.json, }) }, From 95975ca07c10302f224989a0ccb36a3677b19cb9 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 19:11:48 +0900 Subject: [PATCH 03/12] edit --- subtrack/src/__tests__/mcp.test.ts | 179 +++++++++++ .../src/__tests__/scanner-providers.test.ts | 106 +++++++ subtrack/src/__tests__/tui-context.test.ts | 293 ++++++++++++++++++ subtrack/src/analytics.ts | 2 +- subtrack/src/config.ts | 27 ++ subtrack/src/crypto.ts | 6 +- subtrack/src/mcp.ts | 8 +- subtrack/src/tui/components/sidebar.tsx | 6 +- subtrack/src/tui/components/status-bar.tsx | 2 +- subtrack/src/tui/components/toast.tsx | 2 +- subtrack/src/tui/context/app-context.tsx | 35 ++- subtrack/src/tui/screens/calendar-screen.tsx | 2 +- subtrack/src/tui/screens/config.tsx | 4 +- subtrack/src/tui/screens/delete.tsx | 3 +- subtrack/src/tui/screens/detail.tsx | 4 +- subtrack/src/tui/screens/edit.tsx | 3 +- subtrack/src/tui/screens/list.tsx | 158 ++++++++-- subtrack/src/tui/types.ts | 14 +- subtrack/src/types.ts | 6 + 19 files changed, 795 insertions(+), 65 deletions(-) create mode 100644 subtrack/src/__tests__/mcp.test.ts create mode 100644 subtrack/src/__tests__/scanner-providers.test.ts create mode 100644 subtrack/src/__tests__/tui-context.test.ts diff --git a/subtrack/src/__tests__/mcp.test.ts b/subtrack/src/__tests__/mcp.test.ts new file mode 100644 index 0000000..997bfe0 --- /dev/null +++ b/subtrack/src/__tests__/mcp.test.ts @@ -0,0 +1,179 @@ +import { describe, test, expect, beforeAll, beforeEach, afterEach } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" + +let testDb: Database +let dbModule: typeof import("../db.ts") + +function dateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` +} + +beforeAll(async () => { + const SQL = await initSqlJs() + testDb = new SQL.Database() + testDb.run("PRAGMA foreign_keys = ON") + + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS subscription_tags ( + subscription_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (subscription_id, tag_id), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER, + new_price INTEGER NOT NULL, + old_currency TEXT, + new_currency TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + )`) + + dbModule = await import("../db.ts") + dbModule.__setDb(testDb) +}) + +beforeEach(() => { + testDb.run("DELETE FROM price_history") + testDb.run("DELETE FROM subscription_tags") + testDb.run("DELETE FROM tags") + testDb.run("DELETE FROM subscriptions") + testDb.run("DELETE FROM sqlite_sequence") +}) + +describe("MCP helper functions", () => { + test("formatDateISO formats date to ISO string", async () => { + const { formatDateISO } = await import("../mcp.ts") + expect(formatDateISO(new Date("2026-06-15"))).toBe("2026-06-15") + }) + + test("nextDateForCycle — monthly basic case", async () => { + const { nextDateForCycle } = await import("../mcp.ts") + // Use local date constructors for timezone safety + const anchor = new Date(2026, 0, 15) // Jan 15 + const from = new Date(2026, 5, 1) // Jun 1 + const next = nextDateForCycle(15, anchor, "monthly", from) + // Should be Jun 15 (within same month) + expect(next.getMonth()).toBe(5) // June + expect(next.getDate()).toBe(15) + }) + + test("nextDateForCycle — monthly rolls to next month", async () => { + const { nextDateForCycle } = await import("../mcp.ts") + const anchor = new Date(2026, 0, 15) // Jan 15 + const from = new Date(2026, 5, 20) // Jun 20 (past billing day 15) + const next = nextDateForCycle(15, anchor, "monthly", from) + expect(next.getMonth()).toBe(6) // July + expect(next.getDate()).toBe(15) + }) + + test("nextDateForCycle — yearly returns next year", async () => { + const { nextDateForCycle } = await import("../mcp.ts") + const anchor = new Date(2026, 2, 10) // Mar 10 + const from = new Date(2026, 5, 1) // Jun 1 + const next = nextDateForCycle(10, anchor, "yearly", from) + expect(next.getFullYear()).toBe(2027) + expect(next.getMonth()).toBe(2) // March + expect(next.getDate()).toBe(10) + }) + + test("nextDateForCycle — weekly returns next week", async () => { + const { nextDateForCycle } = await import("../mcp.ts") + const anchor = new Date(2026, 5, 1) // Jun 1 (Monday) + const from = new Date(2026, 5, 15) // Jun 15 + const next = nextDateForCycle(1, anchor, "weekly", from) + // Should be a Monday on or after Jun 15 + expect(next.getDay()).toBe(1) // Monday + expect(next.getTime()).toBeGreaterThanOrEqual(from.getTime()) + const diffDays = (next.getTime() - from.getTime()) / (24 * 60 * 60 * 1000) + expect(diffDays).toBeLessThanOrEqual(7) + }) + + test("nextDateForCycle — bi-weekly returns correct date", async () => { + const { nextDateForCycle } = await import("../mcp.ts") + const anchor = new Date(2026, 5, 1) // Jun 1 + const from = new Date(2026, 5, 15) // Jun 15 + const next = nextDateForCycle(1, anchor, "bi-weekly", from) + expect(next.getTime()).toBeGreaterThanOrEqual(from.getTime()) + const diffDays = (next.getTime() - from.getTime()) / (24 * 60 * 60 * 1000) + expect(diffDays).toBeLessThanOrEqual(14) + }) + + test("nextDateForCycle — quarterly returns next quarter", async () => { + const { nextDateForCycle } = await import("../mcp.ts") + const anchor = new Date(2026, 0, 15) // Jan 15 + const from = new Date(2026, 5, 1) // Jun 1 + const next = nextDateForCycle(15, anchor, "quarterly", from) + expect(next.getMonth()).toBe(6) // July (Q3) + expect(next.getDate()).toBe(15) + }) +}) + +describe("calcUpcoming", () => { + test("returns upcoming billings within period", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01'), + (2, 'Spotify', 980, 'JPY', 'monthly', 'active', 1, '2026-01-10'), + (3, 'GitHub Copilot', 1000, 'USD', 'monthly', 'cancelled', 5, '2026-03-01')`, + ) + + const { calcUpcoming } = await import("../mcp.ts") + const result = calcUpcoming(30) + const names = result.map((e: { name: string }) => e.name) + expect(names).toContain("Netflix") + expect(names).not.toContain("GitHub Copilot") + }) +}) + +describe("searchSubscriptions", () => { + test("searches by name pattern", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at, notes) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01', 'Family plan'), + (2, 'Spotify', 980, 'JPY', 'monthly', 'active', 1, '2026-01-10', NULL)`, + ) + + const { searchSubscriptions } = await import("../mcp.ts") + const results = searchSubscriptions("net", {}) + expect(results.length).toBeGreaterThanOrEqual(1) + expect(results.some((r: { name: string }) => r.name === "Netflix")).toBe(true) + }) + + test("returns empty array for no match", async () => { + testDb.run( + `INSERT INTO subscriptions (id, name, price, currency, cycle, status, billing_day, created_at) + VALUES (1, 'Netflix', 1990, 'JPY', 'monthly', 'active', 15, '2026-01-01')`, + ) + + const { searchSubscriptions } = await import("../mcp.ts") + const results = searchSubscriptions("zzzzz", {}) + expect(results.length).toBe(0) + }) +}) + +describe("startMcpServer", () => { + test("exports startMcpServer function", async () => { + const { startMcpServer } = await import("../mcp.ts") + expect(typeof startMcpServer).toBe("function") + }) +}) diff --git a/subtrack/src/__tests__/scanner-providers.test.ts b/subtrack/src/__tests__/scanner-providers.test.ts new file mode 100644 index 0000000..ef61280 --- /dev/null +++ b/subtrack/src/__tests__/scanner-providers.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, afterEach } from "vitest" +import { clearScanners, registerScanner, runAllScanners, getRegisteredScanners } from "../scanner.ts" +import type { Scanner } from "../scanner-types.ts" + +afterEach(() => { + clearScanners() +}) + +describe("built-in scanners", () => { + test("createOpenCodeScanner is a Scanner object", async () => { + const mod = await import("../opencode-scanner.ts") + const scanner: Scanner = mod.createOpenCodeScanner + expect(scanner.name).toBe("opencode") + + const result = scanner.scan() + expect(result.source).toBe("opencode") + expect(Array.isArray(result.entries)).toBe(true) + for (const entry of result.entries) { + expect(entry).toHaveProperty("provider") + expect(typeof entry.input_tokens).toBe("number") + expect(typeof entry.cost).toBe("number") + } + }) + + test("createClaudeScanner is a Scanner object", async () => { + const mod = await import("../claude-scanner.ts") + const scanner: Scanner = mod.createClaudeScanner + expect(scanner.name).toBe("claude") + + const result = scanner.scan() + expect(result.source).toBe("claude") + expect(Array.isArray(result.entries)).toBe(true) + for (const entry of result.entries) { + expect(entry).toHaveProperty("provider") + expect(typeof entry.cost).toBe("number") + } + }) +}) + +describe("scanner registration and execution", () => { + test("custom scanner can be registered and executed", () => { + const custom: Scanner = { + name: "custom-test", + scan: () => ({ + source: "custom-test", + entries: [ + { + provider: "test", + model: "test-model", + input_tokens: 100, + output_tokens: 50, + cost: 0.05, + date: "2026-06-01", + description: "test entry", + generation_id: "custom-1", + }, + ], + }), + } + + registerScanner(custom) + const result = runAllScanners() + const customEntries = result.entries.filter((e) => e.generation_id === "custom-1") + expect(customEntries.length).toBe(1) + expect(customEntries[0].provider).toBe("test") + }) + + test("scanner with no entries returns empty array", () => { + const empty: Scanner = { + name: "empty-scanner", + scan: () => ({ source: "empty-scanner", entries: [] }), + } + registerScanner(empty) + const result = runAllScanners() + expect(result.source).toBe("combined") + expect(Array.isArray(result.entries)).toBe(true) + }) + + test("duplicate scanner name is skipped", () => { + const s1: Scanner = { + name: "test-scanner", + scan: () => ({ source: "test-scanner", entries: [] }), + } + registerScanner(s1) + const countBefore = getRegisteredScanners().length + + const s2: Scanner = { + name: "test-scanner", + scan: () => ({ source: "test-scanner-dup", entries: [] }), + } + registerScanner(s2) + expect(getRegisteredScanners().length).toBe(countBefore) + }) + + test("clearScanners removes all scanners", () => { + const s: Scanner = { + name: "temp-scanner", + scan: () => ({ source: "temp", entries: [] }), + } + registerScanner(s) + expect(getRegisteredScanners().length).toBeGreaterThan(0) + + clearScanners() + expect(getRegisteredScanners().length).toBe(0) + }) +}) diff --git a/subtrack/src/__tests__/tui-context.test.ts b/subtrack/src/__tests__/tui-context.test.ts new file mode 100644 index 0000000..8b2443c --- /dev/null +++ b/subtrack/src/__tests__/tui-context.test.ts @@ -0,0 +1,293 @@ +import { describe, test, expect, beforeEach } from "vitest" +import { appReducer, type AppState, type AppAction } from "../tui/context/app-context.tsx" + +function createState(overrides?: Partial): AppState { + return { + screen: "list", + mode: "NORMAL", + focus: "content", + sidebarIndex: 0, + listIndex: 0, + filterText: "", + selectedId: null, + history: [], + formActive: false, + reportsTab: "summary", + toolsTab: "export", + multiSelect: new Set(), + refreshKey: 0, + sortField: "name", + sortDesc: false, + toast: null, + paletteOpen: false, + paletteQuery: "", + paletteIndex: 0, + showDetail: false, + showSidebar: true, + splitRatio: 0.6, + showTagsCol: false, + showNotesCol: false, + showMethodCol: false, + ...overrides, + } +} + +describe("appReducer", () => { + describe("SET_SCREEN", () => { + test("switches to the requested screen and preserves history", () => { + const state = createState({ screen: "list", selectedId: 5 }) + const action: AppAction = { type: "SET_SCREEN", screen: "add" } + + const next = appReducer(state, action) + + expect(next.screen).toBe("add") + expect(next.history).toEqual(["list"]) + expect(next.mode).toBe("NORMAL") + expect(next.selectedId).toBe(5) // preserved + }) + + test("does nothing when navigating to the same screen", () => { + const state = createState({ screen: "list" }) + const action: AppAction = { type: "SET_SCREEN", screen: "list" } + + const next = appReducer(state, action) + + // Reference equality — unchanged + expect(next).toBe(state) + }) + }) + + describe("GO_BACK", () => { + test("returns to the previous screen", () => { + const state = createState({ screen: "detail", history: ["list"] }) + const action: AppAction = { type: "GO_BACK" } + + const next = appReducer(state, action) + + expect(next.screen).toBe("list") + expect(next.history).toEqual([]) + expect(next.mode).toBe("NORMAL") + }) + + test("does nothing when history is empty", () => { + const state = createState({ screen: "list", history: [] }) + const action: AppAction = { type: "GO_BACK" } + + const next = appReducer(state, action) + + expect(next).toBe(state) + }) + }) + + describe("SET_MODE / SET_FOCUS", () => { + test("SET_MODE changes mode", () => { + const state = createState({ mode: "NORMAL" }) + const next = appReducer(state, { type: "SET_MODE", mode: "COMMAND" }) + expect(next.mode).toBe("COMMAND") + }) + + test("SET_FOCUS changes focus", () => { + const state = createState({ focus: "content" }) + const next = appReducer(state, { type: "SET_FOCUS", focus: "sidebar" }) + expect(next.focus).toBe("sidebar") + }) + }) + + describe("TOGGLE_FOCUS", () => { + test("toggles between sidebar and content", () => { + const state = createState({ focus: "content" }) + const next = appReducer(state, { type: "TOGGLE_FOCUS" }) + expect(next.focus).toBe("sidebar") + + const next2 = appReducer(next, { type: "TOGGLE_FOCUS" }) + expect(next2.focus).toBe("content") + }) + }) + + describe("MULTI_SELECT_TOGGLE", () => { + test("adds id to multiSelect set", () => { + const state = createState({ multiSelect: new Set() }) + const next = appReducer(state, { type: "MULTI_SELECT_TOGGLE", id: 3 }) + expect(next.multiSelect.has(3)).toBe(true) + expect(next.multiSelect.size).toBe(1) + }) + + test("removes id from multiSelect set when already selected", () => { + const state = createState({ multiSelect: new Set([3, 5]) }) + const next = appReducer(state, { type: "MULTI_SELECT_TOGGLE", id: 3 }) + expect(next.multiSelect.has(3)).toBe(false) + expect(next.multiSelect.size).toBe(1) + }) + + test("does not mutate the original set", () => { + const original = new Set([3]) + const state = createState({ multiSelect: original }) + appReducer(state, { type: "MULTI_SELECT_TOGGLE", id: 5 }) + expect(original.has(5)).toBe(false) + expect(original.size).toBe(1) + }) + }) + + describe("SET_SORT", () => { + test("cycles through sort fields: name → price → cycle → status → id → name", () => { + const state = createState({ sortField: "name", sortDesc: false }) + + const step1 = appReducer(state, { type: "SET_SORT" }) + expect(step1.sortField).toBe("price") + expect(step1.sortDesc).toBe(false) + + const step2 = appReducer(step1, { type: "SET_SORT" }) + expect(step2.sortField).toBe("cycle") + expect(step2.sortDesc).toBe(false) + + const step3 = appReducer(step2, { type: "SET_SORT" }) + expect(step3.sortField).toBe("status") + expect(step3.sortDesc).toBe(false) + + const step4 = appReducer(step3, { type: "SET_SORT" }) + expect(step4.sortField).toBe("id") + expect(step4.sortDesc).toBe(false) + + // Wrap around — toggle direction on wrap + const step5 = appReducer(step4, { type: "SET_SORT" }) + expect(step5.sortField).toBe("name") + expect(step5.sortDesc).toBe(true) + }) + }) + + describe("TOAST operations", () => { + test("SET_TOAST sets toast", () => { + const state = createState({ toast: null }) + const next = appReducer(state, { + type: "SET_TOAST", + toast: { message: "Test", type: "success" }, + }) + expect(next.toast).toEqual({ message: "Test", type: "success" }) + }) + + test("CLEAR_TOAST clears toast", () => { + const state = createState({ + toast: { message: "Test", type: "info" }, + }) + const next = appReducer(state, { type: "CLEAR_TOAST" }) + expect(next.toast).toBeNull() + }) + }) + + describe("PALETTE operations", () => { + test("SET_PALETTE_OPEN resets index and keeps query", () => { + const state = createState({ + paletteOpen: false, + paletteQuery: "test", + paletteIndex: 5, + }) + const next = appReducer(state, { type: "SET_PALETTE_OPEN", open: true }) + expect(next.paletteOpen).toBe(true) + // Query is preserved from state on open + expect(next.paletteQuery).toBe("test") + expect(next.paletteIndex).toBe(0) + }) + + test("SET_PALETTE_QUERY resets index", () => { + const state = createState({ paletteQuery: "", paletteIndex: 3 }) + const next = appReducer(state, { + type: "SET_PALETTE_QUERY", + query: "new", + }) + expect(next.paletteQuery).toBe("new") + expect(next.paletteIndex).toBe(0) + }) + }) + + describe("SPLIT RATIO", () => { + test("SET_SPLIT_RATIO clamps between 0.3 and 0.8", () => { + const state = createState({ splitRatio: 0.6 }) + + const tooLow = appReducer(state, { type: "SET_SPLIT_RATIO", ratio: 0.1 }) + expect(tooLow.splitRatio).toBe(0.3) + + const tooHigh = appReducer(state, { type: "SET_SPLIT_RATIO", ratio: 0.9 }) + expect(tooHigh.splitRatio).toBe(0.8) + + const valid = appReducer(state, { type: "SET_SPLIT_RATIO", ratio: 0.5 }) + expect(valid.splitRatio).toBe(0.5) + }) + + test("SET_SPLIT_RATIO_STEP adjusts by delta and clamps", () => { + const state = createState({ splitRatio: 0.5 }) + + const inc = appReducer(state, { type: "SET_SPLIT_RATIO_STEP", delta: 0.05 }) + expect(inc.splitRatio).toBe(0.55) + + const dec = appReducer(inc, { type: "SET_SPLIT_RATIO_STEP", delta: -0.05 }) + expect(dec.splitRatio).toBe(0.5) + }) + }) + + describe("TOGGLE_COLUMN", () => { + test("toggles tags column", () => { + const state = createState({ showTagsCol: false }) + const next = appReducer(state, { type: "TOGGLE_COLUMN", column: "tags" }) + expect(next.showTagsCol).toBe(true) + }) + + test("toggles notes column", () => { + const state = createState({ showNotesCol: true }) + const next = appReducer(state, { type: "TOGGLE_COLUMN", column: "notes" }) + expect(next.showNotesCol).toBe(false) + }) + + test("toggles method column", () => { + const state = createState({ showMethodCol: false }) + const next = appReducer(state, { type: "TOGGLE_COLUMN", column: "method" }) + expect(next.showMethodCol).toBe(true) + }) + }) + + describe("SIDEBAR / LIST INDEX", () => { + test("SET_SIDEBAR_INDEX updates sidebar index", () => { + const state = createState({ sidebarIndex: 0 }) + const next = appReducer(state, { type: "SET_SIDEBAR_INDEX", index: 2 }) + expect(next.sidebarIndex).toBe(2) + }) + + test("SET_LIST_INDEX updates list index", () => { + const state = createState({ listIndex: 0 }) + const next = appReducer(state, { type: "SET_LIST_INDEX", index: 5 }) + expect(next.listIndex).toBe(5) + }) + }) + + describe("SET_REPORTS_TAB / SET_TOOLS_TAB", () => { + test("SET_REPORTS_TAB resets list index", () => { + const state = createState({ reportsTab: "summary", listIndex: 3 }) + const next = appReducer(state, { type: "SET_REPORTS_TAB", tab: "payment" }) + expect(next.reportsTab).toBe("payment") + expect(next.listIndex).toBe(0) + }) + + test("SET_TOOLS_TAB resets list index", () => { + const state = createState({ toolsTab: "export", listIndex: 3 }) + const next = appReducer(state, { type: "SET_TOOLS_TAB", tab: "import" }) + expect(next.toolsTab).toBe("import") + expect(next.listIndex).toBe(0) + }) + }) + + describe("TOGGLE_DETAIL / TOGGLE_SIDEBAR", () => { + test("TOGGLE_DETAIL toggles showDetail", () => { + const state = createState({ showDetail: false }) + const next = appReducer(state, { type: "TOGGLE_DETAIL" }) + expect(next.showDetail).toBe(true) + + const next2 = appReducer(next, { type: "TOGGLE_DETAIL" }) + expect(next2.showDetail).toBe(false) + }) + + test("TOGGLE_SIDEBAR toggles showSidebar", () => { + const state = createState({ showSidebar: true }) + const next = appReducer(state, { type: "TOGGLE_SIDEBAR" }) + expect(next.showSidebar).toBe(false) + }) + }) +}) diff --git a/subtrack/src/analytics.ts b/subtrack/src/analytics.ts index f6683c5..b4d1cac 100644 --- a/subtrack/src/analytics.ts +++ b/subtrack/src/analytics.ts @@ -18,7 +18,7 @@ export function showAnalytics(): void { const data = calcSummary(list) // Header - consola.log(pc.bold("📊 Subscription Analytics")) + consola.log(pc.bold("Subscription Analytics")) consola.log("") // Overview diff --git a/subtrack/src/config.ts b/subtrack/src/config.ts index de4d5a8..83e4d18 100644 --- a/subtrack/src/config.ts +++ b/subtrack/src/config.ts @@ -97,6 +97,33 @@ export function setConfig(key: ConfigKey, value: string): boolean { return true } +// ── TUI column visibility persistence ── + +export type TuiColumnSettings = { + showTagsCol: boolean + showNotesCol: boolean + showMethodCol: boolean +} + +export function loadTuiColumns(): TuiColumnSettings { + const config = loadConfig() + return { + showTagsCol: config.tui?.showTagsCol ?? false, + showNotesCol: config.tui?.showNotesCol ?? false, + showMethodCol: config.tui?.showMethodCol ?? false, + } +} + +export function saveTuiColumns(settings: TuiColumnSettings): void { + const config = loadConfig() + config.tui = { + showTagsCol: settings.showTagsCol, + showNotesCol: settings.showNotesCol, + showMethodCol: settings.showMethodCol, + } + saveConfig(config) +} + function saveConfig(config: SubtrackConfig): void { const configPath = getConfigPath() const dir = path.dirname(configPath) diff --git a/subtrack/src/crypto.ts b/subtrack/src/crypto.ts index 159561c..b2a5ce3 100644 --- a/subtrack/src/crypto.ts +++ b/subtrack/src/crypto.ts @@ -100,9 +100,9 @@ function getOrCreateKey(): Buffer { writeFileSync(keyPath, key, { mode: 0o600 }) consola.warn( `Encryption key created at: ${keyPath}\n` + - ` ⚠ BACKUP THIS KEY! If lost, your encrypted database cannot be recovered.\n` + - ` 💡 Copy the file to a secure location (e.g. password manager).\n` + - ` 🔑 Or set SUBSC_CLI_DB_PASSPHRASE to derive the key from a passphrase instead.`, + ` !! BACKUP THIS KEY! If lost, your encrypted database cannot be recovered.\n` + + ` >> Copy the file to a secure location (e.g. password manager).\n` + + ` >> Or set SUBSC_CLI_DB_PASSPHRASE to derive the key from a passphrase instead.`, ) return key } diff --git a/subtrack/src/mcp.ts b/subtrack/src/mcp.ts index dce30fd..ef0e306 100644 --- a/subtrack/src/mcp.ts +++ b/subtrack/src/mcp.ts @@ -21,7 +21,7 @@ import type { SqlValue } from "sql.js" // ── Date helpers ──────────────────────────────────────── -function formatDateISO(d: Date): string { +export function formatDateISO(d: Date): string { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, "0") const day = String(d.getDate()).padStart(2, "0") @@ -40,7 +40,7 @@ function addMonths(date: Date, n: number): Date { return result } -function nextDateForCycle( +export function nextDateForCycle( anchorDay: number, anchorDate: Date, cycle: Cycle, @@ -107,7 +107,7 @@ function calculateNextBilling(sub: SharedArgs, fromDate: Date): Date { // ── Upcoming calculation ──────────────────────────────── -function calcUpcoming(days: number) { +export function calcUpcoming(days: number) { const list = getSubscriptions().filter((s) => s.status !== "cancelled") const now = new Date() now.setHours(0, 0, 0, 0) @@ -141,7 +141,7 @@ function calcUpcoming(days: number) { // ── Search implementation ──────────────────────────────── -function searchSubscriptions( +export function searchSubscriptions( query: string, fields: { names?: boolean; notes?: boolean; tags?: boolean }, ): SharedArgs[] { diff --git a/subtrack/src/tui/components/sidebar.tsx b/subtrack/src/tui/components/sidebar.tsx index 4eaedd2..3b0024f 100644 --- a/subtrack/src/tui/components/sidebar.tsx +++ b/subtrack/src/tui/components/sidebar.tsx @@ -10,9 +10,9 @@ import { useTui } from "../context/app-context.tsx" import { colors } from "../theme.ts" const SECTION_HEADS: Record = { - data: "📋 Data", - reports: "📊 Reports", - system: "⚙️ System", + data: "[ ] Data", + reports: "[~] Reports", + system: "[*] System", } type Row = diff --git a/subtrack/src/tui/components/status-bar.tsx b/subtrack/src/tui/components/status-bar.tsx index 720474d..500e770 100644 --- a/subtrack/src/tui/components/status-bar.tsx +++ b/subtrack/src/tui/components/status-bar.tsx @@ -54,7 +54,7 @@ export function StatusBar() { {/* Filter indicator */} {state.filterText && ( - {" │ "}🔍{" "} + {" │ "}?{" "} {state.filterText.length > 20 ? state.filterText.slice(0, 20) + "…" : state.filterText} diff --git a/subtrack/src/tui/components/toast.tsx b/subtrack/src/tui/components/toast.tsx index 7307e1c..e17c98f 100644 --- a/subtrack/src/tui/components/toast.tsx +++ b/subtrack/src/tui/components/toast.tsx @@ -12,7 +12,7 @@ const TOAST_COLORS: Record = { const TOAST_ICONS: Record = { success: "✓", error: "✗", - info: "ℹ", + info: "i", } /** diff --git a/subtrack/src/tui/context/app-context.tsx b/subtrack/src/tui/context/app-context.tsx index c8cdeb5..d247ccb 100644 --- a/subtrack/src/tui/context/app-context.tsx +++ b/subtrack/src/tui/context/app-context.tsx @@ -6,6 +6,8 @@ import { type ReactNode, } from "react" import type { Screen, Mode, Focus, ReportsTab, ToolsTab } from "../types.ts" +import { loadTuiColumns, saveTuiColumns } from "../../config.ts" +import type { TuiColumnSettings } from "../../config.ts" export type SortField = "name" | "price" | "cycle" | "status" | "id" @@ -88,7 +90,9 @@ export type AppAction = | { type: "SET_SPLIT_RATIO_STEP"; delta: number } | { type: "TOGGLE_COLUMN"; column: "tags" | "notes" | "method" } -const initialState: AppState = { +const columnDefaults = loadTuiColumns() + +export const initialState: AppState = { screen: "list", mode: "NORMAL", focus: "content", @@ -111,12 +115,12 @@ const initialState: AppState = { showDetail: false, showSidebar: true, splitRatio: 0.6, - showTagsCol: false, - showNotesCol: false, - showMethodCol: false, + showTagsCol: columnDefaults.showTagsCol, + showNotesCol: columnDefaults.showNotesCol, + showMethodCol: columnDefaults.showMethodCol, } -function appReducer(state: AppState, action: AppAction): AppState { +export function appReducer(state: AppState, action: AppAction): AppState { switch (action.type) { case "SET_SCREEN": { // Skip history push when navigating to the same screen @@ -219,15 +223,28 @@ function appReducer(state: AppState, action: AppAction): AppState { return { ...state, splitRatio: Math.max(0.3, Math.min(0.8, next)) } } case "TOGGLE_COLUMN": { + let next: AppState switch (action.column) { case "tags": - return { ...state, showTagsCol: !state.showTagsCol } + next = { ...state, showTagsCol: !state.showTagsCol } + break case "notes": - return { ...state, showNotesCol: !state.showNotesCol } + next = { ...state, showNotesCol: !state.showNotesCol } + break case "method": - return { ...state, showMethodCol: !state.showMethodCol } + next = { ...state, showMethodCol: !state.showMethodCol } + break + default: + return state } - return state + // Persist to config + const settings: TuiColumnSettings = { + showTagsCol: next.showTagsCol, + showNotesCol: next.showNotesCol, + showMethodCol: next.showMethodCol, + } + try { saveTuiColumns(settings) } catch { /* best-effort */ } + return next } default: return state diff --git a/subtrack/src/tui/screens/calendar-screen.tsx b/subtrack/src/tui/screens/calendar-screen.tsx index a909f0c..ad4aa4c 100644 --- a/subtrack/src/tui/screens/calendar-screen.tsx +++ b/subtrack/src/tui/screens/calendar-screen.tsx @@ -74,7 +74,7 @@ export function CalendarScreen() { {/* Header */} - {"📅 "}{MONTH_NAMES[month - 1]} {year} + {MONTH_NAMES[month - 1]} {year} diff --git a/subtrack/src/tui/screens/config.tsx b/subtrack/src/tui/screens/config.tsx index 4dc3187..ef381cf 100644 --- a/subtrack/src/tui/screens/config.tsx +++ b/subtrack/src/tui/screens/config.tsx @@ -6,7 +6,7 @@ import { loadConfig, setConfig, CONFIG_KEYS } from "../../config.ts" import type { ConfigKey } from "../../config.ts" export function ConfigScreen() { - const { dispatch } = useTui() + const { state, dispatch } = useTui() const [editKey, setEditKey] = useState(null) const [editValue, setEditValue] = useState("") const [result, setResult] = useState(null) @@ -20,6 +20,8 @@ export function ConfigScreen() { }, [editKey, setFormActive]) useInput((input, key) => { + if (state.paletteOpen) return + if (key.escape && editKey) { setEditKey(null) setResult(null) diff --git a/subtrack/src/tui/screens/delete.tsx b/subtrack/src/tui/screens/delete.tsx index 79ad776..bb59f7c 100644 --- a/subtrack/src/tui/screens/delete.tsx +++ b/subtrack/src/tui/screens/delete.tsx @@ -24,6 +24,7 @@ export function DeleteScreen() { if (sub) { try { deleteSubscription(sub.id) + dispatch({ type: "INCREMENT_REFRESH_KEY" }) dispatch({ type: "SET_TOAST", toast: { message: `Deleted ${sub.name}`, type: "success" }, @@ -34,10 +35,8 @@ export function DeleteScreen() { } } dispatch({ type: "GO_BACK" }) - dispatch({ type: "SET_SELECTED_ID", id: null }) } else if (input === "n" || input === "N") { dispatch({ type: "GO_BACK" }) - dispatch({ type: "SET_SELECTED_ID", id: null }) } }) diff --git a/subtrack/src/tui/screens/detail.tsx b/subtrack/src/tui/screens/detail.tsx index a08666f..43e912d 100644 --- a/subtrack/src/tui/screens/detail.tsx +++ b/subtrack/src/tui/screens/detail.tsx @@ -180,7 +180,7 @@ export function DetailScreen() { paddingY={0} > - {" "}e: edit d: delete h: history q/ Esc: back + {" "} e: edit d: delete h: history r: raw q/ Esc: back {showRaw && ( @@ -281,7 +281,7 @@ export function DetailPreview() { {"─".repeat(24)} - e:edit d:del h:hist |:close Enter:full + e:edit d:del h:hist |:toggle Enter:full ) diff --git a/subtrack/src/tui/screens/edit.tsx b/subtrack/src/tui/screens/edit.tsx index 88b5091..5ebb73a 100644 --- a/subtrack/src/tui/screens/edit.tsx +++ b/subtrack/src/tui/screens/edit.tsx @@ -27,7 +27,7 @@ export function EditScreen() { try { updateSubscription(sub.id, data) dispatch({ type: "GO_BACK" }) - dispatch({ type: "SET_SELECTED_ID", id: null }) + dispatch({ type: "INCREMENT_REFRESH_KEY" }) dispatch({ type: "SET_TOAST", toast: { message: `Updated ${data.name}`, type: "success" }, @@ -39,7 +39,6 @@ export function EditScreen() { const handleCancel = () => { dispatch({ type: "GO_BACK" }) - dispatch({ type: "SET_SELECTED_ID", id: null }) } return ( diff --git a/subtrack/src/tui/screens/list.tsx b/subtrack/src/tui/screens/list.tsx index 68bfc15..8ba83ea 100644 --- a/subtrack/src/tui/screens/list.tsx +++ b/subtrack/src/tui/screens/list.tsx @@ -1,10 +1,10 @@ import { Box, Text, useWindowSize, useInput, useApp } from "ink" import Gradient from "ink-gradient" -import { getSubscriptions, getSubscription, updateSubscription } from "../../db.ts" +import { getSubscriptions, getSubscription, updateSubscription, deleteSubscription } from "../../db.ts" import { useTui, type SortField } from "../context/app-context.tsx" import type { Status } from "../../types.ts" import { SIDEBAR_WIDTH } from "../types.ts" -import { useMemo, useEffect } from "react" +import { useMemo, useEffect, useState } from "react" import { formatPrice } from "../../price.ts" import { colors, statusColor, statusLabel } from "../theme.ts" @@ -145,12 +145,48 @@ export function ListScreen() { const activeCount = useMemo(() => subs.filter((s) => s.status === "active").length, [subs]) const { exit } = useApp() + // ── Bulk confirm state ── + const [bulkConfirm, setBulkConfirm] = useState<"delete" | null>(null) + + useEffect(() => { + dispatch({ type: "SET_FORM_ACTIVE", active: bulkConfirm !== null }) + }, [bulkConfirm, dispatch]) + // ── Keyboard ── useInput( (input: string, key) => { if (state.focus !== "content") return + // Handle bulk confirm prompt + if (bulkConfirm === "delete") { + if (input === "y" || input === "Y") { + const ids = Array.from(state.multiSelect) + if (ids.length > 0) { + let count = 0 + for (const id of ids) { + try { + deleteSubscription(id) + count++ + } catch { /* skip failed */ } + } + dispatch({ type: "MULTI_SELECT_CLEAR" }) + dispatch({ type: "INCREMENT_REFRESH_KEY" }) + dispatch({ + type: "SET_TOAST", + toast: { message: `Deleted ${count} subscription${count !== 1 ? "s" : ""}`, type: "success" }, + }) + } + setBulkConfirm(null) + return + } + if (input === "n" || input === "N" || key.escape) { + setBulkConfirm(null) + return + } + return // block other keys during confirm + } + // Navigation if (key.upArrow || input === "k") { dispatch({ type: "SET_LIST_INDEX", index: Math.max(0, state.listIndex - 1) }) @@ -205,8 +241,16 @@ export function ListScreen() { dispatch({ type: "SET_SCREEN", screen: "edit" }) return } - if (input === "d" && state.selectedId !== null) { - dispatch({ type: "SET_SCREEN", screen: "delete" }) + if (input === "d") { + // Multi-select: bulk delete + if (state.multiSelect.size > 0) { + setBulkConfirm("delete") + return + } + if (state.selectedId !== null) { + dispatch({ type: "SET_SCREEN", screen: "delete" }) + return + } return } if (input === "/") { @@ -248,9 +292,10 @@ export function ListScreen() { dispatch({ type: "TOGGLE_COLUMN", column: "method" }) return } - if (input === "S" && state.selectedId !== null) { - const sub = getSubscription(state.selectedId) - if (sub) { + if (input === "S") { + // Multi-select: bulk status toggle + if (state.multiSelect.size > 0) { + const ids = Array.from(state.multiSelect) const cycle: Record = { active: "paused", paused: "cancelled", @@ -261,25 +306,59 @@ export function ListScreen() { paused: "Paused", cancelled: "Cancelled", } - const newStatus = cycle[sub.status] - try { - updateSubscription(sub.id, { status: newStatus }) - dispatch({ type: "INCREMENT_REFRESH_KEY" }) - dispatch({ - type: "SET_TOAST", - toast: { - message: `${sub.name} → ${label[newStatus]}`, - type: "info", - }, - }) - } catch (e: unknown) { - dispatch({ - type: "SET_TOAST", - toast: { - message: `Failed to update ${sub.name}: ${e instanceof Error ? e.message : String(e)}`, - type: "error", - }, - }) + let updated = 0 + for (const id of ids) { + const sub = getSubscription(id) + if (!sub) continue + try { + updateSubscription(sub.id, { status: cycle[sub.status] }) + updated++ + } catch { /* skip */ } + } + dispatch({ type: "INCREMENT_REFRESH_KEY" }) + dispatch({ + type: "SET_TOAST", + toast: { + message: `Toggled status for ${updated} subscription${updated !== 1 ? "s" : ""}`, + type: "info", + }, + }) + return + } + // Single item: cycle status + if (state.selectedId !== null) { + const sub = getSubscription(state.selectedId) + if (sub) { + const cycle: Record = { + active: "paused", + paused: "cancelled", + cancelled: "active", + } + const label: Record = { + active: "Active", + paused: "Paused", + cancelled: "Cancelled", + } + const newStatus = cycle[sub.status] + try { + updateSubscription(sub.id, { status: newStatus }) + dispatch({ type: "INCREMENT_REFRESH_KEY" }) + dispatch({ + type: "SET_TOAST", + toast: { + message: `${sub.name} → ${label[newStatus]}`, + type: "info", + }, + }) + } catch (e: unknown) { + dispatch({ + type: "SET_TOAST", + toast: { + message: `Failed to update ${sub.name}: ${e instanceof Error ? e.message : String(e)}`, + type: "error", + }, + }) + } } } return @@ -341,6 +420,11 @@ export function ListScreen() { {state.showNotesCol ? "[N]" : "[·]"} {state.showMethodCol ? "[M]" : "[·]"} + {state.multiSelect.size > 0 && ( + + {" "}● {state.multiSelect.size} selected · d:delete S:toggle + + )} {/* ── Row 3: Column headers ── */} @@ -368,7 +452,7 @@ export function ListScreen() { {state.filterText ? ( <> - 🔍 No subscriptions match filter + No subscriptions match filter Esc or Ctrl+L Clear filter @@ -383,7 +467,7 @@ export function ListScreen() { flexDirection="column" alignItems="center" > - 📋 No subscriptions yet + No subscriptions yet Press a to add your first subscription @@ -532,6 +616,24 @@ export function ListScreen() { ))} )} + + {/* ── Bulk confirm prompt ── */} + {bulkConfirm === "delete" && ( + + + + Delete {state.multiSelect.size} selected subscription{state.multiSelect.size !== 1 ? "s" : ""}? + + + This cannot be undone.{" "} + y + {" to confirm "} + n + {" to cancel"} + + + + )} ) } diff --git a/subtrack/src/tui/types.ts b/subtrack/src/tui/types.ts index a6a3e35..540dcdc 100644 --- a/subtrack/src/tui/types.ts +++ b/subtrack/src/tui/types.ts @@ -44,15 +44,15 @@ export const SIDEBAR_WIDTH = 22 export const SIDEBAR_ITEMS: SidebarItem[] = [ // Data - { screen: "list", label: "List", icon: "📋", section: "data" }, - { screen: "add", label: "Add", icon: "➕", section: "data" }, + { screen: "list", label: "List", icon: "[ ]", section: "data" }, + { screen: "add", label: "Add", icon: "[+]", section: "data" }, // Reports - { screen: "reports", label: "Reports", icon: "📊", section: "reports" }, - { screen: "calendar", label: "Calendar", icon: "📅", section: "reports" }, + { screen: "reports", label: "Reports", icon: "[~]", section: "reports" }, + { screen: "calendar", label: "Calendar", icon: "[@]", section: "reports" }, // System - { screen: "config", label: "Config", icon: "⚙️", section: "system" }, - { screen: "tools", label: "Tools", icon: "🔧", section: "system" }, - { screen: "help", label: "Help", icon: "❓", section: "system" }, + { screen: "config", label: "Config", icon: "[*]", section: "system" }, + { screen: "tools", label: "Tools", icon: "[&]", section: "system" }, + { screen: "help", label: "Help", icon: "[?]", section: "system" }, ] export const SCREEN_TITLES: Record = { diff --git a/subtrack/src/types.ts b/subtrack/src/types.ts index 4cbd483..6c9dbb3 100644 --- a/subtrack/src/types.ts +++ b/subtrack/src/types.ts @@ -151,4 +151,10 @@ export type SubtrackConfig = { monthlyBudget: number theme: string notifyDays: number + /** TUI-specific settings (not shown in CLI config commands) */ + tui?: { + showTagsCol?: boolean + showNotesCol?: boolean + showMethodCol?: boolean + } } From 0c689754b5db7edc79e82dba599e26b563522340 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 19:25:15 +0900 Subject: [PATCH 04/12] edit --- .github/workflows/app-ci.yml | 2 +- .github/workflows/check.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/labeler.yml | 2 +- .github/workflows/pages.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/renovate-approve.yml | 2 +- .github/workflows/scheduled-ci.yml | 7 ++++++- .github/workflows/scorecard.yml | 2 +- SECURITY.md | 14 ++++++++------ subtrack/package.json | 3 ++- 12 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 53e4a5d..4f7ef8e 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -43,7 +43,7 @@ jobs: node-version: 22 runs-on: ${{ matrix.os }} steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 53a1e27..b1bdc51 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,7 +10,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 24f0ee7..1ee8df5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: matrix: language: [javascript-typescript] steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index e8446b0..3c0497a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ jobs: review: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index a4dc17c..4d3feaa 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 66b57bd..6baee38 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,7 +23,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit @@ -59,7 +59,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68503c1..2962457 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: contents: write id-token: write steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/renovate-approve.yml b/.github/workflows/renovate-approve.yml index aa476c3..416f020 100644 --- a/.github/workflows/renovate-approve.yml +++ b/.github/workflows/renovate-approve.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor == 'renovate[bot]' steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/.github/workflows/scheduled-ci.yml b/.github/workflows/scheduled-ci.yml index 0cacc96..2b7e253 100644 --- a/.github/workflows/scheduled-ci.yml +++ b/.github/workflows/scheduled-ci.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit @@ -34,3 +34,8 @@ jobs: run: pnpm lint:types - run: pnpm test + + - name: Scan dependencies with OSV-Scanner + uses: google/osv-scanner-action@v2 + with: + fail-on-vuln: false diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 86f3ce7..8fbb3bf 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,7 +19,7 @@ jobs: security-events: write steps: - - uses: step-security/harden-runner@v2 + - uses: step-security/harden-runner@v3 with: egress-policy: audit diff --git a/SECURITY.md b/SECURITY.md index c50f9f0..e04bb1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,17 +39,19 @@ The following are **out of scope**: subtrack takes supply chain security seriously: -- **Provenance attestation**: All npm releases use `pnpm publish --provenance` (SLSA Level 1+). Release binaries also have build provenance attestation via `actions/attest-build-provenance`. +- **Provenance attestation**: All npm releases use `pnpm publish --provenance` (SLSA Level 1+) with `publishConfig.provenance: true` in package.json. Release binaries also have build provenance attestation via `actions/attest-build-provenance`. - **SBOM generation**: Every release generates an SPDX Software Bill of Materials, attached to the GitHub release. - **Dependency review**: Every pull request is scanned for new vulnerabilities via GitHub's dependency review action. -- **Renovate bot**: Dependencies are updated with a 7-day minimum release age to detect malicious releases before they reach this project. Renovate PRs are only auto-approved when auto-merge is enabled (skipping major/dashboard-approval updates). -- **Dependabot**: Defense-in-depth alongside Renovate — catches vulnerabilities via GitHub Advisory Database integration. +- **OSV-Scanner**: Scheduled CI runs Google's OSV-Scanner against all dependencies to detect known vulnerabilities across open source databases. +- **Renovate bot**: Dependencies are updated with a 7-day minimum release age to detect malicious releases before they reach this project. Renovate PRs are only auto-approved when auto-merge is enabled (skipping major/dashboard-approval updates). OSV vulnerability alerts are enabled in the dependency dashboard. +- **Dependabot**: Defense-in-depth alongside Renovate — catches vulnerabilities faster via GitHub Advisory Database integration. - **Lockfile**: A `pnpm-lock.yaml` is committed and verified with `--frozen-lockfile` in CI. An `npm-shrinkwrap.json` is generated at publish time for downstream reproducability. - **Limited build scripts**: Only `esbuild` is permitted to run install scripts (`allowBuilds` in `pnpm-workspace.yaml`). -- **Harden-Runner**: Every CI workflow uses `step-security/harden-runner` for runtime egress monitoring. +- **Harden-Runner**: Every CI workflow uses `step-security/harden-runner@v3` for runtime egress monitoring and threat detection. - **CodeQL**: Static analysis runs on every push and PR. - **pnpm audit**: Runs in CI to catch known vulnerabilities. -- **OpenSSF Scorecard**: Automated supply chain health assessment. -- **Tag signing**: Release tags should be signed with GPG/SSH — see [Sigining tags](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-tags). +- **OpenSSF Scorecard**: Automated supply chain health assessment with results published to the repository's Security tab. +- **Tag signing**: Release tags should be signed with GPG/SSH — see [Signing tags](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-tags). - **Trojan Source detection**: CI scans for Unicode bidirectional control characters in all source files. - **secretlint**: CI scans for accidentally committed secrets and credentials. +- **Socket.dev**: Published package monitored on [Socket.dev](https://socket.dev/npm/package/subtrack) for supply chain risk indicators. diff --git a/subtrack/package.json b/subtrack/package.json index 64bdca2..1823b8b 100644 --- a/subtrack/package.json +++ b/subtrack/package.json @@ -9,7 +9,8 @@ "subtrack": "./dist/index.mjs" }, "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "description": "CLI tool to manage subscription services from the terminal", "keywords": [ From 2071354ec872ee5ff02390522b4bc52eb6d3118c Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 20:07:23 +0900 Subject: [PATCH 05/12] edit --- .../src/__tests__/untested-commands.test.ts | 250 ++++++++++ subtrack/src/commands.ts | 7 +- subtrack/src/compare.ts | 29 +- subtrack/src/index.ts | 15 +- subtrack/src/mcp.ts | 463 +++++++++++++++++- subtrack/src/notify.ts | 10 +- subtrack/src/subscription.ts | 6 +- subtrack/src/tui/screens/delete.tsx | 4 +- subtrack/src/tui/screens/list.tsx | 7 +- subtrack/src/tui/screens/tools/import-tab.tsx | 39 +- 10 files changed, 782 insertions(+), 48 deletions(-) create mode 100644 subtrack/src/__tests__/untested-commands.test.ts diff --git a/subtrack/src/__tests__/untested-commands.test.ts b/subtrack/src/__tests__/untested-commands.test.ts new file mode 100644 index 0000000..862340a --- /dev/null +++ b/subtrack/src/__tests__/untested-commands.test.ts @@ -0,0 +1,250 @@ +import { test, expect, beforeAll, afterAll, beforeEach, vi } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { mkdtempSync, existsSync, rmSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + + +// Mock consola +const logMessages: string[] = [] +const infoMessages: string[] = [] +const errorMessages: string[] = [] + +const makeFn = (arr: string[]) => (...args: unknown[]) => { + arr.push(args.map((a) => String(a)).join(" ")) +} + +vi.mock("consola", () => ({ + default: { + log: makeFn(logMessages), + info: makeFn(infoMessages), + success: makeFn([]), + error: makeFn(errorMessages), + warn: makeFn([]), + fail: makeFn([]), + }, + consola: { + log: makeFn(logMessages), + info: makeFn(infoMessages), + success: makeFn([]), + error: makeFn(errorMessages), + warn: makeFn([]), + fail: makeFn([]), + }, + logMessages, + infoMessages, + successMessages: [] as string[], + errorMessages, +})) + +let SQL: Awaited> +let cleanEnv: string +let tmpDir: string +let getDb: () => Database +let __setDb: (db: Database) => void + +beforeAll(async () => { + SQL = await initSqlJs() + cleanEnv = process.env.SUBSC_CLI_DB_DIR ?? "" + tmpDir = mkdtempSync(join(tmpdir(), "subtrack-untested-")) + process.env.SUBSC_CLI_DB_DIR = tmpDir + + const dbMod = await import("../db.ts") + getDb = dbMod.getDb + __setDb = dbMod.__setDb +}) + +afterAll(() => { + if (cleanEnv) process.env.SUBSC_CLI_DB_DIR = cleanEnv + else delete process.env.SUBSC_CLI_DB_DIR + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) +}) + +beforeEach(() => { + const db = new SQL.Database() + db.run( + "CREATE TABLE IF NOT EXISTS subscriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price INTEGER NOT NULL, currency TEXT NOT NULL, cycle TEXT NOT NULL DEFAULT 'monthly', status TEXT NOT NULL DEFAULT 'active', billing_day INTEGER, created_at TEXT NOT NULL, notes TEXT, payment_method TEXT)", + ) + db.run( + "CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)", + ) + db.run( + "CREATE TABLE IF NOT EXISTS subscription_tags (subscription_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (subscription_id, tag_id))", + ) + db.run( + "CREATE TABLE IF NOT EXISTS price_history (id INTEGER PRIMARY KEY AUTOINCREMENT, subscription_id INTEGER NOT NULL, old_price INTEGER, new_price INTEGER NOT NULL, old_currency TEXT, new_currency TEXT NOT NULL, changed_at TEXT NOT NULL DEFAULT (datetime('now')))", + ) + db.run("CREATE TABLE IF NOT EXISTS trials (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, expires_at TEXT NOT NULL, price INTEGER, currency TEXT, cycle TEXT, notes TEXT, created_at TEXT NOT NULL)") + + __setDb(db) + + logMessages.length = 0 + infoMessages.length = 0 + errorMessages.length = 0 +}) + +// ── Helper ───────────────────────────────────────────── + +function insertSub(overrides: Record = {}): number { + const db = getDb() + const fields = { + name: "Test Sub", + price: 1000, + currency: "JPY", + cycle: "monthly", + status: "active", + billingDay: 1, + createdAt: "2026-01-01", + notes: null, + paymentMethod: null, + ...overrides, + } + db.run( + "INSERT INTO subscriptions (name, price, currency, cycle, status, billing_day, created_at, notes, payment_method) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [fields.name, fields.price, fields.currency, fields.cycle, fields.status, fields.billingDay, fields.createdAt, fields.notes, fields.paymentMethod], + ) + const row = db.exec("SELECT last_insert_rowid() AS id") + return Number(row[0].values[0][0]) +} + +// ── Calendar tests ───────────────────────────────────── + +test("handleCalendar shows info when no subscriptions", async () => { + const { handleCalendar } = await import("../commands.ts") + handleCalendar({ month: 6, year: 2026 }) + expect(infoMessages.some((m) => m.includes("No billing"))).toBe(true) +}) + +test("handleCalendar JSON output", { timeout: 5000 }, async () => { + insertSub({ name: "Netflix", price: 1500, billingDay: 15, createdAt: "2026-06-01" }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { writes.push(String(chunk)); return true }) as typeof process.stdout.write + + const { handleCalendar } = await import("../commands.ts") + handleCalendar({ month: 6, year: 2026, json: true }) + + process.stdout.write = origWrite + + expect(writes.length).toBeGreaterThan(0) + const parsed = JSON.parse(writes.join("")) + expect(Array.isArray(parsed)).toBe(true) +}) + +test("handleCalendar table output does not error", async () => { + insertSub({ name: "Spotify", price: 980, billingDay: 10, createdAt: "2026-06-01" }) + + const { handleCalendar } = await import("../commands.ts") + handleCalendar({ month: 6, year: 2026 }) + expect(errorMessages.length).toBe(0) +}) + +// ── History tests ────────────────────────────────────── + +test("handleHistory shows info when no history", async () => { + const { handleHistory } = await import("../history.ts") + handleHistory(undefined, { all: true }) + expect(infoMessages.some((m) => m.includes("No price changes recorded"))).toBe(true) +}) + +test("handleHistory shows history for a specific subscription", async () => { + const id = insertSub({ name: "AWS" }) + const { writePriceHistory } = await import("../db.ts") + writePriceHistory(id, 1000, 2000, "JPY", "JPY") + + const { handleHistory } = await import("../history.ts") + handleHistory(id, {}) + expect(logMessages.length).toBeGreaterThan(0) +}) + +test("handleHistory shows all history with --all", async () => { + const id1 = insertSub({ name: "AWS", createdAt: "2026-01-01" }) + const id2 = insertSub({ name: "Azure", createdAt: "2026-02-01" }) + + const { writePriceHistory } = await import("../db.ts") + writePriceHistory(id1, 5000, 6000, "USD", "USD") + writePriceHistory(id2, 8000, 10000, "USD", "USD") + + const { handleHistory } = await import("../history.ts") + handleHistory(undefined, { all: true }) + expect(logMessages.length).toBeGreaterThan(0) +}) + +test("handleHistory JSON output", async () => { + const id = insertSub({ name: "GitHub Copilot" }) + const { writePriceHistory, getPriceHistory } = await import("../db.ts") + writePriceHistory(id, 0, 1000, "USD", "USD") + + // Verify the data exists before testing output + const entries = getPriceHistory(id) + expect(entries.length).toBeGreaterThan(0) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { writes.push(String(chunk)); return true }) as typeof process.stdout.write + + const { handleHistory } = await import("../history.ts") + handleHistory(id, { json: true }) + + process.stdout.write = origWrite + + expect(writes.length).toBeGreaterThan(0) + const parsed = JSON.parse(writes.join("")) + expect(parsed.length).toBeGreaterThan(0) +}) + +test("handleHistory with --days filters recent changes", async () => { + const id = insertSub({ name: "Slack" }) + const { writePriceHistory } = await import("../db.ts") + writePriceHistory(id, 1000, 1500, "USD", "USD") + + const { handleHistory } = await import("../history.ts") + handleHistory(undefined, { all: true, days: 30 }) + expect(logMessages.length).toBeGreaterThan(0) +}) + +// ── Notify tests ─────────────────────────────────────── + +test("handleNotify dry-run shows no upcoming when no subscriptions", async () => { + const { handleNotify } = await import("../notify.ts") + await handleNotify({ days: 7, dryRun: true }) + expect(infoMessages.some((m) => m.includes("No upcoming"))).toBe(true) +}) + +test("handleNotify dry-run shows upcoming bills", async () => { + // Billing on day 2 from a previous month — next billing will be July 2 (within 7 days from July 1) + insertSub({ name: "Netflix", price: 1500, billingDay: 2, createdAt: "2026-06-01" }) + + const { handleNotify } = await import("../notify.ts") + await handleNotify({ days: 7, dryRun: true }) + expect(logMessages.length).toBeGreaterThan(0) +}) + +test("handleNotify JSON output", async () => { + insertSub({ name: "Netflix", price: 1500, billingDay: 2, createdAt: "2026-06-01" }) + + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string) => { writes.push(String(chunk)); return true }) as typeof process.stdout.write + + const { handleNotify } = await import("../notify.ts") + await handleNotify({ days: 7, json: true }) + + process.stdout.write = origWrite + + expect(writes.length).toBeGreaterThan(0) + const parsed = JSON.parse(writes.join("")) + expect(parsed).toHaveProperty("count") + expect(parsed).toHaveProperty("entries") +}) + +test("handleNotify with days=0 returns early", async () => { + // Billing on day 10 — won't be within 0 days so "No upcoming" is expected + insertSub({ name: "Netflix", price: 1500, billingDay: 10, createdAt: "2026-06-01" }) + + const { handleNotify } = await import("../notify.ts") + await handleNotify({ days: 0, dryRun: true }) + expect(infoMessages.some((m) => m.includes("No upcoming"))).toBe(true) +}) diff --git a/subtrack/src/commands.ts b/subtrack/src/commands.ts index 664d437..9815969 100644 --- a/subtrack/src/commands.ts +++ b/subtrack/src/commands.ts @@ -41,7 +41,7 @@ import { periodFactor } from "./types.ts" export async function handleExport( format: string, - options: { currency?: string; tags?: string; output?: string }, + options: { currency?: string; tags?: string; output?: string; status?: string }, ) { const supported = ["csv", "json", "md", "excel", "ics"] as const if (!(supported as readonly string[]).includes(format)) { @@ -53,6 +53,11 @@ export async function handleExport( ? tagsSubscription(options.tags.split(",").map((t) => t.trim())) : getSubscriptions() + if (options.status) { + const statuses = options.status.split(",").map((s) => s.trim().toLowerCase()) + list = list.filter((s) => statuses.includes(s.status)) + } + if (list.length === 0) { consola.info("No subscriptions found") return diff --git a/subtrack/src/compare.ts b/subtrack/src/compare.ts index 83a67bc..e47e8b3 100644 --- a/subtrack/src/compare.ts +++ b/subtrack/src/compare.ts @@ -3,7 +3,7 @@ import pc from "picocolors" import CliTable3 from "cli-table3" import type { SharedArgs, Currency, Cycle } from "./types.ts" import { periodFactor } from "./types.ts" -import { getSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider } from "./db.ts" +import { getSubscriptions, getLlmUsageTotal, getLlmUsageTotalByProvider, getAllPriceChanges } from "./db.ts" import { formatPrice } from "./display.ts" import { getPeriodDateRange, getPreviousPeriodDateRange } from "./payment.ts" import { fetchFxRates, convertPrice } from "./fx.ts" @@ -166,18 +166,35 @@ export async function showCompare( const targetCurrency = options.currency as Currency | undefined - // Calculate subscription totals for current and previous period - // For subscriptions, price is per-cycle, so we need to compare per-cycle totals - // Since subscriptions are ongoing, we use the same active list for both periods const activeSubs = subs.filter((s) => s.status !== "cancelled") if (activeSubs.length === 0) { consola.info("No active subscriptions found") return } + // Current period uses current prices const currentTotals = calcSubTotal(activeSubs, rates, targetCurrency) - // Same subscriptions for previous period (they were active then too) - const previousTotals = calcSubTotal(activeSubs, rates, targetCurrency) + + // Previous period — estimate from price history when available + const priceChanges = getAllPriceChanges() + const priceBefore: Record = {} + for (const change of priceChanges) { + if (change.oldPrice !== null && !priceBefore[change.subscriptionId]) { + priceBefore[change.subscriptionId] = { + price: change.oldPrice, + currency: change.oldCurrency ?? change.newCurrency, + } + } + } + + const previousSubs = activeSubs.map((s) => { + const prev = priceBefore[s.id] + if (prev) { + return { ...s, price: prev.price, currency: prev.currency } + } + return s + }) + const previousTotals = calcSubTotal(previousSubs, rates, targetCurrency) consola.log("") consola.log(pc.bold(`Comparing ${periodStr}: ${currentLabel} vs ${previousLabel}`)) diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index 6544fe6..c683051 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -82,6 +82,10 @@ const listCommand = define({ short: "j", description: "Output as JSON", }, + tags: { + type: "string", + description: "Comma-separated tag names to filter by (AND logic)", + }, }, run: (ctx) => handleList(ctx.values), }); @@ -166,7 +170,7 @@ const tagsCommand = define({ }, }, run: (ctx) => { - const tagNames = ctx.positionals.slice(1) as string[]; + const tagNames = (ctx.values.names as string[] | undefined) ?? []; if (tagNames.length === 0) { consola.error("Please specify at least one tag"); return; @@ -521,6 +525,10 @@ const exportCommand = define({ description: "Convert all prices to target currency", }, tags: { type: "string", description: "Filter by comma-separated tags" }, + status: { + type: "string", + description: "Filter by status: active, paused, cancelled (comma-separated)", + }, output: { type: "string", short: "o", @@ -531,6 +539,7 @@ const exportCommand = define({ handleExport(ctx.values.format, { currency: ctx.values.currency, tags: ctx.values.tags, + status: ctx.values.status, output: ctx.values.output, }), }); @@ -920,13 +929,13 @@ const notifyCommand = define({ description: "Output as JSON", }, }, - run: (ctx) => { + run: async (ctx) => { const days = ctx.values.days !== undefined ? Number(ctx.values.days) : undefined if (days !== undefined && (isNaN(days) || days < 0 || !Number.isInteger(days))) { consola.error("days must be a non-negative integer") return } - handleNotify({ + await handleNotify({ days, dryRun: ctx.values["dry-run"], json: ctx.values.json, diff --git a/subtrack/src/mcp.ts b/subtrack/src/mcp.ts index ef0e306..cdc0a07 100644 --- a/subtrack/src/mcp.ts +++ b/subtrack/src/mcp.ts @@ -9,14 +9,22 @@ import { getSubscription, writeSubscription, deleteSubscription, + updateSubscription, getDb, mapTags, + getPriceHistory, + getAllPriceChanges, + getTrials, + getTrialsExpiringSoon, } from "./db.ts" -import { calcSummary } from "./payment.ts" +import { calcSummary, getPeriodDateRange, getPreviousPeriodDateRange } from "./payment.ts" import { calcCalendarEntries } from "./calendar.ts" import { exportCsv, exportJson, exportMd } from "./export.ts" +import { fetchFxRates, convertPrice } from "./fx.ts" +import { periodFactor } from "./types.ts" -import type { SharedArgs, AddSharedArgs, Cycle, Status } from "./types.ts" +import type { SharedArgs, AddSharedArgs, Cycle, Status, Currency } from "./types.ts" +import type { FxRates } from "./fx.ts" import type { SqlValue } from "sql.js" // ── Date helpers ──────────────────────────────────────── @@ -201,6 +209,74 @@ export function searchSubscriptions( return mapTags(subs) } +// ── Compare helpers ────────────────────────────────────── + +type CcyTotals = Record + +function calcSubTotalHelper( + subs: SharedArgs[], + rates: FxRates | null, + targetCurrency: Currency | undefined, +): CcyTotals { + const totals: CcyTotals = {} + for (const sub of subs) { + if (sub.status === "cancelled") continue + const monthly = sub.price * periodFactor(sub.cycle, "monthly") + if (targetCurrency && rates) { + try { + const converted = convertPrice(monthly, sub.currency, targetCurrency, rates.rates) + totals[targetCurrency] = (totals[targetCurrency] ?? 0) + converted + } catch { + totals[sub.currency] = (totals[sub.currency] ?? 0) + monthly + } + } else { + totals[sub.currency] = (totals[sub.currency] ?? 0) + monthly + } + } + return totals +} + +function calcPreviousTotals( + activeSubs: SharedArgs[], + rates: FxRates | null, + targetCurrency: Currency | undefined, +): CcyTotals { + // Check price history to see if any subscriptions had different prices before + const priceChanges = getAllPriceChanges() + const priceBefore: Record = {} + + for (const change of priceChanges) { + // The previous price is the old price before the change + if (change.oldPrice !== null && !priceBefore[change.subscriptionId]) { + priceBefore[change.subscriptionId] = { + price: change.oldPrice, + currency: change.oldCurrency ?? change.newCurrency, + } + } + } + + const totals: CcyTotals = {} + for (const sub of activeSubs) { + if (sub.status === "cancelled") continue + const prev = priceBefore[sub.id] + const price = prev?.price ?? sub.price + const currency = prev?.currency ?? sub.currency + const monthly = price * periodFactor(sub.cycle, "monthly") + + if (targetCurrency && rates) { + try { + const converted = convertPrice(monthly, currency, targetCurrency, rates.rates) + totals[targetCurrency] = (totals[targetCurrency] ?? 0) + converted + } catch { + totals[currency] = (totals[currency] ?? 0) + monthly + } + } else { + totals[currency] = (totals[currency] ?? 0) + monthly + } + } + return totals +} + // ── MCP Server ─────────────────────────────────────────── const server = new Server( @@ -326,6 +402,112 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["format"], }, }, + { + name: "edit_subscription", + description: "Edit an existing subscription", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Subscription ID to edit" }, + name: { type: "string", description: "New name" }, + price: { type: "number", description: "New price in smallest currency unit" }, + currency: { type: "string", description: "New currency code" }, + cycle: { type: "string", description: "New billing cycle" }, + status: { type: "string", description: "New status: active, paused, cancelled" }, + billingDay: { type: "number", description: "New billing day of month (1-31)" }, + tags: { type: "string", description: "Comma-separated tags (replaces all)" }, + paymentMethod: { type: "string", description: "New payment method" }, + notes: { type: "string", description: "New notes" }, + }, + required: ["id"], + }, + }, + { + name: "get_history", + description: "Get price change history for subscriptions", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Filter by subscription ID" }, + days: { type: "number", description: "Recent days to include (default: all)" }, + }, + }, + }, + { + name: "get_analytics", + description: "Get subscription analytics and statistics", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "get_forecast", + description: "Get spending forecast for upcoming months", + inputSchema: { + type: "object", + properties: { + months: { type: "number", description: "Number of months to forecast (default: 12)" }, + currency: { type: "string", description: "Convert all to target currency" }, + cancel: { + type: "string", + description: "Comma-separated subscription names to exclude from forecast", + }, + }, + }, + }, + { + name: "compare", + description: "Compare subscription spending between current and previous period", + inputSchema: { + type: "object", + properties: { + period: { + type: "string", + description: "Period to compare: monthly (default), yearly, quarterly", + }, + currency: { type: "string", description: "Convert all to target currency" }, + }, + }, + }, + { + name: "bulk_operations", + description: "Perform bulk operations on subscriptions matching filters", + inputSchema: { + type: "object", + properties: { + action: { + type: "string", + description: "Action: status, delete, tag_add, tag_remove", + }, + status: { + type: "string", + description: "Target status for 'status' action: active, paused, cancelled", + }, + tag_name: { + type: "string", + description: "Tag name for tag_add / tag_remove actions", + }, + filter_tag: { type: "string", description: "Only affect subscriptions with this tag" }, + filter_status: { type: "string", description: "Only affect subscriptions with this status" }, + filter_name: { type: "string", description: "Only affect subscriptions whose name contains this" }, + }, + required: ["action"], + }, + }, + { + name: "get_trials", + description: "Get trial periods", + inputSchema: { + type: "object", + properties: { + expiring_soon: { + type: "number", + description: "Filter trials expiring within N days", + }, + }, + }, + }, ], } }) @@ -456,6 +638,283 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: output }] } } + case "edit_subscription": { + if (args?.id === undefined) { + return { + content: [{ type: "text", text: "id is required" }], + isError: true, + } + } + const editFields: Partial = {} + if (args.name !== undefined) editFields.name = String(args.name) + if (args.price !== undefined) editFields.price = Number(args.price) + if (args.currency !== undefined) editFields.currency = String(args.currency) + if (args.cycle !== undefined) editFields.cycle = String(args.cycle) as Cycle + if (args.status !== undefined) editFields.status = String(args.status) as Status + if (args.billingDay !== undefined) editFields.billingDay = Number(args.billingDay) + if (args.paymentMethod !== undefined) editFields.paymentMethod = String(args.paymentMethod) + if (args.notes !== undefined) editFields.notes = String(args.notes) + if (args.tags !== undefined) { + editFields.tags = String(args.tags).split(",").map((t: string) => t.trim()).filter(Boolean) + } + const success = updateSubscription(Number(args.id), editFields) + return { content: [{ type: "text", text: JSON.stringify({ success }) }] } + } + + case "get_history": { + const id = args?.id as number | undefined + const days = args?.days as number | undefined + let entries + if (id !== undefined) { + entries = getPriceHistory(id) + } else { + entries = getAllPriceChanges(days) + } + return { content: [{ type: "text", text: JSON.stringify(entries) }] } + } + + case "get_analytics": { + const subs = getSubscriptions() + const summary = calcSummary(subs) + return { content: [{ type: "text", text: JSON.stringify(summary) }] } + } + + case "get_forecast": { + const months = (args?.months as number | undefined) ?? 12 + const targetCurrency = args?.currency as string | undefined + const cancelNames = args?.cancel + ? String(args.cancel).split(",").map((n: string) => n.trim()).filter(Boolean) + : [] + + let rates: FxRates | null = null + if (targetCurrency) { + try { rates = await fetchFxRates() } catch { /* fall through */ } + } + + const subs = getSubscriptions() + const activeSubs = subs.filter( + (s) => s.status !== "cancelled" && !cancelNames.includes(s.name), + ) + + // Calculate monthly forecast per subscription + const entries: { name: string; price: number; currency: string; cycle: string; monthly: number; monthlyConverted?: number }[] = [] + + for (const sub of activeSubs) { + const monthly = sub.price * periodFactor(sub.cycle, "monthly") + let monthlyConverted: number | undefined + if (targetCurrency && rates) { + try { + monthlyConverted = Math.round(convertPrice(monthly, sub.currency, targetCurrency, rates.rates)) + } catch { /* keep original */ } + } + entries.push({ + name: sub.name, + price: sub.price, + currency: sub.currency, + cycle: sub.cycle, + monthly, + ...(monthlyConverted !== undefined ? { monthlyConverted } : {}), + }) + } + + const displayCcy = targetCurrency || "mixed" + const monthlyTotal = targetCurrency && rates + ? entries.reduce((sum: number, e) => sum + (e.monthlyConverted ?? e.monthly), 0) + : entries.reduce((sum: number, e) => sum + e.monthly, 0) + + const years = Math.ceil(months / 12) + const yearlyTotal = monthlyTotal * 12 + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + months, + currency: displayCcy, + monthlyTotal: Math.round(monthlyTotal), + yearlyTotal: Math.round(yearlyTotal), + totalSubscriptions: entries.length, + entries, + }), + }, + ], + } + } + + case "compare": { + const period = (args?.period as Cycle | undefined) ?? "monthly" + const targetCurrency = args?.currency as Currency | undefined + + let rates: FxRates | null = null + if (targetCurrency) { + try { rates = await fetchFxRates() } catch { /* fall through */ } + } + + const subs = getSubscriptions() + const activeSubs = subs.filter((s) => s.status !== "cancelled") + + // Current period uses current prices + const currentTotals = calcSubTotalHelper(activeSubs, rates, targetCurrency) + // Previous period estimates from price history + const previousTotals = calcPreviousTotals(activeSubs, rates, targetCurrency) + + const allCurrencies = [...new Set([...Object.keys(currentTotals), ...Object.keys(previousTotals)])].sort() + + const currencyRows = allCurrencies.map((ccy) => ({ + currency: ccy, + current: Math.round(currentTotals[ccy] ?? 0), + previous: Math.round(previousTotals[ccy] ?? 0), + })) + + const grandCurrent = currencyRows.reduce((s, r) => s + r.current, 0) + const grandPrevious = currencyRows.reduce((s, r) => s + r.previous, 0) + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + period, + currency: targetCurrency || null, + rows: currencyRows, + grandTotal: { + current: grandCurrent, + previous: grandPrevious, + change: grandCurrent - grandPrevious, + changePercent: grandPrevious > 0 + ? Math.round(((grandCurrent - grandPrevious) / grandPrevious) * 10000) / 100 + : 0, + }, + }), + }, + ], + } + } + + case "bulk_operations": { + const action = String(args?.action ?? "") + const filters: { tag?: string; status?: string; name?: string } = {} + if (args?.filter_tag) filters.tag = String(args.filter_tag) + if (args?.filter_status) filters.status = String(args.filter_status) + if (args?.filter_name) filters.name = String(args.filter_name) + + const subs = getSubscriptions() + let matched = subs + + // Apply filters + if (filters.tag) { + const tagSet = new Set(filters.tag.split(",").map((t: string) => t.trim())) + matched = matched.filter((s) => s.tags?.some((t) => tagSet.has(t))) + } + if (filters.status) { + matched = matched.filter((s) => s.status === filters.status) + } + if (filters.name) { + matched = matched.filter((s) => s.name.toLowerCase().includes(filters.name!.toLowerCase())) + } + + // Filter out cancelled for status actions + let affected = matched + if (action === "status") { + affected = matched.filter((s) => s.status !== "cancelled") + } + + const affectedIds = affected.map((s) => s.id) + let resultCount = 0 + + switch (action) { + case "status": { + const targetStatus = String(args?.status ?? "active") + for (const id of affectedIds) { + try { + updateSubscription(id, { status: targetStatus as Status }) + resultCount++ + } catch { /* skip */ } + } + break + } + case "delete": { + for (const id of affectedIds) { + try { + deleteSubscription(id) + resultCount++ + } catch { /* skip */ } + } + break + } + case "tag_add": { + const tagName = String(args?.tag_name ?? "") + if (!tagName) { + return { + content: [{ type: "text", text: "tag_name is required for tag_add action" }], + isError: true, + } + } + for (const s of affected) { + const currentTags = s.tags ?? [] + if (!currentTags.includes(tagName)) { + try { + updateSubscription(s.id, { tags: [...currentTags, tagName] }) + resultCount++ + } catch { /* skip */ } + } + } + break + } + case "tag_remove": { + const tagName = String(args?.tag_name ?? "") + if (!tagName) { + return { + content: [{ type: "text", text: "tag_name is required for tag_remove action" }], + isError: true, + } + } + for (const s of affected) { + const currentTags = s.tags ?? [] + if (currentTags.includes(tagName)) { + try { + updateSubscription(s.id, { tags: currentTags.filter((t) => t !== tagName) }) + resultCount++ + } catch { /* skip */ } + } + } + break + } + default: + return { + content: [{ type: "text", text: `Unknown bulk action: ${action}. Use: status, delete, tag_add, tag_remove` }], + isError: true, + } + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + action, + filters, + matchedCount: affected.length, + affectedCount: resultCount, + affectedIds, + }), + }, + ], + } + } + + case "get_trials": { + const expiringSoon = args?.expiring_soon as number | undefined + let entries + if (expiringSoon !== undefined) { + entries = getTrialsExpiringSoon(expiringSoon) + } else { + entries = getTrials() + } + return { content: [{ type: "text", text: JSON.stringify(entries) }] } + } + default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], diff --git a/subtrack/src/notify.ts b/subtrack/src/notify.ts index 281295f..3e2659e 100644 --- a/subtrack/src/notify.ts +++ b/subtrack/src/notify.ts @@ -10,7 +10,7 @@ export type NotifyOptions = { json?: boolean } -export function handleNotify(options: NotifyOptions = {}): void { +export async function handleNotify(options: NotifyOptions = {}): Promise { const config = loadConfig() const days = options.days ?? config.notifyDays ?? 7 @@ -45,15 +45,15 @@ export function handleNotify(options: NotifyOptions = {}): void { } // ── Send OS notification ── - sendNotification(entries, days) + await sendNotification(entries, days) } -function sendNotification( +async function sendNotification( entries: { sub: { name: string; price: number; currency: string; cycle: string } }[], days: number, -): void { +): Promise { // Lazy-import to avoid loading the notifier when not needed - const notifier = require("node-notifier") as typeof import("node-notifier") + const { default: notifier } = await import("node-notifier") const count = entries.length let message: string diff --git a/subtrack/src/subscription.ts b/subtrack/src/subscription.ts index 9c1318f..d0fb04f 100644 --- a/subtrack/src/subscription.ts +++ b/subtrack/src/subscription.ts @@ -181,8 +181,10 @@ async function resolveAddOptions(flags: AddFlags) { // ── Command handlers ──────────────────────────────────── -export async function handleList(options: { currency?: string; sort?: string; desc?: boolean; api?: boolean; notes?: boolean; method?: boolean }) { - const list = getSubscriptions(options.sort, options.desc) +export async function handleList(options: { currency?: string; sort?: string; desc?: boolean; api?: boolean; notes?: boolean; method?: boolean; tags?: string }) { + const list = options.tags + ? tagsSubscription(options.tags.split(",").map((t) => t.trim())) + : getSubscriptions(options.sort, options.desc) await spreadSubscription(list, options.currency as Currency | undefined, options.notes, options.method) if (options.api) { diff --git a/subtrack/src/tui/screens/delete.tsx b/subtrack/src/tui/screens/delete.tsx index bb59f7c..0e7300f 100644 --- a/subtrack/src/tui/screens/delete.tsx +++ b/subtrack/src/tui/screens/delete.tsx @@ -19,7 +19,7 @@ export function DeleteScreen() { [state.selectedId], ) - useInput((input) => { + useInput((input, key) => { if (input === "y" || input === "Y") { if (sub) { try { @@ -35,7 +35,7 @@ export function DeleteScreen() { } } dispatch({ type: "GO_BACK" }) - } else if (input === "n" || input === "N") { + } else if (input === "n" || input === "N" || key.escape) { dispatch({ type: "GO_BACK" }) } }) diff --git a/subtrack/src/tui/screens/list.tsx b/subtrack/src/tui/screens/list.tsx index 8ba83ea..11b8ead 100644 --- a/subtrack/src/tui/screens/list.tsx +++ b/subtrack/src/tui/screens/list.tsx @@ -156,7 +156,7 @@ export function ListScreen() { useInput( (input: string, key) => { - if (state.focus !== "content") return + if (state.focus !== "content" || state.mode !== "NORMAL") return // Handle bulk confirm prompt if (bulkConfirm === "delete") { @@ -258,6 +258,11 @@ export function ListScreen() { dispatch({ type: "SET_FILTER_TEXT", value: "/" }) return } + // 3-pane: show history for the selected subscription + if (input === "h" && state.showDetail && state.selectedId !== null) { + dispatch({ type: "SET_SCREEN", screen: "history" }) + return + } if (input === "v") { if (state.selectedId !== null) { dispatch({ type: "MULTI_SELECT_TOGGLE", id: state.selectedId }) diff --git a/subtrack/src/tui/screens/tools/import-tab.tsx b/subtrack/src/tui/screens/tools/import-tab.tsx index f180ee5..5720087 100644 --- a/subtrack/src/tui/screens/tools/import-tab.tsx +++ b/subtrack/src/tui/screens/tools/import-tab.tsx @@ -3,7 +3,7 @@ import Spinner from "ink-spinner" import { TextInput } from "@inkjs/ui" import { useState, useCallback, useEffect } from "react" import { useSetFormActive } from "../../context/app-context.tsx" -import { getDb, saveDb } from "../../../db.ts" +import { writeSubscription } from "../../../db.ts" import { parseCsvLine } from "../../../import-csv.ts" import { isValidCurrency, isValidCycle } from "../../../prompts.ts" import { colors } from "../../theme.ts" @@ -31,38 +31,25 @@ export function ImportTab() { let success = 0 let failed = 0 const errors: string[] = [] - const db = getDb() - db.run("BEGIN TRANSACTION") - try { - for (let i = 1; i < lines.length; i++) { + for (let i = 1; i < lines.length; i++) { + try { const fields = parseCsvLine(lines[i]) if (fields.length < 5) { failed++; continue } if (!isValidCurrency(fields[4]) || !isValidCycle(fields[1])) { failed++; continue } const price = Number(fields[3]) if (isNaN(price) || price < 0 || !Number.isInteger(price)) { failed++; errors.push(`Line ${i + 1}: invalid price "${fields[3]}"`); continue } - db.run( - "INSERT INTO subscriptions (name, price, currency, cycle, status, created_at) VALUES (?, ?, ?, ?, 'active', date('now'))", - [fields[0].trim(), price, fields[4], fields[1]], - ) - const idRow = db.exec("SELECT last_insert_rowid() AS id") - if (idRow.length > 0 && idRow[0].values.length > 0) { - const subId = Number(idRow[0].values[0][0]) - const tags = fields[2].split(";").map((t) => t.trim()).filter(Boolean) - for (const t of tags) { - db.run("INSERT OR IGNORE INTO tags (name) VALUES (?)", [t]) - const tagRow = db.exec("SELECT id FROM tags WHERE name = ?", [t]) - if (tagRow.length > 0 && tagRow[0].values.length > 0) { - db.run("INSERT INTO subscription_tags (subscription_id, tag_id) VALUES (?, ?)", [subId, Number(tagRow[0].values[0][0])]) - } - } - } + writeSubscription({ + name: fields[0].trim(), + price, + currency: fields[4], + cycle: fields[1], + tags: fields[2].split(";").map((t) => t.trim()).filter(Boolean), + status: "active", + }) success++ + } catch { + failed++ } - db.run("COMMIT") - saveDb() - } catch (e) { - db.run("ROLLBACK") - throw e } const msg = `Imported ${success} subscription${success !== 1 ? "s" : ""} from ${filePath}${failed > 0 ? ` (${failed} failed)` : ""}` setResult(errors.length > 0 ? `${msg}\n${errors.join("\n")}` : msg) From 755ede886cc261f8025b56e9f0b23bac9e9aa78b Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 20:52:18 +0900 Subject: [PATCH 06/12] fix: pin step-security/harden-runner to v2.17.0 with SHA The @v3 tag does not exist in the step-security/harden-runner repository, causing all CI jobs to fail at the 'Set up job' stage with: 'Unable to resolve action step-security/harden-runner@v3' Pin to commit SHA f808768 with version comment for immutability and clarity. --- .github/workflows/app-ci.yml | 2 +- .github/workflows/check.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/labeler.yml | 2 +- .github/workflows/pages.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/renovate-approve.yml | 2 +- .github/workflows/scheduled-ci.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 4f7ef8e..4f729a7 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -43,7 +43,7 @@ jobs: node-version: 22 runs-on: ${{ matrix.os }} steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b1bdc51..b586d1d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,7 +10,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1ee8df5..ceed9d0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: matrix: language: [javascript-typescript] steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 3c0497a..6273720 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ jobs: review: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 4d3feaa..9ee876a 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6baee38..0ddbe01 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,7 +23,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit @@ -59,7 +59,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2962457..2723d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: contents: write id-token: write steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/renovate-approve.yml b/.github/workflows/renovate-approve.yml index 416f020..f60e9bc 100644 --- a/.github/workflows/renovate-approve.yml +++ b/.github/workflows/renovate-approve.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor == 'renovate[bot]' steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/scheduled-ci.yml b/.github/workflows/scheduled-ci.yml index 2b7e253..453491a 100644 --- a/.github/workflows/scheduled-ci.yml +++ b/.github/workflows/scheduled-ci.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8fbb3bf..c46ee78 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,7 +19,7 @@ jobs: security-events: write steps: - - uses: step-security/harden-runner@v3 + - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 with: egress-policy: audit From a47960e033090918328df677dbca5def96ef8e60 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 20:57:43 +0900 Subject: [PATCH 07/12] fix: update step-security/harden-runner pin to v2.19.4 Previous pin f808768 (v2.17.0) was only reachable via a tag, not from any branch. GitHub Actions requires the SHA to be reachable from a branch ref. v2.19.4 (9af89fc) is on the main branch and also tagged as v2. --- .github/workflows/app-ci.yml | 2 +- .github/workflows/check.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/labeler.yml | 2 +- .github/workflows/pages.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/renovate-approve.yml | 2 +- .github/workflows/scheduled-ci.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index 4f729a7..555966f 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -43,7 +43,7 @@ jobs: node-version: 22 runs-on: ${{ matrix.os }} steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b586d1d..9efec09 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,7 +10,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ceed9d0..ea3105d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,7 @@ jobs: matrix: language: [javascript-typescript] steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6273720..d6e2614 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ jobs: review: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 9ee876a..5ce67cb 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,7 +12,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0ddbe01..29e3837 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,7 +23,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -59,7 +59,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2723d75..f561fc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: contents: write id-token: write steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/renovate-approve.yml b/.github/workflows/renovate-approve.yml index f60e9bc..e6ccbd1 100644 --- a/.github/workflows/renovate-approve.yml +++ b/.github/workflows/renovate-approve.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor == 'renovate[bot]' steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/scheduled-ci.yml b/.github/workflows/scheduled-ci.yml index 453491a..9119d51 100644 --- a/.github/workflows/scheduled-ci.yml +++ b/.github/workflows/scheduled-ci.yml @@ -12,7 +12,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c46ee78..56313ba 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,7 +19,7 @@ jobs: security-events: write steps: - - uses: step-security/harden-runner@f808768d1510423e55283889c910610ca9b43176 # v2.17.0 + - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit From a435f3b92cb07bf82d8f52a51aebe79ab547174b Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 21:17:55 +0900 Subject: [PATCH 08/12] docs: update SECURITY.md to reflect SHA-pinned harden-runner --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index e04bb1f..9d1393f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,7 +47,7 @@ subtrack takes supply chain security seriously: - **Dependabot**: Defense-in-depth alongside Renovate — catches vulnerabilities faster via GitHub Advisory Database integration. - **Lockfile**: A `pnpm-lock.yaml` is committed and verified with `--frozen-lockfile` in CI. An `npm-shrinkwrap.json` is generated at publish time for downstream reproducability. - **Limited build scripts**: Only `esbuild` is permitted to run install scripts (`allowBuilds` in `pnpm-workspace.yaml`). -- **Harden-Runner**: Every CI workflow uses `step-security/harden-runner@v3` for runtime egress monitoring and threat detection. +- **Harden-Runner**: Every CI workflow uses `step-security/harden-runner` (pinned to v2.19.4 SHA) for runtime egress monitoring and threat detection. - **CodeQL**: Static analysis runs on every push and PR. - **pnpm audit**: Runs in CI to catch known vulnerabilities. - **OpenSSF Scorecard**: Automated supply chain health assessment with results published to the repository's Security tab. From c9cfcc79594b263b1ff37555f79a82d68fc9c9db Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 21:29:22 +0900 Subject: [PATCH 09/12] chore: bump version to 8.0.0 --- subtrack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/subtrack/package.json b/subtrack/package.json index 1823b8b..628d8a5 100644 --- a/subtrack/package.json +++ b/subtrack/package.json @@ -1,6 +1,6 @@ { "name": "subtrack", - "version": "7.0.8", + "version": "8.0.0", "author": "nazozokc", "type": "module", "license": "MIT", From 25e700c8d5f54fba68ea6de94d8c4dbbb714db27 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 21:57:21 +0900 Subject: [PATCH 10/12] feat: add timeline command for monthly spending bar chart --- subtrack/src/__tests__/timeline.test.ts | 208 ++++++++++++++++++++ subtrack/src/commands.ts | 1 + subtrack/src/index.ts | 37 ++++ subtrack/src/timeline.ts | 242 ++++++++++++++++++++++++ 4 files changed, 488 insertions(+) create mode 100644 subtrack/src/__tests__/timeline.test.ts create mode 100644 subtrack/src/timeline.ts diff --git a/subtrack/src/__tests__/timeline.test.ts b/subtrack/src/__tests__/timeline.test.ts new file mode 100644 index 0000000..374e827 --- /dev/null +++ b/subtrack/src/__tests__/timeline.test.ts @@ -0,0 +1,208 @@ +import { describe, test, expect, beforeAll, beforeEach, afterEach } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { consola } from "consola" + +let testDb: Database +let timelineModule: typeof import("../timeline.ts") +let dbModule: typeof import("../db.ts") + +function seedSub( + name: string, + price: number, + cycle: string, + status = "active", + createdAt?: string, +) { + const db = dbModule.getDb() + db.run( + `INSERT INTO subscriptions (name, price, currency, cycle, status, created_at) + VALUES (?, ?, 'USD', ?, ?, ?)`, + [name, price, cycle, status, createdAt || "2020-01-01"], + ) + dbModule.saveDb() +} + +beforeAll(async () => { + const SQL = await initSqlJs() + testDb = new SQL.Database() + testDb.run("PRAGMA foreign_keys = ON") + + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS subscription_tags ( + subscription_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (subscription_id, tag_id), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER, + new_price INTEGER NOT NULL, + old_currency TEXT, + new_currency TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + )`) + + dbModule = await import("../db.ts") + dbModule.__setDb(testDb) + + timelineModule = await import("../timeline.ts") +}) + +beforeEach(() => { + testDb.run("DELETE FROM price_history") + testDb.run("DELETE FROM subscription_tags") + testDb.run("DELETE FROM tags") + testDb.run("DELETE FROM subscriptions") + testDb.run("DELETE FROM sqlite_sequence") +}) + +describe("handleTimeline", () => { + test("shows info when no subscriptions exist", () => { + const infoLogs: string[] = [] + const origInfo = consola.info + consola.info = (msg: unknown) => infoLogs.push(String(msg)) + + timelineModule.handleTimeline() + + expect(infoLogs.length).toBeGreaterThan(0) + expect(infoLogs[0]).toContain("No subscriptions") + consola.info = origInfo + }) + + test("returns chart output for active subscriptions", () => { + seedSub("Netflix", 1549, "monthly") + seedSub("Spotify", 999, "monthly") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + timelineModule.handleTimeline({ months: 3 }) + + expect(loggedLines.length).toBeGreaterThan(0) + expect(loggedLines.some((l) => l.includes("Monthly spending"))).toBe(true) + + consola.log = origLog + }) + + test("excludes cancelled subscriptions", () => { + seedSub("Netflix", 1549, "monthly", "active") + seedSub("Cancelled Thing", 5000, "monthly", "cancelled") + + // With only the cancelled sub, should show no meaningful data + const loggedLines: string[] = [] + const origLog = consola.log + const origInfo = consola.info + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + consola.info = () => {} + + // Clear and add only cancelled + testDb.run("DELETE FROM subscriptions") + seedSub("Cancelled Thing", 5000, "monthly", "cancelled") + + timelineModule.handleTimeline({ months: 3 }) + + // Should still produce chart output (activeSubs is just empty but that's handled) + // Actually: zero active subs, getSubscriptions returns the cancelled one, + // activeSubs filter means empty array, calcMonthlyTotals returns all zeros + expect(loggedLines.some((l) => l.includes("Monthly spending"))).toBe(true) + + consola.log = origLog + consola.info = origInfo + }) + + test("handles yearly cycle subscriptions", () => { + // $120/yr = $10/mo + seedSub("Annual", 12000, "yearly") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + timelineModule.handleTimeline({ months: 3 }) + + expect(loggedLines.some((l) => l.includes("Monthly spending"))).toBe(true) + consola.log = origLog + }) + + test("outputs JSON with --json flag", () => { + seedSub("Netflix", 1549, "monthly") + + const jsonOutputs: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + const mockWrite = ((chunk: unknown) => { + jsonOutputs.push(String(chunk)) + return true + }) as typeof process.stdout.write + process.stdout.write = mockWrite + + timelineModule.handleTimeline({ json: true }) + + process.stdout.write = origWrite + + expect(jsonOutputs.length).toBeGreaterThan(0) + const data = JSON.parse(jsonOutputs[0]) + expect(data).toHaveProperty("months") + expect(data).toHaveProperty("entries") + expect(data.months).toBe(12) + expect(data.entries.length).toBe(12) + expect(data.entries[0]).toHaveProperty("month") + expect(data.entries[0]).toHaveProperty("total") + }) + + test("respects createdAt date for subscription inclusion", () => { + const now = new Date() + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 15) + const lastMonthStr = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, "0")}-${String(lastMonth.getDate()).padStart(2, "0")}` + + seedSub("Old Sub", 1000, "monthly", "active", "2020-01-01") + seedSub("New Sub", 2000, "monthly", "active", lastMonthStr) + + const jsonOutputs: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + const mockWrite = ((chunk: unknown) => { + jsonOutputs.push(String(chunk)) + return true + }) as typeof process.stdout.write + process.stdout.write = mockWrite + + timelineModule.handleTimeline({ months: 12, json: true }) + + process.stdout.write = origWrite + + const data = JSON.parse(jsonOutputs[0]) + expect(data.entries.length).toBe(12) + }) + + test("errors on invalid months", () => { + const errLogs: string[] = [] + const origError = consola.error + consola.error = (msg: unknown) => errLogs.push(String(msg)) + + timelineModule.handleTimeline({ months: 0 }) + + expect(errLogs.length).toBeGreaterThan(0) + expect(errLogs[0]).toContain("positive integer") + consola.error = origError + }) +}) diff --git a/subtrack/src/commands.ts b/subtrack/src/commands.ts index 9815969..6e05f66 100644 --- a/subtrack/src/commands.ts +++ b/subtrack/src/commands.ts @@ -7,6 +7,7 @@ export { handleBulkStatus, handleBulkDelete, handleBulkTagAdd, handleBulkTagRemo export { handleForecast } from "./forecast.ts" export { handleHistory } from "./history.ts" export { handleNotify } from "./notify.ts" +export { handleTimeline } from "./timeline.ts" export { handleBackup, handleRestore } from "./backup.ts" export { handleTagList, handleTagRename, handleTagDelete, handleTagPrune } from "./tag.ts" diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index c683051..458dc66 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -39,6 +39,7 @@ import { handleMcp, handleHistory, handleNotify, + handleTimeline, } from "./commands.ts"; import { handleUsageAdd, @@ -943,6 +944,41 @@ const notifyCommand = define({ }, }) +// ── Timeline ───────────────────────────────────────────── + +const timelineCommand = define({ + name: "timeline", + description: "Show monthly spending timeline with bar chart", + args: { + months: { + type: "string", + description: "Number of months (default: 12)", + }, + categories: { + type: "boolean", + short: "c", + description: "Show breakdown by category (first tag)", + }, + json: { + type: "boolean", + short: "j", + description: "Output as JSON", + }, + }, + run: (ctx) => { + const months = ctx.values.months !== undefined ? Number(ctx.values.months) : undefined + if (months !== undefined && (isNaN(months) || months < 1 || !Number.isInteger(months))) { + consola.error("months must be a positive integer") + return + } + handleTimeline({ + months, + categories: ctx.values.categories, + json: ctx.values.json, + }) + }, +}) + // ── MCP ────────────────────────────────────────────────── const mcpCommand = define({ @@ -1002,6 +1038,7 @@ try { calendar: calendarCommand, history: historyCommand, notify: notifyCommand, + timeline: timelineCommand, mcp: mcpCommand, analytics: analyticsCommand, compare: compareCommand, diff --git a/subtrack/src/timeline.ts b/subtrack/src/timeline.ts new file mode 100644 index 0000000..d97dfce --- /dev/null +++ b/subtrack/src/timeline.ts @@ -0,0 +1,242 @@ +import { consola } from "consola" +import pc from "picocolors" +import { getSubscriptions } from "./db.ts" +import type { SharedArgs } from "./types.ts" +import { periodFactor } from "./types.ts" +import { formatPrice } from "./price.ts" + +export type TimelineOptions = { + months?: number + categories?: boolean + json?: boolean +} + +type MonthTotal = { + label: string + year: number + month: number + total: number +} + +type CategoryMonth = { + category: string + months: number[] +} + +/** + * Calculate monthly spending totals for the past N months. + * Each subscription's monthly cost is: price * periodFactor(cycle, "monthly"). + * Cancelled subscriptions are excluded. Subscriptions that didn't exist yet + * in a given month are excluded from that month's total. + */ +function calcMonthlyTotals( + subs: SharedArgs[], + months: number, +): MonthTotal[] { + const now = new Date() + const results: MonthTotal[] = [] + + for (let i = months - 1; i >= 0; i--) { + const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1) + const y = monthDate.getFullYear() + const m = monthDate.getMonth() + const label = `${y}-${String(m + 1).padStart(2, "0")}` + const monthEnd = new Date(y, m + 1, 0) // last day of month + + let total = 0 + for (const sub of subs) { + // Skip cancelled subscriptions + if (sub.status === "cancelled") continue + + // Skip if subscription was created after this month + const created = new Date(sub.createdAt) + if (created > monthEnd) continue + + total += sub.price * periodFactor(sub.cycle, "monthly") + } + + results.push({ label, year: y, month: m, total: Math.round(total) }) + } + + return results +} + +/** + * Calculate monthly totals broken down by category (first tag). + */ +function calcMonthlyTotalsByCategory( + subs: SharedArgs[], + months: number, +): { totals: MonthTotal[]; categories: CategoryMonth[] } { + const totals = calcMonthlyTotals(subs, months) + + // Collect unique categories (first tag, or "Other") + const catSet = new Set() + for (const sub of subs) { + if (sub.status === "cancelled") continue + catSet.add(sub.tags[0] || "Other") + } + const categories = [...catSet].sort() + + // Per-category per-month totals + const now = new Date() + const catData: CategoryMonth[] = categories.map((cat) => ({ + category: cat, + months: Array.from({ length: months }, () => 0), + })) + + for (let i = 0; i < months; i++) { + const monthDate = new Date(now.getFullYear(), now.getMonth() - (months - 1 - i), 1) + const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0) + + for (const sub of subs) { + if (sub.status === "cancelled") continue + const created = new Date(sub.createdAt) + if (created > monthEnd) continue + + const cat = sub.tags[0] || "Other" + const idx = categories.indexOf(cat) + catData[idx].months[i] += sub.price * periodFactor(sub.cycle, "monthly") + } + } + + // Round all values + for (const cd of catData) { + cd.months = cd.months.map((v) => Math.round(v)) + } + + return { totals, categories: catData } +} + +/** + * Render a bar chart showing monthly spending. + */ +function renderBarChart(totals: MonthTotal[]): string { + const max = Math.max(...totals.map((t) => t.total), 1) + const barWidth = 40 + const labelWidth = 4 // "Dec " or "Jun " + const lines: string[] = [] + + const monthNames = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + + lines.push(pc.bold("Monthly spending")) + lines.push("─".repeat(barWidth + labelWidth + 16)) + lines.push("") + + for (const t of totals) { + const shortMon = monthNames[t.month] + const label = `${shortMon} ${String(t.year).slice(2)}`.padEnd(labelWidth + 3) + const barLen = Math.round((t.total / max) * barWidth) + const bar = "█".repeat(barLen) + "░".repeat(barWidth - barLen) + const price = formatPrice(t.total, "USD") + lines.push(` ${label} ${bar} ${pc.dim(price)}`) + } + + lines.push("") + lines.push("─".repeat(barWidth + labelWidth + 16)) + + const avg = Math.round(totals.reduce((s, t) => s + t.total, 0) / totals.length) + const totalSum = totals.reduce((s, t) => s + t.total, 0) + lines.push( + ` ${pc.dim(`Avg: ${formatPrice(avg, "USD")}/mo │ Total: ${formatPrice(totalSum, "USD")}`)}`, + ) + + return lines.join("\n") +} + +/** + * Render a multi-line category breakdown chart. + */ +function renderCategoryChart( + totals: MonthTotal[], + catData: CategoryMonth[], +): string { + const lines: string[] = [] + const monthNames = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + + lines.push(pc.bold("Monthly spending by category")) + lines.push("") + + const maxCatTotal = Math.max( + ...catData.map((cd) => Math.max(...cd.months, 0)), + 1, + ) + const barWidth = 30 + + for (const cd of catData) { + lines.push(pc.bold(` ${cd.category}`)) + for (let i = 0; i < cd.months.length; i++) { + const t = totals[i] + if (cd.months[i] === 0) continue + const shortMon = monthNames[t.month] + const label = ` ${shortMon}`.padEnd(6) + const barLen = Math.round((cd.months[i] / maxCatTotal) * barWidth) + const bar = "█".repeat(Math.max(barLen, 1)) + lines.push(` ${label} ${bar} ${pc.dim(formatPrice(cd.months[i], "USD"))}`) + } + lines.push("") + } + + return lines.join("\n") +} + +export function handleTimeline(options: TimelineOptions = {}): void { + const months = options.months ?? 12 + + if (months < 1) { + consola.error("months must be a positive integer") + return + } + + const subs = getSubscriptions() + + if (subs.length === 0) { + consola.info("No subscriptions found") + return + } + + // Filter to non-cancelled for active analysis + const activeSubs = subs.filter((s) => s.status !== "cancelled") + + if (options.json) { + const totals = calcMonthlyTotals(activeSubs, months) + const data = { + months, + total: totals.reduce((s, t) => s + t.total, 0), + average: + totals.length > 0 + ? Math.round(totals.reduce((s, t) => s + t.total, 0) / totals.length) + : 0, + entries: totals.map((t) => ({ + month: t.label, + total: t.total, + })), + } + if (options.categories) { + const { categories } = calcMonthlyTotalsByCategory(activeSubs, months) + const catData: Record = {} + for (const cd of categories) { + catData[cd.category] = cd.months + } + ;(data as Record).categories = catData + } + process.stdout.write(JSON.stringify(data, null, 2) + "\n") + return + } + + if (options.categories) { + const { totals, categories } = calcMonthlyTotalsByCategory(activeSubs, months) + consola.log(renderBarChart(totals)) + consola.log("") + consola.log(renderCategoryChart(totals, categories)) + } else { + const totals = calcMonthlyTotals(activeSubs, months) + consola.log(renderBarChart(totals)) + } +} From 5cba4d03f2802823e968fe6f6f6ae241b0e48c40 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 22:02:48 +0900 Subject: [PATCH 11/12] feat: add optimize command for cost optimization suggestions --- subtrack/src/__tests__/optimize.test.ts | 221 +++++++++++++ subtrack/src/commands.ts | 1 + subtrack/src/index.ts | 31 ++ subtrack/src/optimize.ts | 406 ++++++++++++++++++++++++ 4 files changed, 659 insertions(+) create mode 100644 subtrack/src/__tests__/optimize.test.ts create mode 100644 subtrack/src/optimize.ts diff --git a/subtrack/src/__tests__/optimize.test.ts b/subtrack/src/__tests__/optimize.test.ts new file mode 100644 index 0000000..ddfe830 --- /dev/null +++ b/subtrack/src/__tests__/optimize.test.ts @@ -0,0 +1,221 @@ +import { describe, test, expect, beforeAll, beforeEach, afterEach } from "vitest" +import initSqlJs from "sql.js" +import type { Database } from "sql.js" +import { consola } from "consola" + +let testDb: Database +let optimizeModule: typeof import("../optimize.ts") +let dbModule: typeof import("../db.ts") + +function seedSub( + id: number | null, + name: string, + price: number, + cycle: string, + status = "active", + createdAt?: string, +) { + const db = dbModule.getDb() + db.run( + `INSERT INTO subscriptions (id, name, price, cycle, status, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [id ?? null, name, price, cycle, status, createdAt || "2020-01-01"], + ) + dbModule.saveDb() +} + +function seedPriceHistory(subId: number, oldPrice: number, newPrice: number, changedAt: string) { + const db = dbModule.getDb() + db.run( + `INSERT INTO price_history (subscription_id, old_price, new_price, new_currency, changed_at) + VALUES (?, ?, ?, 'USD', ?)`, + [subId, oldPrice, newPrice, changedAt], + ) + dbModule.saveDb() +} + +beforeAll(async () => { + const SQL = await initSqlJs() + testDb = new SQL.Database() + testDb.run("PRAGMA foreign_keys = ON") + + testDb.run(`CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + cycle TEXT NOT NULL DEFAULT 'monthly', + status TEXT NOT NULL DEFAULT 'active', + billing_day INTEGER, + created_at TEXT NOT NULL DEFAULT (date('now')), + notes TEXT, + payment_method TEXT + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS subscription_tags ( + subscription_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (subscription_id, tag_id), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + )`) + testDb.run(`CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + old_price INTEGER, + new_price INTEGER NOT NULL, + old_currency TEXT, + new_currency TEXT NOT NULL DEFAULT 'USD', + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + )`) + + dbModule = await import("../db.ts") + dbModule.__setDb(testDb) + + optimizeModule = await import("../optimize.ts") +}) + +beforeEach(() => { + testDb.run("DELETE FROM price_history") + testDb.run("DELETE FROM subscription_tags") + testDb.run("DELETE FROM tags") + testDb.run("DELETE FROM subscriptions") + testDb.run("DELETE FROM sqlite_sequence") +}) + +describe("handleOptimize", () => { + test("shows info when no subscriptions exist", () => { + const infoLogs: string[] = [] + const origInfo = consola.info + consola.info = (msg: unknown) => infoLogs.push(String(msg)) + + optimizeModule.handleOptimize() + + expect(infoLogs.length).toBeGreaterThan(0) + expect(infoLogs[0]).toContain("No subscriptions") + consola.info = origInfo + }) + + test("suggests yearly billing for monthly subscriptions", () => { + seedSub(null, "Netflix", 1549, "monthly") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + optimizeModule.handleOptimize() + + expect(loggedLines.some((l) => l.includes("Cycle Optimization"))).toBe(true) + expect(loggedLines.some((l) => l.includes("Netflix"))).toBe(true) + expect(loggedLines.some((l) => l.includes("save"))).toBe(true) + + consola.log = origLog + }) + + test("skips yearly cycle subscriptions in cycle optimization", () => { + seedSub(null, "Annual Sub", 12000, "yearly") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + optimizeModule.handleOptimize() + + // Should not suggest cycle optimization for already-yearly subscriptions + const hasCycleSection = loggedLines.some((l) => l.includes("Cycle Optimization")) + expect(hasCycleSection).toBe(false) + + consola.log = origLog + }) + + test("detects duplicate subscriptions by name similarity", () => { + seedSub(null, "Netflix Standard", 1549, "monthly") + seedSub(null, "Netflix Premium", 1999, "monthly") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + optimizeModule.handleOptimize() + + expect(loggedLines.some((l) => l.includes("Possible Duplicates"))).toBe(true) + + consola.log = origLog + }) + + test("detects inactive subscriptions (no price change >18 months)", () => { + seedSub(1, "Old Sub", 1000, "monthly") + // Add a price history entry from 2 years ago + seedPriceHistory(1, 500, 1000, "2024-01-15") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + optimizeModule.handleOptimize() + + expect(loggedLines.some((l) => l.includes("Inactive"))).toBe(true) + + consola.log = origLog + }) + + test("shows cancelled subscription savings", () => { + seedSub(null, "Cancelled Sub", 2000, "monthly", "cancelled") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + optimizeModule.handleOptimize() + + expect(loggedLines.some((l) => l.includes("Cancelled"))).toBe(true) + expect(loggedLines.some((l) => l.includes("Cancelled Sub"))).toBe(true) + + consola.log = origLog + }) + + test("shows nothing to optimize when all is optimal", () => { + seedSub(null, "Good Sub", 10000, "yearly") + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = (msg: unknown) => loggedLines.push(String(msg)) + + optimizeModule.handleOptimize() + + const hasOptimizationSection = + loggedLines.some((l) => l.includes("Cycle Optimization")) || + loggedLines.some((l) => l.includes("Possible Duplicates")) || + loggedLines.some((l) => l.includes("Inactive")) || + loggedLines.some((l) => l.includes("Cancelled")) + expect(hasOptimizationSection).toBe(false) + + consola.log = origLog + }) + + test("outputs JSON with --json flag", () => { + seedSub(null, "Netflix", 1549, "monthly") + + const jsonOutputs: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + const mockWrite = ((chunk: unknown) => { + jsonOutputs.push(String(chunk)) + return true + }) as typeof process.stdout.write + process.stdout.write = mockWrite + + optimizeModule.handleOptimize({ json: true }) + + process.stdout.write = origWrite + + expect(jsonOutputs.length).toBeGreaterThan(0) + const data = JSON.parse(jsonOutputs[0]) + expect(data).toHaveProperty("suggestions") + expect(data).toHaveProperty("totalYearlySavings") + expect(data.suggestions.length).toBeGreaterThan(0) + }) +}) diff --git a/subtrack/src/commands.ts b/subtrack/src/commands.ts index 6e05f66..e2861b6 100644 --- a/subtrack/src/commands.ts +++ b/subtrack/src/commands.ts @@ -8,6 +8,7 @@ export { handleForecast } from "./forecast.ts" export { handleHistory } from "./history.ts" export { handleNotify } from "./notify.ts" export { handleTimeline } from "./timeline.ts" +export { handleOptimize } from "./optimize.ts" export { handleBackup, handleRestore } from "./backup.ts" export { handleTagList, handleTagRename, handleTagDelete, handleTagPrune } from "./tag.ts" diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index 458dc66..e31ff72 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -40,6 +40,7 @@ import { handleHistory, handleNotify, handleTimeline, + handleOptimize, } from "./commands.ts"; import { handleUsageAdd, @@ -944,6 +945,35 @@ const notifyCommand = define({ }, }) +// ── Optimize ───────────────────────────────────────────── + +const optimizeCommand = define({ + name: "optimize", + description: "Analyze subscriptions and suggest cost optimizations", + args: { + json: { + type: "boolean", + short: "j", + description: "Output as JSON", + }, + "min-savings": { + type: "string", + description: "Minimum yearly savings to show (default: 0)", + }, + }, + run: (ctx) => { + const minSavings = ctx.values["min-savings"] !== undefined ? Number(ctx.values["min-savings"]) : undefined + if (minSavings !== undefined && (isNaN(minSavings) || minSavings < 0)) { + consola.error("min-savings must be a non-negative number") + return + } + handleOptimize({ + json: ctx.values.json, + minSavings, + }) + }, +}) + // ── Timeline ───────────────────────────────────────────── const timelineCommand = define({ @@ -1038,6 +1068,7 @@ try { calendar: calendarCommand, history: historyCommand, notify: notifyCommand, + optimize: optimizeCommand, timeline: timelineCommand, mcp: mcpCommand, analytics: analyticsCommand, diff --git a/subtrack/src/optimize.ts b/subtrack/src/optimize.ts new file mode 100644 index 0000000..feb952b --- /dev/null +++ b/subtrack/src/optimize.ts @@ -0,0 +1,406 @@ +import { consola } from "consola" +import pc from "picocolors" +import { getSubscriptions, getAllPriceChanges } from "./db.ts" +import type { SharedArgs } from "./types.ts" +import { periodFactor, OCCURRENCES_PER_YEAR } from "./types.ts" +import { formatPrice } from "./price.ts" + +export type OptimizeOptions = { + json?: boolean + minSavings?: number +} + +type CycleSuggestion = { + type: "cycle" + name: string + currentCycle: string + suggestedCycle: string + currentMonthly: number + suggestedMonthly: number + yearlySavings: number +} + +type DuplicateSuggestion = { + type: "duplicate" + names: string[] + category: string + totalMonthly: number +} + +type InactiveSuggestion = { + type: "inactive" + name: string + id: number + price: number + currency: string + cycle: string + monthly: number + lastChanged: string +} + +type CancelSaving = { + type: "cancelled" + name: string + price: number + currency: string + cycle: string + monthly: number +} + +type Suggestion = + | CycleSuggestion + | DuplicateSuggestion + | InactiveSuggestion + | CancelSaving + +type OptimizeResult = { + suggestions: Suggestion[] + totalYearlySavings: number +} + +// ── Analysis helpers ────────────────────────────────────── + +/** + * Estimate yearly savings from switching monthly to yearly billing. + * Uses a conservative 15% discount (common for most SaaS). + */ +function estimateYearlyDiscount(monthlyPrice: number): number { + return Math.round(monthlyPrice * 12 * 0.15) +} + +/** + * Tokenize a subscription name for comparison. + * Removes common words and splits into tokens. + */ +function tokenizeName(name: string): string[] { + const common = [ + "plan", "pro", "basic", "premium", "standard", "plus", "family", + "duo", "student", "enterprise", "business", "personal", + ] + return name + .toLowerCase() + .replace(/[()]/g, "") + .split(/[\s-]+/) + .filter((t) => t.length > 2 && !common.includes(t)) +} + +/** + * Compute Jaccard similarity between two token arrays. + */ +function jaccardSimilarity(a: string[], b: string[]): number { + const setA = new Set(a) + const setB = new Set(b) + const intersection = new Set([...setA].filter((x) => setB.has(x))) + const union = new Set([...setA, ...setB]) + if (union.size === 0) return 0 + return intersection.size / union.size +} + +// ── Analysis functions ──────────────────────────────────── + +function analyzeCycleOptimization(subs: SharedArgs[]): CycleSuggestion[] { + const results: CycleSuggestion[] = [] + + for (const sub of subs) { + if (sub.status === "cancelled" || sub.status === "paused") continue + if (sub.cycle !== "monthly") continue + + const monthlyCost = sub.price + const yearlyCost = monthlyCost * 12 + const estimatedYearlyWithDiscount = Math.round(yearlyCost * 0.85) + const savings = yearlyCost - estimatedYearlyWithDiscount + + if (savings > 0) { + results.push({ + type: "cycle", + name: sub.name, + currentCycle: "monthly", + suggestedCycle: "yearly", + currentMonthly: monthlyCost, + suggestedMonthly: Math.round(estimatedYearlyWithDiscount / 12), + yearlySavings: savings, + }) + } + } + + return results +} + +function analyzeDuplicates(subs: SharedArgs[]): DuplicateSuggestion[] { + const active = subs.filter((s) => s.status === "active") + const results: DuplicateSuggestion[] = [] + const checked = new Set() + + for (let i = 0; i < active.length; i++) { + if (checked.has(active[i].id)) continue + const tokensA = tokenizeName(active[i].name) + const similar: SharedArgs[] = [] + + for (let j = i + 1; j < active.length; j++) { + if (checked.has(active[j].id)) continue + const tokensB = tokenizeName(active[j].name) + const similarity = jaccardSimilarity(tokensA, tokensB) + if (similarity >= 0.5) { + similar.push(active[j]) + } + } + + if (similar.length > 0) { + const allSimilar = [active[i], ...similar] + for (const s of allSimilar) checked.add(s.id) + + const totalMonthly = allSimilar.reduce( + (sum, s) => sum + s.price * periodFactor(s.cycle, "monthly"), + 0, + ) + + // Find common category from first tag or name prefix + const category = + allSimilar + .map((s) => s.tags[0]) + .filter(Boolean) + .sort()[0] || allSimilar.map((s) => tokenizeName(s.name)[0]).filter(Boolean).sort()[0] || "unknown" + + results.push({ + type: "duplicate", + names: allSimilar.map((s) => s.name), + category, + totalMonthly: Math.round(totalMonthly), + }) + } + } + + return results +} + +function analyzeInactive(subs: SharedArgs[]): InactiveSuggestion[] { + const active = subs.filter((s) => s.status === "active") + const allChanges = getAllPriceChanges() + const results: InactiveSuggestion[] = [] + + // Group changes by subscription + const changesBySub = new Map() + for (const change of allChanges) { + const existing = changesBySub.get(change.subscriptionId) + if (!existing || change.changedAt > existing) { + changesBySub.set(change.subscriptionId, change.changedAt) + } + } + + const now = new Date() + for (const sub of active) { + const lastChange = changesBySub.get(sub.id) + if (lastChange) { + const changeDate = new Date(lastChange) + const monthsSince = (now.getTime() - changeDate.getTime()) / (30 * 24 * 60 * 60 * 1000) + if (monthsSince >= 18) { + results.push({ + type: "inactive", + name: sub.name, + id: sub.id, + price: sub.price, + currency: sub.currency, + cycle: sub.cycle, + monthly: Math.round(sub.price * periodFactor(sub.cycle, "monthly")), + lastChanged: lastChange.slice(0, 10), + }) + } + } + } + + return results +} + +function analyzeCancelledSavings(subs: SharedArgs[]): CancelSaving[] { + const results: CancelSaving[] = [] + + for (const sub of subs) { + if (sub.status !== "cancelled" && sub.status !== "paused") continue + + results.push({ + type: "cancelled", + name: sub.name, + price: sub.price, + currency: sub.currency, + cycle: sub.cycle, + monthly: Math.round(sub.price * periodFactor(sub.cycle, "monthly")), + }) + } + + return results +} + +// ── Rendering ───────────────────────────────────────────── + +function renderCycleSuggestions(suggestions: CycleSuggestion[]): string { + if (suggestions.length === 0) return "" + const lines: string[] = [ + "", + pc.bold(" Cycle Optimization"), + "", + ] + + let totalSavings = 0 + for (const s of suggestions) { + const currentYearly = s.currentMonthly * 12 + const suggestedYearly = s.suggestedMonthly * 12 + lines.push( + ` ${s.name}` + + ` ${pc.dim(`${formatPrice(s.currentMonthly, "USD")}/mo → `)}` + + `${pc.green(`${formatPrice(s.suggestedMonthly, "USD")}/mo`)}` + + ` ${pc.green(`(save ${formatPrice(s.yearlySavings, "USD")}/yr)`)}`, + ) + totalSavings += s.yearlySavings + } + + if (totalSavings > 0) { + lines.push( + ` ${pc.dim("─".repeat(40))}`, + ` ${pc.green(pc.bold(`Total savings: ${formatPrice(totalSavings, "USD")}/yr`))}`, + ) + } + + return lines.join("\n") +} + +function renderDuplicateSuggestions(suggestions: DuplicateSuggestion[]): string { + if (suggestions.length === 0) return "" + const lines: string[] = [ + "", + pc.bold(" Possible Duplicates"), + "", + ] + + for (const s of suggestions) { + lines.push(` ${pc.yellow(s.category)}:`) + for (const name of s.names) { + lines.push(` • ${name}`) + } + lines.push(` ${pc.dim(`Combined: ${formatPrice(s.totalMonthly, "USD")}/mo`)}`) + lines.push("") + } + + return lines.join("\n") +} + +function renderInactiveSuggestions(suggestions: InactiveSuggestion[]): string { + if (suggestions.length === 0) return "" + const lines: string[] = [ + "", + pc.bold(" Inactive (no price change >18 months)"), + "", + ] + + for (const s of suggestions) { + lines.push( + ` ${pc.dim(`#${s.id}`)} ${s.name}` + + ` ${formatPrice(s.monthly, s.currency)}/mo` + + ` ${pc.dim(`last change: ${s.lastChanged}`)}`, + ) + } + + return lines.join("\n") +} + +function renderCancelledSavings(suggestions: CancelSaving[]): string { + if (suggestions.length === 0) return "" + const totalMonthly = suggestions.reduce((s, c) => s + c.monthly, 0) + const lines: string[] = [ + "", + pc.bold(" Cancelled / Paused (previously paid)"), + "", + ] + + for (const s of suggestions) { + lines.push(` • ${s.name} ${pc.dim(`${formatPrice(s.monthly, s.currency)}/mo`)}`) + } + + lines.push( + ` ${pc.dim("─".repeat(40))}`, + ` ${pc.dim(`Previously: ${formatPrice(totalMonthly, "USD")}/mo`)}`, + ) + + return lines.join("\n") +} + +function renderReport(result: OptimizeResult): string { + const sections: string[] = [ + pc.bold("📊 Cost Optimization Report"), + ] + + const cycleSuggestions = result.suggestions.filter((s) => s.type === "cycle") + const duplicateSuggestions = result.suggestions.filter((s) => s.type === "duplicate") + const inactiveSuggestions = result.suggestions.filter((s) => s.type === "inactive") + const cancelledSuggestions = result.suggestions.filter((s) => s.type === "cancelled") + + const cycleSection = renderCycleSuggestions(cycleSuggestions as CycleSuggestion[]) + const duplicateSection = renderDuplicateSuggestions(duplicateSuggestions as DuplicateSuggestion[]) + const inactiveSection = renderInactiveSuggestions(inactiveSuggestions as InactiveSuggestion[]) + const cancelledSection = renderCancelledSavings(cancelledSuggestions as CancelSaving[]) + + if (cycleSection) sections.push(cycleSection) + if (duplicateSection) sections.push(duplicateSection) + if (inactiveSection) sections.push(inactiveSection) + if (cancelledSection) sections.push(cancelledSection) + + if (result.suggestions.length === 0) { + sections.push("", " No optimization opportunities found. Everything looks good!") + } + + if (result.totalYearlySavings > 0) { + sections.push( + "", + pc.bold( + ` Total potential savings: ${pc.green(formatPrice(result.totalYearlySavings, "USD"))}/yr`, + ), + ) + } + + return sections.join("\n") +} + +// ── Main ────────────────────────────────────────────────── + +export function handleOptimize(options: OptimizeOptions = {}): void { + const subs = getSubscriptions() + + if (subs.length === 0) { + consola.info("No subscriptions found") + return + } + + const minSavings = options.minSavings ?? 0 + + const cycleSuggestions = analyzeCycleOptimization(subs) + const duplicateSuggestions = analyzeDuplicates(subs) + const inactiveSuggestions = analyzeInactive(subs) + const cancelledSuggestions = analyzeCancelledSavings(subs) + + const allSuggestions: Suggestion[] = [ + ...cycleSuggestions, + ...duplicateSuggestions, + ...inactiveSuggestions, + ...cancelledSuggestions, + ] + + const totalYearlySavings = + cycleSuggestions.reduce((s, c) => s + c.yearlySavings, 0) + + if (minSavings > 0 && totalYearlySavings < minSavings) { + consola.info( + `Potential savings (${formatPrice(totalYearlySavings, "USD")}/yr) below minimum threshold (${formatPrice(minSavings, "USD")}/yr)`, + ) + return + } + + if (options.json) { + const result: OptimizeResult = { + suggestions: allSuggestions, + totalYearlySavings, + } + process.stdout.write(JSON.stringify(result, null, 2) + "\n") + return + } + + consola.log(renderReport({ suggestions: allSuggestions, totalYearlySavings })) +} From c70bbd6a510094fbe7984a4486c7776a191820c8 Mon Sep 17 00:00:00 2001 From: nazozokc Date: Wed, 1 Jul 2026 22:14:11 +0900 Subject: [PATCH 12/12] feat: add profile command for filter profiles with TUI integration --- subtrack/src/__tests__/profile.test.ts | 219 +++++++++++++++++++++ subtrack/src/commands.ts | 1 + subtrack/src/config.ts | 2 +- subtrack/src/index.ts | 90 ++++++++- subtrack/src/profile.ts | 184 +++++++++++++++++ subtrack/src/tui/components/status-bar.tsx | 8 + subtrack/src/tui/screens/list.tsx | 33 +++- subtrack/src/types.ts | 10 + 8 files changed, 542 insertions(+), 5 deletions(-) create mode 100644 subtrack/src/__tests__/profile.test.ts create mode 100644 subtrack/src/profile.ts diff --git a/subtrack/src/__tests__/profile.test.ts b/subtrack/src/__tests__/profile.test.ts new file mode 100644 index 0000000..46a44c7 --- /dev/null +++ b/subtrack/src/__tests__/profile.test.ts @@ -0,0 +1,219 @@ +import { describe, test, expect, beforeAll, beforeEach } from "vitest" +import { existsSync, readFileSync, unlinkSync, mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { consola } from "consola" + +const testDir = path.join(tmpdir(), `subtrack-profile-test-${Date.now()}`) + +let profileModule: typeof import("../profile.ts") +let configModule: typeof import("../config.ts") + +beforeAll(async () => { + mkdirSync(testDir, { recursive: true }) + process.env.SUBSC_CLI_DB_DIR = testDir + profileModule = await import("../profile.ts") + configModule = await import("../config.ts") +}) + +beforeEach(() => { + const configPath = path.join(testDir, "config.json") + if (existsSync(configPath)) { + unlinkSync(configPath) + } + configModule.resetConfig() +}) + +function readConfig(): Record { + const configPath = path.join(testDir, "config.json") + return JSON.parse(readFileSync(configPath, "utf-8")) +} + +describe("saveProfile", () => { + test("saves a profile with tag filter", () => { + const successLogs: string[] = [] + const origSuccess = consola.success + consola.success = ((msg: unknown) => { + successLogs.push(String(msg)) + }) as typeof consola.success + + profileModule.saveProfile("essential", { tags: ["work", "essential"] }) + + expect(successLogs.some((l) => l.includes("essential"))).toBe(true) + + const raw = readConfig() + expect(raw.profiles).toBeDefined() + expect((raw.profiles as Record).essential).toEqual({ + tags: ["work", "essential"], + }) + + consola.success = origSuccess + }) + + test("saves a profile with status filter", () => { + profileModule.saveProfile("active-only", { status: "active" }) + + const raw = readConfig() + expect((raw.profiles as Record)["active-only"]).toEqual({ + status: "active", + }) + }) + + test("rejects invalid profile names", () => { + const errLogs: string[] = [] + const origError = consola.error + consola.error = ((msg: unknown) => { + errLogs.push(String(msg)) + }) as typeof consola.error + + profileModule.saveProfile("", {}) + expect(errLogs.length).toBeGreaterThan(0) + profileModule.saveProfile("name with spaces", {}) + expect(errLogs.length).toBeGreaterThan(1) + + consola.error = origError + }) + + test("overwrites existing profile", () => { + profileModule.saveProfile("test", { tags: ["old"] }) + profileModule.saveProfile("test", { tags: ["new"] }) + + const raw = readConfig() + expect((raw.profiles as Record).test).toEqual({ tags: ["new"] }) + }) +}) + +describe("listProfiles", () => { + test("shows info when no profiles exist", () => { + const infoLogs: string[] = [] + const origInfo = consola.info + consola.info = ((msg: unknown) => infoLogs.push(String(msg))) as typeof consola.info + + profileModule.listProfiles() + + expect(infoLogs.some((l) => l.includes("No saved profiles"))).toBe(true) + consola.info = origInfo + }) + + test("lists saved profiles", () => { + profileModule.saveProfile("a", { tags: ["x"] }) + profileModule.saveProfile("b", { status: "active" }) + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = ((msg: unknown) => loggedLines.push(String(msg))) as typeof consola.log + + profileModule.listProfiles() + + expect(loggedLines.some((l) => l.includes("a"))).toBe(true) + expect(loggedLines.some((l) => l.includes("b"))).toBe(true) + + consola.log = origLog + }) +}) + +describe("showProfile", () => { + test("shows profile details", () => { + profileModule.saveProfile("test", { + tags: ["dev", "tools"], + status: "active", + }) + + const loggedLines: string[] = [] + const origLog = consola.log + consola.log = ((msg: unknown) => loggedLines.push(String(msg))) as typeof consola.log + + profileModule.showProfile("test") + + expect(loggedLines.some((l) => l.includes("test"))).toBe(true) + expect(loggedLines.some((l) => l.includes("dev"))).toBe(true) + + consola.log = origLog + }) + + test("errors on non-existent profile", () => { + const errLogs: string[] = [] + const origError = consola.error + consola.error = ((msg: unknown) => errLogs.push(String(msg))) as typeof consola.error + + profileModule.showProfile("nonexistent") + + expect(errLogs.some((l) => l.includes("not found"))).toBe(true) + consola.error = origError + }) +}) + +describe("switchProfile", () => { + test("switches to a saved profile", () => { + profileModule.saveProfile("test", { tags: ["work"] }) + + const successLogs: string[] = [] + const origSuccess = consola.success + consola.success = ((msg: unknown) => + successLogs.push(String(msg))) as typeof consola.success + + profileModule.switchProfile("test") + + expect(successLogs.some((l) => l.includes("test"))).toBe(true) + + const raw = readConfig() + expect(raw.activeProfile).toBe("test") + + consola.success = origSuccess + }) + + test("errors on non-existent profile", () => { + const errLogs: string[] = [] + const origError = consola.error + consola.error = ((msg: unknown) => errLogs.push(String(msg))) as typeof consola.error + + profileModule.switchProfile("nonexistent") + + expect(errLogs.some((l) => l.includes("not found"))).toBe(true) + consola.error = origError + }) +}) + +describe("deleteProfile", () => { + test("deletes a saved profile", () => { + profileModule.saveProfile("test", { tags: ["x"] }) + profileModule.deleteProfile("test") + + const raw = readConfig() + expect((raw.profiles as Record).test).toBeUndefined() + }) + + test("clears activeProfile when deleting active profile", () => { + profileModule.saveProfile("test", { tags: ["x"] }) + profileModule.switchProfile("test") + profileModule.deleteProfile("test") + + const raw = readConfig() + expect(raw.activeProfile).toBeUndefined() + }) +}) + +describe("getActiveFilter", () => { + test("returns null when no active profile", () => { + expect(profileModule.getActiveFilter()).toBeNull() + }) + + test("returns filter for active profile", () => { + profileModule.saveProfile("test", { tags: ["work"] }) + profileModule.switchProfile("test") + const filter = profileModule.getActiveFilter() + expect(filter).not.toBeNull() + expect(filter!.tags).toEqual(["work"]) + }) +}) + +describe("buildFilterParams", () => { + test("builds params from profile filter", () => { + const params = profileModule.buildFilterParams({ + tags: ["work", "essential"], + status: "active", + }) + expect(params.tags).toEqual(["work", "essential"]) + expect(params.status).toBe("active") + }) +}) diff --git a/subtrack/src/commands.ts b/subtrack/src/commands.ts index e2861b6..2b242d3 100644 --- a/subtrack/src/commands.ts +++ b/subtrack/src/commands.ts @@ -9,6 +9,7 @@ export { handleHistory } from "./history.ts" export { handleNotify } from "./notify.ts" export { handleTimeline } from "./timeline.ts" export { handleOptimize } from "./optimize.ts" +export { handleProfile } from "./profile.ts" export { handleBackup, handleRestore } from "./backup.ts" export { handleTagList, handleTagRename, handleTagDelete, handleTagPrune } from "./tag.ts" diff --git a/subtrack/src/config.ts b/subtrack/src/config.ts index 83e4d18..f8b83f7 100644 --- a/subtrack/src/config.ts +++ b/subtrack/src/config.ts @@ -124,7 +124,7 @@ export function saveTuiColumns(settings: TuiColumnSettings): void { saveConfig(config) } -function saveConfig(config: SubtrackConfig): void { +export function saveConfig(config: SubtrackConfig): void { const configPath = getConfigPath() const dir = path.dirname(configPath) if (!existsSync(dir)) { diff --git a/subtrack/src/index.ts b/subtrack/src/index.ts index e31ff72..4e8edf6 100644 --- a/subtrack/src/index.ts +++ b/subtrack/src/index.ts @@ -41,6 +41,7 @@ import { handleNotify, handleTimeline, handleOptimize, + handleProfile, } from "./commands.ts"; import { handleUsageAdd, @@ -50,7 +51,7 @@ import { handleUsageRefresh, } from "./usage.ts"; import { handleImport } from "./import-csv.ts"; -import type { Cycle, UsageRefreshFlags } from "./types.ts"; +import type { Cycle, Status, UsageRefreshFlags } from "./types.ts"; // ── Command definitions ────────────────────────────────── @@ -945,6 +946,92 @@ const notifyCommand = define({ }, }) +// ── Profile ────────────────────────────────────────────── + +const profileSaveCmd = define({ + name: "save", + description: "Save a filter profile", + toKebab: true, + args: { + name: { type: "positional", description: "Profile name" }, + tag: { + type: "string", + array: true, + description: "Filter by tags (comma-separated or multiple flags)", + }, + status: { type: "string", description: "Filter by status: active, paused, cancelled" }, + "payment-method": { type: "string", description: "Filter by payment method" }, + }, + run: (ctx) => { + const name = ctx.values.name + if (!name) { + consola.error("Profile name required") + return + } + const tagValues = ctx.values.tag as string[] | undefined + const tags = tagValues + ? tagValues.flatMap((t: string) => t.split(",").map((s: string) => s.trim()).filter(Boolean)) + : undefined + handleProfile("save", name, { + tags: tags && tags.length > 0 ? tags : undefined, + status: ctx.values.status as Status | undefined, + paymentMethod: ctx.values["payment-method"], + }) + }, +}) + +const profileSwitchCmd = define({ + name: "switch", + description: "Switch to a saved profile", + args: { + name: { type: "positional", description: "Profile name" }, + }, + run: (ctx) => { + handleProfile("switch", ctx.values.name) + }, +}) + +const profileListCmd = define({ + name: "list", + description: "List saved profiles", + run: () => handleProfile("list"), +}) + +const profileShowCmd = define({ + name: "show", + description: "Show profile details", + args: { + name: { type: "positional", description: "Profile name", required: false }, + }, + run: (ctx) => { + handleProfile("show", ctx.values.name) + }, +}) + +const profileDeleteCmd = define({ + name: "delete", + description: "Delete a profile", + args: { + name: { type: "positional", description: "Profile name" }, + }, + run: (ctx) => { + handleProfile("delete", ctx.values.name) + }, +}) + +const profileCommand = define({ + name: "profile", + description: "Manage filter profiles", + subCommands: { + save: profileSaveCmd, + switch: profileSwitchCmd, + list: profileListCmd, + show: profileShowCmd, + delete: profileDeleteCmd, + }, + run: () => consola.info("Usage: subtrack profile save|switch|list|show|delete"), +}) + // ── Optimize ───────────────────────────────────────────── const optimizeCommand = define({ @@ -1068,6 +1155,7 @@ try { calendar: calendarCommand, history: historyCommand, notify: notifyCommand, + profile: profileCommand, optimize: optimizeCommand, timeline: timelineCommand, mcp: mcpCommand, diff --git a/subtrack/src/profile.ts b/subtrack/src/profile.ts new file mode 100644 index 0000000..8765ff9 --- /dev/null +++ b/subtrack/src/profile.ts @@ -0,0 +1,184 @@ +import { consola } from "consola" +import pc from "picocolors" +import { loadConfig, saveConfig } from "./config.ts" +import type { ProfileFilter, Status } from "./types.ts" + +export type { ProfileFilter } + +export const PROFILE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/ + +function validateName(name: string): boolean { + return PROFILE_NAME_PATTERN.test(name) && name.length > 0 && name.length <= 50 +} + +/** Save a new profile or overwrite an existing one. */ +export function saveProfile(name: string, filter: ProfileFilter): void { + if (!validateName(name)) { + consola.error( + "Profile name must be 1-50 characters, using only letters, numbers, hyphens, and underscores", + ) + return + } + + const config = loadConfig() + const profiles = config.profiles ?? {} + profiles[name] = filter + config.profiles = profiles + saveConfig(config) + consola.success(`Profile "${name}" saved`) +} + +/** List all saved profiles. */ +export function listProfiles(): void { + const config = loadConfig() + const profiles = config.profiles ?? {} + + if (Object.keys(profiles).length === 0) { + consola.info("No saved profiles") + return + } + + consola.log(pc.bold("Saved profiles:")) + for (const [name, filter] of Object.entries(profiles)) { + const parts: string[] = [] + if (filter.tags && filter.tags.length > 0) { + parts.push(`tags: ${filter.tags.join(", ")}`) + } + if (filter.status) { + parts.push(`status: ${filter.status}`) + } + if (filter.paymentMethod) { + parts.push(`method: ${filter.paymentMethod}`) + } + + const isActive = config.activeProfile === name + const marker = isActive ? pc.green("●") : " " + consola.log(` ${marker} ${pc.bold(name)} ${pc.dim(parts.join(" | "))}${isActive ? pc.green(" (active)") : ""}`) + } +} + +/** Show details of a specific profile. */ +export function showProfile(name: string): void { + const config = loadConfig() + const profiles = config.profiles ?? {} + const filter = profiles[name] + + if (!filter) { + consola.error(`Profile "${name}" not found`) + return + } + + const isActive = config.activeProfile === name + consola.log(pc.bold(`Profile: ${name}${isActive ? pc.green(" (active)") : ""}`)) + consola.log(` tags: ${filter.tags?.join(", ") || pc.dim("none")}`) + consola.log(` status: ${filter.status || pc.dim("any")}`) + consola.log(` payment method: ${filter.paymentMethod || pc.dim("any")}`) +} + +/** Switch to a saved profile (sets it as active). */ +export function switchProfile(name: string): void { + const config = loadConfig() + const profiles = config.profiles ?? {} + + if (!profiles[name]) { + consola.error(`Profile "${name}" not found`) + return + } + + config.activeProfile = name + saveConfig(config) + consola.success(`Switched to profile "${name}"`) +} + +/** Delete a saved profile. */ +export function deleteProfile(name: string): void { + const config = loadConfig() + const profiles = config.profiles ?? {} + + if (!profiles[name]) { + consola.error(`Profile "${name}" not found`) + return + } + + delete profiles[name] + config.profiles = profiles + + // Clear activeProfile if it was the deleted one + if (config.activeProfile === name) { + config.activeProfile = undefined + } + + saveConfig(config) + consola.success(`Profile "${name}" deleted`) +} + +/** Get the currently active profile filter, if any. */ +export function getActiveFilter(): ProfileFilter | null { + const config = loadConfig() + if (!config.activeProfile) return null + const profiles = config.profiles ?? {} + return profiles[config.activeProfile] ?? null +} + +/** Get the name of the active profile, if any. */ +export function getActiveProfileName(): string | null { + const config = loadConfig() + return config.activeProfile ?? null +} + +/** Apply a profile filter to a subscription query. Returns filter params. */ +export function buildFilterParams( + profile: ProfileFilter, + existingTags?: string[], +): { tags?: string[]; status?: Status; paymentMethod?: string } { + return { + tags: profile.tags?.length ? profile.tags : undefined, + status: profile.status as Status | undefined, + paymentMethod: profile.paymentMethod, + } +} + +// ── CLI handler ────────────────────────────────────────── + +export type ProfileCommand = "save" | "switch" | "list" | "show" | "delete" + +export function handleProfile(command?: ProfileCommand, name?: string, filter?: ProfileFilter): void { + if (!command) { + listProfiles() + return + } + + switch (command) { + case "list": + listProfiles() + break + case "save": + if (!name) { + consola.error("Profile name required") + return + } + saveProfile(name, filter ?? {}) + break + case "show": + if (!name) { + consola.error("Profile name required") + return + } + showProfile(name) + break + case "switch": + if (!name) { + consola.error("Profile name required") + return + } + switchProfile(name) + break + case "delete": + if (!name) { + consola.error("Profile name required") + return + } + deleteProfile(name) + break + } +} diff --git a/subtrack/src/tui/components/status-bar.tsx b/subtrack/src/tui/components/status-bar.tsx index 500e770..85f2dcf 100644 --- a/subtrack/src/tui/components/status-bar.tsx +++ b/subtrack/src/tui/components/status-bar.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink" import { useTui } from "../context/app-context.tsx" import { colors } from "../theme.ts" +import { getActiveProfileName } from "../../profile.ts" const SCREEN_LABEL: Record = { list: "List", @@ -51,6 +52,13 @@ export function StatusBar() { {screenLabel} + {/* Active profile indicator */} + {getActiveProfileName() && ( + + {" │ "}● {getActiveProfileName()} + + )} + {/* Filter indicator */} {state.filterText && ( diff --git a/subtrack/src/tui/screens/list.tsx b/subtrack/src/tui/screens/list.tsx index 11b8ead..5c0fc7e 100644 --- a/subtrack/src/tui/screens/list.tsx +++ b/subtrack/src/tui/screens/list.tsx @@ -3,6 +3,7 @@ import Gradient from "ink-gradient" import { getSubscriptions, getSubscription, updateSubscription, deleteSubscription } from "../../db.ts" import { useTui, type SortField } from "../context/app-context.tsx" import type { Status } from "../../types.ts" +import { getActiveFilter, buildFilterParams, getActiveProfileName } from "../../profile.ts" import { SIDEBAR_WIDTH } from "../types.ts" import { useMemo, useEffect, useState } from "react" import { formatPrice } from "../../price.ts" @@ -84,17 +85,38 @@ export function ListScreen() { // ── Data ── const subs = useMemo(() => { - const all = getSubscriptions(sortField, sortDesc) + let list = getSubscriptions(sortField, sortDesc) + + // Apply profile filter (if active) + const activeFilter = getActiveFilter() + if (activeFilter) { + const params = buildFilterParams(activeFilter) + if (params.tags && params.tags.length > 0) { + list = list.filter((s) => + params.tags!.some((t) => s.tags.includes(t)), + ) + } + if (params.status) { + list = list.filter((s) => s.status === params.status) + } + if (params.paymentMethod) { + list = list.filter( + (s) => s.paymentMethod === params.paymentMethod, + ) + } + } + + // Apply text filter if (state.filterText) { const q = state.filterText.toLowerCase() - return all.filter( + list = list.filter( (s) => s.name.toLowerCase().includes(q) || s.tags.some((t) => t.toLowerCase().includes(q)) || (s.notes ?? "").toLowerCase().includes(q), ) } - return all + return list }, [state.filterText, sortField, sortDesc, state.refreshKey]) // Clamp listIndex @@ -389,6 +411,11 @@ export function ListScreen() { {subs.length} total · {activeCount} active + {getActiveProfileName() && ( + + {" "}● {getActiveProfileName()} + + )} {state.filterText && ( {" ▶ "} diff --git a/subtrack/src/types.ts b/subtrack/src/types.ts index 6c9dbb3..80cc1a0 100644 --- a/subtrack/src/types.ts +++ b/subtrack/src/types.ts @@ -146,11 +146,21 @@ export type BackupFileInfo = { size: number } +export type ProfileFilter = { + tags?: string[] + status?: Status + paymentMethod?: string +} + export type SubtrackConfig = { defaultCurrency: string monthlyBudget: number theme: string notifyDays: number + /** Saved filter profiles */ + profiles?: Record + /** Currently active profile name */ + activeProfile?: string /** TUI-specific settings (not shown in CLI config commands) */ tui?: { showTagsCol?: boolean