Compare commits
18 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
7aff0d0283 | |
|
|
ce22139bb3 | |
|
|
440f872d97 | |
|
|
cd61c2ef7f | |
|
|
488f329e14 | |
|
|
7d2d0bf45e | |
|
|
48fe87ea25 | |
|
|
d2acf44846 | |
|
|
34b0f75918 | |
|
|
d32a30495d | |
|
|
831f617893 | |
|
|
579eed37b8 | |
|
|
2ce5328fd2 | |
|
|
98ede20cd3 | |
|
|
22f9a570aa | |
|
|
b29d3a0b02 | |
|
|
890427c75a | |
|
|
24b4e8d24e |
|
|
@ -1,3 +1,15 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# Errors Logged During Phase 1 Verification
|
||||
|
||||
No errors encountered during Build-Verify Phase 1.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Learnings from Phase 1 Verification
|
||||
|
||||
## Business Logic Extraction — Verification Summary
|
||||
|
||||
### What Was Verified
|
||||
|
||||
1. **Build Success**: ✅ `docker build --no-cache -t bill-tracker:local .` completed successfully
|
||||
- 1764 modules transformed
|
||||
- Build time: 1.91s
|
||||
- Output: 35 JS chunks for code splitting
|
||||
|
||||
2. **Container Start**: ✅ Container starts cleanly with migrations applied
|
||||
- All 46 migrations applied correctly
|
||||
- Database initialization successful
|
||||
- No errors in startup logs
|
||||
|
||||
3. **Services/billsService.js Existance**: ✅ Verified
|
||||
- All 8 expected exports present:
|
||||
- `parseDueDay()`
|
||||
- `parseInterestRate()`
|
||||
- `validateCycleDay()`
|
||||
- `getDefaultCycleDay()`
|
||||
- `validateBillData()`
|
||||
- `getValidCycleTypes()`
|
||||
- `VALID_VISIBILITY`
|
||||
- `validateCycleDayOnly()`
|
||||
|
||||
4. **Routes/bills.js Integration**: ✅ Verified
|
||||
- Imports from `../services/billsService`
|
||||
- `validateBillData()` call in POST `/api/bills` endpoint
|
||||
- `validateBillData()` call in PUT `/api/bills/:id` endpoint
|
||||
- No inline validation logic remaining in routes
|
||||
|
||||
### No Errors Found
|
||||
|
||||
The business logic extraction is complete and working correctly. All validation logic has been moved from routes to the service layer, maintaining the same behavior.
|
||||
|
||||
### Test Notes
|
||||
|
||||
- Docker client version (1.42) is older than required (1.44) for docker compose
|
||||
- Workaround: Used `docker run` directly instead of `docker compose`
|
||||
- Existing container stopped and removed before starting fresh build
|
||||
|
||||
### Files Created
|
||||
|
||||
- `.learnings/bishop/ERRORS.md` — Error log (empty - no errors)
|
||||
- `.learnings/bishop/LEARNINGS.md` — This file
|
||||
|
||||
---
|
||||
|
||||
**Verified By**: Bishop (subagent)
|
||||
**Date**: 2026-05-11
|
||||
**Phase**: 1/4 — Build-Verify
|
||||
**Version**: v0.24.4
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Bill Tracker - Neo Errors Log
|
||||
|
||||
## 2026-05-11 - Phase 1 Business Logic Extraction
|
||||
|
||||
### Errors Encountered
|
||||
- None - extraction completed successfully on first attempt
|
||||
|
||||
### Notes
|
||||
-工程参考手册 does not exist in the project directory (expected to be under `Projects/bill-tracker/`)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Bill Tracker - Backend Refactoring Learnings
|
||||
|
||||
## 2026-05-11 - Phase 1 Business Logic Extraction
|
||||
|
||||
### Task
|
||||
Extract business logic from `routes/bills.js` into a dedicated service layer (`services/billsService.js`).
|
||||
|
||||
### Functions Extracted to `services/billsService.js`
|
||||
- `getDefaultCycleDay(cycleType)` - Returns default cycle day based on cycle type
|
||||
- `validateCycleDay(cycleType, cycleDay)` - Validates cycle_day based on cycle_type rules
|
||||
- `parseDueDay(value)` - Parses and validates due_day (must be 1-31 integer)
|
||||
- `parseInterestRate(value)` - Parses and validates interest_rate (0-100 range)
|
||||
- `getValidCycleTypes()` - Returns array of valid cycle types
|
||||
- `validateBillData(data, existingBill)` - Comprehensive validation and normalization for bill create/update
|
||||
- `validateCycleDayOnly(cycleType, cycleDay)` - Convenience wrapper for cycle_day validation
|
||||
|
||||
### Functions Remaining in `routes/bills.js`
|
||||
- Route handlers only - parse request, call service, send response
|
||||
- DB queries remain in routes (tightly coupled to HTTP flow, not pure business logic)
|
||||
- Error formatting with `standardizeError` (HTTP-layer concern)
|
||||
|
||||
### Design Decisions
|
||||
1. **`validateBillData`** - Centralized validation function that handles both create and update scenarios
|
||||
- Takes optional `existingBill` parameter to support partial updates
|
||||
- Returns `{ errors, normalized }` structure
|
||||
- Validates all bill fields including category_id, history_visibility, cycle_type/cycle_day
|
||||
|
||||
2. **`getValidCycleTypes()`** - Exported constant array for consistency across files
|
||||
|
||||
3. **`VALID_VISIBILITY`** - Exported from service for reuse in other files if needed
|
||||
|
||||
### Benefits
|
||||
- Business logic is now testable in isolation without mocking Express request/response
|
||||
- Route handlers are thinner and easier to read
|
||||
- Validation rules are centralized in one place
|
||||
- Easier to add new bill-related operations without touching routes
|
||||
|
||||
### Files Modified
|
||||
- `routes/bills.js` - Removed ~80 lines of business logic, replaced with service imports and calls
|
||||
- `services/billsService.js` - New file created with extracted business logic
|
||||
|
||||
### No Breaking Changes
|
||||
- All API endpoints maintain exact same behavior
|
||||
- Same validation rules applied
|
||||
- Same error messages returned
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# 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 (`sm–lg`), 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 (`sm–lg`): 2-column grid (CRITICAL+HIGH | MEDIUM+LOW+NICE TO HAVE)
|
||||
- Mobile (`<sm`): single column, lanes stack vertically
|
||||
|
||||
### Notes
|
||||
|
||||
- `api.roadmap()` and `api.devLog()` were already present in `client/api.js`
|
||||
- AboutPage's `/admin/about` route now shows the same public content (no admin dashboard appended)
|
||||
- The `aboutAdmin()` API endpoint is still available but no longer called by the frontend for the roadmap view
|
||||
1372
DEVELOPMENT_LOG.md
1372
DEVELOPMENT_LOG.md
File diff suppressed because it is too large
Load Diff
259
FUTURE.md
259
FUTURE.md
|
|
@ -1,259 +0,0 @@
|
|||
# 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
1249
HISTORY.md
File diff suppressed because it is too large
Load Diff
277
STRUCTURE.md
277
STRUCTURE.md
|
|
@ -1,277 +0,0 @@
|
|||
# Bill Tracker Project Structure
|
||||
|
||||
## Project Overview
|
||||
Bill Tracking Website — Full-stack application with Node.js backend and React frontend.
|
||||
|
||||
## Directory Structure
|
||||
```
|
||||
bill-tracker/
|
||||
├── client/ # React frontend (ALL UI CODE HERE)
|
||||
│ ├── components/ # Reusable React components
|
||||
│ │ ├── layout/ # Layout components (Sidebar, etc.)
|
||||
│ │ └── ui/ # UI components (buttons, inputs, etc.)
|
||||
│ ├── pages/ # Page components (one per route)
|
||||
│ │ ├── TrackerPage.jsx
|
||||
│ │ ├── BillsPage.jsx
|
||||
│ │ ├── CategoriesPage.jsx
|
||||
│ │ ├── CalendarPage.jsx
|
||||
│ │ ├── SummaryPage.jsx
|
||||
│ │ ├── AnalyticsPage.jsx
|
||||
│ │ ├── ProfilePage.jsx
|
||||
│ │ ├── SettingsPage.jsx
|
||||
│ │ ├── DataPage.jsx
|
||||
│ │ ├── AdminPage.jsx
|
||||
│ │ ├── LoginPage.jsx
|
||||
│ │ └── AboutPage.jsx
|
||||
│ ├── hooks/ # Custom React hooks (useAuth, etc.)
|
||||
│ ├── api.js # API client functions
|
||||
│ ├── App.jsx # React Router configuration
|
||||
│ ├── main.jsx # React entry point
|
||||
│ └── index.html # HTML template
|
||||
├── server.js # Express backend entry
|
||||
├── routes/ # API route handlers
|
||||
├── services/ # Business logic layer
|
||||
├── middleware/ # Express middleware
|
||||
├── db/ # Database schemas/migrations
|
||||
├── workers/ # Background job workers
|
||||
├── scripts/ # Utility scripts
|
||||
├── docs/ # Documentation
|
||||
├── dist/ # Build output (generated)
|
||||
├── public/ # Static assets
|
||||
├── Dockerfile # Container config
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Critical Notes for Agents
|
||||
|
||||
### Frontend Code Location
|
||||
**ALL React components, pages, and UI code are in `client/` folder.**
|
||||
- Pages: `client/pages/*.jsx`
|
||||
- Components: `client/components/**/*.jsx`
|
||||
- Hooks: `client/hooks/*.js`
|
||||
- API client: `client/api.js`
|
||||
- Router: `client/App.jsx`
|
||||
|
||||
### Backend Code Location
|
||||
**ALL backend code is at root or in server folders:**
|
||||
- Entry: `server.js`
|
||||
- Routes: `routes/*.js`
|
||||
- Services: `services/*.js`
|
||||
- Middleware: `middleware/*.js`
|
||||
- Database: `db/*.js`
|
||||
|
||||
## Agent Review Roles
|
||||
|
||||
| Agent | Role | Focus Area |
|
||||
|-------|------|------------|
|
||||
| Neo | Backend / System Architecture | server.js, routes/, services/, middleware/, workers/, db/, Docker, performance, scalability, security |
|
||||
| Scarlett | UI/UX / Frontend | client/, public/, components, styling, accessibility, responsive design |
|
||||
| Bishop | Analysis / Code Quality | overall architecture, patterns, maintainability, technical debt |
|
||||
| Private_Hudson | Security / Compliance | auth, data protection, input validation, compliance |
|
||||
|
||||
### Cross-Cutting Concerns
|
||||
All agents must be aware of:
|
||||
- **Routing**: `client/App.jsx` defines all frontend routes
|
||||
- **Auth**: `client/hooks/useAuth.jsx` and `services/authService.js`
|
||||
- **API**: `client/api.js` mirrors `routes/` structure
|
||||
- **Database**: `db/database.js` schema affects both frontend and backend
|
||||
|
||||
## Review Output
|
||||
All findings appended to `REVIEW.md` per agent section.
|
||||
|
||||
# OpenClaw Agent Structure
|
||||
|
||||
## Prime
|
||||
|
||||
Role:
|
||||
|
||||
* executive coordinator
|
||||
* project strategist
|
||||
* Discord command interface
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* **Overall Oversight:** Must maintain high-level awareness of all concurrent projects, ensuring every agent's output aligns with the goal set in `projects-requirements.md`.
|
||||
* **Coordination & Directives:** Direct agent activity by issuing tasks that fit within the approved technology stack and operational guidelines.
|
||||
* **Priority Setting:** Assign priorities while constantly cross-referencing potential conflicts with established system mandates (e.g., Security > Performance > Feature).
|
||||
* **Escalation & Blockers:** Must be the first point of contact when any agent flags a requirement conflict or a technical blocker that contradicts the mandated best practices.
|
||||
* **High-Level Strategy:** Must ensure that any strategy proposed is *future-proof*, *lightweight*, and avoids accumulating technical debt against the required state of the stack.
|
||||
* **Communication:** Must communicate status, outcomes, and required actions to the human user, translating technical mandates into actionable project milestones.
|
||||
|
||||
Authority:
|
||||
|
||||
* project coordination and task routing.
|
||||
* Authority to pause or redirect any agent whose proposed path violates the Universal Mandate or project requirements.
|
||||
|
||||
---
|
||||
|
||||
## Riply
|
||||
|
||||
Role:
|
||||
|
||||
* operations
|
||||
* infrastructure
|
||||
* runtime management
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* deployment oversight, adhering to stability and resilience standards (per `projects-requirements.md`).
|
||||
* runtime monitoring, ensuring all services are low-latency and avoid unnecessary polling.
|
||||
* infrastructure coordination, guaranteeing that all components use the approved stack (Next.js, React, etc.).
|
||||
* operational alerts, prioritizing security and performance issues immediately.
|
||||
* service stability, adhering to the "fail gracefully" principle.
|
||||
* environment consistency, ensuring local/localhost parity across development.
|
||||
* Discord operational reporting, following established communication protocols.
|
||||
|
||||
Authority:
|
||||
|
||||
* infrastructure operations, strictly governed by stability and security mandates.
|
||||
* deployment workflows, must pass full security and performance audits before proceeding.
|
||||
* runtime diagnostics, must use established, non-bloated tooling.
|
||||
* operational communication, must be precise and action-oriented.
|
||||
|
||||
---
|
||||
|
||||
## Neo
|
||||
|
||||
Role:
|
||||
|
||||
* senior backend developer
|
||||
* backend architecture lead
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for all technology choices and operational philosophies.
|
||||
* **Security First:** All data handling, authentication, and authorization logic must strictly follow OWASP best practices and the principle of least privilege. No assumption of trust.
|
||||
* **Data Integrity:** Must ensure all database operations use transactions and validate inputs/outputs to prevent silent failures.
|
||||
* **Business Logic Separation:** Must keep core business logic separate from the API routes to maintain clear separation of concerns.
|
||||
* **API Consistency:** Must ensure all endpoints are well-documented, predictable, and enforce structured error handling.
|
||||
* **Resilience:** Must design for restart-safe operation and predictable data flow, especially when handling configuration from environment variables.
|
||||
|
||||
Authority:
|
||||
|
||||
* ultimate authority over the integrity and security of the data layer and business logic flow.
|
||||
* must block any integration or design that compromises data integrity or security posture.
|
||||
|
||||
---
|
||||
|
||||
## Scarlett
|
||||
|
||||
Role:
|
||||
|
||||
* frontend developer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for UI/UX.
|
||||
* **Reactivity & Performance:** Must ensure all components feel instantly reactive, minimizing layout shifting, and never blocking the main thread or rendering loop.
|
||||
* **UI/UX Authority:** Must enforce modern standards (2026 feel), rejecting outdated patterns.
|
||||
* **Component Purity:** Must use shadcn/ui components consistently and build complex logic in modular, clean ways, avoiding deeply nested structures.
|
||||
* **Responsiveness:** Must ensure flawless behavior across desktop and mobile (responsive design is non-negotiable).
|
||||
* **Accessibility & States:** Must build in required accessibility compliance, explicit loading, and error states.
|
||||
* **Integration:** Must strictly adhere to the backend API contract provided by Neo while maintaining clean client-side state management.
|
||||
|
||||
Technology Focus:
|
||||
|
||||
* **React with Vite** is the frontend framework (NOT Next.js — never suggest Next.js patterns).
|
||||
* **Tailwind CSS** must be used predictably to maintain consistency.
|
||||
* **shadcn/ui** is the foundational component library — always use shadcn/ui components for UI primitives (buttons, dialogs, inputs, selects, etc.). Do not build custom components when shadcn/ui provides one.
|
||||
* **Sonner** is used for toast notifications.
|
||||
|
||||
Authority:
|
||||
|
||||
* UI architecture and frontend interaction flows.
|
||||
* Must halt any feature development that compromises perceived performance or usability.
|
||||
|
||||
---
|
||||
|
||||
## Bishop
|
||||
|
||||
Role:
|
||||
|
||||
* code reviewer
|
||||
* architecture validator
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Must enforce adherence to `projects-requirements.md` standards across the entire lifecycle.
|
||||
* **Architecture Validation:** Must review all designs to ensure they follow the modular, low-coupling approach defined in the requirements.
|
||||
* **Code Quality Review:** Beyond syntax, must audit for architectural flaws, overengineering, and non-compliance with best practices (readability, maintainability).
|
||||
* **Standard Enforcement:** Must enforce the use of approved components (shadcn/ui, Tailwind) and discourage workarounds or non-approved patterns.
|
||||
* **Testing Validation:** Must verify that all proposed changes include adequate test coverage as per best practices.
|
||||
* **Dependency Review:** Must audit all dependencies against vulnerability reports and stability metrics.
|
||||
* **Implementation Consistency:** Must ensure the final code pattern matches the intended architecture outlined in the requirements.
|
||||
* **Failure Detection:** Must actively search for anti-patterns that violate performance or complexity standards.
|
||||
|
||||
Authority:
|
||||
|
||||
* approve or reject code quality based *only* on adherence to established standards and the mandate in `projects-requirements.md`.
|
||||
* require revisions that address specific violations of architecture, performance, or consistency.
|
||||
* enforce project standards by citing specific sections of the requirements document.
|
||||
|
||||
---
|
||||
|
||||
## Private Hudson
|
||||
|
||||
Role:
|
||||
|
||||
* security reviewer
|
||||
* defensive operations specialist
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* OWASP validation
|
||||
* authentication security review
|
||||
* authorization validation
|
||||
* dependency vulnerability auditing
|
||||
* secret exposure detection
|
||||
* injection vulnerability analysis
|
||||
* security hardening review
|
||||
* infrastructure security analysis
|
||||
* runtime security assessment
|
||||
|
||||
Authority:
|
||||
|
||||
* approve or reject security posture
|
||||
* block insecure deployments
|
||||
* require remediation before release
|
||||
|
||||
---
|
||||
|
||||
## Universal Mandate
|
||||
|
||||
**All agents are governed by the guidelines set in `projects-requirements.md`.** Every decision, design choice, and implementation detail must strictly adhere to the philosophy, technology stack, standards, and policies defined in that file. Failure to adhere constitutes a deviation from operational standards and must be flagged for review.
|
||||
|
||||
**Mandatory Adherence Checklist:**
|
||||
1. **Always** refer to `projects-requirements.md` for the definitive ruleset.
|
||||
2. Never implement functionality that contradicts the approved Tech Stack (Next.js App Router, React, Tailwind CSS, shadcn/ui, SQLite).
|
||||
3. Treat security and performance checks (per `projects-requirements.md`) as *primary* considerations, not secondary checks.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
Bill Tracker actual stack:
|
||||
|
||||
* **Vite** (build tool, NOT Next.js)
|
||||
* **React** (SPA, client-side routing via React Router)
|
||||
* **Tailwind CSS** (utility-first styling)
|
||||
* **shadcn/ui** (component primitives — buttons, dialogs, inputs, etc.)
|
||||
* **Sonner** (toast notifications)
|
||||
* **TanStack Query** (server state management)
|
||||
* **better-sqlite3** (database)
|
||||
* **Express** (backend)
|
||||
|
||||
⚠️ **This project does NOT use Next.js.** Do not suggest Next.js patterns (App Router, server components, etc.).
|
||||
|
||||
Development target:
|
||||
|
||||
* localhost based development
|
||||
* modular architecture
|
||||
* maintainable systems
|
||||
* production ready implementation
|
||||
|
||||
|
||||
|
||||
---
|
||||
*Generated by Prime for multi-agent review*
|
||||
|
|
@ -35,8 +35,10 @@ 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();
|
||||
|
|
@ -126,7 +128,7 @@ export default function App() {
|
|||
<ErrorBoundary>
|
||||
<AdminShell>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AboutPage admin />
|
||||
<AboutPage />
|
||||
</Suspense>
|
||||
</AdminShell>
|
||||
</ErrorBoundary>
|
||||
|
|
@ -140,7 +142,7 @@ export default function App() {
|
|||
<ErrorBoundary>
|
||||
<AdminShell>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AboutPage admin />
|
||||
<RoadmapPage />
|
||||
</Suspense>
|
||||
</AdminShell>
|
||||
</ErrorBoundary>
|
||||
|
|
@ -184,6 +186,7 @@ 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 />} />
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export const api = {
|
|||
runAdminCleanup: () => post('/admin/cleanup/run'),
|
||||
seedDemoData: () => post('/user/seed-demo-data'),
|
||||
clearDemoData: () => post('/user/clear-demo-data'),
|
||||
seededStatus: () => get('/user/seeded-status'),
|
||||
downloadAdminBackup: async (id) => {
|
||||
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
|
||||
credentials: 'include',
|
||||
|
|
@ -91,7 +92,7 @@ export const api = {
|
|||
const res = await fetch('/api/admin/backups/import', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() },
|
||||
body: file,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
|
@ -141,6 +142,8 @@ 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}`),
|
||||
|
|
@ -159,6 +162,13 @@ 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),
|
||||
|
|
@ -185,6 +195,8 @@ 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'),
|
||||
|
||||
|
|
@ -203,6 +215,7 @@ export const api = {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-csrf-token': getCsrfToken(),
|
||||
...(file.name ? { 'X-Filename': file.name } : {}),
|
||||
},
|
||||
body: file,
|
||||
|
|
@ -228,6 +241,7 @@ export const api = {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-csrf-token': getCsrfToken(),
|
||||
...(file.name ? { 'X-Filename': file.name } : {}),
|
||||
},
|
||||
body: file,
|
||||
|
|
|
|||
|
|
@ -1,444 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
|
@ -12,7 +13,6 @@ 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,6 +26,14 @@ 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;
|
||||
|
||||
|
|
@ -43,12 +51,23 @@ 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({});
|
||||
|
||||
// Real-time validation helpers
|
||||
const isDebtCategory = isDebtCat(categories, categoryId);
|
||||
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
|
||||
|
||||
const validateName = (val) => {
|
||||
if (!val || val.trim() === '') return 'Name is required';
|
||||
if (val.trim().length < 2) return 'Name must be at least 2 characters';
|
||||
|
|
@ -77,44 +96,69 @@ 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
|
||||
)}));
|
||||
};
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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.');
|
||||
|
|
@ -143,6 +187,10 @@ 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 {
|
||||
|
|
@ -198,7 +246,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={setCategoryId}>
|
||||
<Select value={categoryId} onValueChange={handleCategoryChange}>
|
||||
<SelectTrigger className={cn(inp, 'w-full')}>
|
||||
<SelectValue placeholder="— none —" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -250,27 +298,6 @@ 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>
|
||||
|
|
@ -343,12 +370,117 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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, User, X,
|
||||
Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
|
@ -35,6 +35,7 @@ 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 }) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
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 };
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Toaster as Sonner } from 'sonner';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
export function Toaster() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
position="top-right"
|
||||
closeButton
|
||||
expand={false}
|
||||
visibleToasts={5}
|
||||
duration={3500}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:rounded-xl group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:bg-card group-[.toaster]:text-card-foreground group-[.toaster]:shadow-lg group-[.toaster]:backdrop-blur-sm group-[.toaster]:border-l-4',
|
||||
title: 'group-[.toast]:text-sm group-[.toast]:font-semibold group-[.toast]:text-foreground',
|
||||
description: 'group-[.toast]:text-sm group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:rounded-md group-[.toast]:bg-primary group-[.toast]:px-3 group-[.toast]:py-1.5 group-[.toast]:text-sm group-[.toast]:font-medium group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:rounded-md group-[.toast]:bg-muted group-[.toast]:px-3 group-[.toast]:py-1.5 group-[.toast]:text-sm group-[.toast]:font-medium group-[.toast]:text-muted-foreground',
|
||||
closeButton:
|
||||
'group-[.toast]:border-border group-[.toast]:bg-card group-[.toast]:text-muted-foreground group-[.toast]:hover:bg-accent group-[.toast]:hover:text-accent-foreground',
|
||||
success: 'group-[.toaster]:border-l-emerald-500',
|
||||
error: 'group-[.toaster]:border-l-destructive',
|
||||
warning: 'group-[.toaster]:border-l-amber-500',
|
||||
info: 'group-[.toaster]:border-l-sky-500',
|
||||
default: 'group-[.toaster]:border-l-primary',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
export const APP_VERSION = '0.24.4';
|
||||
export const APP_VERSION = '0.27.01';
|
||||
export const APP_NAME = 'BillTracker';
|
||||
|
||||
export const RELEASE_NOTES = {
|
||||
version: '0.24.4',
|
||||
date: '2026-05-11',
|
||||
version: '0.27.01',
|
||||
date: '2026-05-14',
|
||||
highlights: [
|
||||
{ icon: '📱', title: 'Analytics Mobile Layout', desc: 'Charts, heatmap, and controls now display properly on mobile screens.' },
|
||||
{ icon: '🔧', title: 'Previous Month Payment Toggle', desc: 'Clicking payment badges on previous months now creates/removes payments for the correct month.' },
|
||||
{ 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)' },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Toaster } from 'sonner';
|
||||
import App from './App';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import 'sonner/dist/styles.css';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
|
|
@ -15,29 +16,8 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||
<App />
|
||||
</AuthProvider>
|
||||
|
||||
{/* Global Toast System - placed at root level for proper z-index and positioning */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
richColors
|
||||
closeButton
|
||||
theme="system"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className: 'bg-card text-card-foreground border border-border shadow-lg',
|
||||
success: {
|
||||
className: 'border-l-emerald-500',
|
||||
},
|
||||
error: {
|
||||
className: 'border-l-red-500',
|
||||
},
|
||||
warning: {
|
||||
className: 'border-l-amber-500',
|
||||
},
|
||||
info: {
|
||||
className: 'border-l-blue-500',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/* Global shadcn/Sonner toast system */}
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
|
|
|
|||
|
|
@ -4,20 +4,19 @@ 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({ admin = false }) {
|
||||
export default function AboutPage() {
|
||||
const [about, setAbout] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAbout(admin ? await api.aboutAdmin() : await api.about());
|
||||
setAbout(await api.about());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [admin]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
|
|
@ -33,12 +32,6 @@ export default function AboutPage({ admin = false }) {
|
|||
</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">
|
||||
|
|
@ -90,4 +83,4 @@ export default function AboutPage({ admin = false }) {
|
|||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1450,25 +1450,30 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
function SeedDemoDataSection({ onSeeded }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.seededStatus()
|
||||
.then(data => {
|
||||
setSeeded(data.seeded);
|
||||
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
|
||||
})
|
||||
.catch(err => console.error('Failed to check seeded status:', err))
|
||||
.finally(() => setStatusLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSeed = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.seedDemoData();
|
||||
// Ensure data has expected structure
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
setResult(data);
|
||||
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
|
||||
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
|
||||
setSeeded(true);
|
||||
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
||||
// Delay onSeeded callback to allow UI to update
|
||||
setTimeout(() => {
|
||||
onSeeded?.();
|
||||
}, 100);
|
||||
setTimeout(() => onSeeded?.(), 100);
|
||||
} catch (err) {
|
||||
console.error('Seed error:', err);
|
||||
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
||||
|
|
@ -1482,79 +1487,69 @@ function SeedDemoDataSection({ onSeeded }) {
|
|||
try {
|
||||
const data = await api.clearDemoData();
|
||||
setSeeded(false);
|
||||
setResult(null);
|
||||
setCounts({ bills: 0, categories: 0 });
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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}>
|
||||
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
||||
</Button>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ 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() {
|
||||
|
|
@ -156,12 +155,6 @@ 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,472 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -853,18 +853,34 @@ 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>
|
||||
<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',
|
||||
<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>
|
||||
)}
|
||||
title="Edit bill"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
<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>
|
||||
{row.category_name && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
||||
)}
|
||||
|
|
@ -961,27 +977,6 @@ 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>
|
||||
|
||||
|
|
@ -1080,18 +1075,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
|||
AP
|
||||
</span>
|
||||
)}
|
||||
<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',
|
||||
)}
|
||||
{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"
|
||||
onClick={() => onEditBill?.(row)}
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{row.monthly_notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||
|
|
@ -1164,27 +1173,6 @@ 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>
|
||||
|
||||
|
|
|
|||
229
db/database.js
229
db/database.js
|
|
@ -43,6 +43,8 @@ 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',
|
||||
]);
|
||||
|
|
@ -605,9 +607,134 @@ 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 = [
|
||||
|
|
@ -1071,14 +1198,75 @@ function runMigrations() {
|
|||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||
dependsOn: ['v0.45'],
|
||||
run: function() {
|
||||
// Add cycle_type column (default 'monthly' for existing bills)
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||
// Add cycle_day column for specific day within the cycle
|
||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||
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');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// ── 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');
|
||||
|
|
@ -1314,7 +1502,7 @@ function seedDefaults() {
|
|||
['backup_schedule_enabled', 'false'],
|
||||
['backup_schedule_frequency', 'daily'],
|
||||
['backup_schedule_time', '02:00'],
|
||||
['backup_schedule_retention_count', '14'],
|
||||
['backup_schedule_retention_count', '2'],
|
||||
['backup_schedule_last_run_at', ''],
|
||||
['backup_schedule_last_error', ''],
|
||||
['auth_mode', 'multi'],
|
||||
|
|
@ -1439,6 +1627,33 @@ 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']
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ 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'))
|
||||
|
|
@ -39,6 +44,7 @@ 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'))
|
||||
);
|
||||
|
|
@ -58,6 +64,7 @@ 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'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
**Status:** Current code reference
|
||||
**Last Updated:** 2026-05-10
|
||||
**Version:** 0.23.2
|
||||
**Primary stack:** Node.js + Express, React + Vite, SQLite via `better-sqlite3`
|
||||
**Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3`
|
||||
|
||||
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog.
|
||||
|
||||
|
|
@ -1037,8 +1037,8 @@ Use this pattern for database-layer audit calls instead of a top-level `require(
|
|||
- React Router `^6.26.2`
|
||||
- TanStack Query `^5.100.9`
|
||||
- Tailwind CSS `^3.4.14`
|
||||
- Radix/shadcn-style UI primitives
|
||||
- `sonner` for toasts
|
||||
- shadcn/ui component primitives, backed by Radix UI where applicable
|
||||
- Sonner/shadcn toast notifications via `sonner`
|
||||
- `react-markdown`, `remark-gfm`, `rehype-sanitize` for markdown rendering
|
||||
|
||||
### `client/main.jsx`
|
||||
|
|
@ -1167,7 +1167,7 @@ Key runtime dependencies:
|
|||
- nodemailer.
|
||||
- node-cron.
|
||||
- React, React DOM, React Router, TanStack Query.
|
||||
- Radix UI primitives, lucide-react, Tailwind utilities.
|
||||
- shadcn/ui component primitives, Radix UI primitives, lucide-react, Tailwind utilities, Sonner toasts.
|
||||
- xlsx for spreadsheet import/export.
|
||||
|
||||
### Dockerfile
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
# 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 (`sm–lg`): 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
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
# 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 |
|
||||
| `sm–lg` (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)
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.21.1",
|
||||
"version": "0.24.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bill-tracker",
|
||||
"version": "0.21.1",
|
||||
"version": "0.24.6",
|
||||
"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",
|
||||
|
|
@ -969,6 +970,36 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.24.4",
|
||||
"version": "0.27.01",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,358 @@ 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
|
||||
|
|
@ -45,7 +397,7 @@ function redactSensitiveContent(content) {
|
|||
.replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]')
|
||||
}
|
||||
|
||||
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content
|
||||
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat)
|
||||
router.get('/', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
// Read both files directly from the allowlist
|
||||
|
|
@ -71,4 +423,36 @@ 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;
|
||||
|
|
|
|||
|
|
@ -250,6 +250,47 @@ 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();
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => {
|
|||
|
||||
// POST /api/auth/change-password
|
||||
// Password change endpoint with dedicated rate limiter
|
||||
// Exempt from CSRF - session-based auth is primary protection (pre-middleware sets csrfSkip)
|
||||
// CSRF protected via csrfMiddleware on /api/auth mount
|
||||
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
|
||||
const { current_password, new_password } = req.body;
|
||||
|
||||
|
|
|
|||
300
routes/bills.js
300
routes/bills.js
|
|
@ -1,72 +1,9 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||
|
||||
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
||||
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
|
||||
// Helper function to get default cycle day based on cycle type
|
||||
function getDefaultCycleDay(cycleType) {
|
||||
switch (cycleType) {
|
||||
case 'monthly':
|
||||
return '1'; // 1st of the month
|
||||
case 'weekly':
|
||||
return 'monday'; // Monday
|
||||
case 'biweekly':
|
||||
return 'monday'; // Monday
|
||||
case 'quarterly':
|
||||
return '1'; // 1st of the quarter
|
||||
case 'annual':
|
||||
return '1'; // 1st of the year
|
||||
default:
|
||||
return '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cycle_day based on cycle_type
|
||||
function validateCycleDay(cycleType, cycleDay) {
|
||||
if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) };
|
||||
const ct = cycleType || 'monthly';
|
||||
switch (ct) {
|
||||
case 'monthly': {
|
||||
const d = Number(cycleDay);
|
||||
if (!Number.isInteger(d) || d < 1 || d > 31) return { error: 'monthly cycle_day must be 1-31' };
|
||||
return { value: String(d) };
|
||||
}
|
||||
case 'weekly':
|
||||
case 'biweekly': {
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
if (!days.includes(String(cycleDay).toLowerCase())) return { error: 'weekly/biweekly cycle_day must be a valid day name' };
|
||||
return { value: String(cycleDay).toLowerCase() };
|
||||
}
|
||||
case 'quarterly':
|
||||
case 'annual':
|
||||
return { value: String(cycleDay).slice(0, 50) };
|
||||
default:
|
||||
return { value: getDefaultCycleDay(ct) };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDueDay(value) {
|
||||
const day = Number(value);
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||
return { error: 'due_day must be an integer between 1 and 31' };
|
||||
}
|
||||
return { value: day };
|
||||
}
|
||||
|
||||
function parseInterestRate(value) {
|
||||
if (value === undefined) return { value: undefined };
|
||||
if (value === null) return { value: null };
|
||||
if (typeof value === 'string' && value.trim() === '') return { value: null };
|
||||
|
||||
const rate = Number(value);
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
|
||||
return { error: 'interest_rate must be a number between 0 and 100, or null' };
|
||||
}
|
||||
return { value: rate };
|
||||
}
|
||||
|
||||
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
@ -191,66 +128,52 @@ router.post('/', (req, res) => {
|
|||
account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
|
||||
} = req.body;
|
||||
|
||||
if (!name || due_day == null) {
|
||||
return res.status(400).json(standardizeError('name and due_day are required', 'VALIDATION_ERROR', 'name'));
|
||||
// Validate and normalize bill data
|
||||
const validation = validateBillData(req.body);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
||||
}
|
||||
|
||||
// Validate cycle_type if provided
|
||||
const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
|
||||
const cycleType = cycle_type || 'monthly';
|
||||
if (!validCycleTypes.includes(cycleType)) {
|
||||
return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type'));
|
||||
}
|
||||
const { normalized } = validation;
|
||||
|
||||
// Validate cycle_day based on cycle_type
|
||||
const cycleDayResult = validateCycleDay(cycleType, cycle_day);
|
||||
if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day'));
|
||||
const cycleDay = cycleDayResult.value;
|
||||
|
||||
const due = parseDueDay(due_day);
|
||||
if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day'));
|
||||
const day = due.value;
|
||||
|
||||
const parsedInterest = parseInterestRate(interest_rate);
|
||||
if (parsedInterest.error) return res.status(400).json(standardizeError(parsedInterest.error, 'VALIDATION_ERROR', 'interest_rate'));
|
||||
|
||||
const bucket = day <= 14 ? '1st' : '15th';
|
||||
const catId = category_id || null;
|
||||
if (catId && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(catId, req.user.id)) {
|
||||
// Validate category_id exists for this user
|
||||
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) {
|
||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||
}
|
||||
|
||||
const visibility = history_visibility || 'default';
|
||||
if (!VALID_VISIBILITY.includes(visibility)) {
|
||||
return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
|
||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
name,
|
||||
catId,
|
||||
day,
|
||||
override_due_date || null,
|
||||
bucket,
|
||||
parseFloat(expected_amount) || 0,
|
||||
parsedInterest.value ?? null,
|
||||
billing_cycle || 'monthly',
|
||||
autopay_enabled ? 1 : 0,
|
||||
autodraft_status || 'none',
|
||||
website || null,
|
||||
username || null,
|
||||
account_info || null,
|
||||
has_2fa ? 1 : 0,
|
||||
notes || null,
|
||||
visibility,
|
||||
cycleType,
|
||||
cycleDay,
|
||||
normalized.name,
|
||||
normalized.category_id,
|
||||
normalized.due_day,
|
||||
normalized.override_due_date,
|
||||
normalized.bucket,
|
||||
normalized.expected_amount,
|
||||
normalized.interest_rate,
|
||||
normalized.billing_cycle,
|
||||
normalized.autopay_enabled,
|
||||
normalized.autodraft_status,
|
||||
normalized.website,
|
||||
normalized.username,
|
||||
normalized.account_info,
|
||||
normalized.has_2fa,
|
||||
normalized.notes,
|
||||
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);
|
||||
|
|
@ -263,75 +186,54 @@ router.put('/:id', (req, res) => {
|
|||
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
|
||||
const {
|
||||
name, category_id, due_day, override_due_date, expected_amount, interest_rate,
|
||||
billing_cycle, autopay_enabled, autodraft_status, website, username,
|
||||
account_info, has_2fa, notes, active, history_visibility, cycle_type, cycle_day,
|
||||
} = req.body;
|
||||
// Validate and normalize bill data
|
||||
const validation = validateBillData(req.body, existing);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
||||
}
|
||||
|
||||
const due = due_day !== undefined ? parseDueDay(due_day) : { value: existing.due_day };
|
||||
if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day'));
|
||||
const day = due.value;
|
||||
const { normalized } = validation;
|
||||
|
||||
const parsedInterest = parseInterestRate(interest_rate);
|
||||
if (parsedInterest.error) return res.status(400).json(standardizeError(parsedInterest.error, 'VALIDATION_ERROR', 'interest_rate'));
|
||||
|
||||
const bucket = day <= 14 ? '1st' : '15th';
|
||||
const nextCategoryId = category_id !== undefined ? (category_id || null) : existing.category_id;
|
||||
if (nextCategoryId && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(nextCategoryId, req.user.id)) {
|
||||
// Validate category_id exists for this user if changed
|
||||
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) {
|
||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||
}
|
||||
|
||||
const nextVisibility = history_visibility !== undefined ? history_visibility : existing.history_visibility;
|
||||
if (!VALID_VISIBILITY.includes(nextVisibility)) {
|
||||
return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
|
||||
}
|
||||
|
||||
// Handle cycle_type and cycle_day updates
|
||||
const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
|
||||
let nextCycleType = existing.cycle_type || 'monthly';
|
||||
let nextCycleDay = existing.cycle_day || getDefaultCycleDay(nextCycleType);
|
||||
|
||||
if (cycle_type !== undefined) {
|
||||
if (!validCycleTypes.includes(cycle_type)) {
|
||||
return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type'));
|
||||
}
|
||||
nextCycleType = cycle_type;
|
||||
}
|
||||
|
||||
// Validate cycle_day based on the resolved cycle_type
|
||||
const cycleDayResult = validateCycleDay(nextCycleType, cycle_day !== undefined ? cycle_day : nextCycleDay);
|
||||
if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day'));
|
||||
nextCycleDay = cycleDayResult.value;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE bills SET
|
||||
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
|
||||
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
|
||||
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
|
||||
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
||||
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(
|
||||
name ?? existing.name,
|
||||
nextCategoryId,
|
||||
day,
|
||||
override_due_date !== undefined ? (override_due_date || null) : existing.override_due_date,
|
||||
bucket,
|
||||
expected_amount != null ? parseFloat(expected_amount) : existing.expected_amount,
|
||||
parsedInterest.value !== undefined ? parsedInterest.value : existing.interest_rate,
|
||||
billing_cycle ?? existing.billing_cycle,
|
||||
autopay_enabled != null ? (autopay_enabled ? 1 : 0) : existing.autopay_enabled,
|
||||
autodraft_status ?? existing.autodraft_status,
|
||||
website !== undefined ? (website || null) : existing.website,
|
||||
username !== undefined ? (username || null) : existing.username,
|
||||
account_info !== undefined ? (account_info || null) : existing.account_info,
|
||||
has_2fa != null ? (has_2fa ? 1 : 0) : existing.has_2fa,
|
||||
notes !== undefined ? (notes || null) : existing.notes,
|
||||
active != null ? (active ? 1 : 0) : existing.active,
|
||||
nextVisibility,
|
||||
nextCycleType,
|
||||
nextCycleDay,
|
||||
normalized.name,
|
||||
normalized.category_id,
|
||||
normalized.due_day,
|
||||
normalized.override_due_date,
|
||||
normalized.bucket,
|
||||
normalized.expected_amount,
|
||||
normalized.interest_rate,
|
||||
normalized.billing_cycle,
|
||||
normalized.autopay_enabled,
|
||||
normalized.autodraft_status,
|
||||
normalized.website,
|
||||
normalized.username,
|
||||
normalized.account_info,
|
||||
normalized.has_2fa,
|
||||
normalized.notes,
|
||||
normalized.active,
|
||||
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,
|
||||
);
|
||||
|
|
@ -396,7 +298,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 FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
|
||||
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);
|
||||
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
|
||||
|
|
@ -417,6 +319,14 @@ 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,
|
||||
|
|
@ -449,9 +359,17 @@ 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) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(billId, amount, paidDate, method, notes);
|
||||
'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);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
|
|
@ -581,4 +499,46 @@ 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;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,17 @@ function calculatePaidDeductions(db, userId, year, month) {
|
|||
AND b.due_day BETWEEN 15 AND 31
|
||||
`).get(userId, start, end);
|
||||
|
||||
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
|
||||
const otherPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
AND (b.due_day < 1 OR b.due_day > 31)
|
||||
`).get(userId, start, end);
|
||||
|
||||
const totalPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
|
|
@ -74,6 +85,7 @@ function calculatePaidDeductions(db, userId, year, month) {
|
|||
return {
|
||||
paid_from_first: money(firstPaid.paid),
|
||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||
paid_from_other: money(otherPaid.paid),
|
||||
paid_total: money(totalPaid.paid),
|
||||
};
|
||||
}
|
||||
|
|
@ -94,10 +106,11 @@ function buildStartingAmountsResponse(db, userId, year, month) {
|
|||
combined_amount,
|
||||
paid_from_first: paid.paid_from_first,
|
||||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||
paid_from_other: paid.paid_from_other,
|
||||
paid_total,
|
||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||
other_remaining: amounts.other_amount,
|
||||
other_remaining: amounts.other_amount - paid.paid_from_other,
|
||||
combined_remaining: combined_amount - paid_total,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
||||
|
|
@ -91,9 +92,16 @@ 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) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(bill_id, payAmount, payDate, method || null, notes || null);
|
||||
'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);
|
||||
}
|
||||
|
||||
if (bill.autopay_enabled) {
|
||||
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id);
|
||||
|
|
@ -150,8 +158,10 @@ router.post('/bulk', (req, res) => {
|
|||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
|
||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) 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(
|
||||
|
|
@ -181,12 +191,16 @@ router.post('/bulk', (req, res) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id)) {
|
||||
const billRow = getBillForBalance.get(bill_id, req.user.id);
|
||||
if (!billRow) {
|
||||
errors.push({ item, error: `Bill ${bill_id} not found` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null);
|
||||
|
||||
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);
|
||||
|
||||
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
|
|
@ -222,8 +236,18 @@ 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.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);
|
||||
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);
|
||||
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 });
|
||||
});
|
||||
|
|
@ -231,8 +255,18 @@ 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.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);
|
||||
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);
|
||||
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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,10 +57,34 @@ router.get('/', (req, res) => {
|
|||
});
|
||||
|
||||
// ── PATCH /api/profile ────────────────────────────────────────────────────────
|
||||
// Updates safe profile fields: display_name only.
|
||||
// Updates safe profile fields: username and display_name.
|
||||
// Ignores any unknown or restricted fields.
|
||||
router.patch('/', (req, res) => {
|
||||
const { display_name } = req.body;
|
||||
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') });
|
||||
}
|
||||
|
||||
if (display_name !== undefined) {
|
||||
if (typeof display_name !== 'string') {
|
||||
|
|
@ -71,7 +95,7 @@ router.patch('/', (req, res) => {
|
|||
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
||||
}
|
||||
|
||||
getDb().prepare(
|
||||
db.prepare(
|
||||
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(trimmed || null, req.user.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
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;
|
||||
|
|
@ -64,6 +64,17 @@ function calculatePaidDeductions(db, userId, year, month) {
|
|||
AND b.due_day BETWEEN 15 AND 31
|
||||
`).get(userId, start, end);
|
||||
|
||||
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
|
||||
const otherPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
JOIN bills b ON b.id = p.bill_id
|
||||
WHERE b.user_id = ?
|
||||
AND p.paid_date BETWEEN ? AND ?
|
||||
AND p.deleted_at IS NULL
|
||||
AND (b.due_day < 1 OR b.due_day > 31)
|
||||
`).get(userId, start, end);
|
||||
|
||||
const totalPaid = db.prepare(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||
FROM payments p
|
||||
|
|
@ -76,6 +87,7 @@ function calculatePaidDeductions(db, userId, year, month) {
|
|||
return {
|
||||
paid_from_first: money(firstPaid.paid),
|
||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||
paid_from_other: money(otherPaid.paid),
|
||||
paid_total: money(totalPaid.paid),
|
||||
};
|
||||
}
|
||||
|
|
@ -96,10 +108,11 @@ function buildStartingAmountsSummary(db, userId, year, month) {
|
|||
combined_amount,
|
||||
paid_from_first: paid.paid_from_first,
|
||||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||
paid_from_other: paid.paid_from_other,
|
||||
paid_total,
|
||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||
other_remaining: amounts.other_amount,
|
||||
other_remaining: amounts.other_amount - paid.paid_from_other,
|
||||
combined_remaining: combined_amount - paid_total,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ router.get('/', (req, res) => {
|
|||
const hasStartingAmounts = !!startingAmounts;
|
||||
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
|
||||
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
|
||||
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
|
||||
|
||||
// Calculate previous month total
|
||||
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
|
||||
|
|
@ -197,7 +198,7 @@ router.get('/', (req, res) => {
|
|||
total_starting: totalStarting,
|
||||
has_starting_amounts: hasStartingAmounts,
|
||||
total_paid: activeTotalPaid,
|
||||
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
|
||||
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
|
||||
overdue: totalOverdue,
|
||||
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
||||
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,33 @@ const { getDb } = require('../db/database');
|
|||
const { seedDemoData } = require('../scripts/seedDemoData');
|
||||
const { demoDataLimiter } = require('../middleware/rateLimiter');
|
||||
|
||||
// GET /api/user/seeded-status — returns whether the current user has any seeded data
|
||||
router.get('/seeded-status', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check for seeded bills
|
||||
const seededBillsResult = db.prepare('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1').get(userId);
|
||||
const seededBillsCount = seededBillsResult.count;
|
||||
|
||||
// Check for seeded categories
|
||||
const seededCategoriesResult = db.prepare('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1').get(userId);
|
||||
const seededCategoriesCount = seededCategoriesResult.count;
|
||||
|
||||
const hasSeededData = seededBillsCount > 0 || seededCategoriesCount > 0;
|
||||
|
||||
res.json({
|
||||
seeded: hasSeededData,
|
||||
seededBills: seededBillsCount,
|
||||
seededCategories: seededCategoriesCount,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Seeded status check failed' : err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user
|
||||
router.post('/clear-demo-data', demoDataLimiter, (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
#!/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"
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#!/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"
|
||||
|
|
@ -20,7 +20,8 @@ const CATEGORIES = [
|
|||
'Subscriptions',
|
||||
'Transportation',
|
||||
'Healthcare',
|
||||
'Finance',
|
||||
'Credit Cards',
|
||||
'Loans',
|
||||
'Entertainment',
|
||||
];
|
||||
|
||||
|
|
@ -28,19 +29,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: 'Rent/Mortgage', category: 'Housing', amount: 1200, dueDay: 1, 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: '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: '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: '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: '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: 'Finance', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5 },
|
||||
{ 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: '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 },
|
||||
|
|
@ -126,8 +127,10 @@ 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, active, is_seeded)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
||||
expected_amount, autopay_enabled, interest_rate,
|
||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
|
||||
active, is_seeded)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
||||
`);
|
||||
|
||||
for (const billData of BILLS) {
|
||||
|
|
@ -145,7 +148,12 @@ 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.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0),
|
||||
billData.currentBalance ?? null,
|
||||
billData.minPayment ?? null,
|
||||
billData.snowballOrder ?? null,
|
||||
billData.snowballInclude ?? 0,
|
||||
billData.snowballExempt ?? 0
|
||||
);
|
||||
billsCreated++;
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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 ?? '14', 10);
|
||||
const retentionCount = parseInt(input.retention_count ?? '2', 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') || '14',
|
||||
retention_count: getSetting('backup_schedule_retention_count') || '2',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } = require('../db/database');
|
||||
const { closeDb, getDb, getDbPath, getSetting } = require('../db/database');
|
||||
|
||||
const BACKUP_DIR = path.resolve(
|
||||
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
||||
|
|
@ -166,7 +166,10 @@ async function createBackup(prefix = 'bill-tracker-backup') {
|
|||
validateSqliteDatabase(tempPath);
|
||||
fs.renameSync(tempPath, finalPath);
|
||||
fs.chmodSync(finalPath, 0o600);
|
||||
return metadataForFile(finalPath);
|
||||
const meta = metadataForFile(finalPath);
|
||||
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
|
||||
applyRetention(retentionCount);
|
||||
return meta;
|
||||
} catch (err) {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||
cleanupSqliteSidecars(tempPath);
|
||||
|
|
@ -239,25 +242,28 @@ function deleteBackup(id) {
|
|||
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function applyScheduledRetention(retentionCount) {
|
||||
function applyRetention(retentionCount) {
|
||||
const keep = parseInt(retentionCount, 10);
|
||||
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
||||
|
||||
const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
|
||||
const toDelete = scheduled.slice(keep);
|
||||
// listBackups() is already sorted newest-first; delete everything beyond `keep`
|
||||
const toDelete = listBackups().slice(keep);
|
||||
const deleted = [];
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
deleted.push(deleteBackup(backup.id).id);
|
||||
} catch {
|
||||
// Retention should never make a scheduled backup fail.
|
||||
// Retention should never cause a backup operation to 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);
|
||||
|
|
@ -299,6 +305,7 @@ async function restoreBackup(id) {
|
|||
module.exports = {
|
||||
BACKUP_DIR,
|
||||
assertValidBackupId,
|
||||
applyRetention,
|
||||
applyScheduledRetention,
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
||||
|
||||
// Helper function to get default cycle day based on cycle type
|
||||
function getDefaultCycleDay(cycleType) {
|
||||
switch (cycleType) {
|
||||
case 'monthly':
|
||||
return '1'; // 1st of the month
|
||||
case 'weekly':
|
||||
return 'monday'; // Monday
|
||||
case 'biweekly':
|
||||
return 'monday'; // Monday
|
||||
case 'quarterly':
|
||||
return '1'; // 1st of the quarter
|
||||
case 'annual':
|
||||
return '1'; // 1st of the year
|
||||
default:
|
||||
return '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cycle_day based on cycle_type
|
||||
function validateCycleDay(cycleType, cycleDay) {
|
||||
if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) };
|
||||
const ct = cycleType || 'monthly';
|
||||
switch (ct) {
|
||||
case 'monthly': {
|
||||
const d = Number(cycleDay);
|
||||
if (!Number.isInteger(d) || d < 1 || d > 31) return { error: 'monthly cycle_day must be 1-31' };
|
||||
return { value: String(d) };
|
||||
}
|
||||
case 'weekly':
|
||||
case 'biweekly': {
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
if (!days.includes(String(cycleDay).toLowerCase())) return { error: 'weekly/biweekly cycle_day must be a valid day name' };
|
||||
return { value: String(cycleDay).toLowerCase() };
|
||||
}
|
||||
case 'quarterly':
|
||||
case 'annual':
|
||||
return { value: String(cycleDay).slice(0, 50) };
|
||||
default:
|
||||
return { value: getDefaultCycleDay(ct) };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDueDay(value) {
|
||||
const day = Number(value);
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||
return { error: 'due_day must be an integer between 1 and 31' };
|
||||
}
|
||||
return { value: day };
|
||||
}
|
||||
|
||||
function parseInterestRate(value) {
|
||||
if (value === undefined) return { value: undefined };
|
||||
if (value === null) return { value: null };
|
||||
if (typeof value === 'string' && value.trim() === '') return { value: null };
|
||||
|
||||
const rate = Number(value);
|
||||
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
|
||||
return { error: 'interest_rate must be a number between 0 and 100, or null' };
|
||||
}
|
||||
return { value: rate };
|
||||
}
|
||||
|
||||
function getValidCycleTypes() {
|
||||
return ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes bill data for creation/update.
|
||||
* Returns an object with normalized values and any validation errors.
|
||||
*/
|
||||
function validateBillData(data, existingBill = null) {
|
||||
const errors = [];
|
||||
const normalized = {};
|
||||
|
||||
const validCycleTypes = getValidCycleTypes();
|
||||
|
||||
// name is required
|
||||
if (!data.name) {
|
||||
errors.push({ field: 'name', message: 'name is required' });
|
||||
}
|
||||
normalized.name = data.name || null;
|
||||
|
||||
// due_day is required
|
||||
if (data.due_day === undefined || data.due_day === null) {
|
||||
errors.push({ field: 'due_day', message: 'due_day is required' });
|
||||
} else {
|
||||
const dueResult = parseDueDay(data.due_day);
|
||||
if (dueResult.error) {
|
||||
errors.push({ field: 'due_day', message: dueResult.error });
|
||||
} else {
|
||||
normalized.due_day = dueResult.value;
|
||||
}
|
||||
}
|
||||
|
||||
// category_id validation
|
||||
normalized.category_id = data.category_id !== undefined ? (data.category_id || null) : (existingBill?.category_id || null);
|
||||
|
||||
// override_due_date
|
||||
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
|
||||
|
||||
// expected_amount
|
||||
normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
|
||||
|
||||
// interest_rate
|
||||
if (data.interest_rate !== undefined) {
|
||||
const parsedInterest = parseInterestRate(data.interest_rate);
|
||||
if (parsedInterest.error) {
|
||||
errors.push({ field: 'interest_rate', message: parsedInterest.error });
|
||||
} else {
|
||||
normalized.interest_rate = parsedInterest.value ?? null;
|
||||
}
|
||||
} else {
|
||||
normalized.interest_rate = existingBill?.interest_rate ?? null;
|
||||
}
|
||||
|
||||
// billing_cycle
|
||||
normalized.billing_cycle = data.billing_cycle !== undefined ? (data.billing_cycle || 'monthly') : (existingBill?.billing_cycle || 'monthly');
|
||||
|
||||
// autopay_enabled
|
||||
normalized.autopay_enabled = data.autopay_enabled !== undefined ? (data.autopay_enabled ? 1 : 0) : (existingBill?.autopay_enabled || 0);
|
||||
|
||||
// autodraft_status
|
||||
normalized.autodraft_status = data.autodraft_status !== undefined ? (data.autodraft_status || 'none') : (existingBill?.autodraft_status || 'none');
|
||||
|
||||
// website
|
||||
normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null);
|
||||
|
||||
// username
|
||||
normalized.username = data.username !== undefined ? (data.username || null) : (existingBill?.username || null);
|
||||
|
||||
// account_info
|
||||
normalized.account_info = data.account_info !== undefined ? (data.account_info || null) : (existingBill?.account_info || null);
|
||||
|
||||
// has_2fa
|
||||
normalized.has_2fa = data.has_2fa !== undefined ? (data.has_2fa ? 1 : 0) : (existingBill?.has_2fa || 0);
|
||||
|
||||
// notes
|
||||
normalized.notes = data.notes !== undefined ? (data.notes || null) : (existingBill?.notes || null);
|
||||
|
||||
// active
|
||||
normalized.active = data.active !== undefined ? (data.active ? 1 : 0) : (existingBill?.active || 1);
|
||||
|
||||
// history_visibility
|
||||
const nextVisibility = data.history_visibility !== undefined ? data.history_visibility : (existingBill?.history_visibility || 'default');
|
||||
if (!VALID_VISIBILITY.includes(nextVisibility)) {
|
||||
errors.push({ field: 'history_visibility', message: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
|
||||
}
|
||||
normalized.history_visibility = nextVisibility;
|
||||
|
||||
// cycle_type and cycle_day
|
||||
let nextCycleType = (data.cycle_type !== undefined ? data.cycle_type : existingBill?.cycle_type) || 'monthly';
|
||||
let nextCycleDay = existingBill?.cycle_day || getDefaultCycleDay(nextCycleType);
|
||||
|
||||
if (data.cycle_type !== undefined) {
|
||||
if (!validCycleTypes.includes(data.cycle_type)) {
|
||||
errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` });
|
||||
} else {
|
||||
nextCycleType = data.cycle_type;
|
||||
}
|
||||
}
|
||||
|
||||
const cycleDayResult = validateCycleDay(nextCycleType, data.cycle_day !== undefined ? data.cycle_day : nextCycleDay);
|
||||
if (cycleDayResult.error) {
|
||||
errors.push({ field: 'cycle_day', message: cycleDayResult.error });
|
||||
} else {
|
||||
nextCycleDay = cycleDayResult.value;
|
||||
}
|
||||
normalized.cycle_type = nextCycleType;
|
||||
normalized.cycle_day = nextCycleDay;
|
||||
|
||||
// Calculate bucket based on due_day
|
||||
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
|
||||
|
||||
// current_balance — outstanding debt balance (nullable)
|
||||
if (data.current_balance !== undefined) {
|
||||
if (data.current_balance === null || data.current_balance === '') {
|
||||
normalized.current_balance = null;
|
||||
} else {
|
||||
const cb = parseFloat(data.current_balance);
|
||||
if (!Number.isFinite(cb) || cb < 0) {
|
||||
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
|
||||
} else {
|
||||
normalized.current_balance = cb;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized.current_balance = existingBill?.current_balance ?? null;
|
||||
}
|
||||
|
||||
// minimum_payment — required minimum payment for debt (nullable)
|
||||
if (data.minimum_payment !== undefined) {
|
||||
if (data.minimum_payment === null || data.minimum_payment === '') {
|
||||
normalized.minimum_payment = null;
|
||||
} else {
|
||||
const mp = parseFloat(data.minimum_payment);
|
||||
if (!Number.isFinite(mp) || mp < 0) {
|
||||
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
|
||||
} else {
|
||||
normalized.minimum_payment = mp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized.minimum_payment = existingBill?.minimum_payment ?? null;
|
||||
}
|
||||
|
||||
// snowball_order — drag position on snowball page (nullable integer)
|
||||
if (data.snowball_order !== undefined) {
|
||||
if (data.snowball_order === null || data.snowball_order === '') {
|
||||
normalized.snowball_order = null;
|
||||
} else {
|
||||
const so = parseInt(data.snowball_order, 10);
|
||||
if (!Number.isInteger(so) || so < 0) {
|
||||
errors.push({ field: 'snowball_order', message: 'snowball_order must be a non-negative integer' });
|
||||
} else {
|
||||
normalized.snowball_order = so;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized.snowball_order = existingBill?.snowball_order ?? null;
|
||||
}
|
||||
|
||||
// snowball_include — manual override to force bill onto snowball page
|
||||
normalized.snowball_include = data.snowball_include !== undefined
|
||||
? (data.snowball_include ? 1 : 0)
|
||||
: (existingBill?.snowball_include ?? 0);
|
||||
|
||||
// snowball_exempt — manual override to hide an auto-detected debt-like bill
|
||||
normalized.snowball_exempt = data.snowball_exempt !== undefined
|
||||
? (data.snowball_exempt ? 1 : 0)
|
||||
: (existingBill?.snowball_exempt ?? 0);
|
||||
|
||||
return {
|
||||
errors,
|
||||
normalized: {
|
||||
...normalized,
|
||||
name: normalized.name || null,
|
||||
due_day: normalized.due_day || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates cycle_day for a given cycle_type without requiring the full bill data.
|
||||
*/
|
||||
function validateCycleDayOnly(cycleType, cycleDay) {
|
||||
return validateCycleDay(cycleType, cycleDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes how a payment affects a debt bill's current_balance, accounting for
|
||||
* one month of interest accrual.
|
||||
*
|
||||
* Returns { new_balance, balance_delta } where balance_delta is negative when
|
||||
* the balance was reduced (typical case). Returns null when the bill has no
|
||||
* trackable balance.
|
||||
*/
|
||||
function computeBalanceDelta(bill, paymentAmount) {
|
||||
const bal = Number(bill.current_balance);
|
||||
const rate = Number(bill.interest_rate) || 0;
|
||||
const amt = Number(paymentAmount);
|
||||
|
||||
if (!Number.isFinite(bal) || bal <= 0) return null;
|
||||
if (!Number.isFinite(amt) || amt <= 0) return null;
|
||||
|
||||
const monthlyInterest = bal * (rate / 100 / 12);
|
||||
const raw = bal + monthlyInterest - amt;
|
||||
const newBalance = Math.round(Math.max(0, raw) * 100) / 100;
|
||||
const delta = Math.round((newBalance - bal) * 100) / 100;
|
||||
|
||||
return { new_balance: newBalance, balance_delta: delta };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_VISIBILITY,
|
||||
getValidCycleTypes,
|
||||
getDefaultCycleDay,
|
||||
validateCycleDay,
|
||||
parseDueDay,
|
||||
parseInterestRate,
|
||||
validateBillData,
|
||||
validateCycleDayOnly,
|
||||
computeBalanceDelta,
|
||||
};
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* 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 };
|
||||
|
|
@ -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|paid|value)$/i,
|
||||
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/i,
|
||||
due_date: /^(?:due\s*date|due|due\s*day)$/i,
|
||||
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date|date\s*cleared|cleared\s*date)$/i,
|
||||
paid_date: /^(?:paid\s*date|date\s*paid|payment\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)
|
||||
// Reject array (a), error (e), formula (f), shared formula (s)
|
||||
if (cell.t && !['n', 't', 'b', 'd'].includes(cell.t)) {
|
||||
// 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)) {
|
||||
const err = new Error(`Invalid cell type '${cell.t}' found in ${cellRef}. Only numbers and text are supported.`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
|
|
@ -233,8 +233,13 @@ function parseXlsxBuffer(buffer) {
|
|||
function getSheetRows(workbook, sheetName) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheet) 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 });
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Header Detection ─────────────────────────────────────────────────────────
|
||||
|
|
@ -252,12 +257,120 @@ 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;
|
||||
|
|
@ -272,7 +385,17 @@ function isLikelyHeaderRow(cells) {
|
|||
|
||||
function isLikelyTotalRow(cells) {
|
||||
return cells.some(
|
||||
(c) => c != null && /^(?:total|subtotal|sum|grand\s*total)$/i.test(String(c).trim()),
|
||||
(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()),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -499,6 +622,7 @@ function buildRecommendation({
|
|||
billName,
|
||||
detectedAmount,
|
||||
parsedDate,
|
||||
parsedPaidDate,
|
||||
dateHeader,
|
||||
detectedCategory,
|
||||
notesText,
|
||||
|
|
@ -507,6 +631,7 @@ function buildRecommendation({
|
|||
warnings,
|
||||
errors,
|
||||
paymentDateIso,
|
||||
defaultDueDay = null,
|
||||
}) {
|
||||
const recWarnings = [...warnings];
|
||||
const topMatch = possibleMatches[0] || null;
|
||||
|
|
@ -514,7 +639,15 @@ function buildRecommendation({
|
|||
const mediumMatches = possibleMatches.filter((m) => m.match_confidence === 'medium');
|
||||
|
||||
const dateDay = parsedDate?.day;
|
||||
const dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
|
||||
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 paymentDate = isPaymentDateHeader(dateHeader);
|
||||
if (dueDay && paymentDate && !isDueDateHeader(dateHeader)) {
|
||||
recWarnings.push('Date appears to be a payment date, not a due date');
|
||||
|
|
@ -648,8 +781,11 @@ function findFirstAmountCell(cells, skipIndices) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function collectNotesCells(cells, headerMap, billName) {
|
||||
function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null) {
|
||||
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;
|
||||
|
|
@ -666,7 +802,7 @@ function collectNotesCells(cells, headerMap, billName) {
|
|||
|
||||
// ─── Single-Row Analyzer ──────────────────────────────────────────────────────
|
||||
|
||||
function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categories, sheetName, sheetYear, sheetMonth, defaultYear, defaultMonth, rowIdPrefix) {
|
||||
function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categories, sheetName, sheetYear, sheetMonth, defaultYear, defaultMonth, rowIdPrefix, defaultDueDay = null, headerSetIndex = null, allHeaderColumns = null) {
|
||||
const get = (field) => {
|
||||
const idx = headerMap[field];
|
||||
return idx !== undefined ? cells[idx] : undefined;
|
||||
|
|
@ -675,7 +811,12 @@ 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);
|
||||
|
||||
|
|
@ -698,7 +839,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);
|
||||
const notesText = collectNotesCells(cells, headerMap, billName, allHeaderColumns);
|
||||
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));
|
||||
|
|
@ -708,11 +849,32 @@ 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,
|
||||
|
|
@ -721,6 +883,7 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
|||
warnings,
|
||||
errors,
|
||||
paymentDateIso: detectedPaidDate,
|
||||
defaultDueDay,
|
||||
});
|
||||
|
||||
const proposedAction = recommendation.action === 'ambiguous' ? 'mark_ambiguous' : recommendation.action;
|
||||
|
|
@ -751,6 +914,8 @@ 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -764,29 +929,135 @@ 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 };
|
||||
|
||||
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;
|
||||
// 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 rows = [];
|
||||
for (let i = startRow; i < rawRows.length; i++) {
|
||||
const cells = rawRows[i] || [];
|
||||
if (isBlankRow(cells)) continue;
|
||||
if (isLikelyHeaderRow(cells) && i > 0) continue;
|
||||
if (isLikelyTotalRow(cells)) continue;
|
||||
|
||||
// Process each header set independently
|
||||
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;
|
||||
|
||||
rows.push(analyzeRow(
|
||||
i, cells, headerMap, headerLabels, userBills, categories,
|
||||
name, sheetYear, sheetMonth,
|
||||
defaultYear, defaultMonth, rowIdPrefix,
|
||||
));
|
||||
try {
|
||||
rows.push(analyzeRow(
|
||||
i, cells, headerMap, headerLabels, userBills, categories,
|
||||
name, sheetYear, sheetMonth,
|
||||
defaultYear, defaultMonth, rowIdPrefix,
|
||||
defaultDueDay, setIdx, allColumnsIndices,
|
||||
));
|
||||
} catch (err) {
|
||||
console.error(`[import] sheet="${name}" row=${i + 1} failed to analyze — skipping:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
headerRow: hasHeaders ? firstRow.map((c) => (c != null ? String(c) : null)) : null,
|
||||
headerRow: hasHeaders ? headerLabels : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -836,7 +1107,9 @@ function pruneExpiredSessions(db) {
|
|||
|
||||
async function previewSpreadsheet(userId, buffer, options = {}) {
|
||||
const db = getDb();
|
||||
pruneExpiredSessions(db);
|
||||
try { pruneExpiredSessions(db); } catch (err) {
|
||||
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
|
||||
}
|
||||
ensureUserDefaultCategories(userId);
|
||||
|
||||
const workbook = parseXlsxBuffer(buffer);
|
||||
|
|
@ -1232,11 +1505,12 @@ 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, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
|
||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
|
||||
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);
|
||||
|
||||
const newBillId = ins.lastInsertRowid;
|
||||
summary.created++;
|
||||
|
|
@ -1275,9 +1549,14 @@ 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 FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||
const bill = db.prepare('SELECT id, name, autopay_enabled 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' });
|
||||
|
|
@ -1439,6 +1718,7 @@ function getImportHistory(userId) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
detectAllHeaderSets,
|
||||
previewSpreadsheet,
|
||||
applyImportDecisions,
|
||||
getImportHistory,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ function getCycleRange(year, month) {
|
|||
* Returns status for a bill given its payments and due date.
|
||||
*
|
||||
* Statuses:
|
||||
* paid — total payments >= expected_amount
|
||||
* paid — has a non-deleted payment in this billing cycle
|
||||
* — OR total paid >= expected_amount (fully settled)
|
||||
* autodraft — autopay_enabled and assumed_paid (no confirmed payment yet)
|
||||
* upcoming — due_date in the future
|
||||
* due_soon — due within 3 days
|
||||
|
|
@ -43,10 +44,13 @@ function getCycleRange(year, month) {
|
|||
*/
|
||||
function calculateStatus(bill, payments, dueDate, today) {
|
||||
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
|
||||
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const isPaid = totalPaid >= bill.expected_amount;
|
||||
const safePayments = Array.isArray(payments) ? payments : [];
|
||||
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
if (isPaid) return 'paid';
|
||||
// A recorded payment is the user's confirmation that this cycle is handled.
|
||||
// Expected amounts are estimates, so a lower actual payment must not leave a Pay
|
||||
// button visible and invite duplicate payments.
|
||||
if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid';
|
||||
|
||||
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
|
||||
return 'autodraft';
|
||||
|
|
@ -68,10 +72,15 @@ function calculateStatus(bill, payments, dueDate, today) {
|
|||
function buildTrackerRow(bill, payments, year, month, todayStr) {
|
||||
const dueDate = resolveDueDate(bill, year, month);
|
||||
const bucket = resolveBucket(bill);
|
||||
const status = calculateStatus(bill, payments, dueDate, todayStr);
|
||||
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const lastPayment = payments.length
|
||||
? payments.sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
|
||||
const safePayments = Array.isArray(payments) ? payments : [];
|
||||
const status = calculateStatus(bill, safePayments, dueDate, todayStr);
|
||||
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const hasPayment = safePayments.length > 0;
|
||||
const isSettled = status === 'paid' || status === 'autodraft';
|
||||
const rawBalance = bill.expected_amount - totalPaid;
|
||||
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
|
||||
const lastPayment = hasPayment
|
||||
? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
|
@ -85,14 +94,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr) {
|
|||
expected_amount: bill.expected_amount,
|
||||
notes: bill.notes || null, // Bill-level notes (always available)
|
||||
total_paid: totalPaid,
|
||||
balance: bill.expected_amount - totalPaid,
|
||||
balance,
|
||||
has_payment: hasPayment,
|
||||
is_settled: isSettled,
|
||||
last_paid_date: lastPayment ? lastPayment.paid_date : null,
|
||||
last_payment_amount: lastPayment ? lastPayment.amount : null,
|
||||
status,
|
||||
autopay_enabled: !!bill.autopay_enabled,
|
||||
autodraft_status: bill.autodraft_status,
|
||||
billing_cycle: bill.billing_cycle,
|
||||
payments,
|
||||
payments: safePayments,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,10 +61,14 @@ 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue