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
This commit is contained in:
null 2026-05-10 00:39:11 -05:00
parent 5f8c366c70
commit bd796d61c0
8 changed files with 206 additions and 34 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }) {
</Select>
</div>
{/* Cycle Type */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Type</Label>
<Select value={cycleType} onValueChange={setCycleType}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="biweekly">Biweekly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annual">Annual</SelectItem>
</SelectContent>
</Select>
</div>
{/* Cycle Day */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Day</Label>
{cycleType === 'monthly' ? (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[...Array(31)].map((_, i) => (
<SelectItem key={i+1} value={String(i+1)}>{i+1}{getOrdinalSuffix(i+1)}</SelectItem>
))}
</SelectContent>
</Select>
) : cycleType === 'weekly' || cycleType === 'biweekly' ? (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monday">Monday</SelectItem>
<SelectItem value="tuesday">Tuesday</SelectItem>
<SelectItem value="wednesday">Wednesday</SelectItem>
<SelectItem value="thursday">Thursday</SelectItem>
<SelectItem value="friday">Friday</SelectItem>
<SelectItem value="saturday">Saturday</SelectItem>
<SelectItem value="sunday">Sunday</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className={inp}
type="text"
placeholder="Day of period"
value={cycleDay}
onChange={e => setCycleDay(e.target.value)}
/>
)}
<p className="text-[10px] text-muted-foreground/70">
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
'Day of the period'}
</p>
</div>
{/* Website */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>

View File

@ -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.' },
],
};

View File

@ -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`);
}
}
];

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.20.7",
"version": "0.20.8",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

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