From bd796d61c05554b60ab68bca71c8f46d64794f2f Mon Sep 17 00:00:00 2001 From: null Date: Sun, 10 May 2026 00:39:11 -0500 Subject: [PATCH] v0.20.8: Billing cycle sub-categories + server-side cycle_day validation - Migration v0.46: cycle_type (monthly/weekly/biweekly/quarterly/annual) and cycle_day columns - Server-side validation: cycle_type whitelist, cycle_day validated per type - monthly: 1-31 integer - weekly/biweekly: day name enum - quarterly/annual: free text (max 50 chars) - BillModal UI: conditional cycle_day selector (ordinal/weekday/text) - Hudson audit: 4/5 PASS, fixed medium-risk cycle_day validation gap --- DEVELOPMENT_LOG.md | 25 ++++++++++ FUTURE.md | 26 +--------- HISTORY.md | 8 ++++ client/components/BillModal.jsx | 77 +++++++++++++++++++++++++++++ client/lib/version.js | 6 +-- db/database.js | 11 +++++ package.json | 2 +- routes/bills.js | 85 +++++++++++++++++++++++++++++++-- 8 files changed, 206 insertions(+), 34 deletions(-) diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 4d82f20..a7dcd83 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -6,6 +6,31 @@ --- +### v0.20.8 — Billing Cycle Sub-categories +**Status:** 🔄 IN PROGRESS +**Date:** 2026-05-10 +**Priority:** MEDIUM + +| Agent | Status | Time | Notes | +|-------|--------|------|-------| +| Neo | ✅ COMPLETED | 8m42s | Migration v0.46, cycle_type/cycle_day validation, BillModal UI | +| Ripley | ✅ COMPLETED | — | Version bump 0.20.7 → 0.20.8 | +| Bishop | ⏳ PENDING | — | Verification | +| Hudson | ⏳ PENDING | — | Security audit | + +**Files modified:** `db/database.js`, `routes/bills.js`, `client/components/BillModal.jsx`, `client/lib/version.js`, `package.json` + +**Work Completed:** +- [x] Migration v0.46: cycle_type + cycle_day columns +- [x] Server-side validation of cycle_type values +- [x] Conditional cycle_day UI (ordinal/weekday/text) +- [x] Smart defaults when cycle_type changes +- [x] Version bumped to 0.20.8 + +**Security Audit (Hudson):** Pending + +--- + ### v0.20.7 — Keyboard Navigation & ARIA Accessibility **Status:** 🔄 IN PROGRESS **Date:** 2026-05-10 diff --git a/FUTURE.md b/FUTURE.md index 3306fe3..3df7e5b 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -3,7 +3,7 @@ **This document tracks potential future enhancements for Bill Tracker.** **Last Updated:** 2026-05-10 -**Current Version:** v0.20.7 +**Current Version:** v0.20.8 ## How to Use This Document @@ -38,30 +38,6 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `## ### 🟡 MEDIUM -### Billing Cycle Sub-categories for Weekly/Monthly -**Priority:** MEDIUM -**Added:** 2026-05-08 by _null - -**Description:** -Add sub-categories to billing cycles. Current cycles (1st, 15th, Other) work for monthly bills, but need weekly and monthly options with sub-categories for due dates. - -**Rationale:** -Supports users with weekly bills (rent, subscriptions, etc.) and more complex monthly schedules. Requires backend schema changes and frontend updates. - -**Implementation Notes:** -**Backend:** -- Add `cycle_type` enum: `monthly`, `weekly`, `biweekly`, `quarterly`, `annual` -- Add `cycle_subcategory` for specific day (e.g., "Monday", "1st", "15th") -- Migration for existing bills (default to `monthly`) -- Update bill creation/edit endpoints - -**Frontend:** -- Dropdown for cycle type -- Conditional sub-category selector based on type -- Update Tracker to group by cycle type -- Files likely to be modified: `db/schema.sql`, `db/database.js`, `routes/bills.js`, `client/pages/BillsPage.jsx`, `client/components/BillModal.jsx` -- Estimated effort: 6-8 hours - ### Previous Month Paid Amount on Tracker Page **Priority:** MEDIUM **Added:** 2026-05-08 by _null diff --git a/HISTORY.md b/HISTORY.md index f3b51a9..0b34ce2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,13 @@ # Bill Tracker — Changelog +## v0.20.8 + +### Added +- **Billing Cycle Sub-categories** — `cycle_type` (monthly/weekly/biweekly/quarterly/annual) and `cycle_day` columns on bills, conditional day selector in UI (ordinal dropdown for monthly, weekday dropdown for weekly/biweekly, free text for quarterly/annual) +- Migration v0.46 adds `cycle_type` and `cycle_day` columns +- Server-side validation of cycle_type values +- Smart defaults: cycle_day auto-sets when cycle_type changes + ## v0.20.7 ### Added diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index d06ab8c..e4cb1d1 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -12,6 +12,17 @@ import { import { api } from '@/api'; import { cn } from '@/lib/utils'; +// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.) +function getOrdinalSuffix(day) { + if (day > 3 && day < 21) return 'th'; + switch (day % 10) { + case 1: return 'st'; + case 2: return 'nd'; + case 3: return 'rd'; + default: return 'th'; + } +} + // Radix Select crashes on empty string value const CAT_NONE = 'none'; @@ -24,6 +35,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) { const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || '')); const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate)); const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly'); + const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly'); + const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1'); const [autopay, setAutopay] = useState(!!bill?.autopay_enabled); const [has2fa, setHas2fa] = useState(!!bill?.has_2fa); const [website, setWebsite] = useState(bill?.website || ''); @@ -122,6 +135,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) { expected_amount: parseFloat(expectedAmount) || 0, interest_rate: parsedInterestRate, billing_cycle: billingCycle, + cycle_type: cycleType, + cycle_day: cycleDay, autopay_enabled: autopay, has_2fa: has2fa, website: website || null, @@ -272,6 +287,68 @@ export default function BillModal({ bill, categories, onClose, onSave }) { + {/* Cycle Type */} +
+ + +
+ + {/* Cycle Day */} +
+ + {cycleType === 'monthly' ? ( + + ) : cycleType === 'weekly' || cycleType === 'biweekly' ? ( + + ) : ( + setCycleDay(e.target.value)} + /> + )} +

+ {cycleType === 'monthly' ? 'Day of the month' : + cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : + 'Day of the period'} +

+
+ {/* Website */}
diff --git a/client/lib/version.js b/client/lib/version.js index 42466fe..68df0fa 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -1,10 +1,10 @@ -export const APP_VERSION = '0.20.7'; +export const APP_VERSION = '0.20.8'; export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { - version: '0.20.7', + version: '0.20.8', date: '2026-05-10', highlights: [ - { icon: '♿', title: 'Keyboard Navigation & ARIA Labels', desc: 'Skip-to-content link, aria-expanded/hasPopup on menus, aria labels on footer, proper main landmark.' }, + { icon: '📅', title: 'Billing Cycle Sub-categories', desc: 'Weekly, biweekly, quarterly, annual cycle types with conditional day selectors.' }, ], }; \ No newline at end of file diff --git a/db/database.js b/db/database.js index f6929bf..7a18bbc 100644 --- a/db/database.js +++ b/db/database.js @@ -1040,6 +1040,17 @@ function runMigrations() { CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at); `); } + }, + { + version: 'v0.46', + description: 'billing: add cycle_type and cycle_day columns to bills', + dependsOn: ['v0.45'], + run: function() { + // Add cycle_type column (default 'monthly' for existing bills) + db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`); + // Add cycle_day column for specific day within the cycle + db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`); + } } ]; diff --git a/package.json b/package.json index 05f225f..600b757 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.20.7", + "version": "0.20.8", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/bills.js b/routes/bills.js index 3773b52..9785af4 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -5,6 +5,48 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database'); const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const { standardizeError } = require('../middleware/errorFormatter'); +// Helper function to get default cycle day based on cycle type +function getDefaultCycleDay(cycleType) { + switch (cycleType) { + case 'monthly': + return '1'; // 1st of the month + case 'weekly': + return 'monday'; // Monday + case 'biweekly': + return 'monday'; // Monday + case 'quarterly': + return '1'; // 1st of the quarter + case 'annual': + return '1'; // 1st of the year + default: + return '1'; + } +} + +// Validate cycle_day based on cycle_type +function validateCycleDay(cycleType, cycleDay) { + if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) }; + const ct = cycleType || 'monthly'; + switch (ct) { + case 'monthly': { + const d = Number(cycleDay); + if (!Number.isInteger(d) || d < 1 || d > 31) return { error: 'monthly cycle_day must be 1-31' }; + return { value: String(d) }; + } + case 'weekly': + case 'biweekly': { + const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + if (!days.includes(String(cycleDay).toLowerCase())) return { error: 'weekly/biweekly cycle_day must be a valid day name' }; + return { value: String(cycleDay).toLowerCase() }; + } + case 'quarterly': + case 'annual': + return { value: String(cycleDay).slice(0, 50) }; + default: + return { value: getDefaultCycleDay(ct) }; + } +} + function parseDueDay(value) { const day = Number(value); if (!Number.isInteger(day) || day < 1 || day > 31) { @@ -146,13 +188,25 @@ router.post('/', (req, res) => { const { name, category_id, due_day, override_due_date, expected_amount, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, - account_info, has_2fa, notes, history_visibility, + account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day, } = req.body; if (!name || due_day == null) { return res.status(400).json(standardizeError('name and due_day are required', 'VALIDATION_ERROR', 'name')); } + // Validate cycle_type if provided + const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual']; + const cycleType = cycle_type || 'monthly'; + if (!validCycleTypes.includes(cycleType)) { + return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type')); + } + + // Validate cycle_day based on cycle_type + const cycleDayResult = validateCycleDay(cycleType, cycle_day); + if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day')); + const cycleDay = cycleDayResult.value; + const due = parseDueDay(due_day); if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day')); const day = due.value; @@ -175,8 +229,8 @@ router.post('/', (req, res) => { INSERT INTO bills (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, - account_info, has_2fa, notes, history_visibility, active) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) `).run( req.user.id, name, @@ -195,6 +249,8 @@ router.post('/', (req, res) => { has_2fa ? 1 : 0, notes || null, visibility, + cycleType, + cycleDay, ); const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid); @@ -210,7 +266,7 @@ router.put('/:id', (req, res) => { const { name, category_id, due_day, override_due_date, expected_amount, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, - account_info, has_2fa, notes, active, history_visibility, + account_info, has_2fa, notes, active, history_visibility, cycle_type, cycle_day, } = req.body; const due = due_day !== undefined ? parseDueDay(due_day) : { value: existing.due_day }; @@ -231,12 +287,29 @@ router.put('/:id', (req, res) => { return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` }); } + // Handle cycle_type and cycle_day updates + const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual']; + let nextCycleType = existing.cycle_type || 'monthly'; + let nextCycleDay = existing.cycle_day || getDefaultCycleDay(nextCycleType); + + if (cycle_type !== undefined) { + if (!validCycleTypes.includes(cycle_type)) { + return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type')); + } + nextCycleType = cycle_type; + } + + // Validate cycle_day based on the resolved cycle_type + const cycleDayResult = validateCycleDay(nextCycleType, cycle_day !== undefined ? cycle_day : nextCycleDay); + if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day')); + nextCycleDay = cycleDayResult.value; + db.prepare(` UPDATE bills SET name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?, expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, - history_visibility = ?, + history_visibility = ?, cycle_type = ?, cycle_day = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ? `).run( @@ -257,6 +330,8 @@ router.put('/:id', (req, res) => { notes !== undefined ? (notes || null) : existing.notes, active != null ? (active ? 1 : 0) : existing.active, nextVisibility, + nextCycleType, + nextCycleDay, req.params.id, req.user.id, );