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,
);