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:
parent
5f8c366c70
commit
bd796d61c0
|
|
@ -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
|
||||
|
|
|
|||
26
FUTURE.md
26
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.20.7",
|
||||
"version": "0.20.8",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue