Compare commits

..

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

42 changed files with 3980 additions and 3569 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/
dist/
db/*.db

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

1444
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 (Vite, React, React Router, Tailwind CSS, shadcn/ui, Sonner, SQLite via better-sqlite3, Express).
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 ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
const AboutPage = lazy(() => import('@/pages/AboutPage'));
const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
function RequireAuth({ children, role }) {
const { user, singleUserMode } = useAuth();
@ -128,7 +126,7 @@ export default function App() {
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<AboutPage />
<AboutPage admin />
</Suspense>
</AdminShell>
</ErrorBoundary>
@ -142,7 +140,7 @@ export default function App() {
<ErrorBoundary>
<AdminShell>
<Suspense fallback={<PageLoader />}>
<RoadmapPage />
<AboutPage admin />
</Suspense>
</AdminShell>
</ErrorBoundary>
@ -186,7 +184,6 @@ export default function App() {
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></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="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></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="*" element={<Navigate to="/" replace />} />

View File

@ -92,7 +92,7 @@ export const api = {
const res = await fetch('/api/admin/backups/import', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() },
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
});
const data = await res.json();
@ -142,8 +142,6 @@ export const api = {
bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', 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}`),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
@ -162,13 +160,6 @@ export const api = {
deletePayment: (id) => del(`/payments/${id}`),
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: () => get('/categories'),
createCategory: (data) => post('/categories', data),
@ -195,8 +186,6 @@ export const api = {
// Version (public)
about: () => get('/about'),
aboutAdmin: () => get('/about-admin'),
roadmap: () => get('/about-admin/roadmap'),
devLog: () => get('/about-admin/dev-log'),
version: () => get('/version'),
releaseHistory: () => get('/version/history'),
@ -215,7 +204,6 @@ export const api = {
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
@ -241,7 +229,6 @@ export const api = {
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
...(file.name ? { 'X-Filename': file.name } : {}),
},
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 { ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -13,6 +12,7 @@ import {
import { api } from '@/api';
import { cn } from '@/lib/utils';
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
function getOrdinalSuffix(day) {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
@ -26,14 +26,6 @@ function getOrdinalSuffix(day) {
// Radix Select crashes on empty string value
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 }) {
const isNew = !bill;
@ -51,23 +43,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const [username, setUsername] = useState(bill?.username || '');
const [accountInfo, setAccountInfo] = useState(bill?.account_info || '');
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);
// Validation state
const [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
// Real-time validation helpers
const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required';
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 '';
};
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 newErrors = {
name: validateName(name),
dueDay: validateDueDay(dueDay),
expectedAmount: validateExpectedAmount(expectedAmount),
interestRate: validateInterestRate(interestRate),
currentBalance: validateCurrentBalance(currentBalance),
minimumPayment: validateMinimumPayment(minimumPayment),
};
setErrors(newErrors);
return Object.values(newErrors).every(err => err === '');
};
// Validation on blur
const handleBlur = (field, validator) => {
setErrors(prev => ({ ...prev, [field]: validator(
field === 'name' ? name :
field === 'dueDay' ? dueDay :
field === 'expectedAmount' ? expectedAmount :
interestRate
)}));
setErrors(prev => ({ ...prev, [field]: validator(field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate) }));
};
const handleCategoryChange = (val) => {
setCategoryId(val);
if (isDebtCat(categories, val)) {
setShowDebtSection(true);
} else {
setSnowballExempt(false);
}
};
const handleSnowballVisibilityChange = (checked) => {
if (checked) {
setSnowballExempt(false);
setSnowballInclude(!isDebtCategory);
} else {
setSnowballInclude(false);
setSnowballExempt(isDebtCategory);
}
// Validation on change - debounce for better UX
const handleChange = (field, value, validator) => {
if (field === 'name') setName(value);
if (field === 'dueDay') setDueDay(value);
if (field === 'expectedAmount') setExpected(value);
if (field === 'interestRate') setInterestRate(value);
// Only validate after input, not every keystroke
setTimeout(() => {
setErrors(prev => ({ ...prev, [field]: validator(value) }));
}, 300);
};
async function handleSubmit(e) {
e.preventDefault();
// Run form validation
if (!validateForm()) {
toast.error('Please fix the form errors before saving.');
return;
}
// Additional server-side validation checks
const parsedDueDay = Number(dueDay);
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 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,
account_info: accountInfo || null,
notes: notes || null,
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
snowball_include: snowballInclude,
snowball_exempt: snowballExempt,
};
setBusy(true);
try {
@ -246,7 +198,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
{/* Category */}
<div className="space-y-1.5">
<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')}>
<SelectValue placeholder="— none —" />
</SelectTrigger>
@ -298,6 +250,27 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
)}
</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 */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label>
@ -370,117 +343,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
/>
)}
<p className="text-[10px] text-muted-foreground/70">
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
'Day of the period'}
</p>
</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 */}
<div className="space-y-1.5">
<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 {
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';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
@ -35,7 +35,6 @@ const trackerItems = [
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
];
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,14 +1,12 @@
export const APP_VERSION = '0.27.01';
export const APP_VERSION = '0.24.6';
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
version: '0.27.01',
date: '2026-05-14',
version: '0.24.6',
date: '2026-05-11',
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: '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: '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)' },
{ icon: '🛡️', title: 'Duplicate Payment Fix', desc: 'Partial payments below the estimated amount are now correctly treated as paid — no more phantom Pay button after recording a payment.' },
{ icon: '🔧', title: 'Starting Amounts Fix', desc: 'Paid deductions now correctly factor in the "other" bucket for remaining balance calculations.' },
{ icon: '🎨', title: 'Pay Badge Alignment', desc: 'Amount input and Pay button now stay inline and centered, no more wrapping on tight layouts.' },
],
};
};

View File

@ -4,19 +4,20 @@ import { ArrowLeft, Info, Sparkles } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
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 [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setAbout(await api.about());
setAbout(admin ? await api.aboutAdmin() : await api.about());
} finally {
setLoading(false);
}
}, []);
}, [admin]);
useEffect(() => { load(); }, [load]);
@ -32,6 +33,12 @@ export default function AboutPage() {
</Link>
</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">
<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">
@ -83,4 +90,4 @@ export default function AboutPage() {
</main>
</div>
);
}
}

View File

@ -1450,30 +1450,44 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
function SeedDemoDataSection({ onSeeded }) {
const [loading, setLoading] = 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 [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
// Check seeded status on mount
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 checkSeededStatus = async () => {
try {
const data = await api.seededStatus();
if (data.seeded) {
setSeeded(true);
setResult(data);
}
} catch (err) {
console.error('Failed to check seeded status:', err);
} finally {
setStatusLoading(false);
}
};
checkSeededStatus();
}, []);
const handleSeed = async () => {
setLoading(true);
try {
const data = await api.seedDemoData();
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
// Ensure data has expected structure
if (!data || typeof data !== 'object') {
throw new Error('Invalid response from server');
}
setResult(data);
setSeeded(true);
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) {
console.error('Seed error:', err);
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
@ -1487,69 +1501,87 @@ function SeedDemoDataSection({ onSeeded }) {
try {
const data = await api.clearDemoData();
setSeeded(false);
setCounts({ bills: 0, categories: 0 });
setResult(null);
setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.();
} catch (err) {
toast.error(err.message || 'Failed to clear demo data.');
toast.error(err.message || "Failed to clear demo data.");
} finally {
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>
);
}
if (statusLoading) {
return (
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
<div className="px-6 py-4 text-sm text-muted-foreground">Loading</div>
</SectionCard>
);
}
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">
{statusLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : seeded ? (
<>
<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">
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded || statusLoading}>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding</> : 'Seed Demo Data'}
</Button>
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<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>
<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 space-y-4">
<div className="border-t border-border pt-4">
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded}>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding</> : 'Seed Demo Data'}
</Button>
</div>
</div>
</div>
</SectionCard>

View File

@ -12,6 +12,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} 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';
export default function LoginPage() {
@ -155,6 +156,12 @@ export default function LoginPage() {
className="w-full"
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}
</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" />
)}
<div>
<div className="flex items-center gap-1">
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'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
type="button"
onClick={() => onEditBill?.(row)}
className={cn(
'font-medium text-sm leading-tight text-left transition-colors',
'hover:underline decoration-muted-foreground/50 underline-offset-2',
isSkipped && 'line-through',
)}
<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)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
title="Edit bill"
>
{row.name}
</button>
{row.category_name && (
<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>
)}
{/* 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>
</TableCell>
@ -1075,32 +1080,18 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
AP
</span>
)}
{row.website ? (
<a
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"
<button
type="button"
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" />
</Button>
{row.name}
</button>
</div>
{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>
)}
{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>

View File

@ -43,8 +43,6 @@ const COLUMN_WHITELIST = new Set([
'other_amount',
// bills table columns
'history_visibility', 'interest_rate', 'user_id',
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
'snowball_exempt',
// sessions table columns
'created_at',
]);
@ -607,134 +605,9 @@ function reconcileLegacyMigrations() {
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');
}
}
];
// Check for legacy notification columns
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const newUserCols = [
@ -1198,75 +1071,14 @@ function runMigrations() {
description: 'billing: add cycle_type and cycle_day columns to bills',
dependsOn: ['v0.45'],
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',
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');
// Add cycle_type column (default 'monthly' for existing bills)
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
// Add cycle_day column for specific day within the cycle
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
}
];
// ── users: notification columns ───────────────────────────────────────────
// This migration needs to run first since it's not versioned in the schema
console.log('[migration] Applying unversioned user notification columns');
@ -1502,7 +1314,7 @@ function seedDefaults() {
['backup_schedule_enabled', 'false'],
['backup_schedule_frequency', 'daily'],
['backup_schedule_time', '02:00'],
['backup_schedule_retention_count', '2'],
['backup_schedule_retention_count', '14'],
['backup_schedule_last_run_at', ''],
['backup_schedule_last_error', ''],
['auth_mode', 'multi'],
@ -1627,33 +1439,6 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE bills DROP COLUMN cycle_day',
'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,
has_2fa INTEGER NOT NULL DEFAULT 0,
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,
created_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,
method TEXT,
notes TEXT,
balance_delta REAL,
created_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,
must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1,
snowball_extra_payment REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);

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",
"version": "0.24.6",
"version": "0.21.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
"version": "0.24.6",
"version": "0.21.1",
"license": "ISC",
"dependencies": {
"@radix-ui/react-alert-dialog": "^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-dropdown-menu": "^2.1.2",
"@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": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.27.01",
"version": "0.24.6",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
@ -13,7 +13,6 @@
"dependencies": {
"@radix-ui/react-alert-dialog": "^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-dropdown-menu": "^2.1.2",
"@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'),
};
// 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
* @param {string} content - The content to redact
@ -397,7 +45,7 @@ function redactSensitiveContent(content) {
.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) => {
try {
// 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;

View File

@ -250,47 +250,6 @@ router.put('/users/:id/active', (req, res) => {
).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
router.delete('/users/:id', (req, res) => {
const db = getDb();

View File

@ -117,7 +117,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => {
// POST /api/auth/change-password
// 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) => {
const { current_password, new_password } = req.body;

View File

@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData } = require('../services/billsService');
const { standardizeError } = require('../middleware/errorFormatter');
// ── GET /api/bills ────────────────────────────────────────────────────────────
@ -146,9 +146,8 @@ router.post('/', (req, res) => {
INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
`).run(
req.user.id,
normalized.name,
@ -169,11 +168,6 @@ router.post('/', (req, res) => {
normalized.history_visibility,
normalized.cycle_type,
normalized.cycle_day,
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);
@ -206,7 +200,6 @@ router.put('/:id', (req, res) => {
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, cycle_type = ?, cycle_day = ?,
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(
@ -229,11 +222,6 @@ router.put('/:id', (req, res) => {
normalized.history_visibility,
normalized.cycle_type,
normalized.cycle_day,
normalized.current_balance,
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
normalized.snowball_exempt,
req.params.id,
req.user.id,
);
@ -298,7 +286,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const billId = parseInt(req.params.id, 10);
// 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'));
@ -319,14 +307,6 @@ router.post('/:id/toggle-paid', (req, res) => {
// If paid (has payment), remove it → Unpaid
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);
res.json({
success: true,
@ -359,17 +339,9 @@ router.post('/:id/toggle-paid', (req, res) => {
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(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(billId, amount, paidDate, method, notes, balCalc?.balance_delta ?? null);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, billId);
}
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(billId, amount, paidDate, method, notes);
res.status(201).json({
success: true,
@ -499,46 +471,4 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
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;

View File

@ -2,7 +2,6 @@ const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService');
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 balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(bill_id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, bill_id);
}
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(bill_id, payAmount, payDate, method || null, notes || null);
if (bill.autopay_enabled) {
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(
'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
const duplicateCheckStmt = db.prepare(
@ -191,16 +181,12 @@ router.post('/bulk', (req, res) => {
continue;
}
const billRow = getBillForBalance.get(bill_id, req.user.id);
if (!billRow) {
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id)) {
errors.push({ item, error: `Bill ${bill_id} not found` });
continue;
}
const balCalc = computeBalanceDelta(billRow, parsedAmt);
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);
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null);
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)
router.delete('/:id', (req, res) => {
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'));
// 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);
res.json({ success: true });
});
@ -255,18 +231,8 @@ router.delete('/:id', (req, res) => {
// POST /api/payments/:id/restore — undo soft delete
router.post('/:id/restore', (req, res) => {
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'));
// 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);
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 ────────────────────────────────────────────────────────
// Updates safe profile fields: username and display_name.
// Updates safe profile fields: display_name only.
// Ignores any unknown or restricted fields.
router.patch('/', (req, res) => {
const { username, 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') });
}
const { display_name } = req.body;
if (display_name !== undefined) {
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' });
}
db.prepare(
getDb().prepare(
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE 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

@ -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',
'Transportation',
'Healthcare',
'Credit Cards',
'Loans',
'Finance',
'Entertainment',
];
@ -29,19 +28,19 @@ const CATEGORIES = [
const BILLS = [
{ 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: '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: '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: '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: '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: 'Student Loan', category: 'Loans', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5, currentBalance: 12500, minPayment: 150, snowballOrder: 1, snowballInclude: 1 },
{ name: 'Credit Card', category: 'Finance', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99 },
{ 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: '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: '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: '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 },
@ -127,10 +126,8 @@ function seedDemoData(userId = null) {
let billsCreated = 0;
const insertBill = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
expected_amount, autopay_enabled, interest_rate,
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
active, is_seeded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
expected_amount, autopay_enabled, interest_rate, active, is_seeded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
`);
for (const billData of BILLS) {
@ -148,12 +145,7 @@ function seedDemoData(userId = null) {
billData.cycle || 'monthly',
amount,
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.currentBalance ?? null,
billData.minPayment ?? null,
billData.snowballOrder ?? null,
billData.snowballInclude ?? 0,
billData.snowballExempt ?? 0
billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0)
);
billsCreated++;
} 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/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
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/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
app.use('/api/about', require('./routes/about')); // public

View File

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

View File

@ -173,64 +173,6 @@ function validateBillData(data, existingBill = null) {
// 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: {
@ -248,30 +190,6 @@ 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,
@ -281,5 +199,4 @@ module.exports = {
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 = {
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,
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,
category: /^(?:category|cat|type|group)$/i,
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
@ -206,9 +206,9 @@ function parseXlsxBuffer(buffer) {
if (!cell) continue;
// Strict cell type validation
// Only allow n (number), t (text/string), b (boolean), d (date), s (shared formula)
// Reject array (a), error (e), formula (f)
if (cell.t && !['n', 't', 'b', 'd', 's'].includes(cell.t)) {
// Only allow n (number), t (text/string), b (boolean), d (date)
// Reject array (a), error (e), formula (f), shared formula (s)
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.`);
err.status = 400;
throw err;
@ -233,13 +233,8 @@ function parseXlsxBuffer(buffer) {
function getSheetRows(workbook, sheetName) {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return [];
try {
// raw:false → formatted string values; no formula results can leak through
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 [];
}
// raw:false → formatted string values; no formula results can leak through
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
}
// ─── Header Detection ─────────────────────────────────────────────────────────
@ -257,120 +252,12 @@ function detectHeaders(firstRow) {
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 ───────────────────────────────────────────────────────
function isBlankRow(cells) {
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) {
const nonEmpty = cells.filter((c) => c != null && String(c).trim() !== '');
if (nonEmpty.length === 0) return false;
@ -385,17 +272,7 @@ function isLikelyHeaderRow(cells) {
function isLikelyTotalRow(cells) {
return cells.some(
(c) => c != null && /^(?:total|subtotal|sum|grand\s*total|.*total\s*-+>|auto\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()),
(c) => c != null && /^(?:total|subtotal|sum|grand\s*total)$/i.test(String(c).trim()),
);
}
@ -622,7 +499,6 @@ function buildRecommendation({
billName,
detectedAmount,
parsedDate,
parsedPaidDate,
dateHeader,
detectedCategory,
notesText,
@ -631,7 +507,6 @@ function buildRecommendation({
warnings,
errors,
paymentDateIso,
defaultDueDay = null,
}) {
const recWarnings = [...warnings];
const topMatch = possibleMatches[0] || null;
@ -639,15 +514,7 @@ function buildRecommendation({
const mediumMatches = possibleMatches.filter((m) => m.match_confidence === 'medium');
const dateDay = parsedDate?.day;
let 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 dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
const paymentDate = isPaymentDateHeader(dateHeader);
if (dueDay && paymentDate && !isDueDateHeader(dateHeader)) {
recWarnings.push('Date appears to be a payment date, not a due date');
@ -781,11 +648,8 @@ function findFirstAmountCell(cells, skipIndices) {
return null;
}
function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null) {
function collectNotesCells(cells, headerMap, billName) {
const skipIndices = new Set(Object.values(headerMap));
if (allHeaderColumns) {
for (const idx of allHeaderColumns) skipIndices.add(idx);
}
const parts = [];
for (let i = 0; i < cells.length; i++) {
if (skipIndices.has(i) || cells[i] == null) continue;
@ -802,7 +666,7 @@ function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null)
// ─── 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 idx = headerMap[field];
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 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));
if (allHeaderColumns) {
for (const idx of allHeaderColumns) skipIndices.add(idx);
}
const rawAmount = get('amount') ?? findFirstAmountCell(cells, skipIndices);
const detectedAmount = parseAmount(rawAmount);
@ -839,7 +698,7 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
const detectedPaidDate = resolveDateIso(parsedPaidDate, paidDateYear);
const rawCategory = get('category');
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 detectedLabels = detectLabels(allText);
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 (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 recommendation = buildRecommendation({
billName,
detectedAmount,
parsedDate,
parsedPaidDate,
dateHeader,
detectedCategory,
notesText,
@ -883,7 +721,6 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
warnings,
errors,
paymentDateIso: detectedPaidDate,
defaultDueDay,
});
const proposedAction = recommendation.action === 'ambiguous' ? 'mark_ambiguous' : recommendation.action;
@ -914,8 +751,6 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
errors,
possible_bill_matches: possibleMatches,
requires_user_decision: requiresUserDecision,
due_day: recommendation.due_day,
header_set_index: headerSetIndex,
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) {
if (!rawRows.length) return { rows: [], headerRow: null };
// Detect all header sets in each row to handle dual-column layouts
let headerRowIndex = 0;
let headerLabels = rawRows[0]?.map((c) => (c != null ? String(c).trim() : null)) || [];
// First try to detect headers in row 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 firstRow = rawRows[0] || [];
const headerMap = detectHeaders(firstRow);
const headerLabels = firstRow.map((c) => (c != null ? String(c).trim() : null));
const hasHeaders = Object.keys(headerMap).length > 0;
const startRow = hasHeaders ? 1 : 0;
const rows = [];
// Process each header set independently
for (let setIdx = 0; setIdx < allHeaderSets.length; setIdx++) {
const headerSet = allHeaderSets[setIdx];
const headerMap = headerSet.map;
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;
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;
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);
}
}
rows.push(analyzeRow(
i, cells, headerMap, headerLabels, userBills, categories,
name, sheetYear, sheetMonth,
defaultYear, defaultMonth, rowIdPrefix,
));
}
return {
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 = {}) {
const db = getDb();
try { pruneExpiredSessions(db); } catch (err) {
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
}
pruneExpiredSessions(db);
ensureUserDefaultCategories(userId);
const workbook = parseXlsxBuffer(buffer);
@ -1505,12 +1232,11 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const dueDay = decision.due_day ?? 1;
const expectedAmount = decision.expected_amount ?? amount ?? 0;
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
const ins = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active)
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1)
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay);
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, active)
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
const newBillId = ins.lastInsertRowid;
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)) {
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.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) {
summary.ambiguous++;
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 = {
detectAllHeaderSets,
previewSpreadsheet,
applyImportDecisions,
getImportHistory,

View File

@ -61,14 +61,10 @@ module.exports = {
keyframes: {
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
'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: {
'accordion-down': 'accordion-down 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',
},
},
},