Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

54 changed files with 4089 additions and 4064 deletions

12
.gitignore vendored
View File

@ -1,15 +1,3 @@
# Private project/agent docs — never commit
DEVELOPMENT_LOG.md
PROJECT.md
STRUCTURE.md
FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
SCRIPTS.md
project-requirements.md
.learnings/
# Dependencies
node_modules/ node_modules/
dist/ dist/
db/*.db db/*.db

View File

@ -1,3 +0,0 @@
# Errors Logged During Phase 1 Verification
No errors encountered during Build-Verify Phase 1.

View File

@ -1,54 +0,0 @@
# Learnings from Phase 1 Verification
## Business Logic Extraction — Verification Summary
### What Was Verified
1. **Build Success**: ✅ `docker build --no-cache -t bill-tracker:local .` completed successfully
- 1764 modules transformed
- Build time: 1.91s
- Output: 35 JS chunks for code splitting
2. **Container Start**: ✅ Container starts cleanly with migrations applied
- All 46 migrations applied correctly
- Database initialization successful
- No errors in startup logs
3. **Services/billsService.js Existance**: ✅ Verified
- All 8 expected exports present:
- `parseDueDay()`
- `parseInterestRate()`
- `validateCycleDay()`
- `getDefaultCycleDay()`
- `validateBillData()`
- `getValidCycleTypes()`
- `VALID_VISIBILITY`
- `validateCycleDayOnly()`
4. **Routes/bills.js Integration**: ✅ Verified
- Imports from `../services/billsService`
- `validateBillData()` call in POST `/api/bills` endpoint
- `validateBillData()` call in PUT `/api/bills/:id` endpoint
- No inline validation logic remaining in routes
### No Errors Found
The business logic extraction is complete and working correctly. All validation logic has been moved from routes to the service layer, maintaining the same behavior.
### Test Notes
- Docker client version (1.42) is older than required (1.44) for docker compose
- Workaround: Used `docker run` directly instead of `docker compose`
- Existing container stopped and removed before starting fresh build
### Files Created
- `.learnings/bishop/ERRORS.md` — Error log (empty - no errors)
- `.learnings/bishop/LEARNINGS.md` — This file
---
**Verified By**: Bishop (subagent)
**Date**: 2026-05-11
**Phase**: 1/4 — Build-Verify
**Version**: v0.24.4

View File

@ -1,9 +0,0 @@
# Bill Tracker - Neo Errors Log
## 2026-05-11 - Phase 1 Business Logic Extraction
### Errors Encountered
- None - extraction completed successfully on first attempt
### Notes
-工程参考手册 does not exist in the project directory (expected to be under `Projects/bill-tracker/`)

View File

@ -1,45 +0,0 @@
# Bill Tracker - Backend Refactoring Learnings
## 2026-05-11 - Phase 1 Business Logic Extraction
### Task
Extract business logic from `routes/bills.js` into a dedicated service layer (`services/billsService.js`).
### Functions Extracted to `services/billsService.js`
- `getDefaultCycleDay(cycleType)` - Returns default cycle day based on cycle type
- `validateCycleDay(cycleType, cycleDay)` - Validates cycle_day based on cycle_type rules
- `parseDueDay(value)` - Parses and validates due_day (must be 1-31 integer)
- `parseInterestRate(value)` - Parses and validates interest_rate (0-100 range)
- `getValidCycleTypes()` - Returns array of valid cycle types
- `validateBillData(data, existingBill)` - Comprehensive validation and normalization for bill create/update
- `validateCycleDayOnly(cycleType, cycleDay)` - Convenience wrapper for cycle_day validation
### Functions Remaining in `routes/bills.js`
- Route handlers only - parse request, call service, send response
- DB queries remain in routes (tightly coupled to HTTP flow, not pure business logic)
- Error formatting with `standardizeError` (HTTP-layer concern)
### Design Decisions
1. **`validateBillData`** - Centralized validation function that handles both create and update scenarios
- Takes optional `existingBill` parameter to support partial updates
- Returns `{ errors, normalized }` structure
- Validates all bill fields including category_id, history_visibility, cycle_type/cycle_day
2. **`getValidCycleTypes()`** - Exported constant array for consistency across files
3. **`VALID_VISIBILITY`** - Exported from service for reuse in other files if needed
### Benefits
- Business logic is now testable in isolation without mocking Express request/response
- Route handlers are thinner and easier to read
- Validation rules are centralized in one place
- Easier to add new bill-related operations without touching routes
### Files Modified
- `routes/bills.js` - Removed ~80 lines of business logic, replaced with service imports and calls
- `services/billsService.js` - New file created with extracted business logic
### No Breaking Changes
- All API endpoints maintain exact same behavior
- Same validation rules applied
- Same error messages returned

View File

@ -1,52 +0,0 @@
# Bill Tracker — Scarlett's Active Notes
**Last updated:** 2026-05-11
## Task 2: RoadmapPage UI — Kanban Priority Lanes
### What Changed
| File | Action | Description |
|------|--------|-------------|
| `client/pages/RoadmapPage.jsx` | **NEW** | Standalone kanban-style roadmap page with 2 tabs (Roadmap + Activity Log) |
| `client/App.jsx` | **MODIFIED** | Added lazy import for RoadmapPage; `/admin/roadmap` route now renders `<RoadmapPage />`; `/admin/about` route uses `<AboutPage />` without admin prop |
| `client/pages/AboutPage.jsx` | **MODIFIED** | Removed `admin` prop, removed `AdminDashboard` import, removed conditional render block — AboutPage is now public-only |
| `client/components/AdminDashboard.jsx` | **DELETED** | Replaced entirely by RoadmapPage |
| `client/components/ui/collapsible.jsx` | **NEW** | shadcn Collapsible component (Radix-based) |
| `tailwind.config.js` | **MODIFIED** | Added `collapsible-down`/`collapsible-up` keyframes and animations |
| `package.json` | **MODIFIED** | Added `@radix-ui/react-collapsible` dependency |
### Architecture
- **RoadmapPage** is a standalone page rendered at `/admin/roadmap` (behind `<RequireAuth role="admin">` + `<AdminShell>`)
- Uses **shadcn Tabs** for Roadmap / Activity Log tab switching
- **Roadmap tab**: 5-column kanban grid on desktop (`lg+`), 2-column on tablet (`smlg`), single column on mobile (`<sm`)
- **Activity Log tab**: Lazy-loaded — only fetches `/api/about-admin/dev-log` when the tab is selected
- **Collapsible cards**: shadcn `Collapsible` with `CollapsibleTrigger` + `CollapsibleContent` — no more `SimpleCollapsible`
- **Expand All / Collapse All** toggle button above the lane grid
- **Page-level scroll only** — no nested `max-h-[500px]` overflow containers
### API Endpoints Used
- `GET /api/about-admin/roadmap``{ version, items: [...], counts: { critical, high, medium, low, niceToHave } }`
- `GET /api/about-admin/dev-log``{ version, entries: [...] }`
### Accessibility
- All collapsible triggers are `<button>` elements (via shadcn Collapsible)
- `aria-expanded` on all collapsible triggers (Radix handles this)
- `aria-label` on priority badges (e.g., "Critical priority")
- `role="region"` + `aria-label` on each priority lane section
- Keyboard-focusable throughout
### Responsive
- Desktop (`lg+`): 5-column grid
- Tablet (`smlg`): 2-column grid (CRITICAL+HIGH | MEDIUM+LOW+NICE TO HAVE)
- Mobile (`<sm`): single column, lanes stack vertically
### Notes
- `api.roadmap()` and `api.devLog()` were already present in `client/api.js`
- AboutPage's `/admin/about` route now shows the same public content (no admin dashboard appended)
- The `aboutAdmin()` API endpoint is still available but no longer called by the frontend for the roadmap view

1372
DEVELOPMENT_LOG.md Normal file

File diff suppressed because it is too large Load Diff

259
FUTURE.md Normal file
View File

@ -0,0 +1,259 @@
# Bill Tracker — Future Improvements
**This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-10
**Current Version:** v0.24.3
## How to Use This Document
This file is a living document. Agents should:
1. Read this file before proposing changes
2. Add new recommendations with priority levels
3. Never add completed items — move those to HISTORY.md instead
4. Reference this file when dispatching improvement tasks
5. Only Ripley can remove items from this list.
### Priority Format
All items must include the priority emoji in their heading, matching the section they belong to:
| Priority | Emoji | Heading Format |
|----------|-------|---------------|
| CRITICAL | 🔴 | `### 🔴 Title — CRITICAL` |
| HIGH | 🟠 | `### 🟠 Title — HIGH` |
| MEDIUM | 🟡 | `### 🟡 Title — MEDIUM` |
| LOW | 🔵 | `### 🔵 Title — LOW` |
| NICE TO HAVE | 💭 | `### 💭 Title — NICE TO HAVE` |
Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `## 🟠 HIGH`, etc.) and sorted most-impactful-first within each tier.
## Pending Recommendations
### 🔴 CRITICAL
### ~~🔴 Notification Runner Leaks Bill Details Across Users — CRITICAL~~ ✅ FIXED (v0.23.2)
**Moved to HISTORY.md**
### 🟠 HIGH
### ~~🟠 Admin Can Toggle Payments on Any User Bill — HIGH~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟠 Analytics Validation Errors Crash Instead of Returning 400 — HIGH~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟠 User Export Drops Recurrence and History-Range Data — HIGH~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟠 Single-User Mode Can Lock Itself Out When Expired Sessions Exist — HIGH~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### 🟡 MEDIUM
### ~~🟡 Password Change Rate Limiter Applies to Every Profile Endpoint — MEDIUM~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟡 Profile Password Change Does Not Invalidate Other Sessions — MEDIUM~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟡 CSRF Defaults Conflict with SPA Token Loading — MEDIUM~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟡 Change-Password Routes Are Globally Exempted from CSRF — MEDIUM~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟡 Notification Due-Day Math Can Miss Same-Day Reminders — MEDIUM~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### ~~🟡 Upcoming Bills Allows Negative Day Windows — MEDIUM~~ ✅ FIXED (v0.24.0)
**Moved to HISTORY.md**
### Architecture: Business Logic Mixed with Route Handlers
**Priority:** MEDIUM
**Added:** 2026-05-08 by Neo
**Description:**
Many routes contain business logic that should be extracted to service layers.
**Rationale:**
- `bills.js` contains `parseDueDay()`, `parseInterestRate()` — validation logic
- `tracker.js` contains date/range calculations that are reused across routes
- `admin.js` has complex OIDC config building mixed with routing
- `analytics.js` has complex date-building logic (`buildMonths`, `monthKey`, etc.)
**Implementation Notes:**
- Files to modify: Multiple route files + new service files in `/services/`
- Estimated effort: 8 hours
- Proposed structure:
```
/services/billsService.js
/services/trackerService.js
/services/analyticsService.js
/services/authService.js (existing)
/services/oidcService.js (existing)
/services/cleanupService.js (existing)
```
- Route handlers should call services, not contain business logic
### ~~Skip First-Login User Creation When ENV Seeds Users~~ ✅ COMPLETED (v0.22.3)
**Moved to HISTORY.md**
### ~~No Rollback Capability for Failed Migrations~~ ✅ COMPLETED (v0.23.1)
**Moved to HISTORY.md**
### ~~Limited Error Handling and Logging for Migrations~~ ✅ COMPLETED (v0.23.0)
**Moved to HISTORY.md**
**Rationale:**
- Migration errors are silent or unclear
- No logging of which migration failed or why
- No way to diagnose schema inconsistencies
- Risk: slow debugging on production issues
**Implementation Notes:**
- Add detailed logging: `[migration] Applying v0.20.0: Add user_groups table`
- Include timing: `[migration] v0.20.0 completed in 234ms`
- Log precondition checks: `[migration] Checking: table_exists('users')`
- Error log with context: `[migration-error] v0.20.0 failed: UNIQUE constraint failed on users.username`
---
### 🔵 LOW
### ~~🔵 Export Formats Include Sensitive Bill Credential Fields by Default — LOW~~ ✅ FIXED (v0.24.1)
**Moved to HISTORY.md**
### ~~🔵 Duplicate Local Login Route Increases Auth Drift Risk — LOW~~ ✅ FIXED (v0.23.2)
**Moved to HISTORY.md**
### Add comprehensive unit and integration tests
**Priority:** LOW
**Added:** 2026-05-08 by Scarlett
**Description:**
Currently no unit tests exist for components or hooks. The only testing appears to be functional tests in `test-functional.js`. Component-level testing is missing.
**Rationale:**
Code quality and maintainability. Unit tests catch regressions and document component behavior. Bill Tracker has complex business logic (bill calculations, monthly state, analytics) that should be tested.
**Implementation Notes:**
- Set up Jest + React Testing Library
- Test key components: BillModal, TrackerPage row, BillsTableInner
- Test hooks: useAuth, custom form hooks
- Test utility functions in `client/lib/utils.js`
- Consider vitest for faster test execution
- Add CI integration for test execution
- Files likely to be modified: Add `client/test/` directory, add `jest.config.cjs`
- Estimated effort: 8-12 hours for baseline coverage
### Features: Missing Export for User-Specific Reports
**Priority:** LOW
**Added:** 2026-05-08 by Neo
**Description:**
No built-in way to export filtered data (e.g., "all bills in category X for last 6 months").
**Rationale:**
- `/api/analytics/summary` exists but returns JSON only
- Users cannot generate Excel/PDF reports
- No programmatic way to get export links for specific filters
- `/api/export/user-excel` exports everything, not filtered views
**Implementation Notes:**
- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/routes/export.js`
- Estimated effort: 6 hours
- Add endpoints:
- `GET /api/export/user-excel?category_id=1&start=2026-01&end=2026-06`
- `GET /api/export/user-json?filter=bills&status=missed`
- Add report title/description to export metadata
### Features: Missing Bill Grouping and Reorganization API
**Priority:** LOW
**Added:** 2026-05-08 by Neo
**Description:**
No way to reorder bills, drag-and-drop, or group by custom criteria.
**Rationale:**
- `bills` table has `due_day` ordering but no manual sort order
- Frontend likely orders by `due_day` only
- Users cannot create bill groups or categories for bills
- No way to mark bills as "hidden" or "archived" without deactivating
**Implementation Notes:**
- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/db/schema.sql`, `/routes/bills.js`
- Estimated effort: 6 hours
- Add:
- `sort_order` column to bills table (default NULL, ordered first by sort_order then due_day)
- `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}`
- `PUT /api/bills/:id/archived` to soft-dearchive (sets `archived` flag)
---
### 💭 NICE TO HAVE
### Add consistent form state management pattern
**Priority:** MEH
**Added:** 2026-05-08 by Scarlett
**Description:**
Form state management is inconsistent across components. Some use `useState` for each field, others use form libraries. Validation patterns vary.
**Rationale:**
Consistency and maintainability. A consistent pattern makes it easier to add new forms and reduce bugs.
**Implementation Notes:**
- Consider react-hook-form for complex forms
- Create reusable form field components (InputField, SelectField, etc.)
- Standardize validation approach
- Files likely to be modified: `client/components/*.jsx`
- Estimated effort: 4-6 hours for migration
---
## Template for New Recommendations
```markdown
### [Feature Name]
**Priority:** CRITICAL / HIGH / MEDIUM / LOW / MEH
**Added:** YYYY-MM-DD by [Agent]
**Description:**
Brief description of the improvement.
**Rationale:**
Why this matters.
**Implementation Notes:**
- Technical approach
- Files likely to be modified
- Estimated effort
**Depends On:**
Any prerequisites or blocking issues.
```
## Completed Items
### ✅ Security: Rate Limiting on /api/about-admin — MEDIUM
**Completed:** 2026-05-09 (v0.19.0)
**Fix:** `adminActionLimiter` (30 req/15min) applied to `/api/about-admin` route.
### ✅ Security: Markdown Sanitization in AboutPage — MEDIUM
**Completed:** 2026-05-09 (v0.19.0)
**Fix:** `rehype-sanitize` added to `AboutPage.jsx` ReactMarkdown component.
### ✅ Security: aboutAdmin() in API Client — LOW
**Completed:** 2026-05-09 (v0.19.0)
**Fix:** `aboutAdmin` endpoint function added to `client/api.js`.
---

1249
HISTORY.md Normal file

File diff suppressed because it is too large Load Diff

277
STRUCTURE.md Normal file
View File

@ -0,0 +1,277 @@
# Bill Tracker Project Structure
## Project Overview
Bill Tracking Website — Full-stack application with Node.js backend and React frontend.
## Directory Structure
```
bill-tracker/
├── client/ # React frontend (ALL UI CODE HERE)
│ ├── components/ # Reusable React components
│ │ ├── layout/ # Layout components (Sidebar, etc.)
│ │ └── ui/ # UI components (buttons, inputs, etc.)
│ ├── pages/ # Page components (one per route)
│ │ ├── TrackerPage.jsx
│ │ ├── BillsPage.jsx
│ │ ├── CategoriesPage.jsx
│ │ ├── CalendarPage.jsx
│ │ ├── SummaryPage.jsx
│ │ ├── AnalyticsPage.jsx
│ │ ├── ProfilePage.jsx
│ │ ├── SettingsPage.jsx
│ │ ├── DataPage.jsx
│ │ ├── AdminPage.jsx
│ │ ├── LoginPage.jsx
│ │ └── AboutPage.jsx
│ ├── hooks/ # Custom React hooks (useAuth, etc.)
│ ├── api.js # API client functions
│ ├── App.jsx # React Router configuration
│ ├── main.jsx # React entry point
│ └── index.html # HTML template
├── server.js # Express backend entry
├── routes/ # API route handlers
├── services/ # Business logic layer
├── middleware/ # Express middleware
├── db/ # Database schemas/migrations
├── workers/ # Background job workers
├── scripts/ # Utility scripts
├── docs/ # Documentation
├── dist/ # Build output (generated)
├── public/ # Static assets
├── Dockerfile # Container config
└── docker-compose.yml
```
## Critical Notes for Agents
### Frontend Code Location
**ALL React components, pages, and UI code are in `client/` folder.**
- Pages: `client/pages/*.jsx`
- Components: `client/components/**/*.jsx`
- Hooks: `client/hooks/*.js`
- API client: `client/api.js`
- Router: `client/App.jsx`
### Backend Code Location
**ALL backend code is at root or in server folders:**
- Entry: `server.js`
- Routes: `routes/*.js`
- Services: `services/*.js`
- Middleware: `middleware/*.js`
- Database: `db/*.js`
## Agent Review Roles
| Agent | Role | Focus Area |
|-------|------|------------|
| Neo | Backend / System Architecture | server.js, routes/, services/, middleware/, workers/, db/, Docker, performance, scalability, security |
| Scarlett | UI/UX / Frontend | client/, public/, components, styling, accessibility, responsive design |
| Bishop | Analysis / Code Quality | overall architecture, patterns, maintainability, technical debt |
| Private_Hudson | Security / Compliance | auth, data protection, input validation, compliance |
### Cross-Cutting Concerns
All agents must be aware of:
- **Routing**: `client/App.jsx` defines all frontend routes
- **Auth**: `client/hooks/useAuth.jsx` and `services/authService.js`
- **API**: `client/api.js` mirrors `routes/` structure
- **Database**: `db/database.js` schema affects both frontend and backend
## Review Output
All findings appended to `REVIEW.md` per agent section.
# OpenClaw Agent Structure
## Prime
Role:
* executive coordinator
* project strategist
* Discord command interface
Responsibilities:
* **Overall Oversight:** Must maintain high-level awareness of all concurrent projects, ensuring every agent's output aligns with the goal set in `projects-requirements.md`.
* **Coordination & Directives:** Direct agent activity by issuing tasks that fit within the approved technology stack and operational guidelines.
* **Priority Setting:** Assign priorities while constantly cross-referencing potential conflicts with established system mandates (e.g., Security > Performance > Feature).
* **Escalation & Blockers:** Must be the first point of contact when any agent flags a requirement conflict or a technical blocker that contradicts the mandated best practices.
* **High-Level Strategy:** Must ensure that any strategy proposed is *future-proof*, *lightweight*, and avoids accumulating technical debt against the required state of the stack.
* **Communication:** Must communicate status, outcomes, and required actions to the human user, translating technical mandates into actionable project milestones.
Authority:
* project coordination and task routing.
* Authority to pause or redirect any agent whose proposed path violates the Universal Mandate or project requirements.
---
## Riply
Role:
* operations
* infrastructure
* runtime management
Responsibilities:
* deployment oversight, adhering to stability and resilience standards (per `projects-requirements.md`).
* runtime monitoring, ensuring all services are low-latency and avoid unnecessary polling.
* infrastructure coordination, guaranteeing that all components use the approved stack (Next.js, React, etc.).
* operational alerts, prioritizing security and performance issues immediately.
* service stability, adhering to the "fail gracefully" principle.
* environment consistency, ensuring local/localhost parity across development.
* Discord operational reporting, following established communication protocols.
Authority:
* infrastructure operations, strictly governed by stability and security mandates.
* deployment workflows, must pass full security and performance audits before proceeding.
* runtime diagnostics, must use established, non-bloated tooling.
* operational communication, must be precise and action-oriented.
---
## Neo
Role:
* senior backend developer
* backend architecture lead
Responsibilities:
* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for all technology choices and operational philosophies.
* **Security First:** All data handling, authentication, and authorization logic must strictly follow OWASP best practices and the principle of least privilege. No assumption of trust.
* **Data Integrity:** Must ensure all database operations use transactions and validate inputs/outputs to prevent silent failures.
* **Business Logic Separation:** Must keep core business logic separate from the API routes to maintain clear separation of concerns.
* **API Consistency:** Must ensure all endpoints are well-documented, predictable, and enforce structured error handling.
* **Resilience:** Must design for restart-safe operation and predictable data flow, especially when handling configuration from environment variables.
Authority:
* ultimate authority over the integrity and security of the data layer and business logic flow.
* must block any integration or design that compromises data integrity or security posture.
---
## Scarlett
Role:
* frontend developer
Responsibilities:
* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for UI/UX.
* **Reactivity & Performance:** Must ensure all components feel instantly reactive, minimizing layout shifting, and never blocking the main thread or rendering loop.
* **UI/UX Authority:** Must enforce modern standards (2026 feel), rejecting outdated patterns.
* **Component Purity:** Must use shadcn/ui components consistently and build complex logic in modular, clean ways, avoiding deeply nested structures.
* **Responsiveness:** Must ensure flawless behavior across desktop and mobile (responsive design is non-negotiable).
* **Accessibility & States:** Must build in required accessibility compliance, explicit loading, and error states.
* **Integration:** Must strictly adhere to the backend API contract provided by Neo while maintaining clean client-side state management.
Technology Focus:
* **React with Vite** is the frontend framework (NOT Next.js — never suggest Next.js patterns).
* **Tailwind CSS** must be used predictably to maintain consistency.
* **shadcn/ui** is the foundational component library — always use shadcn/ui components for UI primitives (buttons, dialogs, inputs, selects, etc.). Do not build custom components when shadcn/ui provides one.
* **Sonner** is used for toast notifications.
Authority:
* UI architecture and frontend interaction flows.
* Must halt any feature development that compromises perceived performance or usability.
---
## Bishop
Role:
* code reviewer
* architecture validator
Responsibilities:
* Must enforce adherence to `projects-requirements.md` standards across the entire lifecycle.
* **Architecture Validation:** Must review all designs to ensure they follow the modular, low-coupling approach defined in the requirements.
* **Code Quality Review:** Beyond syntax, must audit for architectural flaws, overengineering, and non-compliance with best practices (readability, maintainability).
* **Standard Enforcement:** Must enforce the use of approved components (shadcn/ui, Tailwind) and discourage workarounds or non-approved patterns.
* **Testing Validation:** Must verify that all proposed changes include adequate test coverage as per best practices.
* **Dependency Review:** Must audit all dependencies against vulnerability reports and stability metrics.
* **Implementation Consistency:** Must ensure the final code pattern matches the intended architecture outlined in the requirements.
* **Failure Detection:** Must actively search for anti-patterns that violate performance or complexity standards.
Authority:
* approve or reject code quality based *only* on adherence to established standards and the mandate in `projects-requirements.md`.
* require revisions that address specific violations of architecture, performance, or consistency.
* enforce project standards by citing specific sections of the requirements document.
---
## Private Hudson
Role:
* security reviewer
* defensive operations specialist
Responsibilities:
* OWASP validation
* authentication security review
* authorization validation
* dependency vulnerability auditing
* secret exposure detection
* injection vulnerability analysis
* security hardening review
* infrastructure security analysis
* runtime security assessment
Authority:
* approve or reject security posture
* block insecure deployments
* require remediation before release
---
## Universal Mandate
**All agents are governed by the guidelines set in `projects-requirements.md`.** Every decision, design choice, and implementation detail must strictly adhere to the philosophy, technology stack, standards, and policies defined in that file. Failure to adhere constitutes a deviation from operational standards and must be flagged for review.
**Mandatory Adherence Checklist:**
1. **Always** refer to `projects-requirements.md` for the definitive ruleset.
2. Never implement functionality that contradicts the approved Tech Stack (Next.js App Router, React, Tailwind CSS, shadcn/ui, SQLite).
3. Treat security and performance checks (per `projects-requirements.md`) as *primary* considerations, not secondary checks.
---
## Technology Stack
Bill Tracker actual stack:
* **Vite** (build tool, NOT Next.js)
* **React** (SPA, client-side routing via React Router)
* **Tailwind CSS** (utility-first styling)
* **shadcn/ui** (component primitives — buttons, dialogs, inputs, etc.)
* **Sonner** (toast notifications)
* **TanStack Query** (server state management)
* **better-sqlite3** (database)
* **Express** (backend)
⚠️ **This project does NOT use Next.js.** Do not suggest Next.js patterns (App Router, server components, etc.).
Development target:
* localhost based development
* modular architecture
* maintainable systems
* production ready implementation
---
*Generated by Prime for multi-agent review*

View File

@ -35,10 +35,8 @@ const StatusPage = lazy(() => import('@/pages/StatusPage'));
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage')); const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage'));
const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage')); const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
const AboutPage = lazy(() => import('@/pages/AboutPage')); const AboutPage = lazy(() => import('@/pages/AboutPage'));
const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage')); const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
function RequireAuth({ children, role }) { function RequireAuth({ children, role }) {
const { user, singleUserMode } = useAuth(); const { user, singleUserMode } = useAuth();
@ -128,7 +126,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<AdminShell> <AdminShell>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<AboutPage /> <AboutPage admin />
</Suspense> </Suspense>
</AdminShell> </AdminShell>
</ErrorBoundary> </ErrorBoundary>
@ -142,7 +140,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<AdminShell> <AdminShell>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<RoadmapPage /> <AboutPage admin />
</Suspense> </Suspense>
</AdminShell> </AdminShell>
</ErrorBoundary> </ErrorBoundary>
@ -186,7 +184,6 @@ export default function App() {
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} /> <Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} />
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} /> <Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} /> <Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} /> <Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} /> <Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@ -73,7 +73,6 @@ export const api = {
runAdminCleanup: () => post('/admin/cleanup/run'), runAdminCleanup: () => post('/admin/cleanup/run'),
seedDemoData: () => post('/user/seed-demo-data'), seedDemoData: () => post('/user/seed-demo-data'),
clearDemoData: () => post('/user/clear-demo-data'), clearDemoData: () => post('/user/clear-demo-data'),
seededStatus: () => get('/user/seeded-status'),
downloadAdminBackup: async (id) => { downloadAdminBackup: async (id) => {
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
credentials: 'include', credentials: 'include',
@ -92,7 +91,7 @@ export const api = {
const res = await fetch('/api/admin/backups/import', { const res = await fetch('/api/admin/backups/import', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() }, headers: { 'Content-Type': 'application/octet-stream' },
body: file, body: file,
}); });
const data = await res.json(); const data = await res.json();
@ -142,8 +141,6 @@ export const api = {
bill: (id) => get(`/bills/${id}`), bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data), updateBill: (id, data) => put(`/bills/${id}`, data),
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`), deleteBill: (id) => del(`/bills/${id}`),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
@ -162,13 +159,6 @@ export const api = {
deletePayment: (id) => del(`/payments/${id}`), deletePayment: (id) => del(`/payments/${id}`),
restorePayment: (id) => post(`/payments/${id}/restore`), restorePayment: (id) => post(`/payments/${id}/restore`),
// Snowball
snowball: () => get('/snowball'),
snowballSettings: () => get('/snowball/settings'),
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: () => get('/snowball/projection'),
// Categories // Categories
categories: () => get('/categories'), categories: () => get('/categories'),
createCategory: (data) => post('/categories', data), createCategory: (data) => post('/categories', data),
@ -195,8 +185,6 @@ export const api = {
// Version (public) // Version (public)
about: () => get('/about'), about: () => get('/about'),
aboutAdmin: () => get('/about-admin'), aboutAdmin: () => get('/about-admin'),
roadmap: () => get('/about-admin/roadmap'),
devLog: () => get('/about-admin/dev-log'),
version: () => get('/version'), version: () => get('/version'),
releaseHistory: () => get('/version/history'), releaseHistory: () => get('/version/history'),
@ -215,7 +203,6 @@ export const api = {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
...(file.name ? { 'X-Filename': file.name } : {}), ...(file.name ? { 'X-Filename': file.name } : {}),
}, },
body: file, body: file,
@ -241,7 +228,6 @@ export const api = {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
...(file.name ? { 'X-Filename': file.name } : {}), ...(file.name ? { 'X-Filename': file.name } : {}),
}, },
body: file, body: file,

View File

@ -0,0 +1,444 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChevronDown } from 'lucide-react';
import { APP_VERSION } from '@/lib/version';
/**
* Simple Collapsible Component (no external dependencies)
*/
function SimpleCollapsible({ defaultOpen = false, children, title }) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="mb-3 group">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-muted/30 transition-colors rounded-t-xl"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center gap-2">
{title}
</div>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
{isOpen && (
<div className="border-x border-b border-border/70 rounded-b-xl bg-background/65 p-3">
{children}
</div>
)}
</div>
);
}
// Priority mapping for color coding
const PRIORITY_COLORS = {
'🔴': { bg: 'bg-red-500/10', border: 'border-l-4 border-red-500', text: 'text-red-600', label: 'CRITICAL' },
'🟠': { bg: 'bg-orange-500/10', border: 'border-l-4 border-orange-500', text: 'text-orange-600', label: 'HIGH' },
'🟡': { bg: 'bg-yellow-500/10', border: 'border-l-4 border-yellow-500', text: 'text-yellow-600', label: 'MEDIUM' },
'🔵': { bg: 'bg-blue-500/10', border: 'border-l-4 border-blue-500', text: 'text-blue-600', label: 'LOW' },
'💭': { bg: 'bg-gray-500/10', border: 'border-l-4 border-gray-500', text: 'text-gray-600', label: 'NICE TO HAVE' },
};
/**
* Parse FUTURE.md content into structured roadmap items
*/
function parseFutureMarkdown(markdown) {
const items = [];
const lines = markdown.split('\n');
let currentPriority = null;
let currentItem = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Priority section header: ## 🔴 CRITICAL
if (line.startsWith('## 🔴') || line.startsWith('## 🟠') ||
line.startsWith('## 🟡') || line.startsWith('## 🔵') ||
line.startsWith('## 💭')) {
const match = line.match(/##\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE)/);
if (match) {
currentPriority = match[1];
}
continue;
}
// Item header: ### 🔴 Title CRITICAL
if (line.startsWith('### 🔴') || line.startsWith('### 🟠') ||
line.startsWith('### 🟡') || line.startsWith('### 🔵') ||
line.startsWith('### 💭')) {
if (currentItem) {
items.push(currentItem);
}
const match = line.match(/###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*(—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE))?/);
if (match) {
currentItem = {
priority: match[1],
title: match[2].trim(),
description: '',
status: 'PENDING',
added: '',
addedBy: '',
priorityLabel: match[4] || matchPriorityToLabel(match[1])
};
}
continue;
}
// Parse item content
if (currentItem && line) {
if (line.startsWith('**Status:**')) {
currentItem.status = line.replace('**Status:**', '').trim();
}
else if (line.startsWith('**Added:**')) {
const dateMatch = line.match(/(\d{4}-\d{2}-\d{2})/);
if (dateMatch) {
currentItem.added = dateMatch[1];
}
const byMatch = line.match(/by\s+(.+)/);
if (byMatch) {
currentItem.addedBy = byMatch[1];
}
}
else if (!line.startsWith('**') || line.startsWith('**Description:**') || line.startsWith('**Rationale:**') || line.startsWith('**Implementation Notes:**')) {
currentItem.description += line + '\n';
}
}
}
if (currentItem) {
items.push(currentItem);
}
return items;
}
/**
* Map priority emoji to label
*/
function matchPriorityToLabel(emoji) {
const mapping = {
'🔴': 'CRITICAL',
'🟠': 'HIGH',
'🟡': 'MEDIUM',
'🔵': 'LOW',
'💭': 'NICE TO HAVE'
};
return mapping[emoji] || 'UNKNOWN';
}
/**
* Priority Badge Component
*/
function PriorityBadge({ emoji, label }) {
const colors = PRIORITY_COLORS[emoji] || PRIORITY_COLORS['💭'];
return (
<Badge variant="outline" className={`${colors.bg} ${colors.text} border-0 font-semibold px-2`}>
{emoji} {label}
</Badge>
);
}
/**
* Roadmap Card Component
*/
function RoadmapCard({ item }) {
const colors = PRIORITY_COLORS[item.priority] || PRIORITY_COLORS['💭'];
const isHighPriority = item.priority === '🔴' || item.priority === '🟠';
return (
<SimpleCollapsible defaultOpen={isHighPriority} title={
<div className="flex items-center gap-2">
<PriorityBadge emoji={item.priority} label={item.priorityLabel} />
<span className="font-medium text-sm">{item.title}</span>
</div>
}>
<div className="space-y-2">
<div className="flex flex-wrap gap-2 text-xs">
{item.status && (
<Badge variant="secondary" className="bg-muted/50">
Status: {item.status}
</Badge>
)}
{item.added && (
<span className="text-muted-foreground flex items-center gap-1">
Added: {item.added}
</span>
)}
{item.addedBy && (
<span className="text-muted-foreground flex items-center gap-1">
by {item.addedBy}
</span>
)}
</div>
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
<div className="whitespace-pre-wrap text-muted-foreground">
{item.description}
</div>
</div>
</div>
</SimpleCollapsible>
);
}
/**
* Development Log Entry Component
*/
function DevLogEntry({ entry }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mb-4 rounded-xl border border-border/70 bg-background/65 shadow-sm overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-sm">{entry.version}</span>
<span className="text-xs text-muted-foreground">{entry.date}</span>
</div>
<div className="flex items-center gap-3">
{entry.status && (
<Badge
variant="outline"
className={entry.status.includes('COMPLETED')
? 'bg-green-500/10 text-green-600 border-green-500/20'
: 'bg-muted/50 text-muted-foreground'}
>
{entry.status}
</Badge>
)}
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</div>
{isOpen && (
<div className="px-4 pb-3 pt-1 border-t border-border/70 space-y-2">
{entry.agents && entry.agents.length > 0 && (
<div className="flex flex-wrap gap-2 text-xs">
{entry.agents.map((agent, idx) => (
<span key={idx} className="text-muted-foreground">
{agent.status === 'COMPLETED' && '✅ '}
{agent.name}: {agent.notes}
</span>
))}
</div>
)}
{entry.filesModified && entry.filesModified.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Files Modified:</p>
<div className="flex flex-wrap gap-1">
{entry.filesModified.map((file, idx) => (
<code key={idx} className="text-xs bg-muted/50 px-1.5 py-0.5 rounded text-muted-foreground">
{file}
</code>
))}
</div>
</div>
)}
{entry.details && (
<div className="prose prose-sm dark:prose-invert max-w-none mt-2">
<div className="whitespace-pre-wrap text-sm text-muted-foreground">
{entry.details}
</div>
</div>
)}
</div>
)}
</div>
);
}
/**
* Parse DEVELOPMENT_LOG.md content
*/
function parseDevLogMarkdown(markdown) {
const entries = [];
const sections = markdown.split('---');
for (const section of sections) {
if (!section.trim()) continue;
if (section.includes('Current Work') && !section.includes('Status:')) continue;
if (section.includes('Completed Work') && !section.includes('Date:')) continue;
const versionMatch = section.match(/v(\d+\.\d+\.\d+)/);
const dateMatch = section.match(/(\d{4}-\d{2}-\d{2})/);
if (versionMatch || dateMatch) {
const entry = {
version: versionMatch ? `v${versionMatch[1]}` : 'Unknown',
date: dateMatch ? dateMatch[0] : 'Unknown',
agents: [],
filesModified: [],
status: 'UNKNOWN',
details: section.trim(),
};
// Try to extract agent info from table-like format
// Example: "Neo | COMPLETED | 1m 38s | Added `run()` functions..."
const agentLines = section.split('\n').filter(line =>
line.includes('|') && (line.includes('✅') || line.includes('❌') || line.includes('⏳') || line.includes('⚠️'))
);
for (const agentLine of agentLines) {
const parts = agentLine.split('|').map(p => p.trim());
if (parts.length >= 4) {
entry.agents.push({
name: parts[0],
status: parts[1],
time: parts[2],
notes: parts.slice(3).join('|'),
});
}
}
// Extract files modified
const filesMatch = section.match(/Files Modified:\s*(.*)/);
if (filesMatch) {
entry.filesModified = filesMatch[1].split(',').map(f => f.trim());
}
// Extract status from headers
if (section.includes('COMPLETED')) {
entry.status = 'COMPLETED';
} else if (section.includes('In Progress') || section.includes('IN PROGRESS')) {
entry.status = 'IN PROGRESS';
}
entries.push(entry);
}
}
// Sort by date descending (most recent first)
entries.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB - dateA;
});
return entries;
}
/**
* Admin Dashboard Component
*/
export default function AdminDashboard({ about }) {
const [roadmapItems, setRoadmapItems] = useState([]);
const [devLogEntries, setDevLogEntries] = useState([]);
const [loading, setLoading] = useState(true);
const version = about?.version || APP_VERSION;
const parseData = useCallback(() => {
setLoading(true);
try {
if (about?.future) {
const roadmap = parseFutureMarkdown(about.future);
setRoadmapItems(roadmap);
}
if (about?.developmentLog) {
const logs = parseDevLogMarkdown(about.developmentLog);
setDevLogEntries(logs);
}
} finally {
setLoading(false);
}
}, [about]);
useEffect(() => { parseData(); }, [parseData]);
if (loading) {
return (
<div className="space-y-4">
<div className="h-8 w-48 bg-muted rounded animate-pulse" />
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="h-4 bg-muted rounded animate-pulse" />
</div>
);
}
return (
<div className="space-y-6">
{/* Version Badge */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono">
v{version}
</Badge>
</div>
{/* Roadmap Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
🗺
</span>
Roadmap
</CardTitle>
<CardDescription>
Current and upcoming features organized by priority
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{roadmapItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No roadmap items found
</div>
) : (
<div className="max-h-[500px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
<div className="space-y-2">
{roadmapItems.map((item, idx) => (
<RoadmapCard key={idx} item={item} />
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Activity Log Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
📝
</span>
Development Activity Log
</CardTitle>
<CardDescription>
Recent development work and completed tasks
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{devLogEntries.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No activity log entries found
</div>
) : (
<div className="max-h-[500px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
<div className="space-y-2">
{devLogEntries.map((entry, idx) => (
<DevLogEntry key={idx} entry={entry} />
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -13,6 +12,7 @@ import {
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
function getOrdinalSuffix(day) { function getOrdinalSuffix(day) {
if (day > 3 && day < 21) return 'th'; if (day > 3 && day < 21) return 'th';
switch (day % 10) { switch (day % 10) {
@ -26,14 +26,6 @@ function getOrdinalSuffix(day) {
// Radix Select crashes on empty string value // Radix Select crashes on empty string value
const CAT_NONE = 'none'; const CAT_NONE = 'none';
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
function isDebtCat(categories, catId) {
if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId);
return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
}
export default function BillModal({ bill, categories, onClose, onSave }) { export default function BillModal({ bill, categories, onClose, onSave }) {
const isNew = !bill; const isNew = !bill;
@ -51,23 +43,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const [username, setUsername] = useState(bill?.username || ''); const [username, setUsername] = useState(bill?.username || '');
const [accountInfo, setAccountInfo] = useState(bill?.account_info || ''); const [accountInfo, setAccountInfo] = useState(bill?.account_info || '');
const [notes, setNotes] = useState(bill?.notes || ''); const [notes, setNotes] = useState(bill?.notes || '');
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance));
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt);
const [showDebtSection, setShowDebtSection] = useState(
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE)
|| !!bill?.snowball_include
|| !!bill?.snowball_exempt
|| bill?.current_balance != null
|| bill?.minimum_payment != null
);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Validation state
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId); // Real-time validation helpers
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
const validateName = (val) => { const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required'; if (!val || val.trim() === '') return 'Name is required';
if (val.trim().length < 2) return 'Name must be at least 2 characters'; if (val.trim().length < 2) return 'Name must be at least 2 characters';
@ -96,69 +77,44 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
return ''; return '';
}; };
const validateCurrentBalance = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Balance must be a non-negative number';
return '';
};
const validateMinimumPayment = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number';
return '';
};
const validateForm = () => { const validateForm = () => {
const newErrors = { const newErrors = {
name: validateName(name), name: validateName(name),
dueDay: validateDueDay(dueDay), dueDay: validateDueDay(dueDay),
expectedAmount: validateExpectedAmount(expectedAmount), expectedAmount: validateExpectedAmount(expectedAmount),
interestRate: validateInterestRate(interestRate), interestRate: validateInterestRate(interestRate),
currentBalance: validateCurrentBalance(currentBalance),
minimumPayment: validateMinimumPayment(minimumPayment),
}; };
setErrors(newErrors); setErrors(newErrors);
return Object.values(newErrors).every(err => err === ''); return Object.values(newErrors).every(err => err === '');
}; };
// Validation on blur
const handleBlur = (field, validator) => { const handleBlur = (field, validator) => {
setErrors(prev => ({ ...prev, [field]: validator( setErrors(prev => ({ ...prev, [field]: validator(field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate) }));
field === 'name' ? name :
field === 'dueDay' ? dueDay :
field === 'expectedAmount' ? expectedAmount :
interestRate
)}));
}; };
const handleCategoryChange = (val) => { // Validation on change - debounce for better UX
setCategoryId(val); const handleChange = (field, value, validator) => {
if (isDebtCat(categories, val)) { if (field === 'name') setName(value);
setShowDebtSection(true); if (field === 'dueDay') setDueDay(value);
} else { if (field === 'expectedAmount') setExpected(value);
setSnowballExempt(false); if (field === 'interestRate') setInterestRate(value);
} // Only validate after input, not every keystroke
}; setTimeout(() => {
setErrors(prev => ({ ...prev, [field]: validator(value) }));
const handleSnowballVisibilityChange = (checked) => { }, 300);
if (checked) {
setSnowballExempt(false);
setSnowballInclude(!isDebtCategory);
} else {
setSnowballInclude(false);
setSnowballExempt(isDebtCategory);
}
}; };
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
// Run form validation
if (!validateForm()) { if (!validateForm()) {
toast.error('Please fix the form errors before saving.'); toast.error('Please fix the form errors before saving.');
return; return;
} }
// Additional server-side validation checks
const parsedDueDay = Number(dueDay); const parsedDueDay = Number(dueDay);
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) { if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) {
toast.error('Due day must be a whole number from 1 to 31.'); toast.error('Due day must be a whole number from 1 to 31.');
@ -187,10 +143,6 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
username: username || null, username: username || null,
account_info: accountInfo || null, account_info: accountInfo || null,
notes: notes || null, notes: notes || null,
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
snowball_include: snowballInclude,
snowball_exempt: snowballExempt,
}; };
setBusy(true); setBusy(true);
try { try {
@ -246,7 +198,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
{/* Category */} {/* Category */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label> <Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
<Select value={categoryId} onValueChange={handleCategoryChange}> <Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger className={cn(inp, 'w-full')}> <SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue placeholder="— none —" /> <SelectValue placeholder="— none —" />
</SelectTrigger> </SelectTrigger>
@ -298,6 +250,27 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
)} )}
</div> </div>
{/* Interest Rate */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
<Input
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" max="100" step="0.01" placeholder="Optional"
value={interestRate}
onChange={e => {
setInterestRate(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
}}
onBlur={() => handleBlur('interestRate', validateInterestRate)}
/>
{errors.interestRate && (
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
)}
<p className="text-[10px] text-muted-foreground/70">
Optional, useful for credit cards. Enter 29.99 for 29.99%.
</p>
</div>
{/* Billing Cycle */} {/* Billing Cycle */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label> <Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label>
@ -376,111 +349,6 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</p> </p>
</div> </div>
{/* Debt / Snowball Details — collapsible */}
<div className="col-span-2">
<button
type="button"
onClick={() => setShowDebtSection(s => !s)}
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors w-full text-left py-1"
>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
/>
Debt / Snowball Details
{isDebtCategory && (
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
· auto-detected
</span>
)}
{!showOnSnowball && isDebtCategory && (
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt
</span>
)}
</button>
{showDebtSection && (
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 pt-3 border-t border-border/40">
{/* Interest Rate */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
<Input
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" max="100" step="0.01" placeholder="Optional"
value={interestRate}
onChange={e => {
setInterestRate(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
}}
onBlur={() => handleBlur('interestRate', validateInterestRate)}
/>
{errors.interestRate && (
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
)}
<p className="text-[10px] text-muted-foreground/70">Enter 29.99 for 29.99%.</p>
</div>
{/* Current Balance */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Current Balance ($)</Label>
<Input
className={cn(inp, 'font-mono', errors.currentBalance && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" step="0.01" placeholder="Optional"
value={currentBalance}
onChange={e => {
setCurrentBalance(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
}}
onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))}
/>
{errors.currentBalance && (
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
)}
<p className="text-[10px] text-muted-foreground/70">Outstanding debt balance.</p>
</div>
{/* Minimum Payment */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Minimum Payment ($)</Label>
<Input
className={cn(inp, 'font-mono', errors.minimumPayment && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" step="0.01" placeholder="Optional"
value={minimumPayment}
onChange={e => {
setMinimumPayment(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
}}
onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))}
/>
{errors.minimumPayment && (
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
)}
<p className="text-[10px] text-muted-foreground/70">Required minimum monthly payment.</p>
</div>
{/* Include in Snowball */}
<div className="flex flex-col justify-end pb-1 space-y-1">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={showOnSnowball}
onChange={e => handleSnowballVisibilityChange(e.target.checked)}
className="h-4 w-4 rounded border-border accent-emerald-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Show on Debt Snowball
</span>
</label>
<p className="text-[10px] text-muted-foreground/70 pl-6">
Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
</p>
</div>
</div>
)}
</div>
{/* Website */} {/* Website */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label> <Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>

View File

@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt, Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Settings, ShieldCheck, Tag, TrendingDown, User, X, Settings, ShieldCheck, Tag, User, X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@ -35,7 +35,6 @@ const trackerItems = [
{ to: '/summary', icon: ClipboardList, label: 'Summary' }, { to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' }, { to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
]; ];
function TrackerMenu({ onNavigate }) { function TrackerMenu({ onNavigate }) {

View File

@ -1,17 +0,0 @@
import * as React from 'react';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = React.forwardRef(({ className, ...props }, ref) => (
<CollapsiblePrimitive.Content
ref={ref}
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
{...props}
/>
));
CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -1,37 +0,0 @@
import { Toaster as Sonner } from 'sonner';
import { useTheme } from '../../contexts/ThemeContext';
export function Toaster() {
const { theme } = useTheme();
return (
<Sonner
theme={theme}
position="top-right"
closeButton
expand={false}
visibleToasts={5}
duration={3500}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:rounded-xl group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:bg-card group-[.toaster]:text-card-foreground group-[.toaster]:shadow-lg group-[.toaster]:backdrop-blur-sm group-[.toaster]:border-l-4',
title: 'group-[.toast]:text-sm group-[.toast]:font-semibold group-[.toast]:text-foreground',
description: 'group-[.toast]:text-sm group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:rounded-md group-[.toast]:bg-primary group-[.toast]:px-3 group-[.toast]:py-1.5 group-[.toast]:text-sm group-[.toast]:font-medium group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:rounded-md group-[.toast]:bg-muted group-[.toast]:px-3 group-[.toast]:py-1.5 group-[.toast]:text-sm group-[.toast]:font-medium group-[.toast]:text-muted-foreground',
closeButton:
'group-[.toast]:border-border group-[.toast]:bg-card group-[.toast]:text-muted-foreground group-[.toast]:hover:bg-accent group-[.toast]:hover:text-accent-foreground',
success: 'group-[.toaster]:border-l-emerald-500',
error: 'group-[.toaster]:border-l-destructive',
warning: 'group-[.toaster]:border-l-amber-500',
info: 'group-[.toaster]:border-l-sky-500',
default: 'group-[.toaster]:border-l-primary',
},
}}
/>
);
}

View File

@ -1,14 +1,11 @@
export const APP_VERSION = '0.27.01'; export const APP_VERSION = '0.24.4';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.27.01', version: '0.24.4',
date: '2026-05-14', date: '2026-05-11',
highlights: [ highlights: [
{ icon: '❄️', title: 'Debt Snowball', desc: 'New Snowball page: drag-and-drop debt ordering, Dave Ramsey payoff projections, avalanche method comparison, and balance update by clicking any balance figure.' }, { icon: '📱', title: 'Analytics Mobile Layout', desc: 'Charts, heatmap, and controls now display properly on mobile screens.' },
{ icon: '💳', title: 'Debt Details on Bills', desc: 'Add current balance, minimum payment, and APR directly to any bill. Bills in Credit Cards, Loans, and Mortgage categories are auto-detected.' }, { icon: '🔧', title: 'Previous Month Payment Toggle', desc: 'Clicking payment badges on previous months now creates/removes payments for the correct month.' },
{ icon: '📉', title: 'Payment → Balance Sync', desc: 'Recording a payment on a debt bill automatically reduces its current balance (principal = payment minus one month of interest). Un-marking a payment reverses the change.' },
{ icon: '📊', title: 'Dual-Column XLSX Import', desc: 'Bills due on the 1st and 15th are now both imported from dual-layout spreadsheets' },
{ icon: '🛡️', title: 'Import CSRF Fix', desc: 'XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)' },
], ],
}; };

View File

@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { Toaster } from 'sonner';
import App from './App'; import App from './App';
import { Toaster } from './components/ui/sonner';
import { AuthProvider } from './hooks/useAuth'; import { AuthProvider } from './hooks/useAuth';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import 'sonner/dist/styles.css';
import './index.css'; import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
@ -16,8 +15,29 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<App /> <App />
</AuthProvider> </AuthProvider>
{/* Global shadcn/Sonner toast system */} {/* Global Toast System - placed at root level for proper z-index and positioning */}
<Toaster /> <Toaster
position="top-right"
richColors
closeButton
theme="system"
toastOptions={{
duration: 3500,
className: 'bg-card text-card-foreground border border-border shadow-lg',
success: {
className: 'border-l-emerald-500',
},
error: {
className: 'border-l-red-500',
},
warning: {
className: 'border-l-amber-500',
},
info: {
className: 'border-l-blue-500',
},
}}
/>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>

View File

@ -4,19 +4,20 @@ import { ArrowLeft, Info, Sparkles } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import AdminDashboard from '@/components/AdminDashboard';
export default function AboutPage() { export default function AboutPage({ admin = false }) {
const [about, setAbout] = useState(null); const [about, setAbout] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setAbout(await api.about()); setAbout(admin ? await api.aboutAdmin() : await api.about());
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [admin]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@ -32,6 +33,12 @@ export default function AboutPage() {
</Link> </Link>
</Button> </Button>
{/* Admin Dashboard (visible to admin only) */}
{admin && about?.future && about?.developmentLog && (
<AdminDashboard about={about} />
)}
{/* Standard About Page (visible to all users) */}
<Card className="border-border/70 bg-card/95 shadow-sm"> <Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader> <CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary"> <div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">

View File

@ -1450,30 +1450,25 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
function SeedDemoDataSection({ onSeeded }) { function SeedDemoDataSection({ onSeeded }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [seeded, setSeeded] = useState(false); const [seeded, setSeeded] = useState(false);
const [counts, setCounts] = useState({ bills: 0, categories: 0 }); const [result, setResult] = useState(null);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
useEffect(() => {
api.seededStatus()
.then(data => {
setSeeded(data.seeded);
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
})
.catch(err => console.error('Failed to check seeded status:', err))
.finally(() => setStatusLoading(false));
}, []);
const handleSeed = async () => { const handleSeed = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await api.seedDemoData(); const data = await api.seedDemoData();
if (!data || typeof data !== 'object') throw new Error('Invalid response from server'); // Ensure data has expected structure
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 }); if (!data || typeof data !== 'object') {
throw new Error('Invalid response from server');
}
setResult(data);
setSeeded(true); setSeeded(true);
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`); toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
setTimeout(() => onSeeded?.(), 100); // Delay onSeeded callback to allow UI to update
setTimeout(() => {
onSeeded?.();
}, 100);
} catch (err) { } catch (err) {
console.error('Seed error:', err); console.error('Seed error:', err);
toast.error(err?.message || err?.error || 'Failed to seed demo data.'); toast.error(err?.message || err?.error || 'Failed to seed demo data.');
@ -1487,69 +1482,79 @@ function SeedDemoDataSection({ onSeeded }) {
try { try {
const data = await api.clearDemoData(); const data = await api.clearDemoData();
setSeeded(false); setSeeded(false);
setCounts({ bills: 0, categories: 0 }); setResult(null);
setShowClearConfirm(false); setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`); toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.(); onSeeded?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to clear demo data.'); toast.error(err.message || "Failed to clear demo data.");
} finally { } finally {
setClearing(false); setClearing(false);
} }
}; };
if (seeded) {
return (
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Seed complete</p>
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
<div>
<p className="text-muted-foreground">Bills Created</p>
<p className="font-semibold">{result?.billsCreated || 0}</p>
</div>
<div>
<p className="text-muted-foreground">Categories Created</p>
<p className="font-semibold">{result?.categoriesCreated || 0}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center justify-between gap-3">
<Button size="sm" variant="outline" onClick={() => { setSeeded(false); setResult(null); }}>
Reset
</Button>
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={clearing}>
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing</> : 'Clear Demo Data'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
<AlertDialogDescription>
This action will remove {result?.billsCreated || 0} demo bills and {result?.categoriesCreated || 0} demo categories from your account. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing</> : 'Clear Data'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</SectionCard>
);
}
return ( return (
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}> <SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
<div className="rounded-lg border border-border/60 bg-background/50 p-4"> <div className="rounded-lg border border-border/60 bg-background/50 p-4">
{statusLoading ? ( <p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">Loading</p> Create 20 realistic demo bills and 8 demo categories for testing purposes.
) : seeded ? ( The data will be associated with your account.
<> </p>
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Demo data seeded</p>
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
<div>
<p className="text-muted-foreground">Bills</p>
<p className="font-semibold">{counts.bills}</p>
</div>
<div>
<p className="text-muted-foreground">Categories</p>
<p className="font-semibold">{counts.categories}</p>
</div>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Create 20 realistic demo bills and 8 demo categories for testing purposes.
The data will be associated with your account.
</p>
)}
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4"> <div className="mt-4 space-y-4">
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded || statusLoading}> <div className="border-t border-border pt-4">
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding</> : 'Seed Demo Data'} <Button size="sm" variant="outline" onClick={handleSeed} disabled={loading}>
</Button> {loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding</> : 'Seed Demo Data'}
</Button>
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}> </div>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={!seeded || clearing || statusLoading}>
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing</> : 'Clear Demo Data'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
<AlertDialogDescription>
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing</> : 'Clear Data'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</div> </div>
</SectionCard> </SectionCard>

View File

@ -12,6 +12,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker'; const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
export default function LoginPage() { export default function LoginPage() {
@ -155,6 +156,12 @@ export default function LoginPage() {
className="w-full" className="w-full"
onClick={() => { window.location.href = authMode.oidc_login_url; }} onClick={() => { window.location.href = authMode.oidc_login_url; }}
> >
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="mr-2 h-5 w-5 shrink-0 object-contain"
/>
Continue with {providerName} Continue with {providerName}
</Button> </Button>
)} )}

View File

@ -1,472 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ChevronDown, ChevronsUpDown, Map, FileText, Loader2, Users, FileCode, Clock } from 'lucide-react';
import { api } from '@/api';
import { APP_VERSION } from '@/lib/version';
/* ─── Priority Configuration ───────────────────────────── */
const PRIORITY_LANES = [
{ key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', bgColor: 'bg-red-500/10', textColor: 'text-red-600 dark:text-red-400', badgeClass: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20' },
{ key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', bgColor: 'bg-orange-500/10', textColor: 'text-orange-600 dark:text-orange-400', badgeClass: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/20' },
{ key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', bgColor: 'bg-yellow-500/10', textColor: 'text-yellow-600 dark:text-yellow-400', badgeClass: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20' },
{ key: 'low', emoji: '🔵', label: 'LOW', borderColor: 'border-t-blue-500', bgColor: 'bg-blue-500/10', textColor: 'text-blue-600 dark:text-blue-400', badgeClass: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20' },
{ key: 'niceToHave', emoji: '💭', label: 'NICE TO HAVE', borderColor: 'border-t-gray-400', bgColor: 'bg-gray-400/10', textColor: 'text-gray-600 dark:text-gray-400', badgeClass: 'bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20' },
];
function laneForPriority(priority) {
const key = typeof priority === 'string'
? priority.toLowerCase().replace(/\s+/g, '').replace(/to/ig, 'To')
: '';
// Map API priority keys to lane keys
const mapping = {
critical: 'critical',
high: 'high',
medium: 'medium',
low: 'low',
nicetohave: 'niceToHave',
'nice to have': 'niceToHave',
};
return mapping[key] || 'low';
}
/* ─── Roadmap Item Card ────────────────────────────────── */
function RoadmapItemCard({ item, defaultOpen, onToggle }) {
const lane = PRIORITY_LANES.find(l => l.key === laneForPriority(item.priority)) || PRIORITY_LANES[3];
const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = useCallback((value) => {
setOpen(value);
onToggle?.(value);
}, [onToggle]);
const effortLabel = item.effort || '';
return (
<Collapsible open={open} onOpenChange={handleOpenChange} className="group">
<Card className="border-border/70 bg-card/95 transition-shadow hover:shadow-md">
<CollapsibleTrigger asChild>
<button className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-2xl">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<Badge
variant="outline"
className={`${lane.badgeClass} border text-[11px] font-semibold px-1.5 py-0 mb-1.5`}
aria-label={`${lane.label} priority`}
>
{lane.emoji} {lane.label}
</Badge>
<h4 className="font-semibold text-sm leading-snug line-clamp-3">
{item.title}
</h4>
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 mt-0.5" />
</div>
</CardHeader>
</button>
</CollapsibleTrigger>
<div className="px-6 pb-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{item.added && (
<span className="flex items-center gap-0.5">
<Clock className="h-3 w-3" />
{item.added}
</span>
)}
{item.addedBy && (
<>
<span aria-hidden="true">·</span>
<span className="flex items-center gap-0.5">
<Users className="h-3 w-3" />
{item.addedBy}
</span>
</>
)}
{effortLabel && (
<>
<span aria-hidden="true">·</span>
<span className="flex items-center gap-0.5">
<Clock className="h-3 w-3" />
{effortLabel}
</span>
</>
)}
</div>
<CollapsibleContent>
<CardContent className="pt-0 pb-4 space-y-3">
{item.description && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Description</p>
<p className="text-sm leading-relaxed">{item.description}</p>
</div>
)}
{item.rationale && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Rationale</p>
<p className="text-sm leading-relaxed text-muted-foreground">{item.rationale}</p>
</div>
)}
{item.implementationNotes && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Implementation Notes</p>
<div className="rounded-xl bg-muted/50 border border-border/50 p-3 text-sm font-mono leading-relaxed whitespace-pre-wrap">
{item.implementationNotes}
</div>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
}
/* ─── Priority Lane ─────────────────────────────────────── */
function PriorityLane({ lane, items, defaultOpenCards }) {
if (items.length === 0) return null;
return (
<section
role="region"
aria-label={`${lane.label} priority lane`}
className={`rounded-2xl border ${lane.borderColor} border-t-4 bg-background/50`}
>
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/50">
<span className="text-lg" aria-hidden="true">{lane.emoji}</span>
<h3 className={`font-bold text-sm ${lane.textColor}`}>{lane.label}</h3>
<Badge variant="secondary" className="ml-auto text-[11px]">{items.length}</Badge>
</div>
<div className="p-3 space-y-3">
{items.map((item) => (
<RoadmapItemCard key={item.id} item={item} defaultOpen={defaultOpenCards} />
))}
</div>
</section>
);
}
/* ─── Dev Log Entry ─────────────────────────────────────── */
function DevLogEntry({ entry }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen} className="group">
<div className="relative flex gap-4">
{/* Timeline line */}
<div className="flex flex-col items-center">
<div className="w-3 h-3 rounded-full bg-primary border-2 border-background shrink-0 mt-1.5" />
<div className="w-px flex-1 bg-border/70" />
</div>
<div className="flex-1 pb-6 min-w-0">
<CollapsibleTrigger asChild>
<button className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-bold text-sm">{entry.version}</span>
{entry.date && (
<span className="text-xs text-muted-foreground">{entry.date}</span>
)}
{entry.status && (
<Badge
variant="outline"
className={`text-[11px] ${
entry.status.includes('COMPLETED')
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
: 'bg-muted/50 text-muted-foreground'
}`}
>
{entry.status}
</Badge>
)}
{entry.agents?.length > 0 && (
<span className="text-xs text-muted-foreground">
{entry.agents.map(a => a.name).join(', ')}
</span>
)}
{(entry.filesModified?.length > 0 || entry.workCompleted?.length > 0) && (
<span className="text-xs text-muted-foreground">
<FileCode className="inline h-3 w-3 mr-0.5" />
{entry.filesModified?.length || 0} files
</span>
)}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 ml-auto" />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-3 space-y-3">
{entry.agents?.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Agents</p>
<div className="flex flex-wrap gap-2">
{entry.agents.map((agent, idx) => (
<Badge
key={idx}
variant="outline"
className={`text-[11px] ${
agent.status === 'COMPLETED'
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
: agent.status === 'IN PROGRESS'
? 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20'
: 'bg-muted/50 text-muted-foreground'
}`}
>
{agent.status === 'COMPLETED' ? '✅' : agent.status === 'IN PROGRESS' ? '⏳' : '❓'}{' '}
{agent.name}
{agent.time ? ` · ${agent.time}` : ''}
</Badge>
))}
</div>
</div>
)}
{entry.filesModified?.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">Files Modified</p>
<div className="flex flex-wrap gap-1">
{entry.filesModified.map((file, idx) => (
<code key={idx} className="text-[11px] bg-muted/50 px-1.5 py-0.5 rounded border border-border/50 text-muted-foreground">
{file}
</code>
))}
</div>
</div>
)}
{entry.workCompleted?.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">Work Completed</p>
<ul className="space-y-0.5">
{entry.workCompleted.map((work, idx) => (
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5">
<span className="text-emerald-500 mt-0.5 shrink-0"></span>
{work}
</li>
))}
</ul>
</div>
)}
</div>
</CollapsibleContent>
</div>
</div>
</Collapsible>
);
}
/* ─── Main Page ─────────────────────────────────────────── */
export default function RoadmapPage() {
const [roadmapData, setRoadmapData] = useState(null);
const [devLogData, setDevLogData] = useState(null);
const [roadmapLoading, setRoadmapLoading] = useState(true);
const [devLogLoading, setDevLogLoading] = useState(false);
const [roadmapError, setRoadmapError] = useState(null);
const [devLogError, setDevLogError] = useState(null);
const [allExpanded, setAllExpanded] = useState(true);
// Detect desktop for default expand state
const [isDesktop, setIsDesktop] = useState(
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
);
useEffect(() => {
const mq = window.matchMedia('(min-width: 1024px)');
const handler = (e) => setIsDesktop(e.matches);
mq.addEventListener('change', handler);
setIsDesktop(mq.matches);
return () => mq.removeEventListener('change', handler);
}, []);
// Fetch roadmap on mount
useEffect(() => {
let cancelled = false;
setRoadmapLoading(true);
api.roadmap()
.then((data) => {
if (!cancelled) setRoadmapData(data);
})
.catch((err) => {
if (!cancelled) setRoadmapError(err.message || 'Failed to load roadmap');
})
.finally(() => {
if (!cancelled) setRoadmapLoading(false);
});
return () => { cancelled = true; };
}, []);
const fetchDevLog = useCallback(() => {
if (devLogData) return; // Already loaded
let cancelled = false;
setDevLogLoading(true);
api.devLog()
.then((data) => {
if (!cancelled) setDevLogData(data);
})
.catch((err) => {
if (!cancelled) setDevLogError(err.message || 'Failed to load activity log');
})
.finally(() => {
if (!cancelled) setDevLogLoading(false);
});
return () => { cancelled = true; };
}, [devLogData]);
const version = roadmapData?.version || APP_VERSION;
const items = roadmapData?.items || [];
const counts = roadmapData?.counts || {};
const devLogEntries = devLogData?.entries || [];
// Group items by priority lane
const grouped = PRIORITY_LANES.map(lane => ({
...lane,
items: items.filter(item => laneForPriority(item.priority) === lane.key),
}));
const defaultOpenCards = isDesktop && allExpanded;
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
<Map className="h-5 w-5" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Roadmap</h1>
<p className="text-sm text-muted-foreground">Current and upcoming features by priority</p>
</div>
</div>
<Badge variant="outline" className="font-mono text-sm self-start sm:self-auto">
v{version}
</Badge>
</div>
{/* Tabs */}
<Tabs defaultValue="roadmap" onValueChange={(value) => { if (value === 'activity') fetchDevLog(); }}>
<TabsList>
<TabsTrigger value="roadmap" className="gap-1.5">
<Map className="h-3.5 w-3.5" />
Roadmap
</TabsTrigger>
<TabsTrigger value="activity" className="gap-1.5">
<FileText className="h-3.5 w-3.5" />
Activity Log
</TabsTrigger>
</TabsList>
{/* ─── Roadmap Tab ─── */}
<TabsContent value="roadmap">
{roadmapLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading roadmap</span>
</div>
) : roadmapError ? (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="py-8 text-center">
<p className="text-destructive font-medium">Failed to load roadmap</p>
<p className="text-sm text-muted-foreground mt-1">{roadmapError}</p>
</CardContent>
</Card>
) : items.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
No roadmap items found.
</CardContent>
</Card>
) : (
<>
{/* Expand/Collapse All toggle */}
<div className="flex justify-end mb-3">
<Button
variant="outline"
size="sm"
onClick={() => setAllExpanded(prev => !prev)}
className="gap-1.5"
>
<ChevronsUpDown className="h-3.5 w-3.5" />
{allExpanded ? 'Collapse All' : 'Expand All'}
</Button>
</div>
{/* Desktop: 5-column grid */}
<div className="hidden lg:grid lg:grid-cols-5 gap-4">
{grouped.map(lane => (
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
))}
</div>
{/* Tablet: 2-column grid */}
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-4">
{/* Left column: Critical + High */}
<div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => (
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
))}
</div>
{/* Right column: Medium + Low + Nice to Have */}
<div className="space-y-4">
{grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane => (
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
))}
</div>
</div>
{/* Mobile: single column */}
<div className="sm:hidden space-y-4">
{grouped.map(lane => (
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
))}
</div>
</>
)}
</TabsContent>
{/* ─── Activity Log Tab ─── */}
<TabsContent value="activity">
{devLogLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading activity log</span>
</div>
) : devLogError ? (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="py-8 text-center">
<p className="text-destructive font-medium">Failed to load activity log</p>
<p className="text-sm text-muted-foreground mt-1">{devLogError}</p>
</CardContent>
</Card>
) : devLogEntries.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
No activity log entries found.
</CardContent>
</Card>
) : (
<div className="pt-2">
{devLogEntries.map((entry, idx) => (
<DevLogEntry key={entry.version || idx} entry={entry} />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,635 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils';
import BillModal from '@/components/BillModal';
// formatters
function fmt(val) {
if (val == null) return '—';
return Number(val).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
}
function fmtCompact(val) {
if (val == null || val === 0) return '—';
return Number(val).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
}
function ordinal(n) {
const d = Number(n);
if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`;
switch (d % 10) {
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
}
}
// StatCard
function StatCard({ label, value, sub, highlight }) {
return (
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
<p className={cn('text-2xl font-semibold tabular-nums', highlight && 'text-emerald-400')}>{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
);
}
// Projection panel
function AvalancheComparison({ snowball, avalanche }) {
if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom;
const interestDiff = snowball.total_interest_paid - avalanche.total_interest_paid;
const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
return (
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
vs. Avalanche (highest rate first)
</p>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm text-muted-foreground">{avalanche.payoff_display}</span>
<span className="text-xs tabular-nums text-muted-foreground">{fmt(avalanche.total_interest_paid)} interest</span>
</div>
{same ? (
<p className="text-xs text-muted-foreground/70">Same result your debts have similar rates.</p>
) : interestDiff > 0 ? (
<p className="text-xs text-emerald-400">
Avalanche saves {fmt(interestDiff)} interest
{monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''}
</p>
) : (
<p className="text-xs text-violet-400">
Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster ·
Avalanche costs {fmt(Math.abs(interestDiff))} more
</p>
)}
</div>
);
}
function ProjectionPanel({ projection, projectionLoading, billCount }) {
if (projectionLoading) {
return (
<div className="surface-elevated rounded-xl p-5 space-y-3">
<Skeleton className="h-5 w-36" />
<div className="space-y-2">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10" />)}</div>
</div>
);
}
if (!projection) return null;
const sb = projection.snowball;
const av = projection.avalanche;
if (!sb) return null;
const hasProjection = sb.debts.length > 0;
const needsBalances = billCount > 0 && !hasProjection && sb.skipped.length > 0;
return (
<div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-border/40">
<div className="flex items-center gap-2">
<CalendarCheck className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold">Payoff Projection</span>
</div>
{sb.payoff_display && (
<div className="text-right shrink-0">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Snowball · Debt-Free</p>
<p className="text-base font-semibold text-emerald-400">{sb.payoff_display}</p>
</div>
)}
</div>
{sb.capped && (
<div className="flex items-start gap-2 px-5 py-3 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments.
</div>
)}
{needsBalances && (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
Click any balance to enter it and see your payoff timeline.
</div>
)}
{hasProjection && (
<div className="divide-y divide-border/30">
{sb.debts.map((d, i) => (
<div key={d.id} className="flex items-center gap-3 px-5 py-3">
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 tabular-nums">#{i + 1}</span>
<span className="flex-1 text-sm font-medium truncate min-w-0">{d.name}</span>
<div className="text-right shrink-0 space-y-0.5">
{d.payoff_display ? (
<>
<p className="text-sm font-semibold">{d.payoff_display}</p>
<p className="text-[10px] text-muted-foreground">
{d.months} mo · {fmtCompact(d.total_interest)} interest
</p>
</>
) : (
<p className="text-xs text-muted-foreground">unknown balance</p>
)}
</div>
</div>
))}
</div>
)}
{hasProjection && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border/40 bg-muted/20">
<span className="text-xs text-muted-foreground">Total interest paid</span>
<span className="text-sm font-semibold tabular-nums">{fmt(sb.total_interest_paid)}</span>
</div>
)}
{hasProjection && av && <AvalancheComparison snowball={sb} avalanche={av} />}
{sb.skipped.length > 0 && hasProjection && (
<div className="px-5 pb-3 text-[10px] text-muted-foreground/60">
{sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance):
{' '}{sb.skipped.map(s => s.name).join(', ')}
</div>
)}
</div>
);
}
// Pointer-based drag-and-drop hook (works on touch + mouse)
function useSortable(items, setItems, setDirty) {
const [draggingIdx, setDraggingIdx] = useState(null);
// Refs that live through the entire drag gesture
const state = useRef({
fromIdx: null, // card index where the drag started
currentIdx: null, // card index currently under the pointer
startY: 0,
itemHeight: 0,
containerEl: null,
});
const indexFromPointer = useCallback((clientX, clientY) => {
const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]');
if (direct?.dataset?.cardIndex != null) {
const idx = Number(direct.dataset.cardIndex);
if (Number.isInteger(idx)) return idx;
}
const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])];
if (cards.length === 0) return state.current.currentIdx;
let nearestIdx = state.current.currentIdx;
let nearestDistance = Infinity;
for (const card of cards) {
const rect = card.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
const distance = Math.abs(clientY - centerY);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIdx = Number(card.dataset.cardIndex);
}
}
return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx;
}, []);
const onPointerDown = useCallback((e, index) => {
// Only trigger on the grip handle (data-grip attr)
if (!e.currentTarget.hasAttribute('data-grip')) return;
// Ignore right-click
if (e.button !== undefined && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
const card = e.currentTarget.closest('[data-card]');
const list = card?.parentElement;
const rect = card?.getBoundingClientRect();
state.current = {
fromIdx: index,
currentIdx: index,
startY: e.clientY,
itemHeight: rect?.height ?? 80,
containerEl: list ?? null,
};
setDraggingIdx(index);
}, []);
const onPointerMove = useCallback((e) => {
if (state.current.fromIdx === null) return;
const { containerEl, currentIdx } = state.current;
if (!containerEl) return;
const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
if (newIdx !== currentIdx) {
state.current.currentIdx = newIdx;
setDraggingIdx(newIdx); // visual feedback on where card will land
}
}, [indexFromPointer, items.length]);
const onPointerUp = useCallback((e) => {
const { fromIdx, currentIdx } = state.current;
state.current.fromIdx = null;
state.current.currentIdx = null;
setDraggingIdx(null);
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
next.splice(currentIdx, 0, moved);
return next;
});
setDirty(true);
}, [setItems, setDirty]);
return { draggingIdx, onPointerDown, onPointerMove, onPointerUp };
}
// Page
export default function SnowballPage() {
const [bills, setBills] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [editBill, setEditBill] = useState(null);
const [extraPayment, setExtraPayment] = useState('');
const [savingSettings, setSavingSettings] = useState(false);
const extraPaymentRef = useRef('');
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
const { draggingIdx, onPointerDown, onPointerMove, onPointerUp } =
useSortable(bills, setBills, setDirty);
// loading
const loadProjection = useCallback(async () => {
setProjectionLoading(true);
try { setProjection(await api.snowballProjection()); }
catch { /* non-fatal */ }
finally { setProjectionLoading(false); }
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const [billsArr, catsArr, settings] = await Promise.all([
api.snowball(), api.categories(), api.snowballSettings(),
]);
setCategories(catsArr);
setBills(billsArr);
setDirty(false);
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
setExtraPayment(ep);
extraPaymentRef.current = ep;
} catch (err) {
toast.error(err.message || 'Failed to load snowball data');
} finally { setLoading(false); }
}, []);
useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]);
// auto-arrange
const handleAutoArrange = () => {
setBills(prev => [...prev].sort((a, b) => {
if (a.current_balance == null && b.current_balance == null) return 0;
if (a.current_balance == null) return 1;
if (b.current_balance == null) return -1;
return a.current_balance - b.current_balance;
}));
setDirty(true);
toast.success('Arranged smallest-to-largest balance');
};
// save order
const handleSaveOrder = async () => {
setSaving(true);
try {
await api.saveSnowballOrder(bills.map((b, i) => ({ id: b.id, snowball_order: i })));
setDirty(false);
toast.success('Order saved');
loadProjection();
} catch (err) { toast.error(err.message || 'Failed to save order'); }
finally { setSaving(false); }
};
// extra payment
const handleSaveExtraPayment = async () => {
const val = extraPayment.trim();
if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) {
toast.error('Extra payment must be a positive number'); return;
}
if (val === extraPaymentRef.current) return;
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) });
const saved = result.extra_payment > 0 ? String(result.extra_payment) : '';
extraPaymentRef.current = saved;
setExtraPayment(saved);
toast.success('Extra payment saved');
loadProjection();
} catch (err) { toast.error(err.message || 'Failed to save'); }
finally { setSavingSettings(false); }
};
// inline balance edit
const startEditBalance = (bill) =>
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
const commitBalance = async (billId) => {
const raw = editingBalance.value.trim();
const num = raw === '' ? null : parseFloat(raw);
if (raw !== '' && (isNaN(num) || num < 0)) { toast.error('Balance must be a non-negative number'); return; }
const current = bills.find(b => b.id === billId);
if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
try {
await api.updateBillBalance(billId, num);
setBills(prev => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b));
setEditingBalance({ billId: null, value: '' });
loadProjection();
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
};
const removeFromSnowball = async (bill) => {
try {
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
setBills(prev => prev.filter(b => b.id !== bill.id));
setDirty(true);
toast.success(`${bill.name} removed from Snowball`);
loadProjection();
} catch (err) {
toast.error(err.message || 'Failed to remove bill from Snowball');
}
};
// stats
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
const unknownCount = bills.filter(b => b.current_balance == null).length;
const extraAmt = parseFloat(extraPayment) || 0;
// loading skeleton
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
</div>
);
}
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<TrendingDown className="h-6 w-6 text-primary" />
Debt Snowball
</h1>
<p className="text-sm text-muted-foreground mt-1">
Dave Ramsey method attack the smallest balance first, roll payments as each debt clears.
Marking a payment automatically reduces the outstanding balance.
</p>
</div>
{/* Stats */}
{bills.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Debt" value={fmt(totalBalance)}
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
<StatCard label="Total Attack" value={fmt(totalMinPayment + extraAmt)}
sub="toward #1 target" highlight={extraAmt > 0} />
</div>
)}
{/* Toolbar */}
{bills.length > 0 && (
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Extra monthly budget ($)
</Label>
<Input
type="number" min="0" step="1" placeholder="0.00"
value={extraPayment}
onChange={e => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'w-32')}
disabled={savingSettings}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Button type="button" variant="outline" size="sm" onClick={handleAutoArrange} className="gap-2">
<Zap className="h-3.5 w-3.5" /> Auto-arrange
</Button>
<Button type="button" size="sm" disabled={!dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
</div>
</div>
)}
{/* Empty state */}
{bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
<p className="text-xs text-muted-foreground/70 max-w-sm">
Bills in Credit Cards, Loans, or Mortgage categories appear here automatically.
You can also enable "Include in Snowball" when editing any bill.
</p>
</div>
)}
{/* Cards + projection */}
{bills.length > 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */}
<div
className="space-y-2"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{bills.map((bill, index) => {
const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id;
const isDragging = draggingIdx !== null;
const isTarget = draggingIdx === index; // where it will land
return (
<div
key={bill.id}
data-card
data-card-index={index}
className={cn(
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
isAttack ? 'border-emerald-500/40' : 'border-border/40',
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
)}
>
<div className="flex items-stretch">
{/* Grip handle — pointer-capture trigger */}
<div
data-grip
onPointerDown={e => onPointerDown(e, index)}
className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none"
aria-label="Drag to reorder"
>
<GripVertical className="h-5 w-5" />
</div>
{/* Body */}
<div className="flex-1 py-3.5 pr-4 min-w-0">
{/* Top row */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
#{index + 1}
</span>
{isAttack && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
<Zap className="h-2.5 w-2.5" /> Attack
</span>
)}
<span className="font-semibold truncate">{bill.name}</span>
{bill.category_name && (
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
{bill.category_name}
</span>
)}
{bill.snowball_include === 1 && !bill.category_name && (
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
manual
</span>
)}
<button
type="button"
onClick={() => setEditBill(bill)}
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
Edit
</button>
<button
type="button"
onClick={() => removeFromSnowball(bill)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
title="Remove from Snowball"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
</div>
{/* Stats row */}
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-sm items-center">
{/* Balance — inline editable */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Balance</span>
{isEditingBal ? (
<Input
autoFocus
type="number" min="0" step="0.01"
value={editingBalance.value}
onChange={e => setEditingBalance(p => ({ ...p, value: e.target.value }))}
onBlur={() => commitBalance(bill.id)}
onKeyDown={e => {
if (e.key === 'Enter') e.target.blur();
if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' });
}}
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
/>
) : (
<button
type="button"
onClick={() => startEditBalance(bill)}
className={cn(
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
isAttack && bill.current_balance != null ? 'text-emerald-400' : '',
bill.current_balance == null && 'text-muted-foreground/60 italic text-xs',
)}
title="Click to update balance"
>
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
</button>
)}
</div>
<div>
<span className="text-xs text-muted-foreground">Min/mo </span>
<span className="font-medium tabular-nums">
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
</span>
</div>
{isAttack && extraAmt > 0 && (
<div>
<span className="text-xs text-muted-foreground">Attack </span>
<span className="font-medium tabular-nums text-emerald-400">
{fmt((bill.minimum_payment || 0) + extraAmt)}
</span>
</div>
)}
{bill.interest_rate != null && (
<div>
<span className="text-xs text-muted-foreground">APR </span>
<span className="font-medium tabular-nums">{bill.interest_rate}%</span>
</div>
)}
<div>
<span className="text-xs text-muted-foreground">Due </span>
<span className="font-medium">{ordinal(bill.due_day)}</span>
</div>
</div>
</div>
</div>
</div>
);
})}
<p className="text-xs text-muted-foreground/50 text-center pt-1">
Drag the grip handle to reorder · Click a balance to update it · Save Order to persist
</p>
</div>
{/* Projection (sticky sidebar on large screens) */}
<div className="lg:sticky lg:top-24 lg:self-start">
<ProjectionPanel
projection={projection}
projectionLoading={projectionLoading}
billCount={bills.length}
/>
</div>
</div>
)}
{/* Edit modal */}
{editBill && (
<BillModal
bill={editBill}
categories={categories}
onClose={() => setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }}
/>
)}
</div>
);
}

View File

@ -853,34 +853,18 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" /> <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
)} )}
<div> <div>
<div className="flex items-center gap-1"> <button
{row.website ? ( type="button"
<a onClick={() => onEditBill?.(row)}
href={row.website} className={cn(
target="_blank" 'font-medium text-sm leading-tight text-left transition-colors',
rel="noreferrer" 'hover:underline decoration-muted-foreground/50 underline-offset-2',
className={cn( isSkipped && 'line-through',
'font-medium text-sm leading-tight transition-colors',
'hover:underline decoration-muted-foreground/50 underline-offset-2',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('font-medium text-sm leading-tight', isSkipped && 'line-through')}>
{row.name}
</span>
)} )}
<Button title="Edit bill"
size="icon" variant="ghost" >
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100" {row.name}
title="Edit bill" </button>
onClick={() => onEditBill?.(row)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{row.category_name && ( {row.category_name && (
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p> <p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
)} )}
@ -977,6 +961,27 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
</div> </div>
)} )}
{/* Edit payment (pencil) */}
{row.payments && row.payments.length > 0 && (
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit payment"
onClick={() => setEditPayment(row.payments[0])}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{/* Monthly state editor (gear icon) — always available */}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="h-3 w-3" />
</Button>
</div> </div>
</TableCell> </TableCell>
@ -1075,32 +1080,18 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
AP AP
</span> </span>
)} )}
{row.website ? ( <button
<a type="button"
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'min-w-0 truncate text-sm font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('min-w-0 truncate text-sm font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
{row.name}
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill"
onClick={() => onEditBill?.(row)} onClick={() => onEditBill?.(row)}
className={cn(
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
title="Edit bill"
> >
<Pencil className="h-3 w-3" /> {row.name}
</Button> </button>
</div> </div>
{row.monthly_notes && ( {row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}> <p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
@ -1173,6 +1164,27 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
</div> </div>
)} )}
{row.payments && row.payments.length > 0 && (
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title="Edit payment"
onClick={() => setEditPayment(row.payments[0])}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Payment
</Button>
)}
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
Month
</Button>
</div> </div>
</div> </div>

View File

@ -43,8 +43,6 @@ const COLUMN_WHITELIST = new Set([
'other_amount', 'other_amount',
// bills table columns // bills table columns
'history_visibility', 'interest_rate', 'user_id', 'history_visibility', 'interest_rate', 'user_id',
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
'snowball_exempt',
// sessions table columns // sessions table columns
'created_at', 'created_at',
]); ]);
@ -607,131 +605,6 @@ function reconcileLegacyMigrations() {
console.log('[migration] sessions.created_at column added'); console.log('[migration] sessions.created_at column added');
} }
} }
},
{
version: 'v0.44',
description: 'performance: add missing indexes for frequently queried columns',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
},
run: function() {
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
}
},
{
version: 'v0.45',
description: 'audit: add audit_log table for security event tracking',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
},
run: function() {
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
details_json TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
db.exec('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',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return cols.includes('cycle_type') && cols.includes('cycle_day');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('cycle_type')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
}
if (!cols.includes('cycle_day')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
}
},
{
version: 'v0.47',
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
check: function() {
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
return !row || row.value !== '14';
},
run: function() {
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
}
},
{
version: 'v0.48',
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c));
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
console.log('[migration] bills: debt snowball columns added');
}
},
{
version: 'v0.49',
description: 'users: snowball_extra_payment column',
check: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
return cols.includes('snowball_extra_payment');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!cols.includes('snowball_extra_payment')) {
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
}
console.log('[migration] users: snowball_extra_payment column added');
}
},
{
version: 'v0.50',
description: 'payments: balance_delta column for debt payoff tracking',
check: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
return cols.includes('balance_delta');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!cols.includes('balance_delta')) {
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
}
console.log('[migration] payments: balance_delta column added');
}
},
{
version: 'v0.51',
description: 'bills: snowball_exempt column for hiding debt-like bills',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return cols.includes('snowball_exempt');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('snowball_exempt')) {
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
}
console.log('[migration] bills: snowball_exempt column added');
}
} }
]; ];
@ -1198,71 +1071,10 @@ function runMigrations() {
description: 'billing: add cycle_type and cycle_day columns to bills', description: 'billing: add cycle_type and cycle_day columns to bills',
dependsOn: ['v0.45'], dependsOn: ['v0.45'],
run: function() { run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); // Add cycle_type column (default 'monthly' for existing bills)
if (!cols.includes('cycle_type')) { db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
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`);
if (!cols.includes('cycle_day')) {
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
}
},
{
version: 'v0.47',
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
dependsOn: ['v0.46'],
run: function() {
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
}
},
{
version: 'v0.48',
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
dependsOn: ['v0.47'],
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
console.log('[migration] bills: debt snowball columns added');
}
},
{
version: 'v0.49',
description: 'users: snowball_extra_payment column',
dependsOn: ['v0.48'],
run: function() {
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
if (!cols.includes('snowball_extra_payment')) {
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
}
console.log('[migration] users: snowball_extra_payment column added');
}
},
{
version: 'v0.50',
description: 'payments: balance_delta column for debt payoff tracking',
dependsOn: ['v0.49'],
run: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!cols.includes('balance_delta')) {
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
}
console.log('[migration] payments: balance_delta column added');
}
},
{
version: 'v0.51',
description: 'bills: snowball_exempt column for hiding debt-like bills',
dependsOn: ['v0.50'],
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('snowball_exempt')) {
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
}
console.log('[migration] bills: snowball_exempt column added');
} }
} }
]; ];
@ -1502,7 +1314,7 @@ function seedDefaults() {
['backup_schedule_enabled', 'false'], ['backup_schedule_enabled', 'false'],
['backup_schedule_frequency', 'daily'], ['backup_schedule_frequency', 'daily'],
['backup_schedule_time', '02:00'], ['backup_schedule_time', '02:00'],
['backup_schedule_retention_count', '2'], ['backup_schedule_retention_count', '14'],
['backup_schedule_last_run_at', ''], ['backup_schedule_last_run_at', ''],
['backup_schedule_last_error', ''], ['backup_schedule_last_error', ''],
['auth_mode', 'multi'], ['auth_mode', 'multi'],
@ -1627,33 +1439,6 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE bills DROP COLUMN cycle_day', 'ALTER TABLE bills DROP COLUMN cycle_day',
'ALTER TABLE bills DROP COLUMN cycle_type' 'ALTER TABLE bills DROP COLUMN cycle_type'
] ]
},
'v0.47': {
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
sql: [
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
]
},
'v0.48': {
description: 'bills: debt snowball fields',
sql: [
'ALTER TABLE bills DROP COLUMN snowball_include',
'ALTER TABLE bills DROP COLUMN snowball_order',
'ALTER TABLE bills DROP COLUMN minimum_payment',
'ALTER TABLE bills DROP COLUMN current_balance',
]
},
'v0.49': {
description: 'users: snowball extra payment field',
sql: ['ALTER TABLE users DROP COLUMN snowball_extra_payment']
},
'v0.50': {
description: 'payments: balance_delta column',
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
},
'v0.51': {
description: 'bills: snowball_exempt column',
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
} }
}; };

View File

@ -27,11 +27,6 @@ CREATE TABLE IF NOT EXISTS bills (
account_info TEXT, account_info TEXT,
has_2fa INTEGER NOT NULL DEFAULT 0, has_2fa INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
current_balance REAL,
minimum_payment REAL,
snowball_order INTEGER,
snowball_include INTEGER NOT NULL DEFAULT 0,
snowball_exempt INTEGER NOT NULL DEFAULT 0,
notes TEXT, notes TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
@ -44,7 +39,6 @@ CREATE TABLE IF NOT EXISTS payments (
paid_date TEXT NOT NULL, paid_date TEXT NOT NULL,
method TEXT, method TEXT,
notes TEXT, notes TEXT,
balance_delta REAL,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );
@ -64,7 +58,6 @@ CREATE TABLE IF NOT EXISTS users (
is_default_admin INTEGER NOT NULL DEFAULT 0, is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1, first_login INTEGER NOT NULL DEFAULT 1,
snowball_extra_payment REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );

View File

@ -3,7 +3,7 @@
**Status:** Current code reference **Status:** Current code reference
**Last Updated:** 2026-05-10 **Last Updated:** 2026-05-10
**Version:** 0.23.2 **Version:** 0.23.2
**Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3` **Primary stack:** Node.js + Express, React + Vite, SQLite via `better-sqlite3`
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog. This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog.
@ -1037,8 +1037,8 @@ Use this pattern for database-layer audit calls instead of a top-level `require(
- React Router `^6.26.2` - React Router `^6.26.2`
- TanStack Query `^5.100.9` - TanStack Query `^5.100.9`
- Tailwind CSS `^3.4.14` - Tailwind CSS `^3.4.14`
- shadcn/ui component primitives, backed by Radix UI where applicable - Radix/shadcn-style UI primitives
- Sonner/shadcn toast notifications via `sonner` - `sonner` for toasts
- `react-markdown`, `remark-gfm`, `rehype-sanitize` for markdown rendering - `react-markdown`, `remark-gfm`, `rehype-sanitize` for markdown rendering
### `client/main.jsx` ### `client/main.jsx`
@ -1167,7 +1167,7 @@ Key runtime dependencies:
- nodemailer. - nodemailer.
- node-cron. - node-cron.
- React, React DOM, React Router, TanStack Query. - React, React DOM, React Router, TanStack Query.
- shadcn/ui component primitives, Radix UI primitives, lucide-react, Tailwind utilities, Sonner toasts. - Radix UI primitives, lucide-react, Tailwind utilities.
- xlsx for spreadsheet import/export. - xlsx for spreadsheet import/export.
### Dockerfile ### Dockerfile

View File

@ -1,241 +0,0 @@
# Roadmap Page Redesign — Execution Plan
**Created:** 2026-05-11
**Scope:** Replace AdminDashboard with a standalone RoadmapPage using kanban-style priority lanes
**Reference:** `docs/ROADMAP_UI_AUDIT.md`
---
## Task 1 — Neo: Backend API Split & Parsing Fix
**Agent:** Neo
**Priority:** Must complete before Task 2
**Estimated time:** 2-3 hours
### What
Split `/api/about-admin` into two endpoints so the dev log (54KB) isn't shipped on page load, and add structured FUTURE.md parsing on the backend.
### Changes
**1. New endpoint: `GET /api/roadmap`**
- Reads `FUTURE.md`
- Returns parsed JSON array of roadmap items (not raw markdown)
- Each item: `{ id, priority, priorityLabel, title, description, rationale, implementationNotes, effort, added, addedBy, status }`
- Parse `**Description:**`, `**Rationale:**`, `**Implementation Notes:**` into separate fields
- Extract effort estimate from Implementation Notes (regex: `Estimated effort: X-Y hours``effort: "X-Yh"`)
- Filter out strikethrough/completed items server-side
- Group counts by priority tier in response: `{ items: [...], counts: { critical: 1, high: 3, medium: 4, low: 3, niceToHave: 1 } }`
**2. New endpoint: `GET /api/dev-log`**
- Reads `DEVELOPMENT_LOG.md`
- Returns parsed JSON array of log entries (not raw markdown)
- Each entry: `{ version, date, status, agents: [{name, status, time, notes}], filesModified: [...] }`
- Called lazily — frontend only fetches when Activity Log tab is selected
**3. Keep `/api/about-admin` unchanged**
- Still returns `version`, `future` (raw), `developmentLog` (raw) for backward compatibility
- AdminDashboard continues to work until we swap it out
### Files
- `routes/aboutAdmin.js` — add `/api/roadmap` and `/api/dev-log` routes
- `client/api.js` — add `roadmap()` and `devLog()` functions
### Acceptance criteria
- `GET /api/roadmap` returns JSON with structured items and counts
- `GET /api/dev-log` returns parsed log entries
- `GET /api/about-admin` still works unchanged
- Completed/strikethrough items are excluded from `/api/roadmap`
---
## Task 2 — Scarlett: RoadmapPage UI (Kanban Lanes + Tabs)
**Agent:** Scarlett
**Priority:** Depends on Task 1
**Estimated time:** 6-8 hours
**Stack mandate:** Vite + React (NOT Next.js). All UI components must use shadcn/ui primitives. Styling via Tailwind CSS only.
### What
Build a standalone `RoadmapPage.jsx` with kanban-style priority lanes and a tab for the Activity Log. Replace the current AdminDashboard component.
### Changes
**1. New file: `client/pages/RoadmapPage.jsx`**
- Fetch data from `/api/roadmap` on mount
- Lazy-fetch `/api/dev-log` only when Activity Log tab is selected
- Page-level scroll only (no nested scroll containers)
- Page header: "🗺️ Roadmap" title + version badge (from `/api/roadmap` response or `APP_VERSION`)
**2. Kanban lane layout (Roadmap tab)**
- Desktop (`lg+`): 5-column grid — one lane per priority (CRITICAL, HIGH, MEDIUM, LOW, NICE TO HAVE)
- Tablet (`smlg`): 2-column grid (CRITICAL+HIGH | MEDIUM+LOW+NICE TO HAVE)
- Mobile (`< sm`): single column, lanes stack vertically as collapsible sections
- Each lane header: priority emoji + label + item count badge (e.g., "🔴 Critical (1)")
- Lane header has colored top border from PRIORITY_COLORS map
**3. Roadmap item cards**
- Compact card: priority badge, title (bold, 2-3 line clamp), date added, effort estimate
- Click to expand via shadcn `Collapsible` (Radix-based, accessible, `aria-expanded`)
- Expanded view shows three labeled sections: Description, Rationale, Implementation Notes — properly styled, not raw markdown
- "Expand All / Collapse All" toggle button above the lane grid
**4. Activity Log tab**
- shadcn `Tabs` component with two tabs: "Roadmap" | "Activity Log"
- Activity Log shows parsed dev log entries in vertical timeline format
- Each entry: version, date, agent badges with status icons, files modified count
- Expandable details (click to see full entry content)
- Lazy-loaded — only fetch when tab is selected
**5. Replace shadcn/ui components (not custom)**
- `SimpleCollapsible` → shadcn `Collapsible` (`Collapsible`, `CollapsibleTrigger`, `CollapsibleContent`)
- `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` for the tab switcher
- Keep existing `Card`, `Badge`, `Button` usage
- Use shadcn `Accordion` for mobile lane fallback if needed
### Files
- **NEW:** `client/pages/RoadmapPage.jsx` — the entire new page
- **MODIFY:** `client/App.jsx` — update `/admin/roadmap` route to render `<RoadmapPage />` instead of `<AboutPage admin />`; add lazy import
- **MODIFY:** `client/pages/AboutPage.jsx` — remove `admin` prop, remove `AdminDashboard` import, revert to public-only about page
- **DELETE:** `client/components/AdminDashboard.jsx` — replaced entirely by RoadmapPage
- Check if shadcn `Collapsible` and `Tabs` are already installed; if not, add via `npx shadcn@latest add collapsible`
### Acceptance criteria
- `/admin/roadmap` renders RoadmapPage with kanban lanes
- `/admin` and `/about` no longer show the admin dashboard
- Desktop: 5 priority lanes side by side
- Mobile: lanes stack vertically
- Each item card expands to show Description/Rationale/Notes as separate styled sections
- Activity Log tab lazy-loads dev log data
- No `SimpleCollapsible` usage — all shadcn `Collapsible`
- All interactive elements keyboard-focusable with `aria-expanded`
- Dark mode and light mode both render correctly
---
## Task 3 — Private_Hudson: Security Review
**Agent:** Private_Hudson
**Priority:** After Task 2
**Estimated time:** 1-2 hours
### What
Review the new endpoints and page for security issues.
### Current CSRF Security Context
Bill Tracker uses a **double-submit cookie pattern** for CSRF protection:
- **Cookie:** `bt_csrf_token` (set by `csrfTokenProvider` middleware on every response)
- **Header:** Frontend reads token from `document.cookie` and sends it as `x-csrf-token` header on all state-changing requests (POST, PUT, DELETE, PATCH)
- **Validation:** `csrfMiddleware` compares cookie value to header/query/body value — must match exactly
- **Token generation:** `crypto.randomBytes(32).toString('hex')` (256-bit)
**Configuration (env vars):**
- `CSRF_HTTP_ONLY` — defaults to `false` (SPA needs JS to read cookie for double-submit)
- `CSRF_SAME_SITE` — defaults to `strict`
- `CSRF_SECURE` — defaults to `true` (HTTPS only)
- `CSRF_COOKIE_NAME` — defaults to `bt_csrf_token`
**CSRF-exempt routes (via `req.csrfSkip`):**
- `POST /api/auth/login` — no session exists yet, nothing to hijack
- `POST /api/auth/logout-all` — uses session cookie directly
**All other state-changing routes have CSRF enforced**, including:
- `POST /api/auth/change-password` — covered by `csrfMiddleware` on `/api/auth` mount
- `POST /api/profile/change-password` — covered by `csrfMiddleware` on `/api/profile` mount
- All `/api/bills`, `/api/payments`, `/api/categories`, `/api/tracker`, `/api/analytics`, etc.
⚠️ **Known stale comment:** `routes/auth.js` line 120 has a comment saying "Exempt from CSRF" on the change-password route, but there is NO `req.csrfSkip` set — the route IS protected. The comment is wrong and should be removed.
### Checks for New Endpoints
- `/api/roadmap` and `/api/dev-log` are GET routes — CSRF middleware only validates POST/PUT/DELETE/PATCH, so they're safe by default. But confirm they still require admin auth.
- No FUTURE.md internal file paths leak through the API (the `redactSensitiveContent` function from `aboutAdmin.js` is applied)
- `/api/roadmap` doesn't expose implementation details that could aid an attacker (file paths, internal IPs, etc.)
- `/api/dev-log` doesn't expose agent names/tokens that shouldn't be visible
- XSS check: all parsed content rendered through React's JSX (auto-escaped) or sanitized
- Route: confirm `/admin/roadmap` is behind `<RequireAuth role="admin">`
- Fix stale comment in `routes/auth.js` line 120 (remove or correct the "Exempt from CSRF" note)
### Files
- `routes/aboutAdmin.js` — review new routes
- `client/pages/RoadmapPage.jsx` — review rendering
---
## Task 4 — Bishop: Verification + Docs Update
**Agent:** Bishop
**Priority:** After Tasks 2 and 3
**Estimated time:** 2-3 hours
### What
Build, test, verify the redesign works, update docs.
### Steps
1. Run `scripts/docker-test.sh` — fresh build on port 3036
2. Test: admin login → navigate to `/admin/roadmap`
3. Verify: 5 priority lanes render on desktop
4. Verify: lanes stack on mobile viewport
5. Verify: click item card → expands to show Description/Rationale/Notes
6. Verify: Activity Log tab loads data on click (not on page load)
7. Verify: `/about` and `/admin` no longer show admin dashboard
8. Verify: `/admin/roadmap` requires admin auth (non-admin gets redirect)
9. Verify: dark mode + light mode both look correct
10. Verify: keyboard navigation works (Tab, Enter/Space to expand)
11. Update `client/lib/version.js` — bump patch version
12. Update `STRUCTURE.md` — add RoadmapPage, remove AdminDashboard, update AboutPage description
13. Update Engineering Reference Manual — grep headings, update relevant sections only
### Files
- `client/lib/version.js` — version bump
- `package.json` — version bump
- `STRUCTURE.md` — add RoadmapPage, remove AdminDashboard
- `docs/Engineering_Reference_Manual.md` — targeted section updates
---
## Task 5 — Ripley: Final Commit & Push
**Agent:** Ripley
**Priority:** After Task 4
### What
Final review, commit, push, deploy.
### Steps
1. Review all changes
2. `git add -A && git commit -m "feat: redesign roadmap page as kanban-style priority lanes"`
3. `git push origin dev`
4. `scripts/docker-test.sh` — rebuild and redeploy
5. Update HISTORY.md with the change
6. Update FUTURE.md — add "Roadmap page redesign" if not already there, or reference this work
---
## Dependency Graph
```
Task 1 (Neo: API split)
└──→ Task 2 (Scarlett: UI) ──→ Task 3 (Hudson: Security) ──→ Task 4 (Bishop: Verify) ──→ Task 5 (Ripley: Commit)
```
Tasks 1 and 2 are sequential. Tasks 3 and 4 are sequential after 2. Task 5 is final.
## Estimated Total Time
| Task | Agent | Time |
|------|-------|------|
| 1 | Neo | 2-3h |
| 2 | Scarlett | 6-8h |
| 3 | Hudson | 1-2h |
| 4 | Bishop | 2-3h |
| 5 | Ripley | 30m |
| **Total** | | **12-17h** |
## Rollback Plan
If the redesign has issues in production:
- Revert `App.jsx` route to `<AboutPage admin />`
- Restore `AdminDashboard.jsx` from git
- Roadmap page works again in the old format
- New `/api/roadmap` and `/api/dev-log` endpoints are additive — no data loss

View File

@ -1,227 +0,0 @@
# Roadmap Page — UI Audit & Redesign Proposal
**Audited by:** Ripley
**Date:** 2026-05-11
**Component:** `client/components/AdminDashboard.jsx`
**Route:** `/admin/roadmap` (rendered via `AboutPage admin` prop)
**Framework:** Vite + React + Tailwind CSS + shadcn/ui + Radix
---
## Current State
The Roadmap page is an admin-only dashboard embedded inside `AboutPage.jsx`. It parses `FUTURE.md` and `DEVELOPMENT_LOG.md` via the `/api/about-admin` endpoint and renders two sections: a Roadmap card and a Development Activity Log card.
---
## Problems
### 1. It's Not a Real Page — It's an Appendix to About
The roadmap is rendered *inside* `AboutPage.jsx` with an `admin` prop. The `/admin/roadmap` route literally renders `<AboutPage admin />`. This means:
- The standard About page content (version cards, "Produced with AI" blurb, Sign In button) renders **below** the admin dashboard. An admin sees both the dashboard *and* the public about page stacked vertically.
- The "Back" button links to `/login` — wrong for an admin navigating from the sidebar.
- No dedicated page identity. It doesn't feel like a destination, it feels like a data dump tacked onto another page.
### 2. Two Giant Scrollboxes Trapped in Cards
Both Roadmap and Activity Log are `max-h-[500px]` scroll containers nested inside `Card` components. This creates:
- **Scroll-in-scroll**: The page itself scrolls, and then each card has its own internal scroll. Users fight nested scroll areas.
- **500px is arbitrary and too short** — on a 1080p screen with browser chrome, you see maybe 5-6 roadmap items before needing to scroll inside the card. With 10+ items now, most are hidden.
- **No visual indicator that content is scrollable** — no fade-out gradient, no scroll shadow, nothing signals "there's more below."
### 3. Collapsible Everything = Nothing Visible at a Glance
Every roadmap item is a `SimpleCollapsible` (custom, not shadcn). CRITICAL and HIGH start expanded, but MEDIUM/LOW/NICE-TO-HAVE are collapsed. This means:
- **6 out of 10 items are invisible by default** — you see 4 items, then 6 collapsed headers you have to click one by one.
- The collapsible headers show a priority badge + title, but no description, no effort estimate, no status beyond "PENDING" — you have to click each one to learn anything.
- No way to expand all / collapse all.
- `SimpleCollapsible` is a custom component when shadcn has `Collapsible` (Radix-based, accessible, animated).
### 4. No Priority Grouping or Visual Hierarchy
All roadmap items render as a flat list inside a single scroll container. The priority emoji/badge is the only differentiator:
- No section headers (CRITICAL / HIGH / MEDIUM / LOW) — items from different priorities blend together.
- No count indicators ("2 Critical, 3 High, 4 Medium...").
- No way to filter by priority or toggle visibility of entire tiers.
- The `PRIORITY_COLORS` object defines `border-l-4` left borders but the visual weight difference between orange and yellow on a dark theme is subtle.
### 5. Description Content Is Raw Markdown Dump
The `parseFutureMarkdown` function concatenates description, rationale, and implementation notes into a single `description` string with `whitespace-pre-wrap`. This means:
- Markdown formatting (`**Description:**`, `**Rationale:**`, bullet points) renders as literal text, not styled content.
- No visual separation between Description, Rationale, and Implementation Notes.
- Long implementation notes (the business logic item has code blocks) just dump as plain text.
- The markdown headers (`**Description:**`, etc.) show as bold text but with no structure — looks like a raw file view.
### 6. Activity Log Is Broken / Useless
The `parseDevLogMarkdown` function splits on `---` horizontal rules and tries to parse the development log. Problems:
- The actual `DEVELOPMENT_LOG.md` format doesn't consistently use `---` separators between entries — it uses `###` headers. The parser misses most entries.
- `devLogEntries` often comes back nearly empty or with badly parsed data.
- Each entry is a `DevLogEntry` component that's also collapsible (collapsed by default), so you're clicking to expand... inside a scrollbox... inside a card. Three layers of hiding.
- The dev log is 54KB of data being shipped to the frontend on every page load. Most admins never look at it.
### 7. No Interactivity or Actionability
This is a read-only data wall. There's no:
- Way to reorder priorities
- Way to mark an item as "in progress" or "started"
- Link to create a dispatch for an agent
- Progress indicator (how many items done vs pending)
- Filter or search
- Sorting (by priority, by date added, by effort)
### 8. Version Badge Is Orphaned
A lone `Badge` with the version number floats at the top of the component with no label, no context, no styling weight. It looks like it fell out of another component.
### 9. No Responsive Consideration
The component renders the same way at every breakpoint. On mobile:
- The 500px scroll containers are worse (less visible content).
- Collapsible headers with badges + long titles overflow or wrap poorly.
- No card reflow for small screens.
### 10. Accessibility Issues
- `SimpleCollapsible` uses a `div` with `onClick` — not a button, no `aria-expanded`, no keyboard activation.
- The scroll containers have no `role` or `aria-label`.
- No skip links within the dashboard sections.
- The priority emojis (🔴🟠🟡🔵💭) have no text alternatives for screen readers.
---
## Redesign Proposal
### Core Concept: Kanban-Style Priority Lanes
Replace the single flat scrollbox with a **horizontal lane layout** — one column per priority tier. Each lane shows its items as compact cards. This gives admins an at-a-glance view of the entire roadmap without scrolling or clicking.
### Architecture Changes
1. **Make it a standalone page**`RoadmapPage.jsx`, not `AboutPage admin`. The `/admin/roadmap` route should render its own component with its own layout, header, and identity.
2. **Use shadcn Tabs** for the two sections (Roadmap / Activity Log) instead of stacking two cards.
3. **Separate the About page** — admins who navigate to `/admin/roadmap` shouldn't see the public about page below it.
### Roadmap Tab Layout
```
┌─────────────────────────────────────────────────────┐
│ 🗺️ Roadmap v0.24.4 │
│ ┌─────────┬─────────┬─────────┬─────────┬─────────┐ │
│ │CRITICAL │ HIGH │ MEDIUM │ LOW │ NICE │ │
│ │ (1) │ (3) │ (4) │ (3) │ (1) │ │
│ ├─────────┼─────────┼─────────┼─────────┼─────────┤ │
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │
│ │ │Item │ │ │Item │ │ │Item │ │ │Item │ │ │Item │ │ │
│ │ │card │ │ │card │ │ │card │ │ │card │ │ │card │ │ │
│ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ │
│ │ │ │ │ │card │ │ │card │ │ │card │ │ │ │
│ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │ │
│ │ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ │
│ │ │ │card │ │ │card │ │ │card │ │ │ │
│ │ │ └─────┘ │ └─────┘ │ └─────┘ │ │ │
│ └─────────┴─────────┴─────────┴─────────┴─────────┘ │
└─────────────────────────────────────────────────────┘
```
On mobile (below `lg` breakpoint): lanes stack vertically with each lane as a collapsible section (using shadcn `Collapsible` or `Accordion`).
### Item Card Design (compact, scannable)
```
┌─────────────────────────┐
│ 🔴 CRITICAL │ ← Priority badge
│ No Confirmation Before │ ← Title (bold, 2-3 lines max)
│ Destructive Actions │
│ │
│ Added: 2026-05-11 │ ← Meta line (date, agent)
│ Est: 3-4h │ ← Effort estimate
│ │
│ [Expand ▸] │ ← Click to see full details
└─────────────────────────┘
```
Expanded card shows Description, Rationale, Implementation Notes as **properly styled sections** (not raw markdown dump).
### Activity Log Tab
- Replace the broken `parseDevLogMarkdown` with a **simpler timeline format** — just show version, date, agents involved, files modified. No full content dump.
- Consider lazy-loading: only fetch `developmentLog` when the Activity Log tab is selected (it's 54KB of data nobody needs on page load).
- Timeline format (vertical):
```
v0.24.4 — 2026-05-11
Scarlett ✅ 12m | Neo ✅ 3m | Bishop ✅ 7m
5 files modified
[▸ Expand details]
v0.23.2 — 2026-05-10
Neo ✅ | Bishop ✅
3 files modified
```
### Component Inventory (what to use)
| Need | Use |
|------|-----|
| Page container | Standalone `RoadmapPage.jsx` |
| Priority lanes | CSS Grid (`grid-cols-5` on `lg`, `grid-cols-1` on mobile) |
| Lane sections | `Card` with colored top border from `PRIORITY_COLORS` |
| Item cards | `Card` with `CardHeader`/`CardContent` |
| Item expand/collapse | shadcn `Collapsible` (Radix, accessible) |
| Tab switching | shadcn `Tabs` / `TabsList` / `TabsTrigger` |
| Priority badges | Keep current `Badge` + emoji approach, add `aria-label` |
| Scroll | Page-level scroll only, no nested scroll containers |
| Expand All / Collapse All | `Button` at top of Roadmap tab |
| Item count per lane | `Badge` variant="outline" in lane header |
### Files to Modify
| File | Change |
|------|--------|
| `client/components/AdminDashboard.jsx` | **Delete** (replaced by RoadmapPage) |
| `client/pages/AboutPage.jsx` | Remove `admin` prop, remove AdminDashboard import — AboutPage goes back to being a public-only page |
| `client/pages/RoadmapPage.jsx` | **New** — standalone roadmap page |
| `client/App.jsx` | Update `/admin/roadmap` route to render `<RoadmapPage />` instead of `<AboutPage admin />`; possibly add `/admin/about` route if admins need the about page |
| `client/api.js` | No changes needed (same endpoint) |
### Data Parsing Improvements
- **Parse FUTURE.md into structured sections** — separate Description, Rationale, Implementation Notes into distinct fields on the item object instead of concatenating into one `description` blob.
- **Extract effort estimate** from Implementation Notes (`Estimated effort: 3-4 hours` → `effort: "3-4h"`).
- **Lazy-load dev log** — only call `/api/about-admin` with `developmentLog` when the Activity Log tab is active, or split the API into two endpoints.
### Responsive Breakpoints
| Breakpoint | Layout |
|-----------|--------|
| `< sm` (mobile) | Single column, lanes stack vertically as collapsible sections |
| `smlg` (tablet) | 2 columns (CRITICAL+HIGH | MEDIUM+LOW+NICE) |
| `lg+` (desktop) | 5 columns, one per priority tier |
### Accessibility Fixes
- Replace `SimpleCollapsible` div+onClick with shadcn `Collapsible` (button trigger, `aria-expanded`, keyboard support)
- Add `aria-label` to priority badges (e.g., `aria-label="Critical priority"`)
- Add `role="region"` and `aria-label` to lane sections
- Ensure all interactive elements are keyboard-focusable
- Add `aria-live="polite"` to expand/collapse regions
---
## Priority for Implementation
This is a **MEDIUM** priority redesign. The current page works for data display but fails at being a useful admin tool. The kanban-style layout and proper parsing would make it genuinely useful for planning.
**Estimated effort:** 8-12 hours (Scarlett for UI, Neo for API split if lazy-loading)

35
package-lock.json generated
View File

@ -1,17 +1,16 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.24.6", "version": "0.21.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.24.6", "version": "0.21.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
@ -970,36 +969,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.27.01", "version": "0.24.4",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -13,7 +13,6 @@
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",

View File

@ -14,358 +14,6 @@ const ALLOWED_FILES = {
'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'), 'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'),
}; };
// Priority emoji to label mapping
const PRIORITY_MAP = {
'🔴': 'CRITICAL',
'🟠': 'HIGH',
'🟡': 'MEDIUM',
'🔵': 'LOW',
'💭': 'NICE_TO_HAVE',
};
/**
* Generate a slug from a title: lowercase, hyphens, strip emojis
*/
function slugify(title) {
return title
.replace(/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FAFF}]/gu, '') // strip emojis
.replace(/[^a-zA-Z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, '') // trim leading/trailing hyphens
.toLowerCase();
}
/**
* Extract effort estimate from implementation notes text.
* Matches patterns like "Estimated effort: 3-4 hours", "Estimated effort: 8 hours"
*/
function extractEffort(text) {
if (!text) return null;
const match = text.match(/Estimated effort:\s*(\d+(?:\s*-\s*\d+)?\s*hours?)/i);
if (!match) return null;
// Normalize: "3-4 hours" → "3-4h", "8 hours" → "8h"
return match[1].replace(/\s*hours?/i, 'h').replace(/\s*/g, '');
}
/**
* Parse FUTURE.md into structured roadmap items.
* Filters out completed/strikethrough items and template/meta sections.
*/
function parseFutureMd(content) {
if (!content) return { items: [], counts: {} };
const items = [];
const counts = { critical: 0, high: 0, medium: 0, low: 0, niceToHave: 0 };
const lines = content.split('\n');
let skipSection = false;
let currentSectionLines = [];
let currentPriorityEmoji = null;
let currentPriorityLabel = null;
let currentTitle = null;
let inItem = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip template/meta sections
if (/^##\s+How to Use This Document/i.test(line) || /^###\s+Priority Format/i.test(line)) {
skipSection = true;
continue;
}
// Completed items section
if (/^##\s+Completed/i.test(line)) {
skipSection = true;
continue;
}
// Stop skipping at ## or ### headings that aren't skipped sections
if (skipSection) {
if (/^(?:##|###)\s/.test(line) && !/^(?:##|###)\s+(How to Use|Priority Format|Completed)/i.test(line)) {
skipSection = false;
// Don't continue — process this heading line below
} else {
continue;
}
}
// Skip table rows (Priority Format table)
if (/^\|/.test(line)) continue;
// Strikethrough items: ### ~~Title~~ — PRIORITY
if (/^###\s+~~/.test(line)) {
// Save previous item and skip completed/strikethrough items
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
inItem = false;
currentTitle = null;
currentSectionLines = [];
continue;
}
// Priority section headings: ### 🔴 CRITICAL, ### 🟠 HIGH, etc.
const sectionMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE)\s*$/);
if (sectionMatch) {
// Save previous item
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
currentPriorityEmoji = sectionMatch[1];
currentPriorityLabel = sectionMatch[2];
inItem = false;
currentTitle = null;
currentSectionLines = [];
continue;
}
// Item headings: ### 🔴 Title — CRITICAL or ### Title — HIGH etc.
const headingMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/);
const headingMatchNoEmoji = line.match(/^###\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/);
if (headingMatch) {
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
currentPriorityEmoji = headingMatch[1];
currentPriorityLabel = headingMatch[3];
currentTitle = headingMatch[2].trim();
currentSectionLines = [];
inItem = true;
continue;
}
if (!headingMatch && headingMatchNoEmoji) {
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
currentPriorityEmoji = currentPriorityEmoji || null; // inherit from section
currentPriorityLabel = headingMatchNoEmoji[2];
currentTitle = headingMatchNoEmoji[1].trim();
currentSectionLines = [];
inItem = true;
continue;
}
// Also handle items with emoji but no trailing priority: ### 🔴 Title
const headingEmojiOnly = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*$/);
if (headingEmojiOnly && !headingMatch) {
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
currentPriorityEmoji = headingEmojiOnly[1];
// Use section-level priority if available
currentPriorityLabel = currentPriorityLabel || PRIORITY_MAP[headingEmojiOnly[1]] || 'MEDIUM';
currentTitle = headingEmojiOnly[2].trim();
currentSectionLines = [];
inItem = true;
continue;
}
// Generic ### headings without emoji or priority label (items in a section context)
if (/^###\s+/.test(line) && !headingMatch && !headingMatchNoEmoji && !headingEmojiOnly) {
// Plain ### heading within a known section
if (currentPriorityLabel) {
// Save previous item
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
currentTitle = line.replace(/^###\s+/, '').trim();
currentSectionLines = [];
inItem = true;
continue;
}
}
// ## Pending Recommendations heading — skip
if (/^##\s+Pending Recommendations/.test(line)) {
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
inItem = false;
currentTitle = null;
currentSectionLines = [];
continue;
}
// Collect body lines for current item
if (inItem) {
currentSectionLines.push(line);
}
}
// Save last item
if (inItem && currentTitle) {
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
}
return { items, counts, version: pkg.version };
}
/**
* Add a parsed item to the items array and update counts.
*/
function _addItem(items, counts, emoji, label, title, bodyLines) {
const body = bodyLines.join('\n');
const description = _extractField(body, 'Description');
const rationale = _extractField(body, 'Rationale');
const implementationNotes = _extractField(body, 'Implementation Notes');
const effort = extractEffort(implementationNotes);
// Extract Added and AddedBy metadata
const addedMatch = body.match(/\*\*Added:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+by\s+(.+))?/);
const added = addedMatch ? addedMatch[1] : null;
const addedBy = addedMatch ? (addedMatch[2] || null) : null;
// Determine status — if not specified, default to PENDING
const statusMatch = body.match(/\*\*Status:\*\*\s*(.+)/);
const status = statusMatch ? statusMatch[1].trim().toUpperCase() : 'PENDING';
// Map priority label to count key
const countKey = {
'CRITICAL': 'critical',
'HIGH': 'high',
'MEDIUM': 'medium',
'LOW': 'low',
'NICE TO HAVE': 'niceToHave',
'NICE_TO_HAVE': 'niceToHave',
'MEH': 'niceToHave',
}[label] || 'medium';
counts[countKey]++;
items.push({
id: slugify(title),
priority: emoji || '',
priorityLabel: label,
title,
description,
rationale,
implementationNotes,
effort,
added,
addedBy,
status,
});
}
/**
* Extract a named field from markdown body text.
* Looks for **Field Name:** and captures everything until the next ** field or ### heading or end.
*/
function _extractField(body, fieldName) {
// Match **FieldName:** followed by content until next ** or ### heading
const regex = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*\n([\\s\\S]*?)(?=\\n\\*\\*[^*]|\\n###|$)`, 'i');
const match = body.match(regex);
if (!match) return null;
return match[1].trim();
}
/**
* Parse DEVELOPMENT_LOG.md into structured log entries.
* Returns entries sorted by date descending.
*/
function parseDevLogMd(content) {
if (!content) return [];
const entries = [];
// Split on version headings: ### v0.24.4 - Title
const versionRegex = /^###\s+(v[\d.]+(?:-[\w]+)?)\s+-\s+(.+)$/gm;
const splits = [];
let match;
while ((match = versionRegex.exec(content)) !== null) {
splits.push({
version: match[1],
title: match[2].trim(),
index: match.index,
});
}
for (let i = 0; i < splits.length; i++) {
const start = splits[i].index;
const end = i + 1 < splits.length ? splits[i + 1].index : content.length;
const block = content.substring(start, end);
const entry = _parseDevLogEntry(block, splits[i].version, splits[i].title);
if (entry) entries.push(entry);
}
// Sort by date descending
entries.sort((a, b) => {
const dateA = a.date ? new Date(a.date) : new Date(0);
const dateB = b.date ? new Date(b.date) : new Date(0);
return dateB - dateA;
});
return entries;
}
/**
* Parse a single dev log entry block.
*/
function _parseDevLogEntry(block, version, title) {
// Status
const statusMatch = block.match(/\*\*Status:\*\*\s*(.+)/);
const status = statusMatch ? statusMatch[1].trim() : null;
// Date
const dateMatch = block.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/);
const date = dateMatch ? dateMatch[1] : null;
// Priority
const priorityMatch = block.match(/\*\*Priority:\*\*\s*(.+)/);
const priority = priorityMatch ? priorityMatch[1].trim() : null;
// Agents table
const agents = [];
const agentTableRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g;
let inAgentTable = false;
const blockLines = block.split('\n');
for (const line of blockLines) {
if (/^\|\s*Agent\s*\|/i.test(line)) {
inAgentTable = true;
continue;
}
if (/^\|\s*[-:]+\s*\|/.test(line)) continue; // separator row
if (inAgentTable && /^\|/.test(line)) {
const row = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/);
if (row) {
agents.push({
name: row[1].trim(),
status: row[2].trim(),
time: row[3].trim(),
notes: row[4].trim(),
});
}
} else if (inAgentTable && !/^\|/.test(line) && line.trim() !== '') {
inAgentTable = false;
}
}
// Files modified
const filesMatch = block.match(/\*\*Files modified:\*\*\s*(.+)/);
const filesModified = filesMatch
? filesMatch[1].split(',').map(f => f.trim().replace(/^`|`$/g, '')).filter(Boolean)
: [];
// Work completed (checklist items)
const workCompleted = [];
const workMatch = block.match(/\*\*Work Completed:\*\*\n([\s\S]*?)(?=\n---|\n###|$)/);
if (workMatch) {
const items = workMatch[1].match(/- \[[ x]\] .+/g);
if (items) {
workCompleted.push(...items.map(item => item.replace(/^- \[[ x]\]\s*/, '').trim()));
}
}
return {
version,
title,
date,
status,
priority,
agents,
filesModified,
workCompleted,
};
}
/** /**
* Redact sensitive information from file content * Redact sensitive information from file content
* @param {string} content - The content to redact * @param {string} content - The content to redact
@ -397,7 +45,7 @@ function redactSensitiveContent(content) {
.replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]') .replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]')
} }
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat) // Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content
router.get('/', requireAuth, requireAdmin, (req, res) => { router.get('/', requireAuth, requireAdmin, (req, res) => {
try { try {
// Read both files directly from the allowlist // Read both files directly from the allowlist
@ -423,36 +71,4 @@ router.get('/', requireAuth, requireAdmin, (req, res) => {
} }
}); });
// Admin-only endpoint: parsed roadmap items from FUTURE.md
router.get('/roadmap', requireAuth, requireAdmin, (req, res) => {
try {
const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8');
const sanitized = redactSensitiveContent(futureContent);
const result = parseFutureMd(sanitized);
res.json(result);
} catch (err) {
console.error('[aboutAdmin] Error reading FUTURE.md for roadmap');
res.status(500).json({
error: 'Failed to read roadmap data',
code: 'FILE_READ_ERROR'
});
}
});
// Admin-only endpoint: parsed dev log entries from DEVELOPMENT_LOG.md
router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
try {
const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8');
const sanitized = redactSensitiveContent(devLogContent);
const entries = parseDevLogMd(sanitized);
res.json({ entries, version: pkg.version });
} catch (err) {
console.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log');
res.status(500).json({
error: 'Failed to read dev log data',
code: 'FILE_READ_ERROR'
});
}
});
module.exports = router; module.exports = router;

View File

@ -250,47 +250,6 @@ router.put('/users/:id/active', (req, res) => {
).get(targetId)); ).get(targetId));
}); });
// PUT /api/admin/users/:id/username
router.put('/users/:id/username', (req, res) => {
const { username } = req.body;
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'username is required' });
}
const trimmed = username.trim();
if (trimmed.length < 3) {
return res.status(400).json({ error: 'Username must be at least 3 characters' });
}
if (trimmed.length > 50) {
return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
}
const targetId = Number(req.params.id);
const db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' });
const taken = db.prepare(
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
).get(trimmed, targetId);
if (taken) return res.status(409).json({ error: 'Username already taken' });
const previousUsername = user.username;
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?")
.run(trimmed, targetId);
logAudit({
user_id: req.user.id, action: 'admin.username.change',
entity_type: 'user', entity_id: targetId,
details: { old_username: previousUsername, new_username: trimmed },
ip_address: req.ip, user_agent: req.get('user-agent'),
});
res.json(
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
.get(targetId)
);
});
// DELETE /api/admin/users/:id // DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => { router.delete('/users/:id', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -117,7 +117,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => {
// POST /api/auth/change-password // POST /api/auth/change-password
// Password change endpoint with dedicated rate limiter // Password change endpoint with dedicated rate limiter
// CSRF protected via csrfMiddleware on /api/auth mount // Exempt from CSRF - session-based auth is primary protection (pre-middleware sets csrfSkip)
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => { router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;

View File

@ -1,9 +1,72 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const { standardizeError } = require('../middleware/errorFormatter'); 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) {
return { error: 'due_day must be an integer between 1 and 31' };
}
return { value: day };
}
function parseInterestRate(value) {
if (value === undefined) return { value: undefined };
if (value === null) return { value: null };
if (typeof value === 'string' && value.trim() === '') return { value: null };
const rate = Number(value);
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
return { error: 'interest_rate must be a number between 0 and 100, or null' };
}
return { value: rate };
}
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
@ -128,52 +191,66 @@ router.post('/', (req, res) => {
account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day, account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
} = req.body; } = req.body;
// Validate and normalize bill data if (!name || due_day == null) {
const validation = validateBillData(req.body); return res.status(400).json(standardizeError('name and due_day are required', 'VALIDATION_ERROR', 'name'));
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; // 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 category_id exists for this user // Validate cycle_day based on cycle_type
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) { 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;
const parsedInterest = parseInterestRate(interest_rate);
if (parsedInterest.error) return res.status(400).json(standardizeError(parsedInterest.error, 'VALIDATION_ERROR', 'interest_rate'));
const bucket = day <= 14 ? '1st' : '15th';
const catId = category_id || null;
if (catId && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(catId, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
} }
const visibility = history_visibility || 'default';
if (!VALID_VISIBILITY.includes(visibility)) {
return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
}
const result = db.prepare(` const result = db.prepare(`
INSERT INTO bills INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day, account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day)
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
req.user.id, req.user.id,
normalized.name, name,
normalized.category_id, catId,
normalized.due_day, day,
normalized.override_due_date, override_due_date || null,
normalized.bucket, bucket,
normalized.expected_amount, parseFloat(expected_amount) || 0,
normalized.interest_rate, parsedInterest.value ?? null,
normalized.billing_cycle, billing_cycle || 'monthly',
normalized.autopay_enabled, autopay_enabled ? 1 : 0,
normalized.autodraft_status, autodraft_status || 'none',
normalized.website, website || null,
normalized.username, username || null,
normalized.account_info, account_info || null,
normalized.has_2fa, has_2fa ? 1 : 0,
normalized.notes, notes || null,
normalized.history_visibility, visibility,
normalized.cycle_type, cycleType,
normalized.cycle_day, cycleDay,
normalized.current_balance,
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
normalized.snowball_exempt,
); );
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid); const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
@ -186,54 +263,75 @@ router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Validate and normalize bill data const {
const validation = validateBillData(req.body, existing); name, category_id, due_day, override_due_date, expected_amount, interest_rate,
if (validation.errors.length > 0) { billing_cycle, autopay_enabled, autodraft_status, website, username,
const firstError = validation.errors[0]; account_info, has_2fa, notes, active, history_visibility, cycle_type, cycle_day,
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field)); } = req.body;
}
const { normalized } = validation; const due = due_day !== undefined ? parseDueDay(due_day) : { value: existing.due_day };
if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day'));
const day = due.value;
// Validate category_id exists for this user if changed const parsedInterest = parseInterestRate(interest_rate);
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) { if (parsedInterest.error) return res.status(400).json(standardizeError(parsedInterest.error, 'VALIDATION_ERROR', 'interest_rate'));
const bucket = day <= 14 ? '1st' : '15th';
const nextCategoryId = category_id !== undefined ? (category_id || null) : existing.category_id;
if (nextCategoryId && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(nextCategoryId, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
} }
const nextVisibility = history_visibility !== undefined ? history_visibility : existing.history_visibility;
if (!VALID_VISIBILITY.includes(nextVisibility)) {
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(` db.prepare(`
UPDATE bills SET UPDATE bills SET
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?, name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, cycle_type = ?, cycle_day = ?, history_visibility = ?, cycle_type = ?, cycle_day = ?,
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`).run( `).run(
normalized.name, name ?? existing.name,
normalized.category_id, nextCategoryId,
normalized.due_day, day,
normalized.override_due_date, override_due_date !== undefined ? (override_due_date || null) : existing.override_due_date,
normalized.bucket, bucket,
normalized.expected_amount, expected_amount != null ? parseFloat(expected_amount) : existing.expected_amount,
normalized.interest_rate, parsedInterest.value !== undefined ? parsedInterest.value : existing.interest_rate,
normalized.billing_cycle, billing_cycle ?? existing.billing_cycle,
normalized.autopay_enabled, autopay_enabled != null ? (autopay_enabled ? 1 : 0) : existing.autopay_enabled,
normalized.autodraft_status, autodraft_status ?? existing.autodraft_status,
normalized.website, website !== undefined ? (website || null) : existing.website,
normalized.username, username !== undefined ? (username || null) : existing.username,
normalized.account_info, account_info !== undefined ? (account_info || null) : existing.account_info,
normalized.has_2fa, has_2fa != null ? (has_2fa ? 1 : 0) : existing.has_2fa,
normalized.notes, notes !== undefined ? (notes || null) : existing.notes,
normalized.active, active != null ? (active ? 1 : 0) : existing.active,
normalized.history_visibility, nextVisibility,
normalized.cycle_type, nextCycleType,
normalized.cycle_day, nextCycleDay,
normalized.current_balance,
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
normalized.snowball_exempt,
req.params.id, req.params.id,
req.user.id, req.user.id,
); );
@ -298,7 +396,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
// Get bill - always scope to the requesting user // Get bill - always scope to the requesting user
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); const bill = db.prepare('SELECT id, expected_amount, user_id, due_day FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@ -319,14 +417,6 @@ router.post('/:id/toggle-paid', (req, res) => {
// If paid (has payment), remove it → Unpaid // If paid (has payment), remove it → Unpaid
if (currentPayment) { if (currentPayment) {
// Reverse any balance delta that was applied when this payment was created
if (currentPayment.balance_delta != null) {
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
if (freshBill?.current_balance != null) {
const restored = Math.max(0, Math.round((freshBill.current_balance - currentPayment.balance_delta) * 100) / 100);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId);
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(currentPayment.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(currentPayment.id);
res.json({ res.json({
success: true, success: true,
@ -359,17 +449,9 @@ router.post('/:id/toggle-paid', (req, res) => {
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount')); return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
} }
// Compute balance delta for debt bills before inserting
const balCalc = computeBalanceDelta(bill, amount);
const result = db.prepare( const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(billId, amount, paidDate, method, notes, balCalc?.balance_delta ?? null); ).run(billId, amount, paidDate, method, notes);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, billId);
}
res.status(201).json({ res.status(201).json({
success: true, success: true,
@ -499,46 +581,4 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
router.patch('/:id/balance', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
const raw = req.body.current_balance;
let val = null;
if (raw !== null && raw !== '' && raw !== undefined) {
val = parseFloat(raw);
if (!Number.isFinite(val) || val < 0) {
return res.status(400).json(standardizeError('current_balance must be a non-negative number', 'VALIDATION_ERROR', 'current_balance'));
}
val = Math.round(val * 100) / 100;
}
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId);
res.json({ id: billId, current_balance: val });
});
// ── PATCH /api/bills/:id/snowball — lightweight snowball visibility update ───
router.patch('/:id/snowball', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
const include = req.body.snowball_include ? 1 : 0;
const exempt = req.body.snowball_exempt ? 1 : 0;
db.prepare(`
UPDATE bills
SET snowball_include = ?, snowball_exempt = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(include, exempt, billId, req.user.id);
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
});
module.exports = router; module.exports = router;

View File

@ -62,17 +62,6 @@ function calculatePaidDeductions(db, userId, year, month) {
AND b.due_day BETWEEN 15 AND 31 AND b.due_day BETWEEN 15 AND 31
`).get(userId, start, end); `).get(userId, start, end);
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
const otherPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND (b.due_day < 1 OR b.due_day > 31)
`).get(userId, start, end);
const totalPaid = db.prepare(` const totalPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p FROM payments p
@ -85,7 +74,6 @@ function calculatePaidDeductions(db, userId, year, month) {
return { return {
paid_from_first: money(firstPaid.paid), paid_from_first: money(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid), paid_from_fifteenth: money(fifteenthPaid.paid),
paid_from_other: money(otherPaid.paid),
paid_total: money(totalPaid.paid), paid_total: money(totalPaid.paid),
}; };
} }
@ -106,11 +94,10 @@ function buildStartingAmountsResponse(db, userId, year, month) {
combined_amount, combined_amount,
paid_from_first: paid.paid_from_first, paid_from_first: paid.paid_from_first,
paid_from_fifteenth: paid.paid_from_fifteenth, paid_from_fifteenth: paid.paid_from_fifteenth,
paid_from_other: paid.paid_from_other,
paid_total, paid_total,
first_remaining: amounts.first_amount - paid.paid_from_first, first_remaining: amounts.first_amount - paid.paid_from_first,
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth, fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
other_remaining: amounts.other_amount - paid.paid_from_other, other_remaining: amounts.other_amount,
combined_remaining: combined_amount - paid_total, combined_remaining: combined_amount - paid_total,
}; };
} }

View File

@ -2,7 +2,6 @@ const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
@ -92,16 +91,9 @@ router.post('/quick', (req, res) => {
const payDate = paid_date || new Date().toISOString().slice(0, 10); const payDate = paid_date || new Date().toISOString().slice(0, 10);
const balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare( const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(bill_id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null); ).run(bill_id, payAmount, payDate, method || null, notes || null);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, bill_id);
}
if (bill.autopay_enabled) { if (bill.autopay_enabled) {
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id); db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id);
@ -158,10 +150,8 @@ router.post('/bulk', (req, res) => {
} }
const insert = db.prepare( const insert = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
); );
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?');
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
// Prepare statement for duplicate checking // Prepare statement for duplicate checking
const duplicateCheckStmt = db.prepare( const duplicateCheckStmt = db.prepare(
@ -191,16 +181,12 @@ router.post('/bulk', (req, res) => {
continue; continue;
} }
const billRow = getBillForBalance.get(bill_id, req.user.id); if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id)) {
if (!billRow) {
errors.push({ item, error: `Bill ${bill_id} not found` }); errors.push({ item, error: `Bill ${bill_id} not found` });
continue; continue;
} }
const balCalc = computeBalanceDelta(billRow, parsedAmt); const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null);
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null);
if (balCalc) applyBalance.run(balCalc.new_balance, bill_id);
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)); created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
} }
}); });
@ -236,18 +222,8 @@ router.put('/:id', (req, res) => {
// DELETE /api/payments/:id — soft delete (sets deleted_at) // DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id); const payment = db.prepare(`SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
// Reverse any balance delta that was stored when this payment was created
if (payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id);
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
@ -255,18 +231,8 @@ router.delete('/:id', (req, res) => {
// POST /api/payments/:id/restore — undo soft delete // POST /api/payments/:id/restore — undo soft delete
router.post('/:id/restore', (req, res) => { router.post('/:id/restore', (req, res) => {
const db = getDb(); const db = getDb();
const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id); const payment = db.prepare('SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
// Re-apply the balance delta (undo the reversal done on delete)
if (payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) {
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id);
}
}
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id); db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id);
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
}); });

View File

@ -57,34 +57,10 @@ router.get('/', (req, res) => {
}); });
// ── PATCH /api/profile ──────────────────────────────────────────────────────── // ── PATCH /api/profile ────────────────────────────────────────────────────────
// Updates safe profile fields: username and display_name. // Updates safe profile fields: display_name only.
// Ignores any unknown or restricted fields. // Ignores any unknown or restricted fields.
router.patch('/', (req, res) => { router.patch('/', (req, res) => {
const { username, display_name } = req.body; const { display_name } = req.body;
const db = getDb();
if (username !== undefined) {
if (typeof username !== 'string') {
return res.status(400).json({ error: 'username must be a string' });
}
const trimmedUsername = username.trim();
if (trimmedUsername.length < 3) {
return res.status(400).json({ error: 'username must be at least 3 characters' });
}
if (trimmedUsername.length > 50) {
return res.status(400).json({ error: 'username must be 50 characters or fewer' });
}
const taken = db.prepare(
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
).get(trimmedUsername, req.user.id);
if (taken) {
return res.status(409).json({ error: 'Username already taken' });
}
db.prepare(
"UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?"
).run(trimmedUsername, req.user.id);
logAudit({ user_id: req.user.id, action: 'profile.username.change', ip_address: req.ip, user_agent: req.get('user-agent') });
}
if (display_name !== undefined) { if (display_name !== undefined) {
if (typeof display_name !== 'string') { if (typeof display_name !== 'string') {
@ -95,7 +71,7 @@ router.patch('/', (req, res) => {
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' }); return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
} }
db.prepare( getDb().prepare(
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?" "UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
).run(trimmed || null, req.user.id); ).run(trimmed || null, req.user.id);

View File

@ -1,121 +0,0 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1
OR (
COALESCE(b.snowball_exempt, 0) = 0
AND (
LOWER(c.name) LIKE '%credit%'
OR LOWER(c.name) LIKE '%loan%'
OR LOWER(c.name) LIKE '%mortgage%'
OR LOWER(c.name) LIKE '%housing%'
OR LOWER(c.name) LIKE '%debt%'
)
)
)`;
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const db = getDb();
const bills = db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
WHERE b.user_id = ?
AND b.active = 1
AND ${DEBT_LIKE_CLAUSES}
ORDER BY
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
b.snowball_order ASC,
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC
`).all(req.user.id);
res.json(bills);
});
// GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({ extra_payment: user?.snowball_extra_payment ?? 0 });
});
// PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req, res) => {
const { extra_payment } = req.body;
let val = 0;
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) {
return res.status(400).json(standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
'extra_payment'
));
}
}
const db = getDb();
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
res.json({ extra_payment: val });
});
// GET /api/snowball/projection — payoff timeline using the snowball math service
router.get('/projection', (req, res) => {
const db = getDb();
const bills = db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
WHERE b.user_id = ?
AND b.active = 1
AND ${DEBT_LIKE_CLAUSES}
ORDER BY
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
b.snowball_order ASC,
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC
`).all(req.user.id);
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
const extraPayment = user?.snowball_extra_payment ?? 0;
const now = new Date();
const snowball = calculateSnowball(bills, extraPayment, now);
const avalanche = calculateAvalanche(bills, extraPayment, now);
res.json({ snowball, avalanche });
});
// PATCH /api/snowball/order — batch-save snowball_order positions
router.patch('/order', (req, res) => {
const items = req.body;
if (!Array.isArray(items)) {
return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
}
const db = getDb();
const userId = req.user.id;
const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?');
db.transaction((rows) => {
for (const row of rows) {
const id = parseInt(row.id, 10);
const order = parseInt(row.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) continue;
if (!Number.isInteger(order) || order < 0) continue;
update.run(order, id, userId);
}
})(items);
res.json({ success: true });
});
module.exports = router;

View File

@ -64,17 +64,6 @@ function calculatePaidDeductions(db, userId, year, month) {
AND b.due_day BETWEEN 15 AND 31 AND b.due_day BETWEEN 15 AND 31
`).get(userId, start, end); `).get(userId, start, end);
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
const otherPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND (b.due_day < 1 OR b.due_day > 31)
`).get(userId, start, end);
const totalPaid = db.prepare(` const totalPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p FROM payments p
@ -87,7 +76,6 @@ function calculatePaidDeductions(db, userId, year, month) {
return { return {
paid_from_first: money(firstPaid.paid), paid_from_first: money(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid), paid_from_fifteenth: money(fifteenthPaid.paid),
paid_from_other: money(otherPaid.paid),
paid_total: money(totalPaid.paid), paid_total: money(totalPaid.paid),
}; };
} }
@ -108,11 +96,10 @@ function buildStartingAmountsSummary(db, userId, year, month) {
combined_amount, combined_amount,
paid_from_first: paid.paid_from_first, paid_from_first: paid.paid_from_first,
paid_from_fifteenth: paid.paid_from_fifteenth, paid_from_fifteenth: paid.paid_from_fifteenth,
paid_from_other: paid.paid_from_other,
paid_total, paid_total,
first_remaining: amounts.first_amount - paid.paid_from_first, first_remaining: amounts.first_amount - paid.paid_from_first,
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth, fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
other_remaining: amounts.other_amount - paid.paid_from_other, other_remaining: amounts.other_amount,
combined_remaining: combined_amount - paid_total, combined_remaining: combined_amount - paid_total,
}; };
} }

View File

@ -125,7 +125,6 @@ router.get('/', (req, res) => {
const hasStartingAmounts = !!startingAmounts; const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0); const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0); const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
// Calculate previous month total // Calculate previous month total
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0); const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
@ -198,7 +197,7 @@ router.get('/', (req, res) => {
total_starting: totalStarting, total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts, has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid, total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance, remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
overdue: totalOverdue, overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length, count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length, count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,

View File

@ -6,33 +6,6 @@ const { getDb } = require('../db/database');
const { seedDemoData } = require('../scripts/seedDemoData'); const { seedDemoData } = require('../scripts/seedDemoData');
const { demoDataLimiter } = require('../middleware/rateLimiter'); const { demoDataLimiter } = require('../middleware/rateLimiter');
// GET /api/user/seeded-status — returns whether the current user has any seeded data
router.get('/seeded-status', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
// Check for seeded bills
const seededBillsResult = db.prepare('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1').get(userId);
const seededBillsCount = seededBillsResult.count;
// Check for seeded categories
const seededCategoriesResult = db.prepare('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1').get(userId);
const seededCategoriesCount = seededCategoriesResult.count;
const hasSeededData = seededBillsCount > 0 || seededCategoriesCount > 0;
res.json({
seeded: hasSeededData,
seededBills: seededBillsCount,
seededCategories: seededCategoriesCount,
});
} catch (err) {
const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Seeded status check failed' : err.message });
}
});
// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user // POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user
router.post('/clear-demo-data', demoDataLimiter, (req, res) => { router.post('/clear-demo-data', demoDataLimiter, (req, res) => {
try { try {

View File

@ -1,17 +0,0 @@
#!/usr/bin/env bash
# docker-push.sh — Tag and push dev image to Forgejo registry
# Usage: ./scripts/docker-push.sh
# Requires: ~/.openclaw/docker-registry.env (chmod 600)
set -euo pipefail
cd "$(dirname "$0")/.."
source ~/.openclaw/docker-registry.env
echo "$FORGEJO_REGISTRY_TOKEN" | docker login "$FORGEJO_REGISTRY" -u "$FORGEJO_REGISTRY_USER" --password-stdin
docker tag bill-tracker:local "${FORGEJO_REGISTRY}/null/bill-tracker:dev"
docker push "${FORGEJO_REGISTRY}/null/bill-tracker:dev"
docker logout "$FORGEJO_REGISTRY"
echo "✓ Pushed dev image"

View File

@ -1,27 +0,0 @@
#!/usr/bin/env bash
# docker-test.sh — Build and run bill-tracker in Docker for testing
# Usage: ./scripts/docker-test.sh
# Access: http://localhost:3036
set -euo pipefail
cd "$(dirname "$0")/.."
docker stop bill-tracker 2>/dev/null || true
docker rm bill-tracker 2>/dev/null || true
rm -rf dist node_modules/.vite 2>/dev/null
docker build --no-cache -t bill-tracker:local .
docker run -d --name bill-tracker -p 3036:3000 --restart unless-stopped \
-e INIT_ADMIN_USER=admin \
-e INIT_ADMIN_PASS=admin123 \
-e INIT_TEST_USER=testuser \
-e INIT_TEST_PASS=testpass123 \
-e INIT_REGULAR_USER=regularuser \
-e INIT_REGULAR_PASS=regularpass123 \
-e CSRF_HTTP_ONLY=false \
-e CSRF_SAME_SITE=lax \
-v /tmp/bill-tracker-test/data:/data \
bill-tracker:local
echo "✓ Running on http://localhost:3036"

View File

@ -20,8 +20,7 @@ const CATEGORIES = [
'Subscriptions', 'Subscriptions',
'Transportation', 'Transportation',
'Healthcare', 'Healthcare',
'Credit Cards', 'Finance',
'Loans',
'Entertainment', 'Entertainment',
]; ];
@ -29,19 +28,19 @@ const CATEGORIES = [
const BILLS = [ const BILLS = [
{ name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Mortgage', category: 'Housing', amount: 1200, dueDay: 1, cycle: 'monthly', autopay: true, interestRate: 3.25, currentBalance: 185000, minPayment: 1200, snowballOrder: 3, snowballInclude: 0 }, { name: 'Rent/Mortgage', category: 'Housing', amount: 1200, dueDay: 1, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', autopay: true, interestRate: 0 }, { name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', autopay: true, interestRate: 0 },
{ name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', autopay: true, interestRate: 0 }, { name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', autopay: true, interestRate: 0 },
{ name: 'Credit Card', category: 'Credit Cards', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99, currentBalance: 2800, minPayment: 75, snowballOrder: 0, snowballInclude: 1 }, { name: 'Credit Card', category: 'Finance', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99 },
{ name: 'Student Loan', category: 'Loans', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5, currentBalance: 12500, minPayment: 150, snowballOrder: 1, snowballInclude: 1 }, { name: 'Student Loan', category: 'Finance', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5 },
{ name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', autopay: false, interestRate: 0 }, { name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', autopay: false, interestRate: 0 },
{ name: 'Car Payment', category: 'Loans', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5, currentBalance: 8400, minPayment: 350, snowballOrder: 2, snowballInclude: 1 }, { name: 'Car Payment', category: 'Finance', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5 },
{ name: 'Spotify', category: 'Entertainment', amount: 9.99, dueDay: 14, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Spotify', category: 'Entertainment', amount: 9.99, dueDay: 14, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', autopay: true, interestRate: 0 }, { name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', autopay: true, interestRate: 0 },
{ name: 'Amazon Prime', category: 'Subscriptions', amount: 14.99, dueDay: 1, cycle: 'annually', autopay: true, interestRate: 0 }, { name: 'Amazon Prime', category: 'Subscriptions', amount: 14.99, dueDay: 1, cycle: 'annually', autopay: true, interestRate: 0 },
@ -127,10 +126,8 @@ function seedDemoData(userId = null) {
let billsCreated = 0; let billsCreated = 0;
const insertBill = db.prepare(` const insertBill = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle, INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
expected_amount, autopay_enabled, interest_rate, expected_amount, autopay_enabled, interest_rate, active, is_seeded)
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt, VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
active, is_seeded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
`); `);
for (const billData of BILLS) { for (const billData of BILLS) {
@ -148,12 +145,7 @@ function seedDemoData(userId = null) {
billData.cycle || 'monthly', billData.cycle || 'monthly',
amount, amount,
billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : Math.random() > 0.5 ? 1 : 0, billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : Math.random() > 0.5 ? 1 : 0,
billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0), billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0)
billData.currentBalance ?? null,
billData.minPayment ?? null,
billData.snowballOrder ?? null,
billData.snowballInclude ?? 0,
billData.snowballExempt ?? 0
); );
billsCreated++; billsCreated++;
} catch (err) { } catch (err) {

View File

@ -91,7 +91,6 @@ app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require(
app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary')); app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary'));
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts')); app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics')); app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball'));
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications')); app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status')); app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
app.use('/api/about', require('./routes/about')); // public app.use('/api/about', require('./routes/about')); // public

View File

@ -14,7 +14,7 @@ function validateScheduleSettings(input = {}) {
const enabled = parseBool(input.enabled); const enabled = parseBool(input.enabled);
const frequency = input.frequency || 'daily'; const frequency = input.frequency || 'daily';
const time = input.time || '02:00'; const time = input.time || '02:00';
const retentionCount = parseInt(input.retention_count ?? '2', 10); const retentionCount = parseInt(input.retention_count ?? '14', 10);
if (!['daily', 'weekly'].includes(frequency)) { if (!['daily', 'weekly'].includes(frequency)) {
const err = new Error('frequency must be daily or weekly'); const err = new Error('frequency must be daily or weekly');
@ -47,7 +47,7 @@ function readSettings() {
enabled: getSetting('backup_schedule_enabled') === 'true', enabled: getSetting('backup_schedule_enabled') === 'true',
frequency: getSetting('backup_schedule_frequency') || 'daily', frequency: getSetting('backup_schedule_frequency') || 'daily',
time: getSetting('backup_schedule_time') || '02:00', time: getSetting('backup_schedule_time') || '02:00',
retention_count: getSetting('backup_schedule_retention_count') || '2', retention_count: getSetting('backup_schedule_retention_count') || '14',
}); });
} }

View File

@ -2,7 +2,7 @@ const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const { closeDb, getDb, getDbPath, getSetting } = require('../db/database'); const { closeDb, getDb, getDbPath } = require('../db/database');
const BACKUP_DIR = path.resolve( const BACKUP_DIR = path.resolve(
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups') process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
@ -166,10 +166,7 @@ async function createBackup(prefix = 'bill-tracker-backup') {
validateSqliteDatabase(tempPath); validateSqliteDatabase(tempPath);
fs.renameSync(tempPath, finalPath); fs.renameSync(tempPath, finalPath);
fs.chmodSync(finalPath, 0o600); fs.chmodSync(finalPath, 0o600);
const meta = metadataForFile(finalPath); return metadataForFile(finalPath);
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
applyRetention(retentionCount);
return meta;
} catch (err) { } catch (err) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {} try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
cleanupSqliteSidecars(tempPath); cleanupSqliteSidecars(tempPath);
@ -242,28 +239,25 @@ function deleteBackup(id) {
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() }; return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
} }
function applyRetention(retentionCount) { function applyScheduledRetention(retentionCount) {
const keep = parseInt(retentionCount, 10); const keep = parseInt(retentionCount, 10);
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] }; if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
// listBackups() is already sorted newest-first; delete everything beyond `keep` const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
const toDelete = listBackups().slice(keep); const toDelete = scheduled.slice(keep);
const deleted = []; const deleted = [];
for (const backup of toDelete) { for (const backup of toDelete) {
try { try {
deleted.push(deleteBackup(backup.id).id); deleted.push(deleteBackup(backup.id).id);
} catch { } catch {
// Retention should never cause a backup operation to fail. // Retention should never make a scheduled backup fail.
} }
} }
return { deleted }; return { deleted };
} }
// Keep old name as an alias so the scheduler import still works.
const applyScheduledRetention = applyRetention;
async function restoreBackup(id) { async function restoreBackup(id) {
const source = getBackupFile(id); const source = getBackupFile(id);
validateSqliteDatabase(source.path); validateSqliteDatabase(source.path);
@ -305,7 +299,6 @@ async function restoreBackup(id) {
module.exports = { module.exports = {
BACKUP_DIR, BACKUP_DIR,
assertValidBackupId, assertValidBackupId,
applyRetention,
applyScheduledRetention, applyScheduledRetention,
createBackup, createBackup,
deleteBackup, deleteBackup,

View File

@ -1,285 +0,0 @@
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
// 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) {
return { error: 'due_day must be an integer between 1 and 31' };
}
return { value: day };
}
function parseInterestRate(value) {
if (value === undefined) return { value: undefined };
if (value === null) return { value: null };
if (typeof value === 'string' && value.trim() === '') return { value: null };
const rate = Number(value);
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
return { error: 'interest_rate must be a number between 0 and 100, or null' };
}
return { value: rate };
}
function getValidCycleTypes() {
return ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
}
/**
* Validates and normalizes bill data for creation/update.
* Returns an object with normalized values and any validation errors.
*/
function validateBillData(data, existingBill = null) {
const errors = [];
const normalized = {};
const validCycleTypes = getValidCycleTypes();
// name is required
if (!data.name) {
errors.push({ field: 'name', message: 'name is required' });
}
normalized.name = data.name || null;
// due_day is required
if (data.due_day === undefined || data.due_day === null) {
errors.push({ field: 'due_day', message: 'due_day is required' });
} else {
const dueResult = parseDueDay(data.due_day);
if (dueResult.error) {
errors.push({ field: 'due_day', message: dueResult.error });
} else {
normalized.due_day = dueResult.value;
}
}
// category_id validation
normalized.category_id = data.category_id !== undefined ? (data.category_id || null) : (existingBill?.category_id || null);
// override_due_date
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
// expected_amount
normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
// interest_rate
if (data.interest_rate !== undefined) {
const parsedInterest = parseInterestRate(data.interest_rate);
if (parsedInterest.error) {
errors.push({ field: 'interest_rate', message: parsedInterest.error });
} else {
normalized.interest_rate = parsedInterest.value ?? null;
}
} else {
normalized.interest_rate = existingBill?.interest_rate ?? null;
}
// billing_cycle
normalized.billing_cycle = data.billing_cycle !== undefined ? (data.billing_cycle || 'monthly') : (existingBill?.billing_cycle || 'monthly');
// autopay_enabled
normalized.autopay_enabled = data.autopay_enabled !== undefined ? (data.autopay_enabled ? 1 : 0) : (existingBill?.autopay_enabled || 0);
// autodraft_status
normalized.autodraft_status = data.autodraft_status !== undefined ? (data.autodraft_status || 'none') : (existingBill?.autodraft_status || 'none');
// website
normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null);
// username
normalized.username = data.username !== undefined ? (data.username || null) : (existingBill?.username || null);
// account_info
normalized.account_info = data.account_info !== undefined ? (data.account_info || null) : (existingBill?.account_info || null);
// has_2fa
normalized.has_2fa = data.has_2fa !== undefined ? (data.has_2fa ? 1 : 0) : (existingBill?.has_2fa || 0);
// notes
normalized.notes = data.notes !== undefined ? (data.notes || null) : (existingBill?.notes || null);
// active
normalized.active = data.active !== undefined ? (data.active ? 1 : 0) : (existingBill?.active || 1);
// history_visibility
const nextVisibility = data.history_visibility !== undefined ? data.history_visibility : (existingBill?.history_visibility || 'default');
if (!VALID_VISIBILITY.includes(nextVisibility)) {
errors.push({ field: 'history_visibility', message: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
}
normalized.history_visibility = nextVisibility;
// cycle_type and cycle_day
let nextCycleType = (data.cycle_type !== undefined ? data.cycle_type : existingBill?.cycle_type) || 'monthly';
let nextCycleDay = existingBill?.cycle_day || getDefaultCycleDay(nextCycleType);
if (data.cycle_type !== undefined) {
if (!validCycleTypes.includes(data.cycle_type)) {
errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` });
} else {
nextCycleType = data.cycle_type;
}
}
const cycleDayResult = validateCycleDay(nextCycleType, data.cycle_day !== undefined ? data.cycle_day : nextCycleDay);
if (cycleDayResult.error) {
errors.push({ field: 'cycle_day', message: cycleDayResult.error });
} else {
nextCycleDay = cycleDayResult.value;
}
normalized.cycle_type = nextCycleType;
normalized.cycle_day = nextCycleDay;
// Calculate bucket based on due_day
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
// current_balance — outstanding debt balance (nullable)
if (data.current_balance !== undefined) {
if (data.current_balance === null || data.current_balance === '') {
normalized.current_balance = null;
} else {
const cb = parseFloat(data.current_balance);
if (!Number.isFinite(cb) || cb < 0) {
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
} else {
normalized.current_balance = cb;
}
}
} else {
normalized.current_balance = existingBill?.current_balance ?? null;
}
// minimum_payment — required minimum payment for debt (nullable)
if (data.minimum_payment !== undefined) {
if (data.minimum_payment === null || data.minimum_payment === '') {
normalized.minimum_payment = null;
} else {
const mp = parseFloat(data.minimum_payment);
if (!Number.isFinite(mp) || mp < 0) {
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
} else {
normalized.minimum_payment = mp;
}
}
} else {
normalized.minimum_payment = existingBill?.minimum_payment ?? null;
}
// snowball_order — drag position on snowball page (nullable integer)
if (data.snowball_order !== undefined) {
if (data.snowball_order === null || data.snowball_order === '') {
normalized.snowball_order = null;
} else {
const so = parseInt(data.snowball_order, 10);
if (!Number.isInteger(so) || so < 0) {
errors.push({ field: 'snowball_order', message: 'snowball_order must be a non-negative integer' });
} else {
normalized.snowball_order = so;
}
}
} else {
normalized.snowball_order = existingBill?.snowball_order ?? null;
}
// snowball_include — manual override to force bill onto snowball page
normalized.snowball_include = data.snowball_include !== undefined
? (data.snowball_include ? 1 : 0)
: (existingBill?.snowball_include ?? 0);
// snowball_exempt — manual override to hide an auto-detected debt-like bill
normalized.snowball_exempt = data.snowball_exempt !== undefined
? (data.snowball_exempt ? 1 : 0)
: (existingBill?.snowball_exempt ?? 0);
return {
errors,
normalized: {
...normalized,
name: normalized.name || null,
due_day: normalized.due_day || null,
},
};
}
/**
* Validates cycle_day for a given cycle_type without requiring the full bill data.
*/
function validateCycleDayOnly(cycleType, cycleDay) {
return validateCycleDay(cycleType, cycleDay);
}
/**
* Computes how a payment affects a debt bill's current_balance, accounting for
* one month of interest accrual.
*
* Returns { new_balance, balance_delta } where balance_delta is negative when
* the balance was reduced (typical case). Returns null when the bill has no
* trackable balance.
*/
function computeBalanceDelta(bill, paymentAmount) {
const bal = Number(bill.current_balance);
const rate = Number(bill.interest_rate) || 0;
const amt = Number(paymentAmount);
if (!Number.isFinite(bal) || bal <= 0) return null;
if (!Number.isFinite(amt) || amt <= 0) return null;
const monthlyInterest = bal * (rate / 100 / 12);
const raw = bal + monthlyInterest - amt;
const newBalance = Math.round(Math.max(0, raw) * 100) / 100;
const delta = Math.round((newBalance - bal) * 100) / 100;
return { new_balance: newBalance, balance_delta: delta };
}
module.exports = {
VALID_VISIBILITY,
getValidCycleTypes,
getDefaultCycleDay,
validateCycleDay,
parseDueDay,
parseInterestRate,
validateBillData,
validateCycleDayOnly,
computeBalanceDelta,
};

View File

@ -1,158 +0,0 @@
/**
* Debt payoff calculators Snowball and Avalanche methods.
*
* Snowball (Dave Ramsey): smallest balance first fast psychological wins.
* Avalanche (math-optimal): highest interest rate first minimises total interest.
*
* Both share the same month-by-month simulation loop; only the initial order differs.
*/
// ── Private simulation engine ─────────────────────────────────────────────────
function _simulate(orderedDebts, extraPayment, startDate) {
const extra = Math.max(0, Number(extraPayment) || 0);
const active = [];
const skipped = [];
for (const d of orderedDebts) {
const bal = Number(d.current_balance);
if (d.current_balance == null || !Number.isFinite(bal)) {
skipped.push({ id: d.id, name: d.name, reason: 'no_balance' });
} else if (bal <= 0) {
skipped.push({ id: d.id, name: d.name, reason: 'zero_balance' });
} else {
active.push({
id: d.id,
name: d.name,
balance: bal,
minPayment: Math.max(0, Number(d.minimum_payment) || 0),
monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
payoffMonth: null,
totalInterest: 0,
});
}
}
if (active.length === 0) {
return {
months_to_freedom: null,
total_interest_paid: 0,
payoff_date: null,
payoff_display: null,
debts: [],
skipped,
extra_payment: extra,
capped: false,
};
}
// ── Month-by-month loop ───────────────────────────────────────────────────
const MAX_MONTHS = 600; // 50-year safety cap
let rollingExtra = extra;
let month = 0;
while (active.some(d => d.balance > 0) && month < MAX_MONTHS) {
month++;
// Attack target = first debt in the ordered list that still has a balance
const targetIdx = active.findIndex(d => d.balance > 0);
for (let i = 0; i < active.length; i++) {
const debt = active[i];
if (debt.balance <= 0) continue;
// Accrue monthly interest
const interest = debt.balance * debt.monthlyRate;
debt.balance += interest;
debt.totalInterest += interest;
// Attack target gets minimums + full snowball; others get minimums only
const payment = Math.min(
debt.balance,
i === targetIdx ? debt.minPayment + rollingExtra : debt.minPayment,
);
debt.balance = Math.max(0, debt.balance - payment);
if (debt.balance < 0.005) debt.balance = 0; // eliminate floating-point dust
}
// Mark any debt that just reached zero (attack target OR paid off naturally by minimums)
// and roll its freed minimum into the snowball for next month.
for (let i = 0; i < active.length; i++) {
const debt = active[i];
if (debt.balance === 0 && debt.payoffMonth === null) {
debt.payoffMonth = month;
rollingExtra += debt.minPayment;
}
}
}
// ── Format results ────────────────────────────────────────────────────────
const baseYear = startDate.getFullYear();
const baseMo = startDate.getMonth();
function monthLabel(m) {
const d = new Date(baseYear, baseMo + m, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function monthDisplay(m) {
const d = new Date(baseYear, baseMo + m, 1);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
}
const debtResults = active.map(d => ({
id: d.id,
name: d.name,
payoff_month: d.payoffMonth,
payoff_date: d.payoffMonth ? monthLabel(d.payoffMonth) : null,
payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null,
total_interest: round2(d.totalInterest),
months: d.payoffMonth,
}));
const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0));
const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0);
return {
months_to_freedom: maxMonth || null,
total_interest_paid: round2(totalInterest),
payoff_date: maxMonth ? monthLabel(maxMonth) : null,
payoff_display: maxMonth ? monthDisplay(maxMonth) : null,
debts: debtResults,
skipped,
extra_payment: extra,
capped: month >= MAX_MONTHS,
};
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Snowball: attack the smallest balance first (fast wins, motivational).
* Debts must already be in snowball order (sorted by current_balance ASC by the caller).
*/
function calculateSnowball(debts, extraPayment = 0, startDate = new Date()) {
return _simulate(debts, extraPayment, startDate);
}
/**
* Avalanche: attack the highest interest rate first (minimises total interest paid).
* Re-sorts debts internally caller does not need to pre-sort.
*/
function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) {
const sorted = [...debts].sort((a, b) => {
const ra = Number(a.interest_rate) || 0;
const rb = Number(b.interest_rate) || 0;
if (rb !== ra) return rb - ra; // highest rate first
// Tiebreak: smallest balance (clears fastest, rolling the payment sooner)
return (Number(a.current_balance) || 0) - (Number(b.current_balance) || 0);
});
return _simulate(sorted, extraPayment, startDate);
}
function round2(n) {
return Math.round(n * 100) / 100;
}
module.exports = { calculateSnowball, calculateAvalanche };

View File

@ -29,9 +29,9 @@ const LABEL_PATTERNS = {
const HEADER_PATTERNS = { const HEADER_PATTERNS = {
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i, bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/i, amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|paid|value)$/i,
due_date: /^(?:due\s*date|due|due\s*day)$/i, due_date: /^(?:due\s*date|due|due\s*day)$/i,
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date)$/i, paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date|date\s*cleared|cleared\s*date)$/i,
date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i, date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i,
category: /^(?:category|cat|type|group)$/i, category: /^(?:category|cat|type|group)$/i,
notes: /^(?:notes?|comment|label|status|memo|remark)$/i, notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
@ -206,9 +206,9 @@ function parseXlsxBuffer(buffer) {
if (!cell) continue; if (!cell) continue;
// Strict cell type validation // Strict cell type validation
// Only allow n (number), t (text/string), b (boolean), d (date), s (shared formula) // Only allow n (number), t (text/string), b (boolean), d (date)
// Reject array (a), error (e), formula (f) // Reject array (a), error (e), formula (f), shared formula (s)
if (cell.t && !['n', 't', 'b', 'd', 's'].includes(cell.t)) { if (cell.t && !['n', 't', 'b', 'd'].includes(cell.t)) {
const err = new Error(`Invalid cell type '${cell.t}' found in ${cellRef}. Only numbers and text are supported.`); const err = new Error(`Invalid cell type '${cell.t}' found in ${cellRef}. Only numbers and text are supported.`);
err.status = 400; err.status = 400;
throw err; throw err;
@ -233,13 +233,8 @@ function parseXlsxBuffer(buffer) {
function getSheetRows(workbook, sheetName) { function getSheetRows(workbook, sheetName) {
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
if (!sheet) return []; if (!sheet) return [];
try { // raw:false → formatted string values; no formula results can leak through
// raw:false → formatted string values; no formula results can leak through return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
} catch (err) {
console.error(`[import] sheet="${sheetName}" failed to parse rows — skipping:`, err.message);
return [];
}
} }
// ─── Header Detection ───────────────────────────────────────────────────────── // ─── Header Detection ─────────────────────────────────────────────────────────
@ -257,120 +252,12 @@ function detectHeaders(firstRow) {
return map; return map;
} }
// ─── Dual-Column Header Detection ──────────────────────────────────────────────
/**
* Detect all header sets in a row, handling dual-column layouts.
* When a single row contains TWO sets of bill headers (e.g., columns A-E and G-K),
* this function returns an array of header groups, each with its own column range.
*
* Each group has: startCol, endCol, map, defaultDueDay (1 or 15)
*/
function detectAllHeaderSets(firstRow) {
if (!Array.isArray(firstRow)) return [];
// First, detect header cells and their column indices
const headerCells = [];
firstRow.forEach((cell, idx) => {
if (cell == null) return;
const val = String(cell).trim();
if (!val) return;
for (const field of Object.keys(HEADER_PATTERNS)) {
if (HEADER_PATTERNS[field].test(val)) {
headerCells.push({ idx, field, val });
break;
}
}
});
if (headerCells.length === 0) return [];
// Group header cells into sets by detecting when a field repeats.
// When we see the same field name again (e.g., second "Bill", second "Amount"),
// that indicates the start of a new header group (dual-column layout).
// Null columns between fields within a group are just empty columns — they
// don't split the group (left half has: Due date | Bill | Amount | null | Date Cleared).
const seenFields = new Set();
const groups = [];
let currentGroup = { cells: [headerCells[0]] };
seenFields.add(headerCells[0].field);
for (let i = 1; i < headerCells.length; i++) {
const cell = headerCells[i];
// Start a new group if this field was already seen (repeat = new column set)
// or if there's a large column gap (>3 empty columns) between this and previous
const prevCell = headerCells[i - 1];
const colGap = cell.idx - prevCell.idx;
const isRepeatField = seenFields.has(cell.field);
const isLargeGap = colGap > 3;
if (isRepeatField || isLargeGap) {
groups.push(currentGroup);
currentGroup = { cells: [cell] };
seenFields.clear();
seenFields.add(cell.field);
} else {
currentGroup.cells.push(cell);
seenFields.add(cell.field);
}
}
groups.push(currentGroup);
// Convert groups to return format with header maps and default due days
const result = [];
for (const group of groups) {
const map = {};
group.cells.forEach(h => map[h.field] = h.idx);
const startCol = group.cells[0].idx;
const endCol = group.cells[group.cells.length - 1].idx;
const defaultDueDay = startCol < 5 ? 1 : 15;
// Require at least 2 header fields (bill_name + amount, or similar) to count as a real header set.
// This filters out spurious rows like "Left Over | $3,204.20 | Paid" where
// "Paid" alone matches the amount pattern but isn't a real column header.
if (Object.keys(map).length >= 2) {
result.push({ startCol, endCol, map, defaultDueDay });
}
}
return result;
}
// ─── Row Classification ─────────────────────────────────────────────────────── // ─── Row Classification ───────────────────────────────────────────────────────
function isBlankRow(cells) { function isBlankRow(cells) {
return cells.every((c) => c == null || String(c).trim() === ''); return cells.every((c) => c == null || String(c).trim() === '');
} }
/**
* Check if a row is blank for a specific header set's columns.
* For dual-column layouts, a row may be blank on the left but have data on the right.
* Uses absolute column indices from the header set map.
*/
function isBlankRowForHeaderSet(cells, headerSet) {
const { map } = headerSet;
// Check the bill_name column and amount column for this header set
const billNameIdx = map.bill_name;
const amountIdx = map.amount;
// If we can't find bill_name or amount columns, fall back to full-row blank check
if (billNameIdx === undefined && amountIdx === undefined) {
return isBlankRow(cells);
}
const billNameCell = billNameIdx !== undefined ? cells[billNameIdx] : undefined;
const amountCell = amountIdx !== undefined ? cells[amountIdx] : undefined;
const billNameBlank = billNameCell == null || String(billNameCell).trim() === '';
const amountBlank = amountCell == null || String(amountCell).trim() === '' || parseAmount(amountCell) === null;
// If both bill name and amount are blank, this row is empty for this set
return billNameBlank && amountBlank;
}
function isLikelyHeaderRow(cells) { function isLikelyHeaderRow(cells) {
const nonEmpty = cells.filter((c) => c != null && String(c).trim() !== ''); const nonEmpty = cells.filter((c) => c != null && String(c).trim() !== '');
if (nonEmpty.length === 0) return false; if (nonEmpty.length === 0) return false;
@ -385,17 +272,7 @@ function isLikelyHeaderRow(cells) {
function isLikelyTotalRow(cells) { function isLikelyTotalRow(cells) {
return cells.some( return cells.some(
(c) => c != null && /^(?:total|subtotal|sum|grand\s*total|.*total\s*-+>|auto\s+total)/i.test(String(c).trim()), (c) => c != null && /^(?:total|subtotal|sum|grand\s*total)$/i.test(String(c).trim()),
);
}
/**
* Detect rows that are financial summaries, not bill entries.
* Catches "Paycheck", "Left Over", "Enter how much...", etc.
*/
function isLikelySummaryRow(cells) {
return cells.some(
(c) => c != null && /^(?:paycheck|left\s*over|enter\s+how\s+much|starting\s+balance|ending\s+balance|carry\s*over|carried\s*over|balance\s+(?:forward|carried)|bank\s+balance)/i.test(String(c).trim()),
); );
} }
@ -622,7 +499,6 @@ function buildRecommendation({
billName, billName,
detectedAmount, detectedAmount,
parsedDate, parsedDate,
parsedPaidDate,
dateHeader, dateHeader,
detectedCategory, detectedCategory,
notesText, notesText,
@ -631,7 +507,6 @@ function buildRecommendation({
warnings, warnings,
errors, errors,
paymentDateIso, paymentDateIso,
defaultDueDay = null,
}) { }) {
const recWarnings = [...warnings]; const recWarnings = [...warnings];
const topMatch = possibleMatches[0] || null; const topMatch = possibleMatches[0] || null;
@ -639,15 +514,7 @@ function buildRecommendation({
const mediumMatches = possibleMatches.filter((m) => m.match_confidence === 'medium'); const mediumMatches = possibleMatches.filter((m) => m.match_confidence === 'medium');
const dateDay = parsedDate?.day; const dateDay = parsedDate?.day;
let dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null; const dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
// Fall back to the paid-date column's day (e.g. column D), then to defaultDueDay
if (dueDay === null) {
const paidDay = parsedPaidDate?.day;
if (Number.isInteger(paidDay) && paidDay >= 1 && paidDay <= 31) dueDay = paidDay;
}
if (dueDay === null && defaultDueDay !== null) {
dueDay = defaultDueDay;
}
const paymentDate = isPaymentDateHeader(dateHeader); const paymentDate = isPaymentDateHeader(dateHeader);
if (dueDay && paymentDate && !isDueDateHeader(dateHeader)) { if (dueDay && paymentDate && !isDueDateHeader(dateHeader)) {
recWarnings.push('Date appears to be a payment date, not a due date'); recWarnings.push('Date appears to be a payment date, not a due date');
@ -781,11 +648,8 @@ function findFirstAmountCell(cells, skipIndices) {
return null; return null;
} }
function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null) { function collectNotesCells(cells, headerMap, billName) {
const skipIndices = new Set(Object.values(headerMap)); const skipIndices = new Set(Object.values(headerMap));
if (allHeaderColumns) {
for (const idx of allHeaderColumns) skipIndices.add(idx);
}
const parts = []; const parts = [];
for (let i = 0; i < cells.length; i++) { for (let i = 0; i < cells.length; i++) {
if (skipIndices.has(i) || cells[i] == null) continue; if (skipIndices.has(i) || cells[i] == null) continue;
@ -802,7 +666,7 @@ function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null)
// ─── Single-Row Analyzer ────────────────────────────────────────────────────── // ─── Single-Row Analyzer ──────────────────────────────────────────────────────
function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categories, sheetName, sheetYear, sheetMonth, defaultYear, defaultMonth, rowIdPrefix, defaultDueDay = null, headerSetIndex = null, allHeaderColumns = null) { function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categories, sheetName, sheetYear, sheetMonth, defaultYear, defaultMonth, rowIdPrefix) {
const get = (field) => { const get = (field) => {
const idx = headerMap[field]; const idx = headerMap[field];
return idx !== undefined ? cells[idx] : undefined; return idx !== undefined ? cells[idx] : undefined;
@ -811,12 +675,7 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
const rawBillName = get('bill_name') ?? cells[0]; const rawBillName = get('bill_name') ?? cells[0];
const billName = rawBillName ? String(rawBillName).trim() || null : null; const billName = rawBillName ? String(rawBillName).trim() || null : null;
// Skip indices: own header columns + all other header sets' columns (for dual-column layouts)
// This prevents fallback lookups from picking up values from the other column group.
const skipIndices = new Set(Object.values(headerMap)); const skipIndices = new Set(Object.values(headerMap));
if (allHeaderColumns) {
for (const idx of allHeaderColumns) skipIndices.add(idx);
}
const rawAmount = get('amount') ?? findFirstAmountCell(cells, skipIndices); const rawAmount = get('amount') ?? findFirstAmountCell(cells, skipIndices);
const detectedAmount = parseAmount(rawAmount); const detectedAmount = parseAmount(rawAmount);
@ -839,7 +698,7 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
const detectedPaidDate = resolveDateIso(parsedPaidDate, paidDateYear); const detectedPaidDate = resolveDateIso(parsedPaidDate, paidDateYear);
const rawCategory = get('category'); const rawCategory = get('category');
const detectedCategory = rawCategory ? String(rawCategory).trim() || null : null; const detectedCategory = rawCategory ? String(rawCategory).trim() || null : null;
const notesText = collectNotesCells(cells, headerMap, billName, allHeaderColumns); const notesText = collectNotesCells(cells, headerMap, billName);
const allText = cells.filter((c) => c != null && typeof c === 'string').map((c) => c.trim()).join(' '); const allText = cells.filter((c) => c != null && typeof c === 'string').map((c) => c.trim()).join(' ');
const detectedLabels = detectLabels(allText); const detectedLabels = detectLabels(allText);
const rawValues = cells.map((c) => (c != null ? String(c) : null)); const rawValues = cells.map((c) => (c != null ? String(c) : null));
@ -849,32 +708,11 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
if (!billName) errors.push('No bill name detected'); if (!billName) errors.push('No bill name detected');
if (detectedAmount === null) warnings.push('No amount detected'); if (detectedAmount === null) warnings.push('No amount detected');
// ── Diagnostic logging for auto-detected patterns ──────────────────────────
const _rawDue = get('due_date') != null ? String(get('due_date')).trim() : '';
const _rawPaid = get('paid_date') != null ? String(get('paid_date')).trim() : '';
const _loc = `sheet="${sheetName}" row=${rowIndex + 1}${billName ? ` bill="${billName}"` : ''}`;
if (detectedLabels.includes('autopay') && billName) {
if (_rawDue && /auto/i.test(_rawDue) && /\d/.test(_rawDue)) {
console.log(`[import] ${_loc} autopay+date in due col: "${_rawDue}" (date portion not extracted)`);
} else {
console.log(`[import] ${_loc} autopay detected`);
}
}
if (detectedLabels.includes('past_due')) {
console.log(`[import] ${_loc} PAST DUE detected`);
}
if (_rawPaid && !parsedPaidDate) {
console.log(`[import] ${_loc} unparseable paid date: "${_rawPaid}"`);
}
// ───────────────────────────────────────────────────────────────────────────
const possibleMatches = billName ? findBillMatches(billName, userBills) : []; const possibleMatches = billName ? findBillMatches(billName, userBills) : [];
const recommendation = buildRecommendation({ const recommendation = buildRecommendation({
billName, billName,
detectedAmount, detectedAmount,
parsedDate, parsedDate,
parsedPaidDate,
dateHeader, dateHeader,
detectedCategory, detectedCategory,
notesText, notesText,
@ -883,7 +721,6 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
warnings, warnings,
errors, errors,
paymentDateIso: detectedPaidDate, paymentDateIso: detectedPaidDate,
defaultDueDay,
}); });
const proposedAction = recommendation.action === 'ambiguous' ? 'mark_ambiguous' : recommendation.action; const proposedAction = recommendation.action === 'ambiguous' ? 'mark_ambiguous' : recommendation.action;
@ -914,8 +751,6 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
errors, errors,
possible_bill_matches: possibleMatches, possible_bill_matches: possibleMatches,
requires_user_decision: requiresUserDecision, requires_user_decision: requiresUserDecision,
due_day: recommendation.due_day,
header_set_index: headerSetIndex,
recommendation, recommendation,
}; };
} }
@ -929,135 +764,29 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, rowIdPrefix }, userBills, categories, defaultYear, defaultMonth) { function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, rowIdPrefix }, userBills, categories, defaultYear, defaultMonth) {
if (!rawRows.length) return { rows: [], headerRow: null }; if (!rawRows.length) return { rows: [], headerRow: null };
// Detect all header sets in each row to handle dual-column layouts const firstRow = rawRows[0] || [];
let headerRowIndex = 0; const headerMap = detectHeaders(firstRow);
let headerLabels = rawRows[0]?.map((c) => (c != null ? String(c).trim() : null)) || []; const headerLabels = firstRow.map((c) => (c != null ? String(c).trim() : null));
const hasHeaders = Object.keys(headerMap).length > 0;
// First try to detect headers in row 0 const startRow = hasHeaders ? 1 : 0;
let allHeaderSets = detectAllHeaderSets(rawRows[0]);
// If no headers in row 0, scan up to 5 rows
for (let scanIdx = 1; scanIdx < Math.min(5, rawRows.length); scanIdx++) {
const candidateSets = detectAllHeaderSets(rawRows[scanIdx]);
if (candidateSets.length > 0) {
headerRowIndex = scanIdx;
headerLabels = rawRows[scanIdx].map((c) => (c != null ? String(c).trim() : null));
allHeaderSets = candidateSets;
// Check if this set has all required fields
let hasAllRequired = false;
for (const set of allHeaderSets) {
if (set.map.due_date !== undefined && set.map.bill_name !== undefined && set.map.amount !== undefined) {
hasAllRequired = true;
break;
}
}
if (hasAllRequired) {
break;
}
}
}
// Check if we have valid headers (must have due_date, bill_name, amount)
let hasValidHeaders = false;
for (const set of allHeaderSets) {
if (set.map.due_date !== undefined && set.map.bill_name !== undefined && set.map.amount !== undefined) {
hasValidHeaders = true;
break;
}
}
const hasHeaders = hasValidHeaders;
const startRow = hasHeaders ? headerRowIndex + 1 : 0;
// Log detected layout for this sheet
const _colLetter = (i) => String.fromCharCode(65 + i);
if (!hasHeaders) {
console.log(`[import] sheet="${name}" no valid headers detected — sheet will be skipped`);
} else {
for (const [si, set] of allHeaderSets.entries()) {
const mapped = Object.entries(set.map).map(([f, i]) => `${f}:${_colLetter(i)}`).join(', ');
console.log(`[import] sheet="${name}" group=${si} defaultDueDay=${set.defaultDueDay} columns={${mapped}}`);
}
}
// For dual-column layouts, collect ALL column indices across all header sets
// so that fallback lookups (findFirstAmountCell, collectNotesCells) don't
// accidentally pick up values from the other column set.
// This includes the full range [startCol..endCol] for each set, not just
// the mapped columns, because gap columns within a set also belong to that side.
const allColumnsIndices = new Set();
for (const set of allHeaderSets) {
for (const idx of Object.values(set.map)) {
allColumnsIndices.add(idx);
}
for (let i = set.startCol; i <= set.endCol; i++) {
allColumnsIndices.add(i);
}
}
const rows = []; const rows = [];
for (let i = startRow; i < rawRows.length; i++) {
const cells = rawRows[i] || [];
if (isBlankRow(cells)) continue;
if (isLikelyHeaderRow(cells) && i > 0) continue;
if (isLikelyTotalRow(cells)) continue;
// Process each header set independently rows.push(analyzeRow(
for (let setIdx = 0; setIdx < allHeaderSets.length; setIdx++) { i, cells, headerMap, headerLabels, userBills, categories,
const headerSet = allHeaderSets[setIdx]; name, sheetYear, sheetMonth,
const headerMap = headerSet.map; defaultYear, defaultMonth, rowIdPrefix,
const defaultDueDay = headerSet.defaultDueDay; ));
for (let i = startRow; i < rawRows.length; i++) {
const cells = rawRows[i] || [];
// For dual-column: skip rows blank in this header set's columns only
// For single-column: fall back to regular isBlankRow
if (allHeaderSets.length > 1 ? isBlankRowForHeaderSet(cells, headerSet) : isBlankRow(cells)) continue;
// Skip duplicate header rows (but only if we found headers)
if (hasHeaders && isLikelyHeaderRow(cells) && i > headerRowIndex) continue;
// Skip total rows
if (isLikelyTotalRow(cells)) continue;
// Skip financial summary rows (Paycheck, Left Over, etc.)
if (isLikelySummaryRow(cells)) continue;
// Skip leftover calculation rows: null/blank bill name with negative amount, or dash separators
const getBillName = (field) => {
const idx = headerMap[field];
return idx !== undefined ? cells[idx] : undefined;
};
const get = (field) => {
const idx = headerMap[field];
return idx !== undefined ? cells[idx] : undefined;
};
const rawBillName = getBillName('bill_name') ?? cells[0];
const billName = rawBillName ? String(rawBillName).trim() || null : null;
const rawAmount = get('amount') ?? findFirstAmountCell(cells, new Set(Object.values(headerMap)));
const amount = rawAmount !== null ? parseAmount(rawAmount) : null;
// Check if bill name is a dash separator (--- or ---->)
const isDashSeparator = billName && (billName.match(/^-+>/) || billName.match(/^--+$/));
// Check if this is a leftover calculation row (null/blank bill name + negative amount)
// Skip if bill name is null AND amount is negative
const isLeftoverCalcRow = !billName && amount !== null && amount < 0;
if (isDashSeparator || isLeftoverCalcRow) continue;
try {
rows.push(analyzeRow(
i, cells, headerMap, headerLabels, userBills, categories,
name, sheetYear, sheetMonth,
defaultYear, defaultMonth, rowIdPrefix,
defaultDueDay, setIdx, allColumnsIndices,
));
} catch (err) {
console.error(`[import] sheet="${name}" row=${i + 1} failed to analyze — skipping:`, err.message);
}
}
} }
return { return {
rows, rows,
headerRow: hasHeaders ? headerLabels : null, headerRow: hasHeaders ? firstRow.map((c) => (c != null ? String(c) : null)) : null,
}; };
} }
@ -1107,9 +836,7 @@ function pruneExpiredSessions(db) {
async function previewSpreadsheet(userId, buffer, options = {}) { async function previewSpreadsheet(userId, buffer, options = {}) {
const db = getDb(); const db = getDb();
try { pruneExpiredSessions(db); } catch (err) { pruneExpiredSessions(db);
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
}
ensureUserDefaultCategories(userId); ensureUserDefaultCategories(userId);
const workbook = parseXlsxBuffer(buffer); const workbook = parseXlsxBuffer(buffer);
@ -1505,12 +1232,11 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const dueDay = decision.due_day ?? 1; const dueDay = decision.due_day ?? 1;
const expectedAmount = decision.expected_amount ?? amount ?? 0; const expectedAmount = decision.expected_amount ?? amount ?? 0;
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
const ins = db.prepare(` const ins = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active) INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, active)
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1) VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay); `).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
const newBillId = ins.lastInsertRowid; const newBillId = ins.lastInsertRowid;
summary.created++; summary.created++;
@ -1549,14 +1275,9 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
} else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) { } else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) {
const billId = decision.bill_id; const billId = decision.bill_id;
const bill = db.prepare('SELECT id, name, autopay_enabled FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId); const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`); if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
if (!bill.autopay_enabled && previewRow?.detected_labels?.includes('autopay')) {
db.prepare(`UPDATE bills SET autopay_enabled = 1, updated_at = datetime('now') WHERE id = ?`).run(billId);
console.log(`[import] bill id=${billId} "${bill.name}" autopay_enabled upgraded to 1`);
}
if (!year || !month) { if (!year || !month) {
summary.ambiguous++; summary.ambiguous++;
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' }); summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });
@ -1718,7 +1439,6 @@ function getImportHistory(userId) {
} }
module.exports = { module.exports = {
detectAllHeaderSets,
previewSpreadsheet, previewSpreadsheet,
applyImportDecisions, applyImportDecisions,
getImportHistory, getImportHistory,

View File

@ -34,8 +34,7 @@ function getCycleRange(year, month) {
* Returns status for a bill given its payments and due date. * Returns status for a bill given its payments and due date.
* *
* Statuses: * Statuses:
* paid has a non-deleted payment in this billing cycle * paid total payments >= expected_amount
* OR total paid >= expected_amount (fully settled)
* autodraft autopay_enabled and assumed_paid (no confirmed payment yet) * autodraft autopay_enabled and assumed_paid (no confirmed payment yet)
* upcoming due_date in the future * upcoming due_date in the future
* due_soon due within 3 days * due_soon due within 3 days
@ -44,13 +43,10 @@ function getCycleRange(year, month) {
*/ */
function calculateStatus(bill, payments, dueDate, today) { function calculateStatus(bill, payments, dueDate, today) {
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10); const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
const safePayments = Array.isArray(payments) ? payments : []; const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); const isPaid = totalPaid >= bill.expected_amount;
// A recorded payment is the user's confirmation that this cycle is handled. if (isPaid) return 'paid';
// Expected amounts are estimates, so a lower actual payment must not leave a Pay
// button visible and invite duplicate payments.
if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid';
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') { if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
return 'autodraft'; return 'autodraft';
@ -72,15 +68,10 @@ function calculateStatus(bill, payments, dueDate, today) {
function buildTrackerRow(bill, payments, year, month, todayStr) { function buildTrackerRow(bill, payments, year, month, todayStr) {
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
const bucket = resolveBucket(bill); const bucket = resolveBucket(bill);
const safePayments = Array.isArray(payments) ? payments : []; const status = calculateStatus(bill, payments, dueDate, todayStr);
const status = calculateStatus(bill, safePayments, dueDate, todayStr); const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); const lastPayment = payments.length
const hasPayment = safePayments.length > 0; ? payments.sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
const isSettled = status === 'paid' || status === 'autodraft';
const rawBalance = bill.expected_amount - totalPaid;
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
const lastPayment = hasPayment
? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
: null; : null;
return { return {
@ -94,16 +85,14 @@ function buildTrackerRow(bill, payments, year, month, todayStr) {
expected_amount: bill.expected_amount, expected_amount: bill.expected_amount,
notes: bill.notes || null, // Bill-level notes (always available) notes: bill.notes || null, // Bill-level notes (always available)
total_paid: totalPaid, total_paid: totalPaid,
balance, balance: bill.expected_amount - totalPaid,
has_payment: hasPayment,
is_settled: isSettled,
last_paid_date: lastPayment ? lastPayment.paid_date : null, last_paid_date: lastPayment ? lastPayment.paid_date : null,
last_payment_amount: lastPayment ? lastPayment.amount : null, last_payment_amount: lastPayment ? lastPayment.amount : null,
status, status,
autopay_enabled: !!bill.autopay_enabled, autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status, autodraft_status: bill.autodraft_status,
billing_cycle: bill.billing_cycle, billing_cycle: bill.billing_cycle,
payments: safePayments, payments,
}; };
} }

View File

@ -61,14 +61,10 @@ module.exports = {
keyframes: { keyframes: {
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } }, 'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } }, 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
'collapsible-down': { from: { height: '0' }, to: { height: 'var(--radix-collapsible-content-height)' } },
'collapsible-up': { from: { height: 'var(--radix-collapsible-content-height)' }, to: { height: '0' } },
}, },
animation: { animation: {
'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out',
}, },
}, },
}, },