Compare commits
No commits in common. "dev" and "main" have entirely different histories.
|
|
@ -1,15 +1,3 @@
|
||||||
# Private project/agent docs — never commit
|
|
||||||
DEVELOPMENT_LOG.md
|
|
||||||
PROJECT.md
|
|
||||||
STRUCTURE.md
|
|
||||||
FUTURE.md
|
|
||||||
HISTORY.md
|
|
||||||
BUILD_SUMMARY.md
|
|
||||||
SCRIPTS.md
|
|
||||||
project-requirements.md
|
|
||||||
.learnings/
|
|
||||||
|
|
||||||
# Dependencies
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
db/*.db
|
db/*.db
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Errors Logged During Phase 1 Verification
|
|
||||||
|
|
||||||
No errors encountered during Build-Verify Phase 1.
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# Learnings from Phase 1 Verification
|
|
||||||
|
|
||||||
## Business Logic Extraction — Verification Summary
|
|
||||||
|
|
||||||
### What Was Verified
|
|
||||||
|
|
||||||
1. **Build Success**: ✅ `docker build --no-cache -t bill-tracker:local .` completed successfully
|
|
||||||
- 1764 modules transformed
|
|
||||||
- Build time: 1.91s
|
|
||||||
- Output: 35 JS chunks for code splitting
|
|
||||||
|
|
||||||
2. **Container Start**: ✅ Container starts cleanly with migrations applied
|
|
||||||
- All 46 migrations applied correctly
|
|
||||||
- Database initialization successful
|
|
||||||
- No errors in startup logs
|
|
||||||
|
|
||||||
3. **Services/billsService.js Existance**: ✅ Verified
|
|
||||||
- All 8 expected exports present:
|
|
||||||
- `parseDueDay()`
|
|
||||||
- `parseInterestRate()`
|
|
||||||
- `validateCycleDay()`
|
|
||||||
- `getDefaultCycleDay()`
|
|
||||||
- `validateBillData()`
|
|
||||||
- `getValidCycleTypes()`
|
|
||||||
- `VALID_VISIBILITY`
|
|
||||||
- `validateCycleDayOnly()`
|
|
||||||
|
|
||||||
4. **Routes/bills.js Integration**: ✅ Verified
|
|
||||||
- Imports from `../services/billsService`
|
|
||||||
- `validateBillData()` call in POST `/api/bills` endpoint
|
|
||||||
- `validateBillData()` call in PUT `/api/bills/:id` endpoint
|
|
||||||
- No inline validation logic remaining in routes
|
|
||||||
|
|
||||||
### No Errors Found
|
|
||||||
|
|
||||||
The business logic extraction is complete and working correctly. All validation logic has been moved from routes to the service layer, maintaining the same behavior.
|
|
||||||
|
|
||||||
### Test Notes
|
|
||||||
|
|
||||||
- Docker client version (1.42) is older than required (1.44) for docker compose
|
|
||||||
- Workaround: Used `docker run` directly instead of `docker compose`
|
|
||||||
- Existing container stopped and removed before starting fresh build
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
|
|
||||||
- `.learnings/bishop/ERRORS.md` — Error log (empty - no errors)
|
|
||||||
- `.learnings/bishop/LEARNINGS.md` — This file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Verified By**: Bishop (subagent)
|
|
||||||
**Date**: 2026-05-11
|
|
||||||
**Phase**: 1/4 — Build-Verify
|
|
||||||
**Version**: v0.24.4
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Bill Tracker - Neo Errors Log
|
|
||||||
|
|
||||||
## 2026-05-11 - Phase 1 Business Logic Extraction
|
|
||||||
|
|
||||||
### Errors Encountered
|
|
||||||
- None - extraction completed successfully on first attempt
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
-工程参考手册 does not exist in the project directory (expected to be under `Projects/bill-tracker/`)
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# Bill Tracker - Backend Refactoring Learnings
|
|
||||||
|
|
||||||
## 2026-05-11 - Phase 1 Business Logic Extraction
|
|
||||||
|
|
||||||
### Task
|
|
||||||
Extract business logic from `routes/bills.js` into a dedicated service layer (`services/billsService.js`).
|
|
||||||
|
|
||||||
### Functions Extracted to `services/billsService.js`
|
|
||||||
- `getDefaultCycleDay(cycleType)` - Returns default cycle day based on cycle type
|
|
||||||
- `validateCycleDay(cycleType, cycleDay)` - Validates cycle_day based on cycle_type rules
|
|
||||||
- `parseDueDay(value)` - Parses and validates due_day (must be 1-31 integer)
|
|
||||||
- `parseInterestRate(value)` - Parses and validates interest_rate (0-100 range)
|
|
||||||
- `getValidCycleTypes()` - Returns array of valid cycle types
|
|
||||||
- `validateBillData(data, existingBill)` - Comprehensive validation and normalization for bill create/update
|
|
||||||
- `validateCycleDayOnly(cycleType, cycleDay)` - Convenience wrapper for cycle_day validation
|
|
||||||
|
|
||||||
### Functions Remaining in `routes/bills.js`
|
|
||||||
- Route handlers only - parse request, call service, send response
|
|
||||||
- DB queries remain in routes (tightly coupled to HTTP flow, not pure business logic)
|
|
||||||
- Error formatting with `standardizeError` (HTTP-layer concern)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
1. **`validateBillData`** - Centralized validation function that handles both create and update scenarios
|
|
||||||
- Takes optional `existingBill` parameter to support partial updates
|
|
||||||
- Returns `{ errors, normalized }` structure
|
|
||||||
- Validates all bill fields including category_id, history_visibility, cycle_type/cycle_day
|
|
||||||
|
|
||||||
2. **`getValidCycleTypes()`** - Exported constant array for consistency across files
|
|
||||||
|
|
||||||
3. **`VALID_VISIBILITY`** - Exported from service for reuse in other files if needed
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
- Business logic is now testable in isolation without mocking Express request/response
|
|
||||||
- Route handlers are thinner and easier to read
|
|
||||||
- Validation rules are centralized in one place
|
|
||||||
- Easier to add new bill-related operations without touching routes
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
- `routes/bills.js` - Removed ~80 lines of business logic, replaced with service imports and calls
|
|
||||||
- `services/billsService.js` - New file created with extracted business logic
|
|
||||||
|
|
||||||
### No Breaking Changes
|
|
||||||
- All API endpoints maintain exact same behavior
|
|
||||||
- Same validation rules applied
|
|
||||||
- Same error messages returned
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# Bill Tracker — Scarlett's Active Notes
|
|
||||||
|
|
||||||
**Last updated:** 2026-05-11
|
|
||||||
|
|
||||||
## Task 2: RoadmapPage UI — Kanban Priority Lanes
|
|
||||||
|
|
||||||
### What Changed
|
|
||||||
|
|
||||||
| File | Action | Description |
|
|
||||||
|------|--------|-------------|
|
|
||||||
| `client/pages/RoadmapPage.jsx` | **NEW** | Standalone kanban-style roadmap page with 2 tabs (Roadmap + Activity Log) |
|
|
||||||
| `client/App.jsx` | **MODIFIED** | Added lazy import for RoadmapPage; `/admin/roadmap` route now renders `<RoadmapPage />`; `/admin/about` route uses `<AboutPage />` without admin prop |
|
|
||||||
| `client/pages/AboutPage.jsx` | **MODIFIED** | Removed `admin` prop, removed `AdminDashboard` import, removed conditional render block — AboutPage is now public-only |
|
|
||||||
| `client/components/AdminDashboard.jsx` | **DELETED** | Replaced entirely by RoadmapPage |
|
|
||||||
| `client/components/ui/collapsible.jsx` | **NEW** | shadcn Collapsible component (Radix-based) |
|
|
||||||
| `tailwind.config.js` | **MODIFIED** | Added `collapsible-down`/`collapsible-up` keyframes and animations |
|
|
||||||
| `package.json` | **MODIFIED** | Added `@radix-ui/react-collapsible` dependency |
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
- **RoadmapPage** is a standalone page rendered at `/admin/roadmap` (behind `<RequireAuth role="admin">` + `<AdminShell>`)
|
|
||||||
- Uses **shadcn Tabs** for Roadmap / Activity Log tab switching
|
|
||||||
- **Roadmap tab**: 5-column kanban grid on desktop (`lg+`), 2-column on tablet (`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
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,259 @@
|
||||||
|
# Bill Tracker — Future Improvements
|
||||||
|
|
||||||
|
**This document tracks potential future enhancements for Bill Tracker.**
|
||||||
|
|
||||||
|
**Last Updated:** 2026-05-10
|
||||||
|
**Current Version:** v0.24.3
|
||||||
|
|
||||||
|
## How to Use This Document
|
||||||
|
|
||||||
|
This file is a living document. Agents should:
|
||||||
|
1. Read this file before proposing changes
|
||||||
|
2. Add new recommendations with priority levels
|
||||||
|
3. Never add completed items — move those to HISTORY.md instead
|
||||||
|
4. Reference this file when dispatching improvement tasks
|
||||||
|
5. Only Ripley can remove items from this list.
|
||||||
|
|
||||||
|
### Priority Format
|
||||||
|
|
||||||
|
All items must include the priority emoji in their heading, matching the section they belong to:
|
||||||
|
|
||||||
|
| Priority | Emoji | Heading Format |
|
||||||
|
|----------|-------|---------------|
|
||||||
|
| CRITICAL | 🔴 | `### 🔴 Title — CRITICAL` |
|
||||||
|
| HIGH | 🟠 | `### 🟠 Title — HIGH` |
|
||||||
|
| MEDIUM | 🟡 | `### 🟡 Title — MEDIUM` |
|
||||||
|
| LOW | 🔵 | `### 🔵 Title — LOW` |
|
||||||
|
| NICE TO HAVE | 💭 | `### 💭 Title — NICE TO HAVE` |
|
||||||
|
|
||||||
|
Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `## 🟠 HIGH`, etc.) and sorted most-impactful-first within each tier.
|
||||||
|
|
||||||
|
|
||||||
|
## Pending Recommendations
|
||||||
|
|
||||||
|
### 🔴 CRITICAL
|
||||||
|
|
||||||
|
|
||||||
|
### ~~🔴 Notification Runner Leaks Bill Details Across Users — CRITICAL~~ ✅ FIXED (v0.23.2)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
|
||||||
|
### 🟠 HIGH
|
||||||
|
|
||||||
|
### ~~🟠 Admin Can Toggle Payments on Any User Bill — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟠 Analytics Validation Errors Crash Instead of Returning 400 — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟠 User Export Drops Recurrence and History-Range Data — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟠 Single-User Mode Can Lock Itself Out When Expired Sessions Exist — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
|
||||||
|
### 🟡 MEDIUM
|
||||||
|
|
||||||
|
|
||||||
|
### ~~🟡 Password Change Rate Limiter Applies to Every Profile Endpoint — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟡 Profile Password Change Does Not Invalidate Other Sessions — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟡 CSRF Defaults Conflict with SPA Token Loading — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟡 Change-Password Routes Are Globally Exempted from CSRF — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟡 Notification Due-Day Math Can Miss Same-Day Reminders — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🟡 Upcoming Bills Allows Negative Day Windows — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
|
||||||
|
### Architecture: Business Logic Mixed with Route Handlers
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
**Added:** 2026-05-08 by Neo
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Many routes contain business logic that should be extracted to service layers.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- `bills.js` contains `parseDueDay()`, `parseInterestRate()` — validation logic
|
||||||
|
- `tracker.js` contains date/range calculations that are reused across routes
|
||||||
|
- `admin.js` has complex OIDC config building mixed with routing
|
||||||
|
- `analytics.js` has complex date-building logic (`buildMonths`, `monthKey`, etc.)
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Files to modify: Multiple route files + new service files in `/services/`
|
||||||
|
- Estimated effort: 8 hours
|
||||||
|
- Proposed structure:
|
||||||
|
```
|
||||||
|
/services/billsService.js
|
||||||
|
/services/trackerService.js
|
||||||
|
/services/analyticsService.js
|
||||||
|
/services/authService.js (existing)
|
||||||
|
/services/oidcService.js (existing)
|
||||||
|
/services/cleanupService.js (existing)
|
||||||
|
```
|
||||||
|
- Route handlers should call services, not contain business logic
|
||||||
|
|
||||||
|
### ~~Skip First-Login User Creation When ENV Seeds Users~~ ✅ COMPLETED (v0.22.3)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~No Rollback Capability for Failed Migrations~~ ✅ COMPLETED (v0.23.1)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~Limited Error Handling and Logging for Migrations~~ ✅ COMPLETED (v0.23.0)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Migration errors are silent or unclear
|
||||||
|
- No logging of which migration failed or why
|
||||||
|
- No way to diagnose schema inconsistencies
|
||||||
|
- Risk: slow debugging on production issues
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Add detailed logging: `[migration] Applying v0.20.0: Add user_groups table`
|
||||||
|
- Include timing: `[migration] v0.20.0 completed in 234ms`
|
||||||
|
- Log precondition checks: `[migration] Checking: table_exists('users')`
|
||||||
|
- Error log with context: `[migration-error] v0.20.0 failed: UNIQUE constraint failed on users.username`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔵 LOW
|
||||||
|
|
||||||
|
|
||||||
|
### ~~🔵 Export Formats Include Sensitive Bill Credential Fields by Default — LOW~~ ✅ FIXED (v0.24.1)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
### ~~🔵 Duplicate Local Login Route Increases Auth Drift Risk — LOW~~ ✅ FIXED (v0.23.2)
|
||||||
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
|
|
||||||
|
### Add comprehensive unit and integration tests
|
||||||
|
**Priority:** LOW
|
||||||
|
**Added:** 2026-05-08 by Scarlett
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Currently no unit tests exist for components or hooks. The only testing appears to be functional tests in `test-functional.js`. Component-level testing is missing.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
Code quality and maintainability. Unit tests catch regressions and document component behavior. Bill Tracker has complex business logic (bill calculations, monthly state, analytics) that should be tested.
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Set up Jest + React Testing Library
|
||||||
|
- Test key components: BillModal, TrackerPage row, BillsTableInner
|
||||||
|
- Test hooks: useAuth, custom form hooks
|
||||||
|
- Test utility functions in `client/lib/utils.js`
|
||||||
|
- Consider vitest for faster test execution
|
||||||
|
- Add CI integration for test execution
|
||||||
|
- Files likely to be modified: Add `client/test/` directory, add `jest.config.cjs`
|
||||||
|
- Estimated effort: 8-12 hours for baseline coverage
|
||||||
|
|
||||||
|
### Features: Missing Export for User-Specific Reports
|
||||||
|
**Priority:** LOW
|
||||||
|
**Added:** 2026-05-08 by Neo
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
No built-in way to export filtered data (e.g., "all bills in category X for last 6 months").
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- `/api/analytics/summary` exists but returns JSON only
|
||||||
|
- Users cannot generate Excel/PDF reports
|
||||||
|
- No programmatic way to get export links for specific filters
|
||||||
|
- `/api/export/user-excel` exports everything, not filtered views
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/routes/export.js`
|
||||||
|
- Estimated effort: 6 hours
|
||||||
|
- Add endpoints:
|
||||||
|
- `GET /api/export/user-excel?category_id=1&start=2026-01&end=2026-06`
|
||||||
|
- `GET /api/export/user-json?filter=bills&status=missed`
|
||||||
|
- Add report title/description to export metadata
|
||||||
|
|
||||||
|
### Features: Missing Bill Grouping and Reorganization API
|
||||||
|
**Priority:** LOW
|
||||||
|
**Added:** 2026-05-08 by Neo
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
No way to reorder bills, drag-and-drop, or group by custom criteria.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- `bills` table has `due_day` ordering but no manual sort order
|
||||||
|
- Frontend likely orders by `due_day` only
|
||||||
|
- Users cannot create bill groups or categories for bills
|
||||||
|
- No way to mark bills as "hidden" or "archived" without deactivating
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/db/schema.sql`, `/routes/bills.js`
|
||||||
|
- Estimated effort: 6 hours
|
||||||
|
- Add:
|
||||||
|
- `sort_order` column to bills table (default NULL, ordered first by sort_order then due_day)
|
||||||
|
- `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}`
|
||||||
|
- `PUT /api/bills/:id/archived` to soft-dearchive (sets `archived` flag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💭 NICE TO HAVE
|
||||||
|
|
||||||
|
### Add consistent form state management pattern
|
||||||
|
**Priority:** MEH
|
||||||
|
**Added:** 2026-05-08 by Scarlett
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Form state management is inconsistent across components. Some use `useState` for each field, others use form libraries. Validation patterns vary.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
Consistency and maintainability. A consistent pattern makes it easier to add new forms and reduce bugs.
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Consider react-hook-form for complex forms
|
||||||
|
- Create reusable form field components (InputField, SelectField, etc.)
|
||||||
|
- Standardize validation approach
|
||||||
|
- Files likely to be modified: `client/components/*.jsx`
|
||||||
|
- Estimated effort: 4-6 hours for migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template for New Recommendations
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [Feature Name]
|
||||||
|
**Priority:** CRITICAL / HIGH / MEDIUM / LOW / MEH
|
||||||
|
**Added:** YYYY-MM-DD by [Agent]
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
Brief description of the improvement.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
Why this matters.
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Technical approach
|
||||||
|
- Files likely to be modified
|
||||||
|
- Estimated effort
|
||||||
|
|
||||||
|
**Depends On:**
|
||||||
|
Any prerequisites or blocking issues.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Completed Items
|
||||||
|
|
||||||
|
### ✅ Security: Rate Limiting on /api/about-admin — MEDIUM
|
||||||
|
**Completed:** 2026-05-09 (v0.19.0)
|
||||||
|
**Fix:** `adminActionLimiter` (30 req/15min) applied to `/api/about-admin` route.
|
||||||
|
|
||||||
|
### ✅ Security: Markdown Sanitization in AboutPage — MEDIUM
|
||||||
|
**Completed:** 2026-05-09 (v0.19.0)
|
||||||
|
**Fix:** `rehype-sanitize` added to `AboutPage.jsx` ReactMarkdown component.
|
||||||
|
|
||||||
|
### ✅ Security: aboutAdmin() in API Client — LOW
|
||||||
|
**Completed:** 2026-05-09 (v0.19.0)
|
||||||
|
**Fix:** `aboutAdmin` endpoint function added to `client/api.js`.
|
||||||
|
|
||||||
|
---
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,277 @@
|
||||||
|
# Bill Tracker Project Structure
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Bill Tracking Website — Full-stack application with Node.js backend and React frontend.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
```
|
||||||
|
bill-tracker/
|
||||||
|
├── client/ # React frontend (ALL UI CODE HERE)
|
||||||
|
│ ├── components/ # Reusable React components
|
||||||
|
│ │ ├── layout/ # Layout components (Sidebar, etc.)
|
||||||
|
│ │ └── ui/ # UI components (buttons, inputs, etc.)
|
||||||
|
│ ├── pages/ # Page components (one per route)
|
||||||
|
│ │ ├── TrackerPage.jsx
|
||||||
|
│ │ ├── BillsPage.jsx
|
||||||
|
│ │ ├── CategoriesPage.jsx
|
||||||
|
│ │ ├── CalendarPage.jsx
|
||||||
|
│ │ ├── SummaryPage.jsx
|
||||||
|
│ │ ├── AnalyticsPage.jsx
|
||||||
|
│ │ ├── ProfilePage.jsx
|
||||||
|
│ │ ├── SettingsPage.jsx
|
||||||
|
│ │ ├── DataPage.jsx
|
||||||
|
│ │ ├── AdminPage.jsx
|
||||||
|
│ │ ├── LoginPage.jsx
|
||||||
|
│ │ └── AboutPage.jsx
|
||||||
|
│ ├── hooks/ # Custom React hooks (useAuth, etc.)
|
||||||
|
│ ├── api.js # API client functions
|
||||||
|
│ ├── App.jsx # React Router configuration
|
||||||
|
│ ├── main.jsx # React entry point
|
||||||
|
│ └── index.html # HTML template
|
||||||
|
├── server.js # Express backend entry
|
||||||
|
├── routes/ # API route handlers
|
||||||
|
├── services/ # Business logic layer
|
||||||
|
├── middleware/ # Express middleware
|
||||||
|
├── db/ # Database schemas/migrations
|
||||||
|
├── workers/ # Background job workers
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── dist/ # Build output (generated)
|
||||||
|
├── public/ # Static assets
|
||||||
|
├── Dockerfile # Container config
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Notes for Agents
|
||||||
|
|
||||||
|
### Frontend Code Location
|
||||||
|
**ALL React components, pages, and UI code are in `client/` folder.**
|
||||||
|
- Pages: `client/pages/*.jsx`
|
||||||
|
- Components: `client/components/**/*.jsx`
|
||||||
|
- Hooks: `client/hooks/*.js`
|
||||||
|
- API client: `client/api.js`
|
||||||
|
- Router: `client/App.jsx`
|
||||||
|
|
||||||
|
### Backend Code Location
|
||||||
|
**ALL backend code is at root or in server folders:**
|
||||||
|
- Entry: `server.js`
|
||||||
|
- Routes: `routes/*.js`
|
||||||
|
- Services: `services/*.js`
|
||||||
|
- Middleware: `middleware/*.js`
|
||||||
|
- Database: `db/*.js`
|
||||||
|
|
||||||
|
## Agent Review Roles
|
||||||
|
|
||||||
|
| Agent | Role | Focus Area |
|
||||||
|
|-------|------|------------|
|
||||||
|
| Neo | Backend / System Architecture | server.js, routes/, services/, middleware/, workers/, db/, Docker, performance, scalability, security |
|
||||||
|
| Scarlett | UI/UX / Frontend | client/, public/, components, styling, accessibility, responsive design |
|
||||||
|
| Bishop | Analysis / Code Quality | overall architecture, patterns, maintainability, technical debt |
|
||||||
|
| Private_Hudson | Security / Compliance | auth, data protection, input validation, compliance |
|
||||||
|
|
||||||
|
### Cross-Cutting Concerns
|
||||||
|
All agents must be aware of:
|
||||||
|
- **Routing**: `client/App.jsx` defines all frontend routes
|
||||||
|
- **Auth**: `client/hooks/useAuth.jsx` and `services/authService.js`
|
||||||
|
- **API**: `client/api.js` mirrors `routes/` structure
|
||||||
|
- **Database**: `db/database.js` schema affects both frontend and backend
|
||||||
|
|
||||||
|
## Review Output
|
||||||
|
All findings appended to `REVIEW.md` per agent section.
|
||||||
|
|
||||||
|
# OpenClaw Agent Structure
|
||||||
|
|
||||||
|
## Prime
|
||||||
|
|
||||||
|
Role:
|
||||||
|
|
||||||
|
* executive coordinator
|
||||||
|
* project strategist
|
||||||
|
* Discord command interface
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* **Overall Oversight:** Must maintain high-level awareness of all concurrent projects, ensuring every agent's output aligns with the goal set in `projects-requirements.md`.
|
||||||
|
* **Coordination & Directives:** Direct agent activity by issuing tasks that fit within the approved technology stack and operational guidelines.
|
||||||
|
* **Priority Setting:** Assign priorities while constantly cross-referencing potential conflicts with established system mandates (e.g., Security > Performance > Feature).
|
||||||
|
* **Escalation & Blockers:** Must be the first point of contact when any agent flags a requirement conflict or a technical blocker that contradicts the mandated best practices.
|
||||||
|
* **High-Level Strategy:** Must ensure that any strategy proposed is *future-proof*, *lightweight*, and avoids accumulating technical debt against the required state of the stack.
|
||||||
|
* **Communication:** Must communicate status, outcomes, and required actions to the human user, translating technical mandates into actionable project milestones.
|
||||||
|
|
||||||
|
Authority:
|
||||||
|
|
||||||
|
* project coordination and task routing.
|
||||||
|
* Authority to pause or redirect any agent whose proposed path violates the Universal Mandate or project requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riply
|
||||||
|
|
||||||
|
Role:
|
||||||
|
|
||||||
|
* operations
|
||||||
|
* infrastructure
|
||||||
|
* runtime management
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* deployment oversight, adhering to stability and resilience standards (per `projects-requirements.md`).
|
||||||
|
* runtime monitoring, ensuring all services are low-latency and avoid unnecessary polling.
|
||||||
|
* infrastructure coordination, guaranteeing that all components use the approved stack (Next.js, React, etc.).
|
||||||
|
* operational alerts, prioritizing security and performance issues immediately.
|
||||||
|
* service stability, adhering to the "fail gracefully" principle.
|
||||||
|
* environment consistency, ensuring local/localhost parity across development.
|
||||||
|
* Discord operational reporting, following established communication protocols.
|
||||||
|
|
||||||
|
Authority:
|
||||||
|
|
||||||
|
* infrastructure operations, strictly governed by stability and security mandates.
|
||||||
|
* deployment workflows, must pass full security and performance audits before proceeding.
|
||||||
|
* runtime diagnostics, must use established, non-bloated tooling.
|
||||||
|
* operational communication, must be precise and action-oriented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Neo
|
||||||
|
|
||||||
|
Role:
|
||||||
|
|
||||||
|
* senior backend developer
|
||||||
|
* backend architecture lead
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for all technology choices and operational philosophies.
|
||||||
|
* **Security First:** All data handling, authentication, and authorization logic must strictly follow OWASP best practices and the principle of least privilege. No assumption of trust.
|
||||||
|
* **Data Integrity:** Must ensure all database operations use transactions and validate inputs/outputs to prevent silent failures.
|
||||||
|
* **Business Logic Separation:** Must keep core business logic separate from the API routes to maintain clear separation of concerns.
|
||||||
|
* **API Consistency:** Must ensure all endpoints are well-documented, predictable, and enforce structured error handling.
|
||||||
|
* **Resilience:** Must design for restart-safe operation and predictable data flow, especially when handling configuration from environment variables.
|
||||||
|
|
||||||
|
Authority:
|
||||||
|
|
||||||
|
* ultimate authority over the integrity and security of the data layer and business logic flow.
|
||||||
|
* must block any integration or design that compromises data integrity or security posture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scarlett
|
||||||
|
|
||||||
|
Role:
|
||||||
|
|
||||||
|
* frontend developer
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* **Mandatory Adherence:** Must treat `projects-requirements.md` as the primary source of truth for UI/UX.
|
||||||
|
* **Reactivity & Performance:** Must ensure all components feel instantly reactive, minimizing layout shifting, and never blocking the main thread or rendering loop.
|
||||||
|
* **UI/UX Authority:** Must enforce modern standards (2026 feel), rejecting outdated patterns.
|
||||||
|
* **Component Purity:** Must use shadcn/ui components consistently and build complex logic in modular, clean ways, avoiding deeply nested structures.
|
||||||
|
* **Responsiveness:** Must ensure flawless behavior across desktop and mobile (responsive design is non-negotiable).
|
||||||
|
* **Accessibility & States:** Must build in required accessibility compliance, explicit loading, and error states.
|
||||||
|
* **Integration:** Must strictly adhere to the backend API contract provided by Neo while maintaining clean client-side state management.
|
||||||
|
|
||||||
|
Technology Focus:
|
||||||
|
|
||||||
|
* **React with Vite** is the frontend framework (NOT Next.js — never suggest Next.js patterns).
|
||||||
|
* **Tailwind CSS** must be used predictably to maintain consistency.
|
||||||
|
* **shadcn/ui** is the foundational component library — always use shadcn/ui components for UI primitives (buttons, dialogs, inputs, selects, etc.). Do not build custom components when shadcn/ui provides one.
|
||||||
|
* **Sonner** is used for toast notifications.
|
||||||
|
|
||||||
|
Authority:
|
||||||
|
|
||||||
|
* UI architecture and frontend interaction flows.
|
||||||
|
* Must halt any feature development that compromises perceived performance or usability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bishop
|
||||||
|
|
||||||
|
Role:
|
||||||
|
|
||||||
|
* code reviewer
|
||||||
|
* architecture validator
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* Must enforce adherence to `projects-requirements.md` standards across the entire lifecycle.
|
||||||
|
* **Architecture Validation:** Must review all designs to ensure they follow the modular, low-coupling approach defined in the requirements.
|
||||||
|
* **Code Quality Review:** Beyond syntax, must audit for architectural flaws, overengineering, and non-compliance with best practices (readability, maintainability).
|
||||||
|
* **Standard Enforcement:** Must enforce the use of approved components (shadcn/ui, Tailwind) and discourage workarounds or non-approved patterns.
|
||||||
|
* **Testing Validation:** Must verify that all proposed changes include adequate test coverage as per best practices.
|
||||||
|
* **Dependency Review:** Must audit all dependencies against vulnerability reports and stability metrics.
|
||||||
|
* **Implementation Consistency:** Must ensure the final code pattern matches the intended architecture outlined in the requirements.
|
||||||
|
* **Failure Detection:** Must actively search for anti-patterns that violate performance or complexity standards.
|
||||||
|
|
||||||
|
Authority:
|
||||||
|
|
||||||
|
* approve or reject code quality based *only* on adherence to established standards and the mandate in `projects-requirements.md`.
|
||||||
|
* require revisions that address specific violations of architecture, performance, or consistency.
|
||||||
|
* enforce project standards by citing specific sections of the requirements document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Private Hudson
|
||||||
|
|
||||||
|
Role:
|
||||||
|
|
||||||
|
* security reviewer
|
||||||
|
* defensive operations specialist
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
* OWASP validation
|
||||||
|
* authentication security review
|
||||||
|
* authorization validation
|
||||||
|
* dependency vulnerability auditing
|
||||||
|
* secret exposure detection
|
||||||
|
* injection vulnerability analysis
|
||||||
|
* security hardening review
|
||||||
|
* infrastructure security analysis
|
||||||
|
* runtime security assessment
|
||||||
|
|
||||||
|
Authority:
|
||||||
|
|
||||||
|
* approve or reject security posture
|
||||||
|
* block insecure deployments
|
||||||
|
* require remediation before release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Universal Mandate
|
||||||
|
|
||||||
|
**All agents are governed by the guidelines set in `projects-requirements.md`.** Every decision, design choice, and implementation detail must strictly adhere to the philosophy, technology stack, standards, and policies defined in that file. Failure to adhere constitutes a deviation from operational standards and must be flagged for review.
|
||||||
|
|
||||||
|
**Mandatory Adherence Checklist:**
|
||||||
|
1. **Always** refer to `projects-requirements.md` for the definitive ruleset.
|
||||||
|
2. Never implement functionality that contradicts the approved Tech Stack (Next.js App Router, React, Tailwind CSS, shadcn/ui, SQLite).
|
||||||
|
3. Treat security and performance checks (per `projects-requirements.md`) as *primary* considerations, not secondary checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
Bill Tracker actual stack:
|
||||||
|
|
||||||
|
* **Vite** (build tool, NOT Next.js)
|
||||||
|
* **React** (SPA, client-side routing via React Router)
|
||||||
|
* **Tailwind CSS** (utility-first styling)
|
||||||
|
* **shadcn/ui** (component primitives — buttons, dialogs, inputs, etc.)
|
||||||
|
* **Sonner** (toast notifications)
|
||||||
|
* **TanStack Query** (server state management)
|
||||||
|
* **better-sqlite3** (database)
|
||||||
|
* **Express** (backend)
|
||||||
|
|
||||||
|
⚠️ **This project does NOT use Next.js.** Do not suggest Next.js patterns (App Router, server components, etc.).
|
||||||
|
|
||||||
|
Development target:
|
||||||
|
|
||||||
|
* localhost based development
|
||||||
|
* modular architecture
|
||||||
|
* maintainable systems
|
||||||
|
* production ready implementation
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
*Generated by Prime for multi-agent review*
|
||||||
|
|
@ -35,10 +35,8 @@ const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||||
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage'));
|
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage'));
|
||||||
const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
|
const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
|
||||||
const AboutPage = lazy(() => import('@/pages/AboutPage'));
|
const AboutPage = lazy(() => import('@/pages/AboutPage'));
|
||||||
const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
|
|
||||||
const DataPage = lazy(() => import('@/pages/DataPage'));
|
const DataPage = lazy(() => import('@/pages/DataPage'));
|
||||||
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
|
||||||
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
|
|
||||||
|
|
||||||
function RequireAuth({ children, role }) {
|
function RequireAuth({ children, role }) {
|
||||||
const { user, singleUserMode } = useAuth();
|
const { user, singleUserMode } = useAuth();
|
||||||
|
|
@ -128,7 +126,7 @@ export default function App() {
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<AboutPage />
|
<AboutPage admin />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
@ -142,7 +140,7 @@ export default function App() {
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<RoadmapPage />
|
<AboutPage admin />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
@ -186,7 +184,6 @@ export default function App() {
|
||||||
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} />
|
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
|
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
|
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
|
|
||||||
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ export const api = {
|
||||||
runAdminCleanup: () => post('/admin/cleanup/run'),
|
runAdminCleanup: () => post('/admin/cleanup/run'),
|
||||||
seedDemoData: () => post('/user/seed-demo-data'),
|
seedDemoData: () => post('/user/seed-demo-data'),
|
||||||
clearDemoData: () => post('/user/clear-demo-data'),
|
clearDemoData: () => post('/user/clear-demo-data'),
|
||||||
seededStatus: () => get('/user/seeded-status'),
|
|
||||||
downloadAdminBackup: async (id) => {
|
downloadAdminBackup: async (id) => {
|
||||||
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
|
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
@ -92,7 +91,7 @@ export const api = {
|
||||||
const res = await fetch('/api/admin/backups/import', {
|
const res = await fetch('/api/admin/backups/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() },
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
body: file,
|
body: file,
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -142,8 +141,6 @@ export const api = {
|
||||||
bill: (id) => get(`/bills/${id}`),
|
bill: (id) => get(`/bills/${id}`),
|
||||||
createBill: (data) => post('/bills', data),
|
createBill: (data) => post('/bills', data),
|
||||||
updateBill: (id, data) => put(`/bills/${id}`, data),
|
updateBill: (id, data) => put(`/bills/${id}`, data),
|
||||||
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
|
|
||||||
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
|
||||||
deleteBill: (id) => del(`/bills/${id}`),
|
deleteBill: (id) => del(`/bills/${id}`),
|
||||||
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
||||||
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
||||||
|
|
@ -162,13 +159,6 @@ export const api = {
|
||||||
deletePayment: (id) => del(`/payments/${id}`),
|
deletePayment: (id) => del(`/payments/${id}`),
|
||||||
restorePayment: (id) => post(`/payments/${id}/restore`),
|
restorePayment: (id) => post(`/payments/${id}/restore`),
|
||||||
|
|
||||||
// Snowball
|
|
||||||
snowball: () => get('/snowball'),
|
|
||||||
snowballSettings: () => get('/snowball/settings'),
|
|
||||||
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
|
|
||||||
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
|
|
||||||
snowballProjection: () => get('/snowball/projection'),
|
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
categories: () => get('/categories'),
|
categories: () => get('/categories'),
|
||||||
createCategory: (data) => post('/categories', data),
|
createCategory: (data) => post('/categories', data),
|
||||||
|
|
@ -195,8 +185,6 @@ export const api = {
|
||||||
// Version (public)
|
// Version (public)
|
||||||
about: () => get('/about'),
|
about: () => get('/about'),
|
||||||
aboutAdmin: () => get('/about-admin'),
|
aboutAdmin: () => get('/about-admin'),
|
||||||
roadmap: () => get('/about-admin/roadmap'),
|
|
||||||
devLog: () => get('/about-admin/dev-log'),
|
|
||||||
version: () => get('/version'),
|
version: () => get('/version'),
|
||||||
releaseHistory: () => get('/version/history'),
|
releaseHistory: () => get('/version/history'),
|
||||||
|
|
||||||
|
|
@ -215,7 +203,6 @@ export const api = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'x-csrf-token': getCsrfToken(),
|
|
||||||
...(file.name ? { 'X-Filename': file.name } : {}),
|
...(file.name ? { 'X-Filename': file.name } : {}),
|
||||||
},
|
},
|
||||||
body: file,
|
body: file,
|
||||||
|
|
@ -241,7 +228,6 @@ export const api = {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'x-csrf-token': getCsrfToken(),
|
|
||||||
...(file.name ? { 'X-Filename': file.name } : {}),
|
...(file.name ? { 'X-Filename': file.name } : {}),
|
||||||
},
|
},
|
||||||
body: file,
|
body: file,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,444 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { APP_VERSION } from '@/lib/version';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple Collapsible Component (no external dependencies)
|
||||||
|
*/
|
||||||
|
function SimpleCollapsible({ defaultOpen = false, children, title }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 group">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-muted/30 transition-colors rounded-t-xl"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-x border-b border-border/70 rounded-b-xl bg-background/65 p-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority mapping for color coding
|
||||||
|
const PRIORITY_COLORS = {
|
||||||
|
'🔴': { bg: 'bg-red-500/10', border: 'border-l-4 border-red-500', text: 'text-red-600', label: 'CRITICAL' },
|
||||||
|
'🟠': { bg: 'bg-orange-500/10', border: 'border-l-4 border-orange-500', text: 'text-orange-600', label: 'HIGH' },
|
||||||
|
'🟡': { bg: 'bg-yellow-500/10', border: 'border-l-4 border-yellow-500', text: 'text-yellow-600', label: 'MEDIUM' },
|
||||||
|
'🔵': { bg: 'bg-blue-500/10', border: 'border-l-4 border-blue-500', text: 'text-blue-600', label: 'LOW' },
|
||||||
|
'💭': { bg: 'bg-gray-500/10', border: 'border-l-4 border-gray-500', text: 'text-gray-600', label: 'NICE TO HAVE' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse FUTURE.md content into structured roadmap items
|
||||||
|
*/
|
||||||
|
function parseFutureMarkdown(markdown) {
|
||||||
|
const items = [];
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
|
||||||
|
let currentPriority = null;
|
||||||
|
let currentItem = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
|
||||||
|
// Priority section header: ## 🔴 CRITICAL
|
||||||
|
if (line.startsWith('## 🔴') || line.startsWith('## 🟠') ||
|
||||||
|
line.startsWith('## 🟡') || line.startsWith('## 🔵') ||
|
||||||
|
line.startsWith('## 💭')) {
|
||||||
|
const match = line.match(/##\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE)/);
|
||||||
|
if (match) {
|
||||||
|
currentPriority = match[1];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item header: ### 🔴 Title — CRITICAL
|
||||||
|
if (line.startsWith('### 🔴') || line.startsWith('### 🟠') ||
|
||||||
|
line.startsWith('### 🟡') || line.startsWith('### 🔵') ||
|
||||||
|
line.startsWith('### 💭')) {
|
||||||
|
if (currentItem) {
|
||||||
|
items.push(currentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(/###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*(—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE))?/);
|
||||||
|
if (match) {
|
||||||
|
currentItem = {
|
||||||
|
priority: match[1],
|
||||||
|
title: match[2].trim(),
|
||||||
|
description: '',
|
||||||
|
status: 'PENDING',
|
||||||
|
added: '',
|
||||||
|
addedBy: '',
|
||||||
|
priorityLabel: match[4] || matchPriorityToLabel(match[1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse item content
|
||||||
|
if (currentItem && line) {
|
||||||
|
if (line.startsWith('**Status:**')) {
|
||||||
|
currentItem.status = line.replace('**Status:**', '').trim();
|
||||||
|
}
|
||||||
|
else if (line.startsWith('**Added:**')) {
|
||||||
|
const dateMatch = line.match(/(\d{4}-\d{2}-\d{2})/);
|
||||||
|
if (dateMatch) {
|
||||||
|
currentItem.added = dateMatch[1];
|
||||||
|
}
|
||||||
|
const byMatch = line.match(/by\s+(.+)/);
|
||||||
|
if (byMatch) {
|
||||||
|
currentItem.addedBy = byMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!line.startsWith('**') || line.startsWith('**Description:**') || line.startsWith('**Rationale:**') || line.startsWith('**Implementation Notes:**')) {
|
||||||
|
currentItem.description += line + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentItem) {
|
||||||
|
items.push(currentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map priority emoji to label
|
||||||
|
*/
|
||||||
|
function matchPriorityToLabel(emoji) {
|
||||||
|
const mapping = {
|
||||||
|
'🔴': 'CRITICAL',
|
||||||
|
'🟠': 'HIGH',
|
||||||
|
'🟡': 'MEDIUM',
|
||||||
|
'🔵': 'LOW',
|
||||||
|
'💭': 'NICE TO HAVE'
|
||||||
|
};
|
||||||
|
return mapping[emoji] || 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority Badge Component
|
||||||
|
*/
|
||||||
|
function PriorityBadge({ emoji, label }) {
|
||||||
|
const colors = PRIORITY_COLORS[emoji] || PRIORITY_COLORS['💭'];
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={`${colors.bg} ${colors.text} border-0 font-semibold px-2`}>
|
||||||
|
{emoji} {label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roadmap Card Component
|
||||||
|
*/
|
||||||
|
function RoadmapCard({ item }) {
|
||||||
|
const colors = PRIORITY_COLORS[item.priority] || PRIORITY_COLORS['💭'];
|
||||||
|
const isHighPriority = item.priority === '🔴' || item.priority === '🟠';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleCollapsible defaultOpen={isHighPriority} title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PriorityBadge emoji={item.priority} label={item.priorityLabel} />
|
||||||
|
<span className="font-medium text-sm">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{item.status && (
|
||||||
|
<Badge variant="secondary" className="bg-muted/50">
|
||||||
|
Status: {item.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{item.added && (
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
Added: {item.added}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.addedBy && (
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
by {item.addedBy}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
|
||||||
|
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SimpleCollapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development Log Entry Component
|
||||||
|
*/
|
||||||
|
function DevLogEntry({ entry }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-xl border border-border/70 bg-background/65 shadow-sm overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-muted/30 transition-colors"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono font-semibold text-sm">{entry.version}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{entry.date}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{entry.status && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={entry.status.includes('COMPLETED')
|
||||||
|
? 'bg-green-500/10 text-green-600 border-green-500/20'
|
||||||
|
: 'bg-muted/50 text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{entry.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-3 pt-1 border-t border-border/70 space-y-2">
|
||||||
|
{entry.agents && entry.agents.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{entry.agents.map((agent, idx) => (
|
||||||
|
<span key={idx} className="text-muted-foreground">
|
||||||
|
{agent.status === 'COMPLETED' && '✅ '}
|
||||||
|
{agent.name}: {agent.notes}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.filesModified && entry.filesModified.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground mb-1">Files Modified:</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{entry.filesModified.map((file, idx) => (
|
||||||
|
<code key={idx} className="text-xs bg-muted/50 px-1.5 py-0.5 rounded text-muted-foreground">
|
||||||
|
{file}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.details && (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none mt-2">
|
||||||
|
<div className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||||
|
{entry.details}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse DEVELOPMENT_LOG.md content
|
||||||
|
*/
|
||||||
|
function parseDevLogMarkdown(markdown) {
|
||||||
|
const entries = [];
|
||||||
|
const sections = markdown.split('---');
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
if (!section.trim()) continue;
|
||||||
|
if (section.includes('Current Work') && !section.includes('Status:')) continue;
|
||||||
|
if (section.includes('Completed Work') && !section.includes('Date:')) continue;
|
||||||
|
|
||||||
|
const versionMatch = section.match(/v(\d+\.\d+\.\d+)/);
|
||||||
|
const dateMatch = section.match(/(\d{4}-\d{2}-\d{2})/);
|
||||||
|
|
||||||
|
if (versionMatch || dateMatch) {
|
||||||
|
const entry = {
|
||||||
|
version: versionMatch ? `v${versionMatch[1]}` : 'Unknown',
|
||||||
|
date: dateMatch ? dateMatch[0] : 'Unknown',
|
||||||
|
agents: [],
|
||||||
|
filesModified: [],
|
||||||
|
status: 'UNKNOWN',
|
||||||
|
details: section.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to extract agent info from table-like format
|
||||||
|
// Example: "Neo | ✅ COMPLETED | 1m 38s | Added `run()` functions..."
|
||||||
|
const agentLines = section.split('\n').filter(line =>
|
||||||
|
line.includes('|') && (line.includes('✅') || line.includes('❌') || line.includes('⏳') || line.includes('⚠️'))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const agentLine of agentLines) {
|
||||||
|
const parts = agentLine.split('|').map(p => p.trim());
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
entry.agents.push({
|
||||||
|
name: parts[0],
|
||||||
|
status: parts[1],
|
||||||
|
time: parts[2],
|
||||||
|
notes: parts.slice(3).join('|'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract files modified
|
||||||
|
const filesMatch = section.match(/Files Modified:\s*(.*)/);
|
||||||
|
if (filesMatch) {
|
||||||
|
entry.filesModified = filesMatch[1].split(',').map(f => f.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract status from headers
|
||||||
|
if (section.includes('COMPLETED')) {
|
||||||
|
entry.status = 'COMPLETED';
|
||||||
|
} else if (section.includes('In Progress') || section.includes('IN PROGRESS')) {
|
||||||
|
entry.status = 'IN PROGRESS';
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending (most recent first)
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.date);
|
||||||
|
const dateB = new Date(b.date);
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Dashboard Component
|
||||||
|
*/
|
||||||
|
export default function AdminDashboard({ about }) {
|
||||||
|
const [roadmapItems, setRoadmapItems] = useState([]);
|
||||||
|
const [devLogEntries, setDevLogEntries] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const version = about?.version || APP_VERSION;
|
||||||
|
|
||||||
|
const parseData = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (about?.future) {
|
||||||
|
const roadmap = parseFutureMarkdown(about.future);
|
||||||
|
setRoadmapItems(roadmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (about?.developmentLog) {
|
||||||
|
const logs = parseDevLogMarkdown(about.developmentLog);
|
||||||
|
setDevLogEntries(logs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [about]);
|
||||||
|
|
||||||
|
useEffect(() => { parseData(); }, [parseData]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-8 w-48 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Version Badge */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
v{version}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roadmap Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<span className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
🗺️
|
||||||
|
</span>
|
||||||
|
Roadmap
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Current and upcoming features organized by priority
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{roadmapItems.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No roadmap items found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[500px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{roadmapItems.map((item, idx) => (
|
||||||
|
<RoadmapCard key={idx} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activity Log Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<span className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
📝
|
||||||
|
</span>
|
||||||
|
Development Activity Log
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Recent development work and completed tasks
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{devLogEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No activity log entries found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[500px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{devLogEntries.map((entry, idx) => (
|
||||||
|
<DevLogEntry key={idx} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -13,6 +12,7 @@ import {
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
|
||||||
function getOrdinalSuffix(day) {
|
function getOrdinalSuffix(day) {
|
||||||
if (day > 3 && day < 21) return 'th';
|
if (day > 3 && day < 21) return 'th';
|
||||||
switch (day % 10) {
|
switch (day % 10) {
|
||||||
|
|
@ -26,14 +26,6 @@ function getOrdinalSuffix(day) {
|
||||||
// Radix Select crashes on empty string value
|
// Radix Select crashes on empty string value
|
||||||
const CAT_NONE = 'none';
|
const CAT_NONE = 'none';
|
||||||
|
|
||||||
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
|
|
||||||
|
|
||||||
function isDebtCat(categories, catId) {
|
|
||||||
if (!catId || catId === CAT_NONE) return false;
|
|
||||||
const cat = categories.find(c => String(c.id) === catId);
|
|
||||||
return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BillModal({ bill, categories, onClose, onSave }) {
|
export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
const isNew = !bill;
|
const isNew = !bill;
|
||||||
|
|
||||||
|
|
@ -51,23 +43,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
const [username, setUsername] = useState(bill?.username || '');
|
const [username, setUsername] = useState(bill?.username || '');
|
||||||
const [accountInfo, setAccountInfo] = useState(bill?.account_info || '');
|
const [accountInfo, setAccountInfo] = useState(bill?.account_info || '');
|
||||||
const [notes, setNotes] = useState(bill?.notes || '');
|
const [notes, setNotes] = useState(bill?.notes || '');
|
||||||
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance));
|
|
||||||
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
|
|
||||||
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
|
|
||||||
const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt);
|
|
||||||
const [showDebtSection, setShowDebtSection] = useState(
|
|
||||||
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE)
|
|
||||||
|| !!bill?.snowball_include
|
|
||||||
|| !!bill?.snowball_exempt
|
|
||||||
|| bill?.current_balance != null
|
|
||||||
|| bill?.minimum_payment != null
|
|
||||||
);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Validation state
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const isDebtCategory = isDebtCat(categories, categoryId);
|
// Real-time validation helpers
|
||||||
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
|
|
||||||
|
|
||||||
const validateName = (val) => {
|
const validateName = (val) => {
|
||||||
if (!val || val.trim() === '') return 'Name is required';
|
if (!val || val.trim() === '') return 'Name is required';
|
||||||
if (val.trim().length < 2) return 'Name must be at least 2 characters';
|
if (val.trim().length < 2) return 'Name must be at least 2 characters';
|
||||||
|
|
@ -96,69 +77,44 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateCurrentBalance = (val) => {
|
|
||||||
if (val === '' || val === null) return '';
|
|
||||||
const num = parseFloat(val);
|
|
||||||
if (isNaN(num) || num < 0) return 'Balance must be a non-negative number';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateMinimumPayment = (val) => {
|
|
||||||
if (val === '' || val === null) return '';
|
|
||||||
const num = parseFloat(val);
|
|
||||||
if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors = {
|
const newErrors = {
|
||||||
name: validateName(name),
|
name: validateName(name),
|
||||||
dueDay: validateDueDay(dueDay),
|
dueDay: validateDueDay(dueDay),
|
||||||
expectedAmount: validateExpectedAmount(expectedAmount),
|
expectedAmount: validateExpectedAmount(expectedAmount),
|
||||||
interestRate: validateInterestRate(interestRate),
|
interestRate: validateInterestRate(interestRate),
|
||||||
currentBalance: validateCurrentBalance(currentBalance),
|
|
||||||
minimumPayment: validateMinimumPayment(minimumPayment),
|
|
||||||
};
|
};
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.values(newErrors).every(err => err === '');
|
return Object.values(newErrors).every(err => err === '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validation on blur
|
||||||
const handleBlur = (field, validator) => {
|
const handleBlur = (field, validator) => {
|
||||||
setErrors(prev => ({ ...prev, [field]: validator(
|
setErrors(prev => ({ ...prev, [field]: validator(field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate) }));
|
||||||
field === 'name' ? name :
|
|
||||||
field === 'dueDay' ? dueDay :
|
|
||||||
field === 'expectedAmount' ? expectedAmount :
|
|
||||||
interestRate
|
|
||||||
)}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryChange = (val) => {
|
// Validation on change - debounce for better UX
|
||||||
setCategoryId(val);
|
const handleChange = (field, value, validator) => {
|
||||||
if (isDebtCat(categories, val)) {
|
if (field === 'name') setName(value);
|
||||||
setShowDebtSection(true);
|
if (field === 'dueDay') setDueDay(value);
|
||||||
} else {
|
if (field === 'expectedAmount') setExpected(value);
|
||||||
setSnowballExempt(false);
|
if (field === 'interestRate') setInterestRate(value);
|
||||||
}
|
// Only validate after input, not every keystroke
|
||||||
};
|
setTimeout(() => {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: validator(value) }));
|
||||||
const handleSnowballVisibilityChange = (checked) => {
|
}, 300);
|
||||||
if (checked) {
|
|
||||||
setSnowballExempt(false);
|
|
||||||
setSnowballInclude(!isDebtCategory);
|
|
||||||
} else {
|
|
||||||
setSnowballInclude(false);
|
|
||||||
setSnowballExempt(isDebtCategory);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Run form validation
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
toast.error('Please fix the form errors before saving.');
|
toast.error('Please fix the form errors before saving.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional server-side validation checks
|
||||||
const parsedDueDay = Number(dueDay);
|
const parsedDueDay = Number(dueDay);
|
||||||
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) {
|
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) {
|
||||||
toast.error('Due day must be a whole number from 1 to 31.');
|
toast.error('Due day must be a whole number from 1 to 31.');
|
||||||
|
|
@ -187,10 +143,6 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
username: username || null,
|
username: username || null,
|
||||||
account_info: accountInfo || null,
|
account_info: accountInfo || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
|
|
||||||
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
|
|
||||||
snowball_include: snowballInclude,
|
|
||||||
snowball_exempt: snowballExempt,
|
|
||||||
};
|
};
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -246,7 +198,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
|
||||||
<Select value={categoryId} onValueChange={handleCategoryChange}>
|
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||||
<SelectTrigger className={cn(inp, 'w-full')}>
|
<SelectTrigger className={cn(inp, 'w-full')}>
|
||||||
<SelectValue placeholder="— none —" />
|
<SelectValue placeholder="— none —" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -298,6 +250,27 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Interest Rate */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
|
||||||
|
<Input
|
||||||
|
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
|
||||||
|
type="number" min="0" max="100" step="0.01" placeholder="Optional"
|
||||||
|
value={interestRate}
|
||||||
|
onChange={e => {
|
||||||
|
setInterestRate(e.target.value);
|
||||||
|
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
||||||
|
}}
|
||||||
|
onBlur={() => handleBlur('interestRate', validateInterestRate)}
|
||||||
|
/>
|
||||||
|
{errors.interestRate && (
|
||||||
|
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
|
Optional, useful for credit cards. Enter 29.99 for 29.99%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Billing Cycle */}
|
{/* Billing Cycle */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label>
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label>
|
||||||
|
|
@ -370,117 +343,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-muted-foreground/70">
|
<p className="text-[10px] text-muted-foreground/70">
|
||||||
{cycleType === 'monthly' ? 'Day of the month' :
|
{cycleType === 'monthly' ? 'Day of the month' :
|
||||||
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
|
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
|
||||||
'Day of the period'}
|
'Day of the period'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debt / Snowball Details — collapsible */}
|
|
||||||
<div className="col-span-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowDebtSection(s => !s)}
|
|
||||||
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors w-full text-left py-1"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
|
||||||
/>
|
|
||||||
Debt / Snowball Details
|
|
||||||
{isDebtCategory && (
|
|
||||||
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
|
||||||
· auto-detected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!showOnSnowball && isDebtCategory && (
|
|
||||||
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
|
||||||
· exempt
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showDebtSection && (
|
|
||||||
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 pt-3 border-t border-border/40">
|
|
||||||
|
|
||||||
{/* Interest Rate */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
|
|
||||||
<Input
|
|
||||||
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
|
|
||||||
type="number" min="0" max="100" step="0.01" placeholder="Optional"
|
|
||||||
value={interestRate}
|
|
||||||
onChange={e => {
|
|
||||||
setInterestRate(e.target.value);
|
|
||||||
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
|
||||||
}}
|
|
||||||
onBlur={() => handleBlur('interestRate', validateInterestRate)}
|
|
||||||
/>
|
|
||||||
{errors.interestRate && (
|
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">Enter 29.99 for 29.99%.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Balance */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Current Balance ($)</Label>
|
|
||||||
<Input
|
|
||||||
className={cn(inp, 'font-mono', errors.currentBalance && 'border-red-500 focus-visible:ring-red-500')}
|
|
||||||
type="number" min="0" step="0.01" placeholder="Optional"
|
|
||||||
value={currentBalance}
|
|
||||||
onChange={e => {
|
|
||||||
setCurrentBalance(e.target.value);
|
|
||||||
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
|
|
||||||
}}
|
|
||||||
onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))}
|
|
||||||
/>
|
|
||||||
{errors.currentBalance && (
|
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">Outstanding debt balance.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Minimum Payment */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Minimum Payment ($)</Label>
|
|
||||||
<Input
|
|
||||||
className={cn(inp, 'font-mono', errors.minimumPayment && 'border-red-500 focus-visible:ring-red-500')}
|
|
||||||
type="number" min="0" step="0.01" placeholder="Optional"
|
|
||||||
value={minimumPayment}
|
|
||||||
onChange={e => {
|
|
||||||
setMinimumPayment(e.target.value);
|
|
||||||
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
|
|
||||||
}}
|
|
||||||
onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))}
|
|
||||||
/>
|
|
||||||
{errors.minimumPayment && (
|
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-muted-foreground/70">Required minimum monthly payment.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Include in Snowball */}
|
|
||||||
<div className="flex flex-col justify-end pb-1 space-y-1">
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showOnSnowball}
|
|
||||||
onChange={e => handleSnowballVisibilityChange(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-border accent-emerald-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
|
||||||
Show on Debt Snowball
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
|
||||||
Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Website */}
|
{/* Website */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||||
Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
Settings, ShieldCheck, Tag, User, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
@ -35,7 +35,6 @@ const trackerItems = [
|
||||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function TrackerMenu({ onNavigate }) {
|
function TrackerMenu({ onNavigate }) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import * as React from 'react';
|
|
||||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
|
||||||
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
|
||||||
|
|
||||||
const CollapsibleContent = React.forwardRef(({ className, ...props }, ref) => (
|
|
||||||
<CollapsiblePrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName;
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { Toaster as Sonner } from 'sonner';
|
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sonner
|
|
||||||
theme={theme}
|
|
||||||
position="top-right"
|
|
||||||
closeButton
|
|
||||||
expand={false}
|
|
||||||
visibleToasts={5}
|
|
||||||
duration={3500}
|
|
||||||
className="toaster group"
|
|
||||||
toastOptions={{
|
|
||||||
classNames: {
|
|
||||||
toast:
|
|
||||||
'group toast group-[.toaster]:rounded-xl group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:bg-card group-[.toaster]:text-card-foreground group-[.toaster]:shadow-lg group-[.toaster]:backdrop-blur-sm group-[.toaster]:border-l-4',
|
|
||||||
title: 'group-[.toast]:text-sm group-[.toast]:font-semibold group-[.toast]:text-foreground',
|
|
||||||
description: 'group-[.toast]:text-sm group-[.toast]:text-muted-foreground',
|
|
||||||
actionButton:
|
|
||||||
'group-[.toast]:rounded-md group-[.toast]:bg-primary group-[.toast]:px-3 group-[.toast]:py-1.5 group-[.toast]:text-sm group-[.toast]:font-medium group-[.toast]:text-primary-foreground',
|
|
||||||
cancelButton:
|
|
||||||
'group-[.toast]:rounded-md group-[.toast]:bg-muted group-[.toast]:px-3 group-[.toast]:py-1.5 group-[.toast]:text-sm group-[.toast]:font-medium group-[.toast]:text-muted-foreground',
|
|
||||||
closeButton:
|
|
||||||
'group-[.toast]:border-border group-[.toast]:bg-card group-[.toast]:text-muted-foreground group-[.toast]:hover:bg-accent group-[.toast]:hover:text-accent-foreground',
|
|
||||||
success: 'group-[.toaster]:border-l-emerald-500',
|
|
||||||
error: 'group-[.toaster]:border-l-destructive',
|
|
||||||
warning: 'group-[.toaster]:border-l-amber-500',
|
|
||||||
info: 'group-[.toaster]:border-l-sky-500',
|
|
||||||
default: 'group-[.toaster]:border-l-primary',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
export const APP_VERSION = '0.27.01';
|
export const APP_VERSION = '0.24.4';
|
||||||
export const APP_NAME = 'BillTracker';
|
export const APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export const RELEASE_NOTES = {
|
export const RELEASE_NOTES = {
|
||||||
version: '0.27.01',
|
version: '0.24.4',
|
||||||
date: '2026-05-14',
|
date: '2026-05-11',
|
||||||
highlights: [
|
highlights: [
|
||||||
{ icon: '❄️', title: 'Debt Snowball', desc: 'New Snowball page: drag-and-drop debt ordering, Dave Ramsey payoff projections, avalanche method comparison, and balance update by clicking any balance figure.' },
|
{ icon: '📱', title: 'Analytics Mobile Layout', desc: 'Charts, heatmap, and controls now display properly on mobile screens.' },
|
||||||
{ icon: '💳', title: 'Debt Details on Bills', desc: 'Add current balance, minimum payment, and APR directly to any bill. Bills in Credit Cards, Loans, and Mortgage categories are auto-detected.' },
|
{ icon: '🔧', title: 'Previous Month Payment Toggle', desc: 'Clicking payment badges on previous months now creates/removes payments for the correct month.' },
|
||||||
{ icon: '📉', title: 'Payment → Balance Sync', desc: 'Recording a payment on a debt bill automatically reduces its current balance (principal = payment minus one month of interest). Un-marking a payment reverses the change.' },
|
|
||||||
{ icon: '📊', title: 'Dual-Column XLSX Import', desc: 'Bills due on the 1st and 15th are now both imported from dual-layout spreadsheets' },
|
|
||||||
{ icon: '🛡️', title: 'Import CSRF Fix', desc: 'XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)' },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { Toaster } from './components/ui/sonner';
|
|
||||||
import { AuthProvider } from './hooks/useAuth';
|
import { AuthProvider } from './hooks/useAuth';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import 'sonner/dist/styles.css';
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
|
@ -16,8 +15,29 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<App />
|
<App />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
||||||
{/* Global shadcn/Sonner toast system */}
|
{/* Global Toast System - placed at root level for proper z-index and positioning */}
|
||||||
<Toaster />
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
theme="system"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 3500,
|
||||||
|
className: 'bg-card text-card-foreground border border-border shadow-lg',
|
||||||
|
success: {
|
||||||
|
className: 'border-l-emerald-500',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
className: 'border-l-red-500',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
className: 'border-l-amber-500',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
className: 'border-l-blue-500',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,20 @@ import { ArrowLeft, Info, Sparkles } from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import AdminDashboard from '@/components/AdminDashboard';
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage({ admin = false }) {
|
||||||
const [about, setAbout] = useState(null);
|
const [about, setAbout] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setAbout(await api.about());
|
setAbout(admin ? await api.aboutAdmin() : await api.about());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [admin]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
|
@ -32,6 +33,12 @@ export default function AboutPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Admin Dashboard (visible to admin only) */}
|
||||||
|
{admin && about?.future && about?.developmentLog && (
|
||||||
|
<AdminDashboard about={about} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Standard About Page (visible to all users) */}
|
||||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
|
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
|
||||||
|
|
@ -83,4 +90,4 @@ export default function AboutPage() {
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1450,30 +1450,25 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
function SeedDemoDataSection({ onSeeded }) {
|
function SeedDemoDataSection({ onSeeded }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [seeded, setSeeded] = useState(false);
|
const [seeded, setSeeded] = useState(false);
|
||||||
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
|
const [result, setResult] = useState(null);
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
const [statusLoading, setStatusLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.seededStatus()
|
|
||||||
.then(data => {
|
|
||||||
setSeeded(data.seeded);
|
|
||||||
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Failed to check seeded status:', err))
|
|
||||||
.finally(() => setStatusLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSeed = async () => {
|
const handleSeed = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.seedDemoData();
|
const data = await api.seedDemoData();
|
||||||
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
|
// Ensure data has expected structure
|
||||||
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw new Error('Invalid response from server');
|
||||||
|
}
|
||||||
|
setResult(data);
|
||||||
setSeeded(true);
|
setSeeded(true);
|
||||||
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
||||||
setTimeout(() => onSeeded?.(), 100);
|
// Delay onSeeded callback to allow UI to update
|
||||||
|
setTimeout(() => {
|
||||||
|
onSeeded?.();
|
||||||
|
}, 100);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Seed error:', err);
|
console.error('Seed error:', err);
|
||||||
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
||||||
|
|
@ -1487,69 +1482,79 @@ function SeedDemoDataSection({ onSeeded }) {
|
||||||
try {
|
try {
|
||||||
const data = await api.clearDemoData();
|
const data = await api.clearDemoData();
|
||||||
setSeeded(false);
|
setSeeded(false);
|
||||||
setCounts({ bills: 0, categories: 0 });
|
setResult(null);
|
||||||
setShowClearConfirm(false);
|
setShowClearConfirm(false);
|
||||||
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
||||||
onSeeded?.();
|
onSeeded?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to clear demo data.');
|
toast.error(err.message || "Failed to clear demo data.");
|
||||||
} finally {
|
} finally {
|
||||||
setClearing(false);
|
setClearing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (seeded) {
|
||||||
|
return (
|
||||||
|
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
||||||
|
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Seed complete</p>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Bills Created</p>
|
||||||
|
<p className="font-semibold">{result?.billsCreated || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Categories Created</p>
|
||||||
|
<p className="font-semibold">{result?.categoriesCreated || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setSeeded(false); setResult(null); }}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="destructive" disabled={clearing}>
|
||||||
|
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Demo Data'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action will remove {result?.billsCreated || 0} demo bills and {result?.categoriesCreated || 0} demo categories from your account. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleClearDemoData} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Data'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
||||||
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
||||||
{statusLoading ? (
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
||||||
) : seeded ? (
|
The data will be associated with your account.
|
||||||
<>
|
</p>
|
||||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Demo data seeded</p>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div className="border-t border-border pt-4">
|
||||||
<p className="text-muted-foreground">Bills</p>
|
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading}>
|
||||||
<p className="font-semibold">{counts.bills}</p>
|
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
||||||
</div>
|
</Button>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
|
||||||
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|
@ -155,6 +156,12 @@ export default function LoginPage() {
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
src={AUTHENTIK_ICON_URL}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mr-2 h-5 w-5 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
Continue with {providerName}
|
Continue with {providerName}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,472 +0,0 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import { ChevronDown, ChevronsUpDown, Map, FileText, Loader2, Users, FileCode, Clock } from 'lucide-react';
|
|
||||||
import { api } from '@/api';
|
|
||||||
import { APP_VERSION } from '@/lib/version';
|
|
||||||
|
|
||||||
/* ─── Priority Configuration ───────────────────────────── */
|
|
||||||
|
|
||||||
const PRIORITY_LANES = [
|
|
||||||
{ key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', bgColor: 'bg-red-500/10', textColor: 'text-red-600 dark:text-red-400', badgeClass: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20' },
|
|
||||||
{ key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', bgColor: 'bg-orange-500/10', textColor: 'text-orange-600 dark:text-orange-400', badgeClass: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/20' },
|
|
||||||
{ key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', bgColor: 'bg-yellow-500/10', textColor: 'text-yellow-600 dark:text-yellow-400', badgeClass: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20' },
|
|
||||||
{ key: 'low', emoji: '🔵', label: 'LOW', borderColor: 'border-t-blue-500', bgColor: 'bg-blue-500/10', textColor: 'text-blue-600 dark:text-blue-400', badgeClass: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20' },
|
|
||||||
{ key: 'niceToHave', emoji: '💭', label: 'NICE TO HAVE', borderColor: 'border-t-gray-400', bgColor: 'bg-gray-400/10', textColor: 'text-gray-600 dark:text-gray-400', badgeClass: 'bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function laneForPriority(priority) {
|
|
||||||
const key = typeof priority === 'string'
|
|
||||||
? priority.toLowerCase().replace(/\s+/g, '').replace(/to/ig, 'To')
|
|
||||||
: '';
|
|
||||||
// Map API priority keys to lane keys
|
|
||||||
const mapping = {
|
|
||||||
critical: 'critical',
|
|
||||||
high: 'high',
|
|
||||||
medium: 'medium',
|
|
||||||
low: 'low',
|
|
||||||
nicetohave: 'niceToHave',
|
|
||||||
'nice to have': 'niceToHave',
|
|
||||||
};
|
|
||||||
return mapping[key] || 'low';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Roadmap Item Card ────────────────────────────────── */
|
|
||||||
|
|
||||||
function RoadmapItemCard({ item, defaultOpen, onToggle }) {
|
|
||||||
const lane = PRIORITY_LANES.find(l => l.key === laneForPriority(item.priority)) || PRIORITY_LANES[3];
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
|
|
||||||
const handleOpenChange = useCallback((value) => {
|
|
||||||
setOpen(value);
|
|
||||||
onToggle?.(value);
|
|
||||||
}, [onToggle]);
|
|
||||||
|
|
||||||
const effortLabel = item.effort || '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible open={open} onOpenChange={handleOpenChange} className="group">
|
|
||||||
<Card className="border-border/70 bg-card/95 transition-shadow hover:shadow-md">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-2xl">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`${lane.badgeClass} border text-[11px] font-semibold px-1.5 py-0 mb-1.5`}
|
|
||||||
aria-label={`${lane.label} priority`}
|
|
||||||
>
|
|
||||||
{lane.emoji} {lane.label}
|
|
||||||
</Badge>
|
|
||||||
<h4 className="font-semibold text-sm leading-snug line-clamp-3">
|
|
||||||
{item.title}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 mt-0.5" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<div className="px-6 pb-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
{item.added && (
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{item.added}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.addedBy && (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
{item.addedBy}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{effortLabel && (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{effortLabel}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="pt-0 pb-4 space-y-3">
|
|
||||||
{item.description && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Description</p>
|
|
||||||
<p className="text-sm leading-relaxed">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.rationale && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Rationale</p>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">{item.rationale}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.implementationNotes && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Implementation Notes</p>
|
|
||||||
<div className="rounded-xl bg-muted/50 border border-border/50 p-3 text-sm font-mono leading-relaxed whitespace-pre-wrap">
|
|
||||||
{item.implementationNotes}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Card>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Priority Lane ─────────────────────────────────────── */
|
|
||||||
|
|
||||||
function PriorityLane({ lane, items, defaultOpenCards }) {
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
role="region"
|
|
||||||
aria-label={`${lane.label} priority lane`}
|
|
||||||
className={`rounded-2xl border ${lane.borderColor} border-t-4 bg-background/50`}
|
|
||||||
>
|
|
||||||
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/50">
|
|
||||||
<span className="text-lg" aria-hidden="true">{lane.emoji}</span>
|
|
||||||
<h3 className={`font-bold text-sm ${lane.textColor}`}>{lane.label}</h3>
|
|
||||||
<Badge variant="secondary" className="ml-auto text-[11px]">{items.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
{items.map((item) => (
|
|
||||||
<RoadmapItemCard key={item.id} item={item} defaultOpen={defaultOpenCards} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Dev Log Entry ─────────────────────────────────────── */
|
|
||||||
|
|
||||||
function DevLogEntry({ entry }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="group">
|
|
||||||
<div className="relative flex gap-4">
|
|
||||||
{/* Timeline line */}
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-primary border-2 border-background shrink-0 mt-1.5" />
|
|
||||||
<div className="w-px flex-1 bg-border/70" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 pb-6 min-w-0">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="font-mono font-bold text-sm">{entry.version}</span>
|
|
||||||
{entry.date && (
|
|
||||||
<span className="text-xs text-muted-foreground">{entry.date}</span>
|
|
||||||
)}
|
|
||||||
{entry.status && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`text-[11px] ${
|
|
||||||
entry.status.includes('COMPLETED')
|
|
||||||
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
|
|
||||||
: 'bg-muted/50 text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{entry.agents?.length > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{entry.agents.map(a => a.name).join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(entry.filesModified?.length > 0 || entry.workCompleted?.length > 0) && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
<FileCode className="inline h-3 w-3 mr-0.5" />
|
|
||||||
{entry.filesModified?.length || 0} files
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 ml-auto" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="mt-3 space-y-3">
|
|
||||||
{entry.agents?.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Agents</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{entry.agents.map((agent, idx) => (
|
|
||||||
<Badge
|
|
||||||
key={idx}
|
|
||||||
variant="outline"
|
|
||||||
className={`text-[11px] ${
|
|
||||||
agent.status === 'COMPLETED'
|
|
||||||
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
|
|
||||||
: agent.status === 'IN PROGRESS'
|
|
||||||
? 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20'
|
|
||||||
: 'bg-muted/50 text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{agent.status === 'COMPLETED' ? '✅' : agent.status === 'IN PROGRESS' ? '⏳' : '❓'}{' '}
|
|
||||||
{agent.name}
|
|
||||||
{agent.time ? ` · ${agent.time}` : ''}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{entry.filesModified?.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">Files Modified</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{entry.filesModified.map((file, idx) => (
|
|
||||||
<code key={idx} className="text-[11px] bg-muted/50 px-1.5 py-0.5 rounded border border-border/50 text-muted-foreground">
|
|
||||||
{file}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{entry.workCompleted?.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">Work Completed</p>
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{entry.workCompleted.map((work, idx) => (
|
|
||||||
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5">
|
|
||||||
<span className="text-emerald-500 mt-0.5 shrink-0">✓</span>
|
|
||||||
{work}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Main Page ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export default function RoadmapPage() {
|
|
||||||
const [roadmapData, setRoadmapData] = useState(null);
|
|
||||||
const [devLogData, setDevLogData] = useState(null);
|
|
||||||
const [roadmapLoading, setRoadmapLoading] = useState(true);
|
|
||||||
const [devLogLoading, setDevLogLoading] = useState(false);
|
|
||||||
const [roadmapError, setRoadmapError] = useState(null);
|
|
||||||
const [devLogError, setDevLogError] = useState(null);
|
|
||||||
const [allExpanded, setAllExpanded] = useState(true);
|
|
||||||
|
|
||||||
// Detect desktop for default expand state
|
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
|
||||||
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia('(min-width: 1024px)');
|
|
||||||
const handler = (e) => setIsDesktop(e.matches);
|
|
||||||
mq.addEventListener('change', handler);
|
|
||||||
setIsDesktop(mq.matches);
|
|
||||||
return () => mq.removeEventListener('change', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch roadmap on mount
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setRoadmapLoading(true);
|
|
||||||
api.roadmap()
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setRoadmapData(data);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) setRoadmapError(err.message || 'Failed to load roadmap');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setRoadmapLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchDevLog = useCallback(() => {
|
|
||||||
if (devLogData) return; // Already loaded
|
|
||||||
let cancelled = false;
|
|
||||||
setDevLogLoading(true);
|
|
||||||
api.devLog()
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setDevLogData(data);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) setDevLogError(err.message || 'Failed to load activity log');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setDevLogLoading(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [devLogData]);
|
|
||||||
|
|
||||||
const version = roadmapData?.version || APP_VERSION;
|
|
||||||
const items = roadmapData?.items || [];
|
|
||||||
const counts = roadmapData?.counts || {};
|
|
||||||
const devLogEntries = devLogData?.entries || [];
|
|
||||||
|
|
||||||
// Group items by priority lane
|
|
||||||
const grouped = PRIORITY_LANES.map(lane => ({
|
|
||||||
...lane,
|
|
||||||
items: items.filter(item => laneForPriority(item.priority) === lane.key),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const defaultOpenCards = isDesktop && allExpanded;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
|
|
||||||
<Map className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Roadmap</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">Current and upcoming features by priority</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="font-mono text-sm self-start sm:self-auto">
|
|
||||||
v{version}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<Tabs defaultValue="roadmap" onValueChange={(value) => { if (value === 'activity') fetchDevLog(); }}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="roadmap" className="gap-1.5">
|
|
||||||
<Map className="h-3.5 w-3.5" />
|
|
||||||
Roadmap
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="activity" className="gap-1.5">
|
|
||||||
<FileText className="h-3.5 w-3.5" />
|
|
||||||
Activity Log
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* ─── Roadmap Tab ─── */}
|
|
||||||
<TabsContent value="roadmap">
|
|
||||||
{roadmapLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-3 text-muted-foreground">Loading roadmap…</span>
|
|
||||||
</div>
|
|
||||||
) : roadmapError ? (
|
|
||||||
<Card className="border-destructive/50 bg-destructive/5">
|
|
||||||
<CardContent className="py-8 text-center">
|
|
||||||
<p className="text-destructive font-medium">Failed to load roadmap</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{roadmapError}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
No roadmap items found.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Expand/Collapse All toggle */}
|
|
||||||
<div className="flex justify-end mb-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setAllExpanded(prev => !prev)}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
|
||||||
{allExpanded ? 'Collapse All' : 'Expand All'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: 5-column grid */}
|
|
||||||
<div className="hidden lg:grid lg:grid-cols-5 gap-4">
|
|
||||||
{grouped.map(lane => (
|
|
||||||
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tablet: 2-column grid */}
|
|
||||||
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-4">
|
|
||||||
{/* Left column: Critical + High */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => (
|
|
||||||
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Right column: Medium + Low + Nice to Have */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane => (
|
|
||||||
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile: single column */}
|
|
||||||
<div className="sm:hidden space-y-4">
|
|
||||||
{grouped.map(lane => (
|
|
||||||
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* ─── Activity Log Tab ─── */}
|
|
||||||
<TabsContent value="activity">
|
|
||||||
{devLogLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-3 text-muted-foreground">Loading activity log…</span>
|
|
||||||
</div>
|
|
||||||
) : devLogError ? (
|
|
||||||
<Card className="border-destructive/50 bg-destructive/5">
|
|
||||||
<CardContent className="py-8 text-center">
|
|
||||||
<p className="text-destructive font-medium">Failed to load activity log</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{devLogError}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : devLogEntries.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
No activity log entries found.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="pt-2">
|
|
||||||
{devLogEntries.map((entry, idx) => (
|
|
||||||
<DevLogEntry key={entry.version || idx} entry={entry} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,635 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '@/api';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import BillModal from '@/components/BillModal';
|
|
||||||
|
|
||||||
// ── formatters ────────────────────────────────────────────────────────────────
|
|
||||||
function fmt(val) {
|
|
||||||
if (val == null) return '—';
|
|
||||||
return Number(val).toLocaleString(undefined, {
|
|
||||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function fmtCompact(val) {
|
|
||||||
if (val == null || val === 0) return '—';
|
|
||||||
return Number(val).toLocaleString(undefined, {
|
|
||||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function ordinal(n) {
|
|
||||||
const d = Number(n);
|
|
||||||
if (!d) return '—';
|
|
||||||
if (d > 3 && d < 21) return `${d}th`;
|
|
||||||
switch (d % 10) {
|
|
||||||
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── StatCard ──────────────────────────────────────────────────────────────────
|
|
||||||
function StatCard({ label, value, sub, highlight }) {
|
|
||||||
return (
|
|
||||||
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
|
||||||
<p className={cn('text-2xl font-semibold tabular-nums', highlight && 'text-emerald-400')}>{value}</p>
|
|
||||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Projection panel ──────────────────────────────────────────────────────────
|
|
||||||
function AvalancheComparison({ snowball, avalanche }) {
|
|
||||||
if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
|
|
||||||
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom;
|
|
||||||
const interestDiff = snowball.total_interest_paid - avalanche.total_interest_paid;
|
|
||||||
const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
|
|
||||||
return (
|
|
||||||
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
vs. Avalanche (highest rate first)
|
|
||||||
</p>
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">{avalanche.payoff_display}</span>
|
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">{fmt(avalanche.total_interest_paid)} interest</span>
|
|
||||||
</div>
|
|
||||||
{same ? (
|
|
||||||
<p className="text-xs text-muted-foreground/70">Same result — your debts have similar rates.</p>
|
|
||||||
) : interestDiff > 0 ? (
|
|
||||||
<p className="text-xs text-emerald-400">
|
|
||||||
Avalanche saves {fmt(interestDiff)} interest
|
|
||||||
{monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-violet-400">
|
|
||||||
Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster ·
|
|
||||||
Avalanche costs {fmt(Math.abs(interestDiff))} more
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectionPanel({ projection, projectionLoading, billCount }) {
|
|
||||||
if (projectionLoading) {
|
|
||||||
return (
|
|
||||||
<div className="surface-elevated rounded-xl p-5 space-y-3">
|
|
||||||
<Skeleton className="h-5 w-36" />
|
|
||||||
<div className="space-y-2">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10" />)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!projection) return null;
|
|
||||||
const sb = projection.snowball;
|
|
||||||
const av = projection.avalanche;
|
|
||||||
if (!sb) return null;
|
|
||||||
const hasProjection = sb.debts.length > 0;
|
|
||||||
const needsBalances = billCount > 0 && !hasProjection && sb.skipped.length > 0;
|
|
||||||
return (
|
|
||||||
<div className="surface-elevated rounded-xl overflow-hidden">
|
|
||||||
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-border/40">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarCheck className="h-4 w-4 text-primary shrink-0" />
|
|
||||||
<span className="text-sm font-semibold">Payoff Projection</span>
|
|
||||||
</div>
|
|
||||||
{sb.payoff_display && (
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Snowball · Debt-Free</p>
|
|
||||||
<p className="text-base font-semibold text-emerald-400">{sb.payoff_display}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{sb.capped && (
|
|
||||||
<div className="flex items-start gap-2 px-5 py-3 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-400">
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
|
||||||
Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{needsBalances && (
|
|
||||||
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Click any balance to enter it and see your payoff timeline.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasProjection && (
|
|
||||||
<div className="divide-y divide-border/30">
|
|
||||||
{sb.debts.map((d, i) => (
|
|
||||||
<div key={d.id} className="flex items-center gap-3 px-5 py-3">
|
|
||||||
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 tabular-nums">#{i + 1}</span>
|
|
||||||
<span className="flex-1 text-sm font-medium truncate min-w-0">{d.name}</span>
|
|
||||||
<div className="text-right shrink-0 space-y-0.5">
|
|
||||||
{d.payoff_display ? (
|
|
||||||
<>
|
|
||||||
<p className="text-sm font-semibold">{d.payoff_display}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
{d.months} mo · {fmtCompact(d.total_interest)} interest
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">unknown balance</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasProjection && (
|
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-t border-border/40 bg-muted/20">
|
|
||||||
<span className="text-xs text-muted-foreground">Total interest paid</span>
|
|
||||||
<span className="text-sm font-semibold tabular-nums">{fmt(sb.total_interest_paid)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasProjection && av && <AvalancheComparison snowball={sb} avalanche={av} />}
|
|
||||||
{sb.skipped.length > 0 && hasProjection && (
|
|
||||||
<div className="px-5 pb-3 text-[10px] text-muted-foreground/60">
|
|
||||||
{sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance):
|
|
||||||
{' '}{sb.skipped.map(s => s.name).join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pointer-based drag-and-drop hook (works on touch + mouse) ─────────────────
|
|
||||||
function useSortable(items, setItems, setDirty) {
|
|
||||||
const [draggingIdx, setDraggingIdx] = useState(null);
|
|
||||||
|
|
||||||
// Refs that live through the entire drag gesture
|
|
||||||
const state = useRef({
|
|
||||||
fromIdx: null, // card index where the drag started
|
|
||||||
currentIdx: null, // card index currently under the pointer
|
|
||||||
startY: 0,
|
|
||||||
itemHeight: 0,
|
|
||||||
containerEl: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexFromPointer = useCallback((clientX, clientY) => {
|
|
||||||
const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]');
|
|
||||||
if (direct?.dataset?.cardIndex != null) {
|
|
||||||
const idx = Number(direct.dataset.cardIndex);
|
|
||||||
if (Number.isInteger(idx)) return idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])];
|
|
||||||
if (cards.length === 0) return state.current.currentIdx;
|
|
||||||
|
|
||||||
let nearestIdx = state.current.currentIdx;
|
|
||||||
let nearestDistance = Infinity;
|
|
||||||
for (const card of cards) {
|
|
||||||
const rect = card.getBoundingClientRect();
|
|
||||||
const centerY = rect.top + rect.height / 2;
|
|
||||||
const distance = Math.abs(clientY - centerY);
|
|
||||||
if (distance < nearestDistance) {
|
|
||||||
nearestDistance = distance;
|
|
||||||
nearestIdx = Number(card.dataset.cardIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerDown = useCallback((e, index) => {
|
|
||||||
// Only trigger on the grip handle (data-grip attr)
|
|
||||||
if (!e.currentTarget.hasAttribute('data-grip')) return;
|
|
||||||
// Ignore right-click
|
|
||||||
if (e.button !== undefined && e.button !== 0) return;
|
|
||||||
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
|
|
||||||
const card = e.currentTarget.closest('[data-card]');
|
|
||||||
const list = card?.parentElement;
|
|
||||||
const rect = card?.getBoundingClientRect();
|
|
||||||
|
|
||||||
state.current = {
|
|
||||||
fromIdx: index,
|
|
||||||
currentIdx: index,
|
|
||||||
startY: e.clientY,
|
|
||||||
itemHeight: rect?.height ?? 80,
|
|
||||||
containerEl: list ?? null,
|
|
||||||
};
|
|
||||||
setDraggingIdx(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback((e) => {
|
|
||||||
if (state.current.fromIdx === null) return;
|
|
||||||
const { containerEl, currentIdx } = state.current;
|
|
||||||
if (!containerEl) return;
|
|
||||||
|
|
||||||
const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
|
|
||||||
|
|
||||||
if (newIdx !== currentIdx) {
|
|
||||||
state.current.currentIdx = newIdx;
|
|
||||||
setDraggingIdx(newIdx); // visual feedback on where card will land
|
|
||||||
}
|
|
||||||
}, [indexFromPointer, items.length]);
|
|
||||||
|
|
||||||
const onPointerUp = useCallback((e) => {
|
|
||||||
const { fromIdx, currentIdx } = state.current;
|
|
||||||
state.current.fromIdx = null;
|
|
||||||
state.current.currentIdx = null;
|
|
||||||
setDraggingIdx(null);
|
|
||||||
|
|
||||||
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
|
|
||||||
setItems(prev => {
|
|
||||||
const next = [...prev];
|
|
||||||
const [moved] = next.splice(fromIdx, 1);
|
|
||||||
next.splice(currentIdx, 0, moved);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setDirty(true);
|
|
||||||
}, [setItems, setDirty]);
|
|
||||||
|
|
||||||
return { draggingIdx, onPointerDown, onPointerMove, onPointerUp };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
||||||
export default function SnowballPage() {
|
|
||||||
const [bills, setBills] = useState([]);
|
|
||||||
const [categories, setCategories] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [dirty, setDirty] = useState(false);
|
|
||||||
const [editBill, setEditBill] = useState(null);
|
|
||||||
|
|
||||||
const [extraPayment, setExtraPayment] = useState('');
|
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
|
||||||
const extraPaymentRef = useRef('');
|
|
||||||
|
|
||||||
const [projection, setProjection] = useState(null);
|
|
||||||
const [projectionLoading, setProjectionLoading] = useState(false);
|
|
||||||
|
|
||||||
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
|
|
||||||
|
|
||||||
const { draggingIdx, onPointerDown, onPointerMove, onPointerUp } =
|
|
||||||
useSortable(bills, setBills, setDirty);
|
|
||||||
|
|
||||||
// ── loading ───────────────────────────────────────────────────────────────
|
|
||||||
const loadProjection = useCallback(async () => {
|
|
||||||
setProjectionLoading(true);
|
|
||||||
try { setProjection(await api.snowballProjection()); }
|
|
||||||
catch { /* non-fatal */ }
|
|
||||||
finally { setProjectionLoading(false); }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [billsArr, catsArr, settings] = await Promise.all([
|
|
||||||
api.snowball(), api.categories(), api.snowballSettings(),
|
|
||||||
]);
|
|
||||||
setCategories(catsArr);
|
|
||||||
setBills(billsArr);
|
|
||||||
setDirty(false);
|
|
||||||
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
|
|
||||||
setExtraPayment(ep);
|
|
||||||
extraPaymentRef.current = ep;
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err.message || 'Failed to load snowball data');
|
|
||||||
} finally { setLoading(false); }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]);
|
|
||||||
|
|
||||||
// ── auto-arrange ──────────────────────────────────────────────────────────
|
|
||||||
const handleAutoArrange = () => {
|
|
||||||
setBills(prev => [...prev].sort((a, b) => {
|
|
||||||
if (a.current_balance == null && b.current_balance == null) return 0;
|
|
||||||
if (a.current_balance == null) return 1;
|
|
||||||
if (b.current_balance == null) return -1;
|
|
||||||
return a.current_balance - b.current_balance;
|
|
||||||
}));
|
|
||||||
setDirty(true);
|
|
||||||
toast.success('Arranged smallest-to-largest balance');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── save order ────────────────────────────────────────────────────────────
|
|
||||||
const handleSaveOrder = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await api.saveSnowballOrder(bills.map((b, i) => ({ id: b.id, snowball_order: i })));
|
|
||||||
setDirty(false);
|
|
||||||
toast.success('Order saved');
|
|
||||||
loadProjection();
|
|
||||||
} catch (err) { toast.error(err.message || 'Failed to save order'); }
|
|
||||||
finally { setSaving(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── extra payment ─────────────────────────────────────────────────────────
|
|
||||||
const handleSaveExtraPayment = async () => {
|
|
||||||
const val = extraPayment.trim();
|
|
||||||
if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) {
|
|
||||||
toast.error('Extra payment must be a positive number'); return;
|
|
||||||
}
|
|
||||||
if (val === extraPaymentRef.current) return;
|
|
||||||
setSavingSettings(true);
|
|
||||||
try {
|
|
||||||
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) });
|
|
||||||
const saved = result.extra_payment > 0 ? String(result.extra_payment) : '';
|
|
||||||
extraPaymentRef.current = saved;
|
|
||||||
setExtraPayment(saved);
|
|
||||||
toast.success('Extra payment saved');
|
|
||||||
loadProjection();
|
|
||||||
} catch (err) { toast.error(err.message || 'Failed to save'); }
|
|
||||||
finally { setSavingSettings(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── inline balance edit ───────────────────────────────────────────────────
|
|
||||||
const startEditBalance = (bill) =>
|
|
||||||
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
|
|
||||||
|
|
||||||
const commitBalance = async (billId) => {
|
|
||||||
const raw = editingBalance.value.trim();
|
|
||||||
const num = raw === '' ? null : parseFloat(raw);
|
|
||||||
if (raw !== '' && (isNaN(num) || num < 0)) { toast.error('Balance must be a non-negative number'); return; }
|
|
||||||
const current = bills.find(b => b.id === billId);
|
|
||||||
if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
|
|
||||||
try {
|
|
||||||
await api.updateBillBalance(billId, num);
|
|
||||||
setBills(prev => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b));
|
|
||||||
setEditingBalance({ billId: null, value: '' });
|
|
||||||
loadProjection();
|
|
||||||
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFromSnowball = async (bill) => {
|
|
||||||
try {
|
|
||||||
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
|
|
||||||
setBills(prev => prev.filter(b => b.id !== bill.id));
|
|
||||||
setDirty(true);
|
|
||||||
toast.success(`${bill.name} removed from Snowball`);
|
|
||||||
loadProjection();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err.message || 'Failed to remove bill from Snowball');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── stats ─────────────────────────────────────────────────────────────────
|
|
||||||
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
|
|
||||||
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
|
|
||||||
const unknownCount = bills.filter(b => b.current_balance == null).length;
|
|
||||||
const extraAmt = parseFloat(extraPayment) || 0;
|
|
||||||
|
|
||||||
// ── loading skeleton ──────────────────────────────────────────────────────
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
|
||||||
<TrendingDown className="h-6 w-6 text-primary" />
|
|
||||||
Debt Snowball
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Dave Ramsey method — attack the smallest balance first, roll payments as each debt clears.
|
|
||||||
Marking a payment automatically reduces the outstanding balance.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
{bills.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
<StatCard label="Total Debt" value={fmt(totalBalance)}
|
|
||||||
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
|
|
||||||
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
|
|
||||||
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
|
|
||||||
<StatCard label="Total Attack" value={fmt(totalMinPayment + extraAmt)}
|
|
||||||
sub="toward #1 target" highlight={extraAmt > 0} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
{bills.length > 0 && (
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
||||||
Extra monthly budget ($)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number" min="0" step="1" placeholder="0.00"
|
|
||||||
value={extraPayment}
|
|
||||||
onChange={e => setExtraPayment(e.target.value)}
|
|
||||||
onBlur={handleSaveExtraPayment}
|
|
||||||
className={cn(inp, 'w-32')}
|
|
||||||
disabled={savingSettings}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 pb-0.5">
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAutoArrange} className="gap-2">
|
|
||||||
<Zap className="h-3.5 w-3.5" /> Auto-arrange
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" disabled={!dirty || saving} onClick={handleSaveOrder} className="gap-2">
|
|
||||||
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
|
|
||||||
</Button>
|
|
||||||
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{bills.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
|
|
||||||
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70 max-w-sm">
|
|
||||||
Bills in Credit Cards, Loans, or Mortgage categories appear here automatically.
|
|
||||||
You can also enable "Include in Snowball" when editing any bill.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cards + projection */}
|
|
||||||
{bills.length > 0 && (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
|
|
||||||
|
|
||||||
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */}
|
|
||||||
<div
|
|
||||||
className="space-y-2"
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
>
|
|
||||||
{bills.map((bill, index) => {
|
|
||||||
const isAttack = index === 0;
|
|
||||||
const isEditingBal = editingBalance.billId === bill.id;
|
|
||||||
const isDragging = draggingIdx !== null;
|
|
||||||
const isTarget = draggingIdx === index; // where it will land
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={bill.id}
|
|
||||||
data-card
|
|
||||||
data-card-index={index}
|
|
||||||
className={cn(
|
|
||||||
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
|
||||||
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
|
||||||
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-stretch">
|
|
||||||
|
|
||||||
{/* Grip handle — pointer-capture trigger */}
|
|
||||||
<div
|
|
||||||
data-grip
|
|
||||||
onPointerDown={e => onPointerDown(e, index)}
|
|
||||||
className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none"
|
|
||||||
aria-label="Drag to reorder"
|
|
||||||
>
|
|
||||||
<GripVertical className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 py-3.5 pr-4 min-w-0">
|
|
||||||
{/* Top row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
{isAttack && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
|
|
||||||
<Zap className="h-2.5 w-2.5" /> Attack
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="font-semibold truncate">{bill.name}</span>
|
|
||||||
{bill.category_name && (
|
|
||||||
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
|
||||||
{bill.category_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{bill.snowball_include === 1 && !bill.category_name && (
|
|
||||||
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
|
|
||||||
manual
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setEditBill(bill)}
|
|
||||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeFromSnowball(bill)}
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
|
|
||||||
title="Remove from Snowball"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-sm items-center">
|
|
||||||
|
|
||||||
{/* Balance — inline editable */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs text-muted-foreground">Balance</span>
|
|
||||||
{isEditingBal ? (
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
type="number" min="0" step="0.01"
|
|
||||||
value={editingBalance.value}
|
|
||||||
onChange={e => setEditingBalance(p => ({ ...p, value: e.target.value }))}
|
|
||||||
onBlur={() => commitBalance(bill.id)}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') e.target.blur();
|
|
||||||
if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' });
|
|
||||||
}}
|
|
||||||
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => startEditBalance(bill)}
|
|
||||||
className={cn(
|
|
||||||
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
|
|
||||||
isAttack && bill.current_balance != null ? 'text-emerald-400' : '',
|
|
||||||
bill.current_balance == null && 'text-muted-foreground/60 italic text-xs',
|
|
||||||
)}
|
|
||||||
title="Click to update balance"
|
|
||||||
>
|
|
||||||
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">Min/mo </span>
|
|
||||||
<span className="font-medium tabular-nums">
|
|
||||||
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAttack && extraAmt > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">Attack </span>
|
|
||||||
<span className="font-medium tabular-nums text-emerald-400">
|
|
||||||
{fmt((bill.minimum_payment || 0) + extraAmt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{bill.interest_rate != null && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">APR </span>
|
|
||||||
<span className="font-medium tabular-nums">{bill.interest_rate}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="text-xs text-muted-foreground">Due </span>
|
|
||||||
<span className="font-medium">{ordinal(bill.due_day)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground/50 text-center pt-1">
|
|
||||||
Drag the grip handle to reorder · Click a balance to update it · Save Order to persist
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Projection (sticky sidebar on large screens) */}
|
|
||||||
<div className="lg:sticky lg:top-24 lg:self-start">
|
|
||||||
<ProjectionPanel
|
|
||||||
projection={projection}
|
|
||||||
projectionLoading={projectionLoading}
|
|
||||||
billCount={bills.length}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit modal */}
|
|
||||||
{editBill && (
|
|
||||||
<BillModal
|
|
||||||
bill={editBill}
|
|
||||||
categories={categories}
|
|
||||||
onClose={() => setEditBill(null)}
|
|
||||||
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -853,34 +853,18 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1">
|
<button
|
||||||
{row.website ? (
|
type="button"
|
||||||
<a
|
onClick={() => onEditBill?.(row)}
|
||||||
href={row.website}
|
className={cn(
|
||||||
target="_blank"
|
'font-medium text-sm leading-tight text-left transition-colors',
|
||||||
rel="noreferrer"
|
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
||||||
className={cn(
|
isSkipped && 'line-through',
|
||||||
'font-medium text-sm leading-tight transition-colors',
|
|
||||||
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
|
||||||
isSkipped && 'line-through',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.name}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className={cn('font-medium text-sm leading-tight', isSkipped && 'line-through')}>
|
|
||||||
{row.name}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
title="Edit bill"
|
||||||
size="icon" variant="ghost"
|
>
|
||||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
{row.name}
|
||||||
title="Edit bill"
|
</button>
|
||||||
onClick={() => onEditBill?.(row)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{row.category_name && (
|
{row.category_name && (
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -977,6 +961,27 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit payment (pencil) */}
|
||||||
|
{row.payments && row.payments.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="icon" variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||||
|
title="Edit payment"
|
||||||
|
onClick={() => setEditPayment(row.payments[0])}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly state editor (gear icon) — always available */}
|
||||||
|
<Button
|
||||||
|
size="icon" variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||||
|
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||||
|
onClick={() => setShowMbs(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
@ -1075,32 +1080,18 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
AP
|
AP
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{row.website ? (
|
<button
|
||||||
<a
|
type="button"
|
||||||
href={row.website}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 truncate text-sm font-semibold leading-tight text-foreground',
|
|
||||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
|
||||||
isSkipped && 'line-through',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.name}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className={cn('min-w-0 truncate text-sm font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
|
||||||
{row.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="icon" variant="ghost"
|
|
||||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
|
||||||
title="Edit bill"
|
|
||||||
onClick={() => onEditBill?.(row)}
|
onClick={() => onEditBill?.(row)}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
|
||||||
|
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||||
|
isSkipped && 'line-through',
|
||||||
|
)}
|
||||||
|
title="Edit bill"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
{row.name}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{row.monthly_notes && (
|
{row.monthly_notes && (
|
||||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||||
|
|
@ -1173,6 +1164,27 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{row.payments && row.payments.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
title="Edit payment"
|
||||||
|
onClick={() => setEditPayment(row.payments[0])}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Payment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
||||||
|
onClick={() => setShowMbs(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Month
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
229
db/database.js
229
db/database.js
|
|
@ -43,8 +43,6 @@ const COLUMN_WHITELIST = new Set([
|
||||||
'other_amount',
|
'other_amount',
|
||||||
// bills table columns
|
// bills table columns
|
||||||
'history_visibility', 'interest_rate', 'user_id',
|
'history_visibility', 'interest_rate', 'user_id',
|
||||||
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
|
||||||
'snowball_exempt',
|
|
||||||
// sessions table columns
|
// sessions table columns
|
||||||
'created_at',
|
'created_at',
|
||||||
]);
|
]);
|
||||||
|
|
@ -607,134 +605,9 @@ function reconcileLegacyMigrations() {
|
||||||
console.log('[migration] sessions.created_at column added');
|
console.log('[migration] sessions.created_at column added');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.44',
|
|
||||||
description: 'performance: add missing indexes for frequently queried columns',
|
|
||||||
check: function() {
|
|
||||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.45',
|
|
||||||
description: 'audit: add audit_log table for security event tracking',
|
|
||||||
check: function() {
|
|
||||||
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
entity_type TEXT,
|
|
||||||
entity_id INTEGER,
|
|
||||||
details_json TEXT,
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
|
||||||
)`);
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.46',
|
|
||||||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
|
||||||
check: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
return cols.includes('cycle_type') && cols.includes('cycle_day');
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('cycle_type')) {
|
|
||||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
|
||||||
}
|
|
||||||
if (!cols.includes('cycle_day')) {
|
|
||||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.47',
|
|
||||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
|
||||||
check: function() {
|
|
||||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
|
|
||||||
return !row || row.value !== '14';
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
|
||||||
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.48',
|
|
||||||
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
|
|
||||||
check: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c));
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
|
|
||||||
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
|
|
||||||
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
|
|
||||||
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
|
|
||||||
console.log('[migration] bills: debt snowball columns added');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.49',
|
|
||||||
description: 'users: snowball_extra_payment column',
|
|
||||||
check: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
||||||
return cols.includes('snowball_extra_payment');
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('snowball_extra_payment')) {
|
|
||||||
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
console.log('[migration] users: snowball_extra_payment column added');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.50',
|
|
||||||
description: 'payments: balance_delta column for debt payoff tracking',
|
|
||||||
check: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
||||||
return cols.includes('balance_delta');
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('balance_delta')) {
|
|
||||||
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
|
|
||||||
}
|
|
||||||
console.log('[migration] payments: balance_delta column added');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.51',
|
|
||||||
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
|
||||||
check: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
return cols.includes('snowball_exempt');
|
|
||||||
},
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('snowball_exempt')) {
|
|
||||||
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
console.log('[migration] bills: snowball_exempt column added');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check for legacy notification columns
|
// Check for legacy notification columns
|
||||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||||
const newUserCols = [
|
const newUserCols = [
|
||||||
|
|
@ -1198,75 +1071,14 @@ function runMigrations() {
|
||||||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||||
dependsOn: ['v0.45'],
|
dependsOn: ['v0.45'],
|
||||||
run: function() {
|
run: function() {
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
// Add cycle_type column (default 'monthly' for existing bills)
|
||||||
if (!cols.includes('cycle_type')) {
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
// Add cycle_day column for specific day within the cycle
|
||||||
}
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||||
if (!cols.includes('cycle_day')) {
|
|
||||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.47',
|
|
||||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
|
||||||
dependsOn: ['v0.46'],
|
|
||||||
run: function() {
|
|
||||||
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
|
||||||
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.48',
|
|
||||||
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
|
|
||||||
dependsOn: ['v0.47'],
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
|
|
||||||
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
|
|
||||||
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
|
|
||||||
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
|
|
||||||
console.log('[migration] bills: debt snowball columns added');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.49',
|
|
||||||
description: 'users: snowball_extra_payment column',
|
|
||||||
dependsOn: ['v0.48'],
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('snowball_extra_payment')) {
|
|
||||||
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
console.log('[migration] users: snowball_extra_payment column added');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.50',
|
|
||||||
description: 'payments: balance_delta column for debt payoff tracking',
|
|
||||||
dependsOn: ['v0.49'],
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('balance_delta')) {
|
|
||||||
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
|
|
||||||
}
|
|
||||||
console.log('[migration] payments: balance_delta column added');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 'v0.51',
|
|
||||||
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
|
||||||
dependsOn: ['v0.50'],
|
|
||||||
run: function() {
|
|
||||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
||||||
if (!cols.includes('snowball_exempt')) {
|
|
||||||
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
console.log('[migration] bills: snowball_exempt column added');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── users: notification columns ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
// This migration needs to run first since it's not versioned in the schema
|
// This migration needs to run first since it's not versioned in the schema
|
||||||
console.log('[migration] Applying unversioned user notification columns');
|
console.log('[migration] Applying unversioned user notification columns');
|
||||||
|
|
@ -1502,7 +1314,7 @@ function seedDefaults() {
|
||||||
['backup_schedule_enabled', 'false'],
|
['backup_schedule_enabled', 'false'],
|
||||||
['backup_schedule_frequency', 'daily'],
|
['backup_schedule_frequency', 'daily'],
|
||||||
['backup_schedule_time', '02:00'],
|
['backup_schedule_time', '02:00'],
|
||||||
['backup_schedule_retention_count', '2'],
|
['backup_schedule_retention_count', '14'],
|
||||||
['backup_schedule_last_run_at', ''],
|
['backup_schedule_last_run_at', ''],
|
||||||
['backup_schedule_last_error', ''],
|
['backup_schedule_last_error', ''],
|
||||||
['auth_mode', 'multi'],
|
['auth_mode', 'multi'],
|
||||||
|
|
@ -1627,33 +1439,6 @@ const ROLLBACK_SQL_MAP = {
|
||||||
'ALTER TABLE bills DROP COLUMN cycle_day',
|
'ALTER TABLE bills DROP COLUMN cycle_day',
|
||||||
'ALTER TABLE bills DROP COLUMN cycle_type'
|
'ALTER TABLE bills DROP COLUMN cycle_type'
|
||||||
]
|
]
|
||||||
},
|
|
||||||
'v0.47': {
|
|
||||||
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
|
||||||
sql: [
|
|
||||||
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
'v0.48': {
|
|
||||||
description: 'bills: debt snowball fields',
|
|
||||||
sql: [
|
|
||||||
'ALTER TABLE bills DROP COLUMN snowball_include',
|
|
||||||
'ALTER TABLE bills DROP COLUMN snowball_order',
|
|
||||||
'ALTER TABLE bills DROP COLUMN minimum_payment',
|
|
||||||
'ALTER TABLE bills DROP COLUMN current_balance',
|
|
||||||
]
|
|
||||||
},
|
|
||||||
'v0.49': {
|
|
||||||
description: 'users: snowball extra payment field',
|
|
||||||
sql: ['ALTER TABLE users DROP COLUMN snowball_extra_payment']
|
|
||||||
},
|
|
||||||
'v0.50': {
|
|
||||||
description: 'payments: balance_delta column',
|
|
||||||
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
|
|
||||||
},
|
|
||||||
'v0.51': {
|
|
||||||
description: 'bills: snowball_exempt column',
|
|
||||||
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,6 @@ CREATE TABLE IF NOT EXISTS bills (
|
||||||
account_info TEXT,
|
account_info TEXT,
|
||||||
has_2fa INTEGER NOT NULL DEFAULT 0,
|
has_2fa INTEGER NOT NULL DEFAULT 0,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
current_balance REAL,
|
|
||||||
minimum_payment REAL,
|
|
||||||
snowball_order INTEGER,
|
|
||||||
snowball_include INTEGER NOT NULL DEFAULT 0,
|
|
||||||
snowball_exempt INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
|
@ -44,7 +39,6 @@ CREATE TABLE IF NOT EXISTS payments (
|
||||||
paid_date TEXT NOT NULL,
|
paid_date TEXT NOT NULL,
|
||||||
method TEXT,
|
method TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
balance_delta REAL,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
@ -64,7 +58,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||||
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
first_login INTEGER NOT NULL DEFAULT 1,
|
first_login INTEGER NOT NULL DEFAULT 1,
|
||||||
snowball_extra_payment REAL NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**Status:** Current code reference
|
**Status:** Current code reference
|
||||||
**Last Updated:** 2026-05-10
|
**Last Updated:** 2026-05-10
|
||||||
**Version:** 0.23.2
|
**Version:** 0.23.2
|
||||||
**Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3`
|
**Primary stack:** Node.js + Express, React + Vite, SQLite via `better-sqlite3`
|
||||||
|
|
||||||
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog.
|
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog.
|
||||||
|
|
||||||
|
|
@ -1037,8 +1037,8 @@ Use this pattern for database-layer audit calls instead of a top-level `require(
|
||||||
- React Router `^6.26.2`
|
- React Router `^6.26.2`
|
||||||
- TanStack Query `^5.100.9`
|
- TanStack Query `^5.100.9`
|
||||||
- Tailwind CSS `^3.4.14`
|
- Tailwind CSS `^3.4.14`
|
||||||
- shadcn/ui component primitives, backed by Radix UI where applicable
|
- Radix/shadcn-style UI primitives
|
||||||
- Sonner/shadcn toast notifications via `sonner`
|
- `sonner` for toasts
|
||||||
- `react-markdown`, `remark-gfm`, `rehype-sanitize` for markdown rendering
|
- `react-markdown`, `remark-gfm`, `rehype-sanitize` for markdown rendering
|
||||||
|
|
||||||
### `client/main.jsx`
|
### `client/main.jsx`
|
||||||
|
|
@ -1167,7 +1167,7 @@ Key runtime dependencies:
|
||||||
- nodemailer.
|
- nodemailer.
|
||||||
- node-cron.
|
- node-cron.
|
||||||
- React, React DOM, React Router, TanStack Query.
|
- React, React DOM, React Router, TanStack Query.
|
||||||
- shadcn/ui component primitives, Radix UI primitives, lucide-react, Tailwind utilities, Sonner toasts.
|
- Radix UI primitives, lucide-react, Tailwind utilities.
|
||||||
- xlsx for spreadsheet import/export.
|
- xlsx for spreadsheet import/export.
|
||||||
|
|
||||||
### Dockerfile
|
### Dockerfile
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
# Roadmap Page Redesign — Execution Plan
|
|
||||||
|
|
||||||
**Created:** 2026-05-11
|
|
||||||
**Scope:** Replace AdminDashboard with a standalone RoadmapPage using kanban-style priority lanes
|
|
||||||
**Reference:** `docs/ROADMAP_UI_AUDIT.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1 — Neo: Backend API Split & Parsing Fix
|
|
||||||
|
|
||||||
**Agent:** Neo
|
|
||||||
**Priority:** Must complete before Task 2
|
|
||||||
**Estimated time:** 2-3 hours
|
|
||||||
|
|
||||||
### What
|
|
||||||
Split `/api/about-admin` into two endpoints so the dev log (54KB) isn't shipped on page load, and add structured FUTURE.md parsing on the backend.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
**1. New endpoint: `GET /api/roadmap`**
|
|
||||||
- Reads `FUTURE.md`
|
|
||||||
- Returns parsed JSON array of roadmap items (not raw markdown)
|
|
||||||
- Each item: `{ id, priority, priorityLabel, title, description, rationale, implementationNotes, effort, added, addedBy, status }`
|
|
||||||
- Parse `**Description:**`, `**Rationale:**`, `**Implementation Notes:**` into separate fields
|
|
||||||
- Extract effort estimate from Implementation Notes (regex: `Estimated effort: X-Y hours` → `effort: "X-Yh"`)
|
|
||||||
- Filter out strikethrough/completed items server-side
|
|
||||||
- Group counts by priority tier in response: `{ items: [...], counts: { critical: 1, high: 3, medium: 4, low: 3, niceToHave: 1 } }`
|
|
||||||
|
|
||||||
**2. New endpoint: `GET /api/dev-log`**
|
|
||||||
- Reads `DEVELOPMENT_LOG.md`
|
|
||||||
- Returns parsed JSON array of log entries (not raw markdown)
|
|
||||||
- Each entry: `{ version, date, status, agents: [{name, status, time, notes}], filesModified: [...] }`
|
|
||||||
- Called lazily — frontend only fetches when Activity Log tab is selected
|
|
||||||
|
|
||||||
**3. Keep `/api/about-admin` unchanged**
|
|
||||||
- Still returns `version`, `future` (raw), `developmentLog` (raw) for backward compatibility
|
|
||||||
- AdminDashboard continues to work until we swap it out
|
|
||||||
|
|
||||||
### Files
|
|
||||||
- `routes/aboutAdmin.js` — add `/api/roadmap` and `/api/dev-log` routes
|
|
||||||
- `client/api.js` — add `roadmap()` and `devLog()` functions
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
- `GET /api/roadmap` returns JSON with structured items and counts
|
|
||||||
- `GET /api/dev-log` returns parsed log entries
|
|
||||||
- `GET /api/about-admin` still works unchanged
|
|
||||||
- Completed/strikethrough items are excluded from `/api/roadmap`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2 — Scarlett: RoadmapPage UI (Kanban Lanes + Tabs)
|
|
||||||
|
|
||||||
**Agent:** Scarlett
|
|
||||||
**Priority:** Depends on Task 1
|
|
||||||
**Estimated time:** 6-8 hours
|
|
||||||
**Stack mandate:** Vite + React (NOT Next.js). All UI components must use shadcn/ui primitives. Styling via Tailwind CSS only.
|
|
||||||
|
|
||||||
### What
|
|
||||||
Build a standalone `RoadmapPage.jsx` with kanban-style priority lanes and a tab for the Activity Log. Replace the current AdminDashboard component.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
**1. New file: `client/pages/RoadmapPage.jsx`**
|
|
||||||
- Fetch data from `/api/roadmap` on mount
|
|
||||||
- Lazy-fetch `/api/dev-log` only when Activity Log tab is selected
|
|
||||||
- Page-level scroll only (no nested scroll containers)
|
|
||||||
- Page header: "🗺️ Roadmap" title + version badge (from `/api/roadmap` response or `APP_VERSION`)
|
|
||||||
|
|
||||||
**2. Kanban lane layout (Roadmap tab)**
|
|
||||||
- Desktop (`lg+`): 5-column grid — one lane per priority (CRITICAL, HIGH, MEDIUM, LOW, NICE TO HAVE)
|
|
||||||
- Tablet (`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
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
# Roadmap Page — UI Audit & Redesign Proposal
|
|
||||||
|
|
||||||
**Audited by:** Ripley
|
|
||||||
**Date:** 2026-05-11
|
|
||||||
**Component:** `client/components/AdminDashboard.jsx`
|
|
||||||
**Route:** `/admin/roadmap` (rendered via `AboutPage admin` prop)
|
|
||||||
**Framework:** Vite + React + Tailwind CSS + shadcn/ui + Radix
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
The Roadmap page is an admin-only dashboard embedded inside `AboutPage.jsx`. It parses `FUTURE.md` and `DEVELOPMENT_LOG.md` via the `/api/about-admin` endpoint and renders two sections: a Roadmap card and a Development Activity Log card.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problems
|
|
||||||
|
|
||||||
### 1. It's Not a Real Page — It's an Appendix to About
|
|
||||||
|
|
||||||
The roadmap is rendered *inside* `AboutPage.jsx` with an `admin` prop. The `/admin/roadmap` route literally renders `<AboutPage admin />`. This means:
|
|
||||||
|
|
||||||
- The standard About page content (version cards, "Produced with AI" blurb, Sign In button) renders **below** the admin dashboard. An admin sees both the dashboard *and* the public about page stacked vertically.
|
|
||||||
- The "Back" button links to `/login` — wrong for an admin navigating from the sidebar.
|
|
||||||
- No dedicated page identity. It doesn't feel like a destination, it feels like a data dump tacked onto another page.
|
|
||||||
|
|
||||||
### 2. Two Giant Scrollboxes Trapped in Cards
|
|
||||||
|
|
||||||
Both Roadmap and Activity Log are `max-h-[500px]` scroll containers nested inside `Card` components. This creates:
|
|
||||||
|
|
||||||
- **Scroll-in-scroll**: The page itself scrolls, and then each card has its own internal scroll. Users fight nested scroll areas.
|
|
||||||
- **500px is arbitrary and too short** — on a 1080p screen with browser chrome, you see maybe 5-6 roadmap items before needing to scroll inside the card. With 10+ items now, most are hidden.
|
|
||||||
- **No visual indicator that content is scrollable** — no fade-out gradient, no scroll shadow, nothing signals "there's more below."
|
|
||||||
|
|
||||||
### 3. Collapsible Everything = Nothing Visible at a Glance
|
|
||||||
|
|
||||||
Every roadmap item is a `SimpleCollapsible` (custom, not shadcn). CRITICAL and HIGH start expanded, but MEDIUM/LOW/NICE-TO-HAVE are collapsed. This means:
|
|
||||||
|
|
||||||
- **6 out of 10 items are invisible by default** — you see 4 items, then 6 collapsed headers you have to click one by one.
|
|
||||||
- The collapsible headers show a priority badge + title, but no description, no effort estimate, no status beyond "PENDING" — you have to click each one to learn anything.
|
|
||||||
- No way to expand all / collapse all.
|
|
||||||
- `SimpleCollapsible` is a custom component when shadcn has `Collapsible` (Radix-based, accessible, animated).
|
|
||||||
|
|
||||||
### 4. No Priority Grouping or Visual Hierarchy
|
|
||||||
|
|
||||||
All roadmap items render as a flat list inside a single scroll container. The priority emoji/badge is the only differentiator:
|
|
||||||
|
|
||||||
- No section headers (CRITICAL / HIGH / MEDIUM / LOW) — items from different priorities blend together.
|
|
||||||
- No count indicators ("2 Critical, 3 High, 4 Medium...").
|
|
||||||
- No way to filter by priority or toggle visibility of entire tiers.
|
|
||||||
- The `PRIORITY_COLORS` object defines `border-l-4` left borders but the visual weight difference between orange and yellow on a dark theme is subtle.
|
|
||||||
|
|
||||||
### 5. Description Content Is Raw Markdown Dump
|
|
||||||
|
|
||||||
The `parseFutureMarkdown` function concatenates description, rationale, and implementation notes into a single `description` string with `whitespace-pre-wrap`. This means:
|
|
||||||
|
|
||||||
- Markdown formatting (`**Description:**`, `**Rationale:**`, bullet points) renders as literal text, not styled content.
|
|
||||||
- No visual separation between Description, Rationale, and Implementation Notes.
|
|
||||||
- Long implementation notes (the business logic item has code blocks) just dump as plain text.
|
|
||||||
- The markdown headers (`**Description:**`, etc.) show as bold text but with no structure — looks like a raw file view.
|
|
||||||
|
|
||||||
### 6. Activity Log Is Broken / Useless
|
|
||||||
|
|
||||||
The `parseDevLogMarkdown` function splits on `---` horizontal rules and tries to parse the development log. Problems:
|
|
||||||
|
|
||||||
- The actual `DEVELOPMENT_LOG.md` format doesn't consistently use `---` separators between entries — it uses `###` headers. The parser misses most entries.
|
|
||||||
- `devLogEntries` often comes back nearly empty or with badly parsed data.
|
|
||||||
- Each entry is a `DevLogEntry` component that's also collapsible (collapsed by default), so you're clicking to expand... inside a scrollbox... inside a card. Three layers of hiding.
|
|
||||||
- The dev log is 54KB of data being shipped to the frontend on every page load. Most admins never look at it.
|
|
||||||
|
|
||||||
### 7. No Interactivity or Actionability
|
|
||||||
|
|
||||||
This is a read-only data wall. There's no:
|
|
||||||
|
|
||||||
- Way to reorder priorities
|
|
||||||
- Way to mark an item as "in progress" or "started"
|
|
||||||
- Link to create a dispatch for an agent
|
|
||||||
- Progress indicator (how many items done vs pending)
|
|
||||||
- Filter or search
|
|
||||||
- Sorting (by priority, by date added, by effort)
|
|
||||||
|
|
||||||
### 8. Version Badge Is Orphaned
|
|
||||||
|
|
||||||
A lone `Badge` with the version number floats at the top of the component with no label, no context, no styling weight. It looks like it fell out of another component.
|
|
||||||
|
|
||||||
### 9. No Responsive Consideration
|
|
||||||
|
|
||||||
The component renders the same way at every breakpoint. On mobile:
|
|
||||||
|
|
||||||
- The 500px scroll containers are worse (less visible content).
|
|
||||||
- Collapsible headers with badges + long titles overflow or wrap poorly.
|
|
||||||
- No card reflow for small screens.
|
|
||||||
|
|
||||||
### 10. Accessibility Issues
|
|
||||||
|
|
||||||
- `SimpleCollapsible` uses a `div` with `onClick` — not a button, no `aria-expanded`, no keyboard activation.
|
|
||||||
- The scroll containers have no `role` or `aria-label`.
|
|
||||||
- No skip links within the dashboard sections.
|
|
||||||
- The priority emojis (🔴🟠🟡🔵💭) have no text alternatives for screen readers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Redesign Proposal
|
|
||||||
|
|
||||||
### Core Concept: Kanban-Style Priority Lanes
|
|
||||||
|
|
||||||
Replace the single flat scrollbox with a **horizontal lane layout** — one column per priority tier. Each lane shows its items as compact cards. This gives admins an at-a-glance view of the entire roadmap without scrolling or clicking.
|
|
||||||
|
|
||||||
### Architecture Changes
|
|
||||||
|
|
||||||
1. **Make it a standalone page** — `RoadmapPage.jsx`, not `AboutPage admin`. The `/admin/roadmap` route should render its own component with its own layout, header, and identity.
|
|
||||||
2. **Use shadcn Tabs** for the two sections (Roadmap / Activity Log) instead of stacking two cards.
|
|
||||||
3. **Separate the About page** — admins who navigate to `/admin/roadmap` shouldn't see the public about page below it.
|
|
||||||
|
|
||||||
### Roadmap Tab Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ 🗺️ Roadmap v0.24.4 │
|
|
||||||
│ ┌─────────┬─────────┬─────────┬─────────┬─────────┐ │
|
|
||||||
│ │CRITICAL │ HIGH │ MEDIUM │ LOW │ NICE │ │
|
|
||||||
│ │ (1) │ (3) │ (4) │ (3) │ (1) │ │
|
|
||||||
│ ├─────────┼─────────┼─────────┼─────────┼─────────┤ │
|
|
||||||
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │
|
|
||||||
│ │ │Item │ │ │Item │ │ │Item │ │ │Item │ │ │Item │ │ │
|
|
||||||
│ │ │card │ │ │card │ │ │card │ │ │card │ │ │card │ │ │
|
|
||||||
│ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │
|
|
||||||
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ │
|
|
||||||
│ │ │ │ │ │card │ │ │card │ │ │card │ │ │ │
|
|
||||||
│ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │ │
|
|
||||||
│ │ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ │
|
|
||||||
│ │ │ │card │ │ │card │ │ │card │ │ │ │
|
|
||||||
│ │ │ └─────┘ │ └─────┘ │ └─────┘ │ │ │
|
|
||||||
│ └─────────┴─────────┴─────────┴─────────┴─────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
On mobile (below `lg` breakpoint): lanes stack vertically with each lane as a collapsible section (using shadcn `Collapsible` or `Accordion`).
|
|
||||||
|
|
||||||
### Item Card Design (compact, scannable)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ 🔴 CRITICAL │ ← Priority badge
|
|
||||||
│ No Confirmation Before │ ← Title (bold, 2-3 lines max)
|
|
||||||
│ Destructive Actions │
|
|
||||||
│ │
|
|
||||||
│ Added: 2026-05-11 │ ← Meta line (date, agent)
|
|
||||||
│ Est: 3-4h │ ← Effort estimate
|
|
||||||
│ │
|
|
||||||
│ [Expand ▸] │ ← Click to see full details
|
|
||||||
└─────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Expanded card shows Description, Rationale, Implementation Notes as **properly styled sections** (not raw markdown dump).
|
|
||||||
|
|
||||||
### Activity Log Tab
|
|
||||||
|
|
||||||
- Replace the broken `parseDevLogMarkdown` with a **simpler timeline format** — just show version, date, agents involved, files modified. No full content dump.
|
|
||||||
- Consider lazy-loading: only fetch `developmentLog` when the Activity Log tab is selected (it's 54KB of data nobody needs on page load).
|
|
||||||
- Timeline format (vertical):
|
|
||||||
|
|
||||||
```
|
|
||||||
v0.24.4 — 2026-05-11
|
|
||||||
Scarlett ✅ 12m | Neo ✅ 3m | Bishop ✅ 7m
|
|
||||||
5 files modified
|
|
||||||
[▸ Expand details]
|
|
||||||
|
|
||||||
v0.23.2 — 2026-05-10
|
|
||||||
Neo ✅ | Bishop ✅
|
|
||||||
3 files modified
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Inventory (what to use)
|
|
||||||
|
|
||||||
| Need | Use |
|
|
||||||
|------|-----|
|
|
||||||
| Page container | Standalone `RoadmapPage.jsx` |
|
|
||||||
| Priority lanes | CSS Grid (`grid-cols-5` on `lg`, `grid-cols-1` on mobile) |
|
|
||||||
| Lane sections | `Card` with colored top border from `PRIORITY_COLORS` |
|
|
||||||
| Item cards | `Card` with `CardHeader`/`CardContent` |
|
|
||||||
| Item expand/collapse | shadcn `Collapsible` (Radix, accessible) |
|
|
||||||
| Tab switching | shadcn `Tabs` / `TabsList` / `TabsTrigger` |
|
|
||||||
| Priority badges | Keep current `Badge` + emoji approach, add `aria-label` |
|
|
||||||
| Scroll | Page-level scroll only, no nested scroll containers |
|
|
||||||
| Expand All / Collapse All | `Button` at top of Roadmap tab |
|
|
||||||
| Item count per lane | `Badge` variant="outline" in lane header |
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `client/components/AdminDashboard.jsx` | **Delete** (replaced by RoadmapPage) |
|
|
||||||
| `client/pages/AboutPage.jsx` | Remove `admin` prop, remove AdminDashboard import — AboutPage goes back to being a public-only page |
|
|
||||||
| `client/pages/RoadmapPage.jsx` | **New** — standalone roadmap page |
|
|
||||||
| `client/App.jsx` | Update `/admin/roadmap` route to render `<RoadmapPage />` instead of `<AboutPage admin />`; possibly add `/admin/about` route if admins need the about page |
|
|
||||||
| `client/api.js` | No changes needed (same endpoint) |
|
|
||||||
|
|
||||||
### Data Parsing Improvements
|
|
||||||
|
|
||||||
- **Parse FUTURE.md into structured sections** — separate Description, Rationale, Implementation Notes into distinct fields on the item object instead of concatenating into one `description` blob.
|
|
||||||
- **Extract effort estimate** from Implementation Notes (`Estimated effort: 3-4 hours` → `effort: "3-4h"`).
|
|
||||||
- **Lazy-load dev log** — only call `/api/about-admin` with `developmentLog` when the Activity Log tab is active, or split the API into two endpoints.
|
|
||||||
|
|
||||||
### Responsive Breakpoints
|
|
||||||
|
|
||||||
| Breakpoint | Layout |
|
|
||||||
|-----------|--------|
|
|
||||||
| `< sm` (mobile) | Single column, lanes stack vertically as collapsible sections |
|
|
||||||
| `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,17 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.24.6",
|
"version": "0.21.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.24.6",
|
"version": "0.21.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
|
@ -970,36 +969,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-collapsible": {
|
|
||||||
"version": "1.1.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
|
||||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/primitive": "1.1.3",
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
|
||||||
"@radix-ui/react-context": "1.1.2",
|
|
||||||
"@radix-ui/react-id": "1.1.1",
|
|
||||||
"@radix-ui/react-presence": "1.1.5",
|
|
||||||
"@radix-ui/react-primitive": "2.1.3",
|
|
||||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.27.01",
|
"version": "0.24.4",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
|
|
||||||
|
|
@ -14,358 +14,6 @@ const ALLOWED_FILES = {
|
||||||
'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'),
|
'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Priority emoji to label mapping
|
|
||||||
const PRIORITY_MAP = {
|
|
||||||
'🔴': 'CRITICAL',
|
|
||||||
'🟠': 'HIGH',
|
|
||||||
'🟡': 'MEDIUM',
|
|
||||||
'🔵': 'LOW',
|
|
||||||
'💭': 'NICE_TO_HAVE',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a slug from a title: lowercase, hyphens, strip emojis
|
|
||||||
*/
|
|
||||||
function slugify(title) {
|
|
||||||
return title
|
|
||||||
.replace(/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FAFF}]/gu, '') // strip emojis
|
|
||||||
.replace(/[^a-zA-Z0-9]+/g, '-') // non-alphanumeric → hyphens
|
|
||||||
.replace(/^-+|-+$/g, '') // trim leading/trailing hyphens
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract effort estimate from implementation notes text.
|
|
||||||
* Matches patterns like "Estimated effort: 3-4 hours", "Estimated effort: 8 hours"
|
|
||||||
*/
|
|
||||||
function extractEffort(text) {
|
|
||||||
if (!text) return null;
|
|
||||||
const match = text.match(/Estimated effort:\s*(\d+(?:\s*-\s*\d+)?\s*hours?)/i);
|
|
||||||
if (!match) return null;
|
|
||||||
// Normalize: "3-4 hours" → "3-4h", "8 hours" → "8h"
|
|
||||||
return match[1].replace(/\s*hours?/i, 'h').replace(/\s*/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse FUTURE.md into structured roadmap items.
|
|
||||||
* Filters out completed/strikethrough items and template/meta sections.
|
|
||||||
*/
|
|
||||||
function parseFutureMd(content) {
|
|
||||||
if (!content) return { items: [], counts: {} };
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
const counts = { critical: 0, high: 0, medium: 0, low: 0, niceToHave: 0 };
|
|
||||||
|
|
||||||
const lines = content.split('\n');
|
|
||||||
let skipSection = false;
|
|
||||||
let currentSectionLines = [];
|
|
||||||
let currentPriorityEmoji = null;
|
|
||||||
let currentPriorityLabel = null;
|
|
||||||
let currentTitle = null;
|
|
||||||
let inItem = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Skip template/meta sections
|
|
||||||
if (/^##\s+How to Use This Document/i.test(line) || /^###\s+Priority Format/i.test(line)) {
|
|
||||||
skipSection = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Completed items section
|
|
||||||
if (/^##\s+Completed/i.test(line)) {
|
|
||||||
skipSection = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Stop skipping at ## or ### headings that aren't skipped sections
|
|
||||||
if (skipSection) {
|
|
||||||
if (/^(?:##|###)\s/.test(line) && !/^(?:##|###)\s+(How to Use|Priority Format|Completed)/i.test(line)) {
|
|
||||||
skipSection = false;
|
|
||||||
// Don't continue — process this heading line below
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip table rows (Priority Format table)
|
|
||||||
if (/^\|/.test(line)) continue;
|
|
||||||
|
|
||||||
// Strikethrough items: ### ~~Title~~ — PRIORITY
|
|
||||||
if (/^###\s+~~/.test(line)) {
|
|
||||||
// Save previous item and skip completed/strikethrough items
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
inItem = false;
|
|
||||||
currentTitle = null;
|
|
||||||
currentSectionLines = [];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority section headings: ### 🔴 CRITICAL, ### 🟠 HIGH, etc.
|
|
||||||
const sectionMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE)\s*$/);
|
|
||||||
if (sectionMatch) {
|
|
||||||
// Save previous item
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
currentPriorityEmoji = sectionMatch[1];
|
|
||||||
currentPriorityLabel = sectionMatch[2];
|
|
||||||
inItem = false;
|
|
||||||
currentTitle = null;
|
|
||||||
currentSectionLines = [];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item headings: ### 🔴 Title — CRITICAL or ### Title — HIGH etc.
|
|
||||||
const headingMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/);
|
|
||||||
const headingMatchNoEmoji = line.match(/^###\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/);
|
|
||||||
|
|
||||||
if (headingMatch) {
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
currentPriorityEmoji = headingMatch[1];
|
|
||||||
currentPriorityLabel = headingMatch[3];
|
|
||||||
currentTitle = headingMatch[2].trim();
|
|
||||||
currentSectionLines = [];
|
|
||||||
inItem = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!headingMatch && headingMatchNoEmoji) {
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
currentPriorityEmoji = currentPriorityEmoji || null; // inherit from section
|
|
||||||
currentPriorityLabel = headingMatchNoEmoji[2];
|
|
||||||
currentTitle = headingMatchNoEmoji[1].trim();
|
|
||||||
currentSectionLines = [];
|
|
||||||
inItem = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also handle items with emoji but no trailing priority: ### 🔴 Title
|
|
||||||
const headingEmojiOnly = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*$/);
|
|
||||||
if (headingEmojiOnly && !headingMatch) {
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
currentPriorityEmoji = headingEmojiOnly[1];
|
|
||||||
// Use section-level priority if available
|
|
||||||
currentPriorityLabel = currentPriorityLabel || PRIORITY_MAP[headingEmojiOnly[1]] || 'MEDIUM';
|
|
||||||
currentTitle = headingEmojiOnly[2].trim();
|
|
||||||
currentSectionLines = [];
|
|
||||||
inItem = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic ### headings without emoji or priority label (items in a section context)
|
|
||||||
if (/^###\s+/.test(line) && !headingMatch && !headingMatchNoEmoji && !headingEmojiOnly) {
|
|
||||||
// Plain ### heading within a known section
|
|
||||||
if (currentPriorityLabel) {
|
|
||||||
// Save previous item
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
currentTitle = line.replace(/^###\s+/, '').trim();
|
|
||||||
currentSectionLines = [];
|
|
||||||
inItem = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ## Pending Recommendations heading — skip
|
|
||||||
if (/^##\s+Pending Recommendations/.test(line)) {
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
inItem = false;
|
|
||||||
currentTitle = null;
|
|
||||||
currentSectionLines = [];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect body lines for current item
|
|
||||||
if (inItem) {
|
|
||||||
currentSectionLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save last item
|
|
||||||
if (inItem && currentTitle) {
|
|
||||||
_addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, counts, version: pkg.version };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a parsed item to the items array and update counts.
|
|
||||||
*/
|
|
||||||
function _addItem(items, counts, emoji, label, title, bodyLines) {
|
|
||||||
const body = bodyLines.join('\n');
|
|
||||||
const description = _extractField(body, 'Description');
|
|
||||||
const rationale = _extractField(body, 'Rationale');
|
|
||||||
const implementationNotes = _extractField(body, 'Implementation Notes');
|
|
||||||
const effort = extractEffort(implementationNotes);
|
|
||||||
|
|
||||||
// Extract Added and AddedBy metadata
|
|
||||||
const addedMatch = body.match(/\*\*Added:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+by\s+(.+))?/);
|
|
||||||
const added = addedMatch ? addedMatch[1] : null;
|
|
||||||
const addedBy = addedMatch ? (addedMatch[2] || null) : null;
|
|
||||||
|
|
||||||
// Determine status — if not specified, default to PENDING
|
|
||||||
const statusMatch = body.match(/\*\*Status:\*\*\s*(.+)/);
|
|
||||||
const status = statusMatch ? statusMatch[1].trim().toUpperCase() : 'PENDING';
|
|
||||||
|
|
||||||
// Map priority label to count key
|
|
||||||
const countKey = {
|
|
||||||
'CRITICAL': 'critical',
|
|
||||||
'HIGH': 'high',
|
|
||||||
'MEDIUM': 'medium',
|
|
||||||
'LOW': 'low',
|
|
||||||
'NICE TO HAVE': 'niceToHave',
|
|
||||||
'NICE_TO_HAVE': 'niceToHave',
|
|
||||||
'MEH': 'niceToHave',
|
|
||||||
}[label] || 'medium';
|
|
||||||
counts[countKey]++;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: slugify(title),
|
|
||||||
priority: emoji || '',
|
|
||||||
priorityLabel: label,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
rationale,
|
|
||||||
implementationNotes,
|
|
||||||
effort,
|
|
||||||
added,
|
|
||||||
addedBy,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a named field from markdown body text.
|
|
||||||
* Looks for **Field Name:** and captures everything until the next ** field or ### heading or end.
|
|
||||||
*/
|
|
||||||
function _extractField(body, fieldName) {
|
|
||||||
// Match **FieldName:** followed by content until next ** or ### heading
|
|
||||||
const regex = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*\n([\\s\\S]*?)(?=\\n\\*\\*[^*]|\\n###|$)`, 'i');
|
|
||||||
const match = body.match(regex);
|
|
||||||
if (!match) return null;
|
|
||||||
return match[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse DEVELOPMENT_LOG.md into structured log entries.
|
|
||||||
* Returns entries sorted by date descending.
|
|
||||||
*/
|
|
||||||
function parseDevLogMd(content) {
|
|
||||||
if (!content) return [];
|
|
||||||
|
|
||||||
const entries = [];
|
|
||||||
// Split on version headings: ### v0.24.4 - Title
|
|
||||||
const versionRegex = /^###\s+(v[\d.]+(?:-[\w]+)?)\s+-\s+(.+)$/gm;
|
|
||||||
const splits = [];
|
|
||||||
let match;
|
|
||||||
while ((match = versionRegex.exec(content)) !== null) {
|
|
||||||
splits.push({
|
|
||||||
version: match[1],
|
|
||||||
title: match[2].trim(),
|
|
||||||
index: match.index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < splits.length; i++) {
|
|
||||||
const start = splits[i].index;
|
|
||||||
const end = i + 1 < splits.length ? splits[i + 1].index : content.length;
|
|
||||||
const block = content.substring(start, end);
|
|
||||||
const entry = _parseDevLogEntry(block, splits[i].version, splits[i].title);
|
|
||||||
if (entry) entries.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by date descending
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
const dateA = a.date ? new Date(a.date) : new Date(0);
|
|
||||||
const dateB = b.date ? new Date(b.date) : new Date(0);
|
|
||||||
return dateB - dateA;
|
|
||||||
});
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a single dev log entry block.
|
|
||||||
*/
|
|
||||||
function _parseDevLogEntry(block, version, title) {
|
|
||||||
// Status
|
|
||||||
const statusMatch = block.match(/\*\*Status:\*\*\s*(.+)/);
|
|
||||||
const status = statusMatch ? statusMatch[1].trim() : null;
|
|
||||||
|
|
||||||
// Date
|
|
||||||
const dateMatch = block.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/);
|
|
||||||
const date = dateMatch ? dateMatch[1] : null;
|
|
||||||
|
|
||||||
// Priority
|
|
||||||
const priorityMatch = block.match(/\*\*Priority:\*\*\s*(.+)/);
|
|
||||||
const priority = priorityMatch ? priorityMatch[1].trim() : null;
|
|
||||||
|
|
||||||
// Agents table
|
|
||||||
const agents = [];
|
|
||||||
const agentTableRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g;
|
|
||||||
let inAgentTable = false;
|
|
||||||
const blockLines = block.split('\n');
|
|
||||||
for (const line of blockLines) {
|
|
||||||
if (/^\|\s*Agent\s*\|/i.test(line)) {
|
|
||||||
inAgentTable = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/^\|\s*[-:]+\s*\|/.test(line)) continue; // separator row
|
|
||||||
if (inAgentTable && /^\|/.test(line)) {
|
|
||||||
const row = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/);
|
|
||||||
if (row) {
|
|
||||||
agents.push({
|
|
||||||
name: row[1].trim(),
|
|
||||||
status: row[2].trim(),
|
|
||||||
time: row[3].trim(),
|
|
||||||
notes: row[4].trim(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (inAgentTable && !/^\|/.test(line) && line.trim() !== '') {
|
|
||||||
inAgentTable = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Files modified
|
|
||||||
const filesMatch = block.match(/\*\*Files modified:\*\*\s*(.+)/);
|
|
||||||
const filesModified = filesMatch
|
|
||||||
? filesMatch[1].split(',').map(f => f.trim().replace(/^`|`$/g, '')).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Work completed (checklist items)
|
|
||||||
const workCompleted = [];
|
|
||||||
const workMatch = block.match(/\*\*Work Completed:\*\*\n([\s\S]*?)(?=\n---|\n###|$)/);
|
|
||||||
if (workMatch) {
|
|
||||||
const items = workMatch[1].match(/- \[[ x]\] .+/g);
|
|
||||||
if (items) {
|
|
||||||
workCompleted.push(...items.map(item => item.replace(/^- \[[ x]\]\s*/, '').trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
status,
|
|
||||||
priority,
|
|
||||||
agents,
|
|
||||||
filesModified,
|
|
||||||
workCompleted,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redact sensitive information from file content
|
* Redact sensitive information from file content
|
||||||
* @param {string} content - The content to redact
|
* @param {string} content - The content to redact
|
||||||
|
|
@ -397,7 +45,7 @@ function redactSensitiveContent(content) {
|
||||||
.replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]')
|
.replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat)
|
// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content
|
||||||
router.get('/', requireAuth, requireAdmin, (req, res) => {
|
router.get('/', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Read both files directly from the allowlist
|
// Read both files directly from the allowlist
|
||||||
|
|
@ -423,36 +71,4 @@ router.get('/', requireAuth, requireAdmin, (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin-only endpoint: parsed roadmap items from FUTURE.md
|
|
||||||
router.get('/roadmap', requireAuth, requireAdmin, (req, res) => {
|
|
||||||
try {
|
|
||||||
const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8');
|
|
||||||
const sanitized = redactSensitiveContent(futureContent);
|
|
||||||
const result = parseFutureMd(sanitized);
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[aboutAdmin] Error reading FUTURE.md for roadmap');
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Failed to read roadmap data',
|
|
||||||
code: 'FILE_READ_ERROR'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Admin-only endpoint: parsed dev log entries from DEVELOPMENT_LOG.md
|
|
||||||
router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
|
|
||||||
try {
|
|
||||||
const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8');
|
|
||||||
const sanitized = redactSensitiveContent(devLogContent);
|
|
||||||
const entries = parseDevLogMd(sanitized);
|
|
||||||
res.json({ entries, version: pkg.version });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log');
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Failed to read dev log data',
|
|
||||||
code: 'FILE_READ_ERROR'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -250,47 +250,6 @@ router.put('/users/:id/active', (req, res) => {
|
||||||
).get(targetId));
|
).get(targetId));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/admin/users/:id/username
|
|
||||||
router.put('/users/:id/username', (req, res) => {
|
|
||||||
const { username } = req.body;
|
|
||||||
if (!username || typeof username !== 'string') {
|
|
||||||
return res.status(400).json({ error: 'username is required' });
|
|
||||||
}
|
|
||||||
const trimmed = username.trim();
|
|
||||||
if (trimmed.length < 3) {
|
|
||||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
|
||||||
}
|
|
||||||
if (trimmed.length > 50) {
|
|
||||||
return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetId = Number(req.params.id);
|
|
||||||
const db = getDb();
|
|
||||||
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
||||||
|
|
||||||
const taken = db.prepare(
|
|
||||||
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
|
|
||||||
).get(trimmed, targetId);
|
|
||||||
if (taken) return res.status(409).json({ error: 'Username already taken' });
|
|
||||||
|
|
||||||
const previousUsername = user.username;
|
|
||||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(trimmed, targetId);
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
user_id: req.user.id, action: 'admin.username.change',
|
|
||||||
entity_type: 'user', entity_id: targetId,
|
|
||||||
details: { old_username: previousUsername, new_username: trimmed },
|
|
||||||
ip_address: req.ip, user_agent: req.get('user-agent'),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(
|
|
||||||
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
|
|
||||||
.get(targetId)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/admin/users/:id
|
// DELETE /api/admin/users/:id
|
||||||
router.delete('/users/:id', (req, res) => {
|
router.delete('/users/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => {
|
||||||
|
|
||||||
// POST /api/auth/change-password
|
// POST /api/auth/change-password
|
||||||
// Password change endpoint with dedicated rate limiter
|
// Password change endpoint with dedicated rate limiter
|
||||||
// CSRF protected via csrfMiddleware on /api/auth mount
|
// Exempt from CSRF - session-based auth is primary protection (pre-middleware sets csrfSkip)
|
||||||
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
|
router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => {
|
||||||
const { current_password, new_password } = req.body;
|
const { current_password, new_password } = req.body;
|
||||||
|
|
||||||
|
|
|
||||||
300
routes/bills.js
300
routes/bills.js
|
|
@ -1,9 +1,72 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
|
|
||||||
|
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
|
|
||||||
|
// Helper function to get default cycle day based on cycle type
|
||||||
|
function getDefaultCycleDay(cycleType) {
|
||||||
|
switch (cycleType) {
|
||||||
|
case 'monthly':
|
||||||
|
return '1'; // 1st of the month
|
||||||
|
case 'weekly':
|
||||||
|
return 'monday'; // Monday
|
||||||
|
case 'biweekly':
|
||||||
|
return 'monday'; // Monday
|
||||||
|
case 'quarterly':
|
||||||
|
return '1'; // 1st of the quarter
|
||||||
|
case 'annual':
|
||||||
|
return '1'; // 1st of the year
|
||||||
|
default:
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cycle_day based on cycle_type
|
||||||
|
function validateCycleDay(cycleType, cycleDay) {
|
||||||
|
if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) };
|
||||||
|
const ct = cycleType || 'monthly';
|
||||||
|
switch (ct) {
|
||||||
|
case 'monthly': {
|
||||||
|
const d = Number(cycleDay);
|
||||||
|
if (!Number.isInteger(d) || d < 1 || d > 31) return { error: 'monthly cycle_day must be 1-31' };
|
||||||
|
return { value: String(d) };
|
||||||
|
}
|
||||||
|
case 'weekly':
|
||||||
|
case 'biweekly': {
|
||||||
|
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
|
if (!days.includes(String(cycleDay).toLowerCase())) return { error: 'weekly/biweekly cycle_day must be a valid day name' };
|
||||||
|
return { value: String(cycleDay).toLowerCase() };
|
||||||
|
}
|
||||||
|
case 'quarterly':
|
||||||
|
case 'annual':
|
||||||
|
return { value: String(cycleDay).slice(0, 50) };
|
||||||
|
default:
|
||||||
|
return { value: getDefaultCycleDay(ct) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDueDay(value) {
|
||||||
|
const day = Number(value);
|
||||||
|
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||||
|
return { error: 'due_day must be an integer between 1 and 31' };
|
||||||
|
}
|
||||||
|
return { value: day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInterestRate(value) {
|
||||||
|
if (value === undefined) return { value: undefined };
|
||||||
|
if (value === null) return { value: null };
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return { value: null };
|
||||||
|
|
||||||
|
const rate = Number(value);
|
||||||
|
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
|
||||||
|
return { error: 'interest_rate must be a number between 0 and 100, or null' };
|
||||||
|
}
|
||||||
|
return { value: rate };
|
||||||
|
}
|
||||||
|
|
||||||
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
@ -128,52 +191,66 @@ router.post('/', (req, res) => {
|
||||||
account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
|
account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate and normalize bill data
|
if (!name || due_day == null) {
|
||||||
const validation = validateBillData(req.body);
|
return res.status(400).json(standardizeError('name and due_day are required', 'VALIDATION_ERROR', 'name'));
|
||||||
if (validation.errors.length > 0) {
|
|
||||||
const firstError = validation.errors[0];
|
|
||||||
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { normalized } = validation;
|
// Validate cycle_type if provided
|
||||||
|
const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
|
||||||
|
const cycleType = cycle_type || 'monthly';
|
||||||
|
if (!validCycleTypes.includes(cycleType)) {
|
||||||
|
return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type'));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate category_id exists for this user
|
// Validate cycle_day based on cycle_type
|
||||||
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) {
|
const cycleDayResult = validateCycleDay(cycleType, cycle_day);
|
||||||
|
if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day'));
|
||||||
|
const cycleDay = cycleDayResult.value;
|
||||||
|
|
||||||
|
const due = parseDueDay(due_day);
|
||||||
|
if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day'));
|
||||||
|
const day = due.value;
|
||||||
|
|
||||||
|
const parsedInterest = parseInterestRate(interest_rate);
|
||||||
|
if (parsedInterest.error) return res.status(400).json(standardizeError(parsedInterest.error, 'VALIDATION_ERROR', 'interest_rate'));
|
||||||
|
|
||||||
|
const bucket = day <= 14 ? '1st' : '15th';
|
||||||
|
const catId = category_id || null;
|
||||||
|
if (catId && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(catId, req.user.id)) {
|
||||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibility = history_visibility || 'default';
|
||||||
|
if (!VALID_VISIBILITY.includes(visibility)) {
|
||||||
|
return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO bills
|
INSERT INTO bills
|
||||||
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
|
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
|
||||||
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
|
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
|
||||||
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
|
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day)
|
||||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
`).run(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
normalized.name,
|
name,
|
||||||
normalized.category_id,
|
catId,
|
||||||
normalized.due_day,
|
day,
|
||||||
normalized.override_due_date,
|
override_due_date || null,
|
||||||
normalized.bucket,
|
bucket,
|
||||||
normalized.expected_amount,
|
parseFloat(expected_amount) || 0,
|
||||||
normalized.interest_rate,
|
parsedInterest.value ?? null,
|
||||||
normalized.billing_cycle,
|
billing_cycle || 'monthly',
|
||||||
normalized.autopay_enabled,
|
autopay_enabled ? 1 : 0,
|
||||||
normalized.autodraft_status,
|
autodraft_status || 'none',
|
||||||
normalized.website,
|
website || null,
|
||||||
normalized.username,
|
username || null,
|
||||||
normalized.account_info,
|
account_info || null,
|
||||||
normalized.has_2fa,
|
has_2fa ? 1 : 0,
|
||||||
normalized.notes,
|
notes || null,
|
||||||
normalized.history_visibility,
|
visibility,
|
||||||
normalized.cycle_type,
|
cycleType,
|
||||||
normalized.cycle_day,
|
cycleDay,
|
||||||
normalized.current_balance,
|
|
||||||
normalized.minimum_payment,
|
|
||||||
normalized.snowball_order,
|
|
||||||
normalized.snowball_include,
|
|
||||||
normalized.snowball_exempt,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
|
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
|
@ -186,54 +263,75 @@ router.put('/:id', (req, res) => {
|
||||||
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||||
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
// Validate and normalize bill data
|
const {
|
||||||
const validation = validateBillData(req.body, existing);
|
name, category_id, due_day, override_due_date, expected_amount, interest_rate,
|
||||||
if (validation.errors.length > 0) {
|
billing_cycle, autopay_enabled, autodraft_status, website, username,
|
||||||
const firstError = validation.errors[0];
|
account_info, has_2fa, notes, active, history_visibility, cycle_type, cycle_day,
|
||||||
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
} = req.body;
|
||||||
}
|
|
||||||
|
|
||||||
const { normalized } = validation;
|
const due = due_day !== undefined ? parseDueDay(due_day) : { value: existing.due_day };
|
||||||
|
if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day'));
|
||||||
|
const day = due.value;
|
||||||
|
|
||||||
// Validate category_id exists for this user if changed
|
const parsedInterest = parseInterestRate(interest_rate);
|
||||||
if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(normalized.category_id, req.user.id)) {
|
if (parsedInterest.error) return res.status(400).json(standardizeError(parsedInterest.error, 'VALIDATION_ERROR', 'interest_rate'));
|
||||||
|
|
||||||
|
const bucket = day <= 14 ? '1st' : '15th';
|
||||||
|
const nextCategoryId = category_id !== undefined ? (category_id || null) : existing.category_id;
|
||||||
|
if (nextCategoryId && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(nextCategoryId, req.user.id)) {
|
||||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextVisibility = history_visibility !== undefined ? history_visibility : existing.history_visibility;
|
||||||
|
if (!VALID_VISIBILITY.includes(nextVisibility)) {
|
||||||
|
return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cycle_type and cycle_day updates
|
||||||
|
const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
|
||||||
|
let nextCycleType = existing.cycle_type || 'monthly';
|
||||||
|
let nextCycleDay = existing.cycle_day || getDefaultCycleDay(nextCycleType);
|
||||||
|
|
||||||
|
if (cycle_type !== undefined) {
|
||||||
|
if (!validCycleTypes.includes(cycle_type)) {
|
||||||
|
return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type'));
|
||||||
|
}
|
||||||
|
nextCycleType = cycle_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cycle_day based on the resolved cycle_type
|
||||||
|
const cycleDayResult = validateCycleDay(nextCycleType, cycle_day !== undefined ? cycle_day : nextCycleDay);
|
||||||
|
if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day'));
|
||||||
|
nextCycleDay = cycleDayResult.value;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE bills SET
|
UPDATE bills SET
|
||||||
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
|
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
|
||||||
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
|
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
|
||||||
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
|
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
|
||||||
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
||||||
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
|
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
`).run(
|
`).run(
|
||||||
normalized.name,
|
name ?? existing.name,
|
||||||
normalized.category_id,
|
nextCategoryId,
|
||||||
normalized.due_day,
|
day,
|
||||||
normalized.override_due_date,
|
override_due_date !== undefined ? (override_due_date || null) : existing.override_due_date,
|
||||||
normalized.bucket,
|
bucket,
|
||||||
normalized.expected_amount,
|
expected_amount != null ? parseFloat(expected_amount) : existing.expected_amount,
|
||||||
normalized.interest_rate,
|
parsedInterest.value !== undefined ? parsedInterest.value : existing.interest_rate,
|
||||||
normalized.billing_cycle,
|
billing_cycle ?? existing.billing_cycle,
|
||||||
normalized.autopay_enabled,
|
autopay_enabled != null ? (autopay_enabled ? 1 : 0) : existing.autopay_enabled,
|
||||||
normalized.autodraft_status,
|
autodraft_status ?? existing.autodraft_status,
|
||||||
normalized.website,
|
website !== undefined ? (website || null) : existing.website,
|
||||||
normalized.username,
|
username !== undefined ? (username || null) : existing.username,
|
||||||
normalized.account_info,
|
account_info !== undefined ? (account_info || null) : existing.account_info,
|
||||||
normalized.has_2fa,
|
has_2fa != null ? (has_2fa ? 1 : 0) : existing.has_2fa,
|
||||||
normalized.notes,
|
notes !== undefined ? (notes || null) : existing.notes,
|
||||||
normalized.active,
|
active != null ? (active ? 1 : 0) : existing.active,
|
||||||
normalized.history_visibility,
|
nextVisibility,
|
||||||
normalized.cycle_type,
|
nextCycleType,
|
||||||
normalized.cycle_day,
|
nextCycleDay,
|
||||||
normalized.current_balance,
|
|
||||||
normalized.minimum_payment,
|
|
||||||
normalized.snowball_order,
|
|
||||||
normalized.snowball_include,
|
|
||||||
normalized.snowball_exempt,
|
|
||||||
req.params.id,
|
req.params.id,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
|
|
@ -298,7 +396,7 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
// Get bill - always scope to the requesting user
|
// Get bill - always scope to the requesting user
|
||||||
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
|
const bill = db.prepare('SELECT id, expected_amount, user_id, due_day FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
|
||||||
|
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
|
@ -319,14 +417,6 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
|
|
||||||
// If paid (has payment), remove it → Unpaid
|
// If paid (has payment), remove it → Unpaid
|
||||||
if (currentPayment) {
|
if (currentPayment) {
|
||||||
// Reverse any balance delta that was applied when this payment was created
|
|
||||||
if (currentPayment.balance_delta != null) {
|
|
||||||
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
|
|
||||||
if (freshBill?.current_balance != null) {
|
|
||||||
const restored = Math.max(0, Math.round((freshBill.current_balance - currentPayment.balance_delta) * 100) / 100);
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(currentPayment.id);
|
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(currentPayment.id);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -359,17 +449,9 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
|
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute balance delta for debt bills before inserting
|
|
||||||
const balCalc = computeBalanceDelta(bill, amount);
|
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
|
||||||
).run(billId, amount, paidDate, method, notes, balCalc?.balance_delta ?? null);
|
).run(billId, amount, paidDate, method, notes);
|
||||||
|
|
||||||
if (balCalc) {
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, billId);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -499,46 +581,4 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => {
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
|
|
||||||
router.patch('/:id/balance', (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
const billId = parseInt(req.params.id, 10);
|
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = req.body.current_balance;
|
|
||||||
let val = null;
|
|
||||||
if (raw !== null && raw !== '' && raw !== undefined) {
|
|
||||||
val = parseFloat(raw);
|
|
||||||
if (!Number.isFinite(val) || val < 0) {
|
|
||||||
return res.status(400).json(standardizeError('current_balance must be a non-negative number', 'VALIDATION_ERROR', 'current_balance'));
|
|
||||||
}
|
|
||||||
val = Math.round(val * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId);
|
|
||||||
res.json({ id: billId, current_balance: val });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── PATCH /api/bills/:id/snowball — lightweight snowball visibility update ───
|
|
||||||
router.patch('/:id/snowball', (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
const billId = parseInt(req.params.id, 10);
|
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const include = req.body.snowball_include ? 1 : 0;
|
|
||||||
const exempt = req.body.snowball_exempt ? 1 : 0;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE bills
|
|
||||||
SET snowball_include = ?, snowball_exempt = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ? AND user_id = ?
|
|
||||||
`).run(include, exempt, billId, req.user.id);
|
|
||||||
|
|
||||||
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -62,17 +62,6 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
AND b.due_day BETWEEN 15 AND 31
|
AND b.due_day BETWEEN 15 AND 31
|
||||||
`).get(userId, start, end);
|
`).get(userId, start, end);
|
||||||
|
|
||||||
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
|
|
||||||
const otherPaid = db.prepare(`
|
|
||||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
|
||||||
FROM payments p
|
|
||||||
JOIN bills b ON b.id = p.bill_id
|
|
||||||
WHERE b.user_id = ?
|
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
|
||||||
AND p.deleted_at IS NULL
|
|
||||||
AND (b.due_day < 1 OR b.due_day > 31)
|
|
||||||
`).get(userId, start, end);
|
|
||||||
|
|
||||||
const totalPaid = db.prepare(`
|
const totalPaid = db.prepare(`
|
||||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||||
FROM payments p
|
FROM payments p
|
||||||
|
|
@ -85,7 +74,6 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
return {
|
return {
|
||||||
paid_from_first: money(firstPaid.paid),
|
paid_from_first: money(firstPaid.paid),
|
||||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||||
paid_from_other: money(otherPaid.paid),
|
|
||||||
paid_total: money(totalPaid.paid),
|
paid_total: money(totalPaid.paid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -106,11 +94,10 @@ function buildStartingAmountsResponse(db, userId, year, month) {
|
||||||
combined_amount,
|
combined_amount,
|
||||||
paid_from_first: paid.paid_from_first,
|
paid_from_first: paid.paid_from_first,
|
||||||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||||
paid_from_other: paid.paid_from_other,
|
|
||||||
paid_total,
|
paid_total,
|
||||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||||
other_remaining: amounts.other_amount - paid.paid_from_other,
|
other_remaining: amounts.other_amount,
|
||||||
combined_remaining: combined_amount - paid_total,
|
combined_remaining: combined_amount - paid_total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ const express = require('express');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { computeBalanceDelta } = require('../services/billsService');
|
|
||||||
|
|
||||||
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
|
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
|
||||||
|
|
||||||
|
|
@ -92,16 +91,9 @@ router.post('/quick', (req, res) => {
|
||||||
|
|
||||||
const payDate = paid_date || new Date().toISOString().slice(0, 10);
|
const payDate = paid_date || new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, payAmount);
|
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
|
||||||
).run(bill_id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null);
|
).run(bill_id, payAmount, payDate, method || null, notes || null);
|
||||||
|
|
||||||
if (balCalc) {
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(balCalc.new_balance, bill_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bill.autopay_enabled) {
|
if (bill.autopay_enabled) {
|
||||||
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id);
|
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id);
|
||||||
|
|
@ -158,10 +150,8 @@ router.post('/bulk', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?');
|
|
||||||
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
|
|
||||||
|
|
||||||
// Prepare statement for duplicate checking
|
// Prepare statement for duplicate checking
|
||||||
const duplicateCheckStmt = db.prepare(
|
const duplicateCheckStmt = db.prepare(
|
||||||
|
|
@ -191,16 +181,12 @@ router.post('/bulk', (req, res) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const billRow = getBillForBalance.get(bill_id, req.user.id);
|
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id)) {
|
||||||
if (!billRow) {
|
|
||||||
errors.push({ item, error: `Bill ${bill_id} not found` });
|
errors.push({ item, error: `Bill ${bill_id} not found` });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(billRow, parsedAmt);
|
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null);
|
||||||
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null);
|
|
||||||
if (balCalc) applyBalance.run(balCalc.new_balance, bill_id);
|
|
||||||
|
|
||||||
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
|
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -236,18 +222,8 @@ router.put('/:id', (req, res) => {
|
||||||
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
// DELETE /api/payments/:id — soft delete (sets deleted_at)
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
|
const payment = db.prepare(`SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ?`).get(req.params.id, req.user.id);
|
||||||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
// Reverse any balance delta that was stored when this payment was created
|
|
||||||
if (payment.balance_delta != null) {
|
|
||||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
|
||||||
if (bill?.current_balance != null) {
|
|
||||||
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
|
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
@ -255,18 +231,8 @@ router.delete('/:id', (req, res) => {
|
||||||
// POST /api/payments/:id/restore — undo soft delete
|
// POST /api/payments/:id/restore — undo soft delete
|
||||||
router.post('/:id/restore', (req, res) => {
|
router.post('/:id/restore', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id);
|
const payment = db.prepare('SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?').get(req.params.id, req.user.id);
|
||||||
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
// Re-apply the balance delta (undo the reversal done on delete)
|
|
||||||
if (payment.balance_delta != null) {
|
|
||||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
|
|
||||||
if (bill?.current_balance != null) {
|
|
||||||
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id);
|
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id);
|
||||||
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -57,34 +57,10 @@ router.get('/', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PATCH /api/profile ────────────────────────────────────────────────────────
|
// ── PATCH /api/profile ────────────────────────────────────────────────────────
|
||||||
// Updates safe profile fields: username and display_name.
|
// Updates safe profile fields: display_name only.
|
||||||
// Ignores any unknown or restricted fields.
|
// Ignores any unknown or restricted fields.
|
||||||
router.patch('/', (req, res) => {
|
router.patch('/', (req, res) => {
|
||||||
const { username, display_name } = req.body;
|
const { display_name } = req.body;
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
if (username !== undefined) {
|
|
||||||
if (typeof username !== 'string') {
|
|
||||||
return res.status(400).json({ error: 'username must be a string' });
|
|
||||||
}
|
|
||||||
const trimmedUsername = username.trim();
|
|
||||||
if (trimmedUsername.length < 3) {
|
|
||||||
return res.status(400).json({ error: 'username must be at least 3 characters' });
|
|
||||||
}
|
|
||||||
if (trimmedUsername.length > 50) {
|
|
||||||
return res.status(400).json({ error: 'username must be 50 characters or fewer' });
|
|
||||||
}
|
|
||||||
const taken = db.prepare(
|
|
||||||
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
|
|
||||||
).get(trimmedUsername, req.user.id);
|
|
||||||
if (taken) {
|
|
||||||
return res.status(409).json({ error: 'Username already taken' });
|
|
||||||
}
|
|
||||||
db.prepare(
|
|
||||||
"UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?"
|
|
||||||
).run(trimmedUsername, req.user.id);
|
|
||||||
logAudit({ user_id: req.user.id, action: 'profile.username.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (display_name !== undefined) {
|
if (display_name !== undefined) {
|
||||||
if (typeof display_name !== 'string') {
|
if (typeof display_name !== 'string') {
|
||||||
|
|
@ -95,7 +71,7 @@ router.patch('/', (req, res) => {
|
||||||
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(
|
getDb().prepare(
|
||||||
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
).run(trimmed || null, req.user.id);
|
).run(trimmed || null, req.user.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { getDb } = require('../db/database');
|
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
|
||||||
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
|
|
||||||
|
|
||||||
const DEBT_LIKE_CLAUSES = `(
|
|
||||||
b.snowball_include = 1
|
|
||||||
OR (
|
|
||||||
COALESCE(b.snowball_exempt, 0) = 0
|
|
||||||
AND (
|
|
||||||
LOWER(c.name) LIKE '%credit%'
|
|
||||||
OR LOWER(c.name) LIKE '%loan%'
|
|
||||||
OR LOWER(c.name) LIKE '%mortgage%'
|
|
||||||
OR LOWER(c.name) LIKE '%housing%'
|
|
||||||
OR LOWER(c.name) LIKE '%debt%'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)`;
|
|
||||||
|
|
||||||
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
|
|
||||||
router.get('/', (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
const bills = db.prepare(`
|
|
||||||
SELECT b.*, c.name AS category_name
|
|
||||||
FROM bills b
|
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
|
|
||||||
WHERE b.user_id = ?
|
|
||||||
AND b.active = 1
|
|
||||||
AND ${DEBT_LIKE_CLAUSES}
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
|
|
||||||
b.snowball_order ASC,
|
|
||||||
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
|
|
||||||
b.current_balance ASC
|
|
||||||
`).all(req.user.id);
|
|
||||||
|
|
||||||
res.json(bills);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/snowball/settings — extra monthly payment for this user
|
|
||||||
router.get('/settings', (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
|
||||||
res.json({ extra_payment: user?.snowball_extra_payment ?? 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// PATCH /api/snowball/settings — save extra monthly payment
|
|
||||||
router.patch('/settings', (req, res) => {
|
|
||||||
const { extra_payment } = req.body;
|
|
||||||
let val = 0;
|
|
||||||
|
|
||||||
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
|
|
||||||
val = parseFloat(extra_payment);
|
|
||||||
if (!Number.isFinite(val) || val < 0) {
|
|
||||||
return res.status(400).json(standardizeError(
|
|
||||||
'extra_payment must be a non-negative number',
|
|
||||||
'VALIDATION_ERROR',
|
|
||||||
'extra_payment'
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
|
|
||||||
res.json({ extra_payment: val });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/snowball/projection — payoff timeline using the snowball math service
|
|
||||||
router.get('/projection', (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const bills = db.prepare(`
|
|
||||||
SELECT b.*, c.name AS category_name
|
|
||||||
FROM bills b
|
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
|
|
||||||
WHERE b.user_id = ?
|
|
||||||
AND b.active = 1
|
|
||||||
AND ${DEBT_LIKE_CLAUSES}
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
|
|
||||||
b.snowball_order ASC,
|
|
||||||
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
|
|
||||||
b.current_balance ASC
|
|
||||||
`).all(req.user.id);
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
|
||||||
const extraPayment = user?.snowball_extra_payment ?? 0;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const snowball = calculateSnowball(bills, extraPayment, now);
|
|
||||||
const avalanche = calculateAvalanche(bills, extraPayment, now);
|
|
||||||
|
|
||||||
res.json({ snowball, avalanche });
|
|
||||||
});
|
|
||||||
|
|
||||||
// PATCH /api/snowball/order — batch-save snowball_order positions
|
|
||||||
router.patch('/order', (req, res) => {
|
|
||||||
const items = req.body;
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return res.status(400).json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const userId = req.user.id;
|
|
||||||
const update = db.prepare('UPDATE bills SET snowball_order = ? WHERE id = ? AND user_id = ?');
|
|
||||||
|
|
||||||
db.transaction((rows) => {
|
|
||||||
for (const row of rows) {
|
|
||||||
const id = parseInt(row.id, 10);
|
|
||||||
const order = parseInt(row.snowball_order, 10);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) continue;
|
|
||||||
if (!Number.isInteger(order) || order < 0) continue;
|
|
||||||
update.run(order, id, userId);
|
|
||||||
}
|
|
||||||
})(items);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
|
|
@ -64,17 +64,6 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
AND b.due_day BETWEEN 15 AND 31
|
AND b.due_day BETWEEN 15 AND 31
|
||||||
`).get(userId, start, end);
|
`).get(userId, start, end);
|
||||||
|
|
||||||
// Paid from other bucket: bills with due_day outside 1-14 and 15-31 (shouldn't happen with current schema)
|
|
||||||
const otherPaid = db.prepare(`
|
|
||||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
|
||||||
FROM payments p
|
|
||||||
JOIN bills b ON b.id = p.bill_id
|
|
||||||
WHERE b.user_id = ?
|
|
||||||
AND p.paid_date BETWEEN ? AND ?
|
|
||||||
AND p.deleted_at IS NULL
|
|
||||||
AND (b.due_day < 1 OR b.due_day > 31)
|
|
||||||
`).get(userId, start, end);
|
|
||||||
|
|
||||||
const totalPaid = db.prepare(`
|
const totalPaid = db.prepare(`
|
||||||
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
SELECT COALESCE(SUM(p.amount), 0) AS paid
|
||||||
FROM payments p
|
FROM payments p
|
||||||
|
|
@ -87,7 +76,6 @@ function calculatePaidDeductions(db, userId, year, month) {
|
||||||
return {
|
return {
|
||||||
paid_from_first: money(firstPaid.paid),
|
paid_from_first: money(firstPaid.paid),
|
||||||
paid_from_fifteenth: money(fifteenthPaid.paid),
|
paid_from_fifteenth: money(fifteenthPaid.paid),
|
||||||
paid_from_other: money(otherPaid.paid),
|
|
||||||
paid_total: money(totalPaid.paid),
|
paid_total: money(totalPaid.paid),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -108,11 +96,10 @@ function buildStartingAmountsSummary(db, userId, year, month) {
|
||||||
combined_amount,
|
combined_amount,
|
||||||
paid_from_first: paid.paid_from_first,
|
paid_from_first: paid.paid_from_first,
|
||||||
paid_from_fifteenth: paid.paid_from_fifteenth,
|
paid_from_fifteenth: paid.paid_from_fifteenth,
|
||||||
paid_from_other: paid.paid_from_other,
|
|
||||||
paid_total,
|
paid_total,
|
||||||
first_remaining: amounts.first_amount - paid.paid_from_first,
|
first_remaining: amounts.first_amount - paid.paid_from_first,
|
||||||
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
|
||||||
other_remaining: amounts.other_amount - paid.paid_from_other,
|
other_remaining: amounts.other_amount,
|
||||||
combined_remaining: combined_amount - paid_total,
|
combined_remaining: combined_amount - paid_total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ router.get('/', (req, res) => {
|
||||||
const hasStartingAmounts = !!startingAmounts;
|
const hasStartingAmounts = !!startingAmounts;
|
||||||
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
|
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
|
||||||
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
|
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
|
||||||
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
|
|
||||||
|
|
||||||
// Calculate previous month total
|
// Calculate previous month total
|
||||||
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
|
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
|
||||||
|
|
@ -198,7 +197,7 @@ router.get('/', (req, res) => {
|
||||||
total_starting: totalStarting,
|
total_starting: totalStarting,
|
||||||
has_starting_amounts: hasStartingAmounts,
|
has_starting_amounts: hasStartingAmounts,
|
||||||
total_paid: activeTotalPaid,
|
total_paid: activeTotalPaid,
|
||||||
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
|
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
|
||||||
overdue: totalOverdue,
|
overdue: totalOverdue,
|
||||||
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
||||||
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
||||||
|
|
|
||||||
|
|
@ -6,33 +6,6 @@ const { getDb } = require('../db/database');
|
||||||
const { seedDemoData } = require('../scripts/seedDemoData');
|
const { seedDemoData } = require('../scripts/seedDemoData');
|
||||||
const { demoDataLimiter } = require('../middleware/rateLimiter');
|
const { demoDataLimiter } = require('../middleware/rateLimiter');
|
||||||
|
|
||||||
// GET /api/user/seeded-status — returns whether the current user has any seeded data
|
|
||||||
router.get('/seeded-status', (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const userId = req.user.id;
|
|
||||||
|
|
||||||
// Check for seeded bills
|
|
||||||
const seededBillsResult = db.prepare('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1').get(userId);
|
|
||||||
const seededBillsCount = seededBillsResult.count;
|
|
||||||
|
|
||||||
// Check for seeded categories
|
|
||||||
const seededCategoriesResult = db.prepare('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1').get(userId);
|
|
||||||
const seededCategoriesCount = seededCategoriesResult.count;
|
|
||||||
|
|
||||||
const hasSeededData = seededBillsCount > 0 || seededCategoriesCount > 0;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
seeded: hasSeededData,
|
|
||||||
seededBills: seededBillsCount,
|
|
||||||
seededCategories: seededCategoriesCount,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const status = err.status || 500;
|
|
||||||
res.status(status).json({ error: status === 500 ? 'Seeded status check failed' : err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user
|
// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user
|
||||||
router.post('/clear-demo-data', demoDataLimiter, (req, res) => {
|
router.post('/clear-demo-data', demoDataLimiter, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# docker-push.sh — Tag and push dev image to Forgejo registry
|
|
||||||
# Usage: ./scripts/docker-push.sh
|
|
||||||
# Requires: ~/.openclaw/docker-registry.env (chmod 600)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
source ~/.openclaw/docker-registry.env
|
|
||||||
|
|
||||||
echo "$FORGEJO_REGISTRY_TOKEN" | docker login "$FORGEJO_REGISTRY" -u "$FORGEJO_REGISTRY_USER" --password-stdin
|
|
||||||
|
|
||||||
docker tag bill-tracker:local "${FORGEJO_REGISTRY}/null/bill-tracker:dev"
|
|
||||||
docker push "${FORGEJO_REGISTRY}/null/bill-tracker:dev"
|
|
||||||
|
|
||||||
docker logout "$FORGEJO_REGISTRY"
|
|
||||||
echo "✓ Pushed dev image"
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# docker-test.sh — Build and run bill-tracker in Docker for testing
|
|
||||||
# Usage: ./scripts/docker-test.sh
|
|
||||||
# Access: http://localhost:3036
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
docker stop bill-tracker 2>/dev/null || true
|
|
||||||
docker rm bill-tracker 2>/dev/null || true
|
|
||||||
rm -rf dist node_modules/.vite 2>/dev/null
|
|
||||||
|
|
||||||
docker build --no-cache -t bill-tracker:local .
|
|
||||||
|
|
||||||
docker run -d --name bill-tracker -p 3036:3000 --restart unless-stopped \
|
|
||||||
-e INIT_ADMIN_USER=admin \
|
|
||||||
-e INIT_ADMIN_PASS=admin123 \
|
|
||||||
-e INIT_TEST_USER=testuser \
|
|
||||||
-e INIT_TEST_PASS=testpass123 \
|
|
||||||
-e INIT_REGULAR_USER=regularuser \
|
|
||||||
-e INIT_REGULAR_PASS=regularpass123 \
|
|
||||||
-e CSRF_HTTP_ONLY=false \
|
|
||||||
-e CSRF_SAME_SITE=lax \
|
|
||||||
-v /tmp/bill-tracker-test/data:/data \
|
|
||||||
bill-tracker:local
|
|
||||||
|
|
||||||
echo "✓ Running on http://localhost:3036"
|
|
||||||
|
|
@ -20,8 +20,7 @@ const CATEGORIES = [
|
||||||
'Subscriptions',
|
'Subscriptions',
|
||||||
'Transportation',
|
'Transportation',
|
||||||
'Healthcare',
|
'Healthcare',
|
||||||
'Credit Cards',
|
'Finance',
|
||||||
'Loans',
|
|
||||||
'Entertainment',
|
'Entertainment',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -29,19 +28,19 @@ const CATEGORIES = [
|
||||||
const BILLS = [
|
const BILLS = [
|
||||||
{ name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Mortgage', category: 'Housing', amount: 1200, dueDay: 1, cycle: 'monthly', autopay: true, interestRate: 3.25, currentBalance: 185000, minPayment: 1200, snowballOrder: 3, snowballInclude: 0 },
|
{ name: 'Rent/Mortgage', category: 'Housing', amount: 1200, dueDay: 1, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', autopay: true, interestRate: 0 },
|
{ name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', autopay: true, interestRate: 0 },
|
{ name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Credit Card', category: 'Credit Cards', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99, currentBalance: 2800, minPayment: 75, snowballOrder: 0, snowballInclude: 1 },
|
{ name: 'Credit Card', category: 'Finance', amount: 150, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 19.99 },
|
||||||
{ name: 'Student Loan', category: 'Loans', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5, currentBalance: 12500, minPayment: 150, snowballOrder: 1, snowballInclude: 1 },
|
{ name: 'Student Loan', category: 'Finance', amount: 250, dueDay: 15, cycle: 'monthly', autopay: true, interestRate: 5.5 },
|
||||||
{ name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', autopay: false, interestRate: 0 },
|
{ name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', autopay: false, interestRate: 0 },
|
||||||
{ name: 'Car Payment', category: 'Loans', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5, currentBalance: 8400, minPayment: 350, snowballOrder: 2, snowballInclude: 1 },
|
{ name: 'Car Payment', category: 'Finance', amount: 350, dueDay: 22, cycle: 'monthly', autopay: true, interestRate: 4.5 },
|
||||||
{ name: 'Spotify', category: 'Entertainment', amount: 9.99, dueDay: 14, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Spotify', category: 'Entertainment', amount: 9.99, dueDay: 14, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', autopay: true, interestRate: 0 },
|
{ name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', autopay: true, interestRate: 0 },
|
||||||
{ name: 'Amazon Prime', category: 'Subscriptions', amount: 14.99, dueDay: 1, cycle: 'annually', autopay: true, interestRate: 0 },
|
{ name: 'Amazon Prime', category: 'Subscriptions', amount: 14.99, dueDay: 1, cycle: 'annually', autopay: true, interestRate: 0 },
|
||||||
|
|
@ -127,10 +126,8 @@ function seedDemoData(userId = null) {
|
||||||
let billsCreated = 0;
|
let billsCreated = 0;
|
||||||
const insertBill = db.prepare(`
|
const insertBill = db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
|
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
|
||||||
expected_amount, autopay_enabled, interest_rate,
|
expected_amount, autopay_enabled, interest_rate, active, is_seeded)
|
||||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
||||||
active, is_seeded)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const billData of BILLS) {
|
for (const billData of BILLS) {
|
||||||
|
|
@ -148,12 +145,7 @@ function seedDemoData(userId = null) {
|
||||||
billData.cycle || 'monthly',
|
billData.cycle || 'monthly',
|
||||||
amount,
|
amount,
|
||||||
billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : Math.random() > 0.5 ? 1 : 0,
|
billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : Math.random() > 0.5 ? 1 : 0,
|
||||||
billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0),
|
billData.interestRate || (Math.random() > 0.7 ? Math.round(Math.random() * 15 * 100) / 100 : 0)
|
||||||
billData.currentBalance ?? null,
|
|
||||||
billData.minPayment ?? null,
|
|
||||||
billData.snowballOrder ?? null,
|
|
||||||
billData.snowballInclude ?? 0,
|
|
||||||
billData.snowballExempt ?? 0
|
|
||||||
);
|
);
|
||||||
billsCreated++;
|
billsCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,6 @@ app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require(
|
||||||
app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary'));
|
app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary'));
|
||||||
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
|
app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
|
||||||
app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics'));
|
app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics'));
|
||||||
app.use('/api/snowball', csrfMiddleware, requireAuth, requireUser, require('./routes/snowball'));
|
|
||||||
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
|
app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications'));
|
||||||
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
|
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
|
||||||
app.use('/api/about', require('./routes/about')); // public
|
app.use('/api/about', require('./routes/about')); // public
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ function validateScheduleSettings(input = {}) {
|
||||||
const enabled = parseBool(input.enabled);
|
const enabled = parseBool(input.enabled);
|
||||||
const frequency = input.frequency || 'daily';
|
const frequency = input.frequency || 'daily';
|
||||||
const time = input.time || '02:00';
|
const time = input.time || '02:00';
|
||||||
const retentionCount = parseInt(input.retention_count ?? '2', 10);
|
const retentionCount = parseInt(input.retention_count ?? '14', 10);
|
||||||
|
|
||||||
if (!['daily', 'weekly'].includes(frequency)) {
|
if (!['daily', 'weekly'].includes(frequency)) {
|
||||||
const err = new Error('frequency must be daily or weekly');
|
const err = new Error('frequency must be daily or weekly');
|
||||||
|
|
@ -47,7 +47,7 @@ function readSettings() {
|
||||||
enabled: getSetting('backup_schedule_enabled') === 'true',
|
enabled: getSetting('backup_schedule_enabled') === 'true',
|
||||||
frequency: getSetting('backup_schedule_frequency') || 'daily',
|
frequency: getSetting('backup_schedule_frequency') || 'daily',
|
||||||
time: getSetting('backup_schedule_time') || '02:00',
|
time: getSetting('backup_schedule_time') || '02:00',
|
||||||
retention_count: getSetting('backup_schedule_retention_count') || '2',
|
retention_count: getSetting('backup_schedule_retention_count') || '14',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const { closeDb, getDb, getDbPath, getSetting } = require('../db/database');
|
const { closeDb, getDb, getDbPath } = require('../db/database');
|
||||||
|
|
||||||
const BACKUP_DIR = path.resolve(
|
const BACKUP_DIR = path.resolve(
|
||||||
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
||||||
|
|
@ -166,10 +166,7 @@ async function createBackup(prefix = 'bill-tracker-backup') {
|
||||||
validateSqliteDatabase(tempPath);
|
validateSqliteDatabase(tempPath);
|
||||||
fs.renameSync(tempPath, finalPath);
|
fs.renameSync(tempPath, finalPath);
|
||||||
fs.chmodSync(finalPath, 0o600);
|
fs.chmodSync(finalPath, 0o600);
|
||||||
const meta = metadataForFile(finalPath);
|
return metadataForFile(finalPath);
|
||||||
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
|
|
||||||
applyRetention(retentionCount);
|
|
||||||
return meta;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||||
cleanupSqliteSidecars(tempPath);
|
cleanupSqliteSidecars(tempPath);
|
||||||
|
|
@ -242,28 +239,25 @@ function deleteBackup(id) {
|
||||||
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRetention(retentionCount) {
|
function applyScheduledRetention(retentionCount) {
|
||||||
const keep = parseInt(retentionCount, 10);
|
const keep = parseInt(retentionCount, 10);
|
||||||
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
||||||
|
|
||||||
// listBackups() is already sorted newest-first; delete everything beyond `keep`
|
const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
|
||||||
const toDelete = listBackups().slice(keep);
|
const toDelete = scheduled.slice(keep);
|
||||||
const deleted = [];
|
const deleted = [];
|
||||||
|
|
||||||
for (const backup of toDelete) {
|
for (const backup of toDelete) {
|
||||||
try {
|
try {
|
||||||
deleted.push(deleteBackup(backup.id).id);
|
deleted.push(deleteBackup(backup.id).id);
|
||||||
} catch {
|
} catch {
|
||||||
// Retention should never cause a backup operation to fail.
|
// Retention should never make a scheduled backup fail.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deleted };
|
return { deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep old name as an alias so the scheduler import still works.
|
|
||||||
const applyScheduledRetention = applyRetention;
|
|
||||||
|
|
||||||
async function restoreBackup(id) {
|
async function restoreBackup(id) {
|
||||||
const source = getBackupFile(id);
|
const source = getBackupFile(id);
|
||||||
validateSqliteDatabase(source.path);
|
validateSqliteDatabase(source.path);
|
||||||
|
|
@ -305,7 +299,6 @@ async function restoreBackup(id) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BACKUP_DIR,
|
BACKUP_DIR,
|
||||||
assertValidBackupId,
|
assertValidBackupId,
|
||||||
applyRetention,
|
|
||||||
applyScheduledRetention,
|
applyScheduledRetention,
|
||||||
createBackup,
|
createBackup,
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
|
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
|
||||||
|
|
||||||
// Helper function to get default cycle day based on cycle type
|
|
||||||
function getDefaultCycleDay(cycleType) {
|
|
||||||
switch (cycleType) {
|
|
||||||
case 'monthly':
|
|
||||||
return '1'; // 1st of the month
|
|
||||||
case 'weekly':
|
|
||||||
return 'monday'; // Monday
|
|
||||||
case 'biweekly':
|
|
||||||
return 'monday'; // Monday
|
|
||||||
case 'quarterly':
|
|
||||||
return '1'; // 1st of the quarter
|
|
||||||
case 'annual':
|
|
||||||
return '1'; // 1st of the year
|
|
||||||
default:
|
|
||||||
return '1';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate cycle_day based on cycle_type
|
|
||||||
function validateCycleDay(cycleType, cycleDay) {
|
|
||||||
if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) };
|
|
||||||
const ct = cycleType || 'monthly';
|
|
||||||
switch (ct) {
|
|
||||||
case 'monthly': {
|
|
||||||
const d = Number(cycleDay);
|
|
||||||
if (!Number.isInteger(d) || d < 1 || d > 31) return { error: 'monthly cycle_day must be 1-31' };
|
|
||||||
return { value: String(d) };
|
|
||||||
}
|
|
||||||
case 'weekly':
|
|
||||||
case 'biweekly': {
|
|
||||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
||||||
if (!days.includes(String(cycleDay).toLowerCase())) return { error: 'weekly/biweekly cycle_day must be a valid day name' };
|
|
||||||
return { value: String(cycleDay).toLowerCase() };
|
|
||||||
}
|
|
||||||
case 'quarterly':
|
|
||||||
case 'annual':
|
|
||||||
return { value: String(cycleDay).slice(0, 50) };
|
|
||||||
default:
|
|
||||||
return { value: getDefaultCycleDay(ct) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDueDay(value) {
|
|
||||||
const day = Number(value);
|
|
||||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
|
||||||
return { error: 'due_day must be an integer between 1 and 31' };
|
|
||||||
}
|
|
||||||
return { value: day };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInterestRate(value) {
|
|
||||||
if (value === undefined) return { value: undefined };
|
|
||||||
if (value === null) return { value: null };
|
|
||||||
if (typeof value === 'string' && value.trim() === '') return { value: null };
|
|
||||||
|
|
||||||
const rate = Number(value);
|
|
||||||
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
|
|
||||||
return { error: 'interest_rate must be a number between 0 and 100, or null' };
|
|
||||||
}
|
|
||||||
return { value: rate };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValidCycleTypes() {
|
|
||||||
return ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates and normalizes bill data for creation/update.
|
|
||||||
* Returns an object with normalized values and any validation errors.
|
|
||||||
*/
|
|
||||||
function validateBillData(data, existingBill = null) {
|
|
||||||
const errors = [];
|
|
||||||
const normalized = {};
|
|
||||||
|
|
||||||
const validCycleTypes = getValidCycleTypes();
|
|
||||||
|
|
||||||
// name is required
|
|
||||||
if (!data.name) {
|
|
||||||
errors.push({ field: 'name', message: 'name is required' });
|
|
||||||
}
|
|
||||||
normalized.name = data.name || null;
|
|
||||||
|
|
||||||
// due_day is required
|
|
||||||
if (data.due_day === undefined || data.due_day === null) {
|
|
||||||
errors.push({ field: 'due_day', message: 'due_day is required' });
|
|
||||||
} else {
|
|
||||||
const dueResult = parseDueDay(data.due_day);
|
|
||||||
if (dueResult.error) {
|
|
||||||
errors.push({ field: 'due_day', message: dueResult.error });
|
|
||||||
} else {
|
|
||||||
normalized.due_day = dueResult.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// category_id validation
|
|
||||||
normalized.category_id = data.category_id !== undefined ? (data.category_id || null) : (existingBill?.category_id || null);
|
|
||||||
|
|
||||||
// override_due_date
|
|
||||||
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
|
|
||||||
|
|
||||||
// expected_amount
|
|
||||||
normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
|
|
||||||
|
|
||||||
// interest_rate
|
|
||||||
if (data.interest_rate !== undefined) {
|
|
||||||
const parsedInterest = parseInterestRate(data.interest_rate);
|
|
||||||
if (parsedInterest.error) {
|
|
||||||
errors.push({ field: 'interest_rate', message: parsedInterest.error });
|
|
||||||
} else {
|
|
||||||
normalized.interest_rate = parsedInterest.value ?? null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
normalized.interest_rate = existingBill?.interest_rate ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// billing_cycle
|
|
||||||
normalized.billing_cycle = data.billing_cycle !== undefined ? (data.billing_cycle || 'monthly') : (existingBill?.billing_cycle || 'monthly');
|
|
||||||
|
|
||||||
// autopay_enabled
|
|
||||||
normalized.autopay_enabled = data.autopay_enabled !== undefined ? (data.autopay_enabled ? 1 : 0) : (existingBill?.autopay_enabled || 0);
|
|
||||||
|
|
||||||
// autodraft_status
|
|
||||||
normalized.autodraft_status = data.autodraft_status !== undefined ? (data.autodraft_status || 'none') : (existingBill?.autodraft_status || 'none');
|
|
||||||
|
|
||||||
// website
|
|
||||||
normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null);
|
|
||||||
|
|
||||||
// username
|
|
||||||
normalized.username = data.username !== undefined ? (data.username || null) : (existingBill?.username || null);
|
|
||||||
|
|
||||||
// account_info
|
|
||||||
normalized.account_info = data.account_info !== undefined ? (data.account_info || null) : (existingBill?.account_info || null);
|
|
||||||
|
|
||||||
// has_2fa
|
|
||||||
normalized.has_2fa = data.has_2fa !== undefined ? (data.has_2fa ? 1 : 0) : (existingBill?.has_2fa || 0);
|
|
||||||
|
|
||||||
// notes
|
|
||||||
normalized.notes = data.notes !== undefined ? (data.notes || null) : (existingBill?.notes || null);
|
|
||||||
|
|
||||||
// active
|
|
||||||
normalized.active = data.active !== undefined ? (data.active ? 1 : 0) : (existingBill?.active || 1);
|
|
||||||
|
|
||||||
// history_visibility
|
|
||||||
const nextVisibility = data.history_visibility !== undefined ? data.history_visibility : (existingBill?.history_visibility || 'default');
|
|
||||||
if (!VALID_VISIBILITY.includes(nextVisibility)) {
|
|
||||||
errors.push({ field: 'history_visibility', message: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
|
|
||||||
}
|
|
||||||
normalized.history_visibility = nextVisibility;
|
|
||||||
|
|
||||||
// cycle_type and cycle_day
|
|
||||||
let nextCycleType = (data.cycle_type !== undefined ? data.cycle_type : existingBill?.cycle_type) || 'monthly';
|
|
||||||
let nextCycleDay = existingBill?.cycle_day || getDefaultCycleDay(nextCycleType);
|
|
||||||
|
|
||||||
if (data.cycle_type !== undefined) {
|
|
||||||
if (!validCycleTypes.includes(data.cycle_type)) {
|
|
||||||
errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` });
|
|
||||||
} else {
|
|
||||||
nextCycleType = data.cycle_type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cycleDayResult = validateCycleDay(nextCycleType, data.cycle_day !== undefined ? data.cycle_day : nextCycleDay);
|
|
||||||
if (cycleDayResult.error) {
|
|
||||||
errors.push({ field: 'cycle_day', message: cycleDayResult.error });
|
|
||||||
} else {
|
|
||||||
nextCycleDay = cycleDayResult.value;
|
|
||||||
}
|
|
||||||
normalized.cycle_type = nextCycleType;
|
|
||||||
normalized.cycle_day = nextCycleDay;
|
|
||||||
|
|
||||||
// Calculate bucket based on due_day
|
|
||||||
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
|
|
||||||
|
|
||||||
// current_balance — outstanding debt balance (nullable)
|
|
||||||
if (data.current_balance !== undefined) {
|
|
||||||
if (data.current_balance === null || data.current_balance === '') {
|
|
||||||
normalized.current_balance = null;
|
|
||||||
} else {
|
|
||||||
const cb = parseFloat(data.current_balance);
|
|
||||||
if (!Number.isFinite(cb) || cb < 0) {
|
|
||||||
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
|
|
||||||
} else {
|
|
||||||
normalized.current_balance = cb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
normalized.current_balance = existingBill?.current_balance ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// minimum_payment — required minimum payment for debt (nullable)
|
|
||||||
if (data.minimum_payment !== undefined) {
|
|
||||||
if (data.minimum_payment === null || data.minimum_payment === '') {
|
|
||||||
normalized.minimum_payment = null;
|
|
||||||
} else {
|
|
||||||
const mp = parseFloat(data.minimum_payment);
|
|
||||||
if (!Number.isFinite(mp) || mp < 0) {
|
|
||||||
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
|
|
||||||
} else {
|
|
||||||
normalized.minimum_payment = mp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
normalized.minimum_payment = existingBill?.minimum_payment ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// snowball_order — drag position on snowball page (nullable integer)
|
|
||||||
if (data.snowball_order !== undefined) {
|
|
||||||
if (data.snowball_order === null || data.snowball_order === '') {
|
|
||||||
normalized.snowball_order = null;
|
|
||||||
} else {
|
|
||||||
const so = parseInt(data.snowball_order, 10);
|
|
||||||
if (!Number.isInteger(so) || so < 0) {
|
|
||||||
errors.push({ field: 'snowball_order', message: 'snowball_order must be a non-negative integer' });
|
|
||||||
} else {
|
|
||||||
normalized.snowball_order = so;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
normalized.snowball_order = existingBill?.snowball_order ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// snowball_include — manual override to force bill onto snowball page
|
|
||||||
normalized.snowball_include = data.snowball_include !== undefined
|
|
||||||
? (data.snowball_include ? 1 : 0)
|
|
||||||
: (existingBill?.snowball_include ?? 0);
|
|
||||||
|
|
||||||
// snowball_exempt — manual override to hide an auto-detected debt-like bill
|
|
||||||
normalized.snowball_exempt = data.snowball_exempt !== undefined
|
|
||||||
? (data.snowball_exempt ? 1 : 0)
|
|
||||||
: (existingBill?.snowball_exempt ?? 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
errors,
|
|
||||||
normalized: {
|
|
||||||
...normalized,
|
|
||||||
name: normalized.name || null,
|
|
||||||
due_day: normalized.due_day || null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates cycle_day for a given cycle_type without requiring the full bill data.
|
|
||||||
*/
|
|
||||||
function validateCycleDayOnly(cycleType, cycleDay) {
|
|
||||||
return validateCycleDay(cycleType, cycleDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes how a payment affects a debt bill's current_balance, accounting for
|
|
||||||
* one month of interest accrual.
|
|
||||||
*
|
|
||||||
* Returns { new_balance, balance_delta } where balance_delta is negative when
|
|
||||||
* the balance was reduced (typical case). Returns null when the bill has no
|
|
||||||
* trackable balance.
|
|
||||||
*/
|
|
||||||
function computeBalanceDelta(bill, paymentAmount) {
|
|
||||||
const bal = Number(bill.current_balance);
|
|
||||||
const rate = Number(bill.interest_rate) || 0;
|
|
||||||
const amt = Number(paymentAmount);
|
|
||||||
|
|
||||||
if (!Number.isFinite(bal) || bal <= 0) return null;
|
|
||||||
if (!Number.isFinite(amt) || amt <= 0) return null;
|
|
||||||
|
|
||||||
const monthlyInterest = bal * (rate / 100 / 12);
|
|
||||||
const raw = bal + monthlyInterest - amt;
|
|
||||||
const newBalance = Math.round(Math.max(0, raw) * 100) / 100;
|
|
||||||
const delta = Math.round((newBalance - bal) * 100) / 100;
|
|
||||||
|
|
||||||
return { new_balance: newBalance, balance_delta: delta };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
VALID_VISIBILITY,
|
|
||||||
getValidCycleTypes,
|
|
||||||
getDefaultCycleDay,
|
|
||||||
validateCycleDay,
|
|
||||||
parseDueDay,
|
|
||||||
parseInterestRate,
|
|
||||||
validateBillData,
|
|
||||||
validateCycleDayOnly,
|
|
||||||
computeBalanceDelta,
|
|
||||||
};
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
/**
|
|
||||||
* Debt payoff calculators — Snowball and Avalanche methods.
|
|
||||||
*
|
|
||||||
* Snowball (Dave Ramsey): smallest balance first — fast psychological wins.
|
|
||||||
* Avalanche (math-optimal): highest interest rate first — minimises total interest.
|
|
||||||
*
|
|
||||||
* Both share the same month-by-month simulation loop; only the initial order differs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Private simulation engine ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function _simulate(orderedDebts, extraPayment, startDate) {
|
|
||||||
const extra = Math.max(0, Number(extraPayment) || 0);
|
|
||||||
|
|
||||||
const active = [];
|
|
||||||
const skipped = [];
|
|
||||||
|
|
||||||
for (const d of orderedDebts) {
|
|
||||||
const bal = Number(d.current_balance);
|
|
||||||
if (d.current_balance == null || !Number.isFinite(bal)) {
|
|
||||||
skipped.push({ id: d.id, name: d.name, reason: 'no_balance' });
|
|
||||||
} else if (bal <= 0) {
|
|
||||||
skipped.push({ id: d.id, name: d.name, reason: 'zero_balance' });
|
|
||||||
} else {
|
|
||||||
active.push({
|
|
||||||
id: d.id,
|
|
||||||
name: d.name,
|
|
||||||
balance: bal,
|
|
||||||
minPayment: Math.max(0, Number(d.minimum_payment) || 0),
|
|
||||||
monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
|
|
||||||
payoffMonth: null,
|
|
||||||
totalInterest: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (active.length === 0) {
|
|
||||||
return {
|
|
||||||
months_to_freedom: null,
|
|
||||||
total_interest_paid: 0,
|
|
||||||
payoff_date: null,
|
|
||||||
payoff_display: null,
|
|
||||||
debts: [],
|
|
||||||
skipped,
|
|
||||||
extra_payment: extra,
|
|
||||||
capped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Month-by-month loop ───────────────────────────────────────────────────
|
|
||||||
const MAX_MONTHS = 600; // 50-year safety cap
|
|
||||||
let rollingExtra = extra;
|
|
||||||
let month = 0;
|
|
||||||
|
|
||||||
while (active.some(d => d.balance > 0) && month < MAX_MONTHS) {
|
|
||||||
month++;
|
|
||||||
|
|
||||||
// Attack target = first debt in the ordered list that still has a balance
|
|
||||||
const targetIdx = active.findIndex(d => d.balance > 0);
|
|
||||||
|
|
||||||
for (let i = 0; i < active.length; i++) {
|
|
||||||
const debt = active[i];
|
|
||||||
if (debt.balance <= 0) continue;
|
|
||||||
|
|
||||||
// Accrue monthly interest
|
|
||||||
const interest = debt.balance * debt.monthlyRate;
|
|
||||||
debt.balance += interest;
|
|
||||||
debt.totalInterest += interest;
|
|
||||||
|
|
||||||
// Attack target gets minimums + full snowball; others get minimums only
|
|
||||||
const payment = Math.min(
|
|
||||||
debt.balance,
|
|
||||||
i === targetIdx ? debt.minPayment + rollingExtra : debt.minPayment,
|
|
||||||
);
|
|
||||||
debt.balance = Math.max(0, debt.balance - payment);
|
|
||||||
if (debt.balance < 0.005) debt.balance = 0; // eliminate floating-point dust
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark any debt that just reached zero (attack target OR paid off naturally by minimums)
|
|
||||||
// and roll its freed minimum into the snowball for next month.
|
|
||||||
for (let i = 0; i < active.length; i++) {
|
|
||||||
const debt = active[i];
|
|
||||||
if (debt.balance === 0 && debt.payoffMonth === null) {
|
|
||||||
debt.payoffMonth = month;
|
|
||||||
rollingExtra += debt.minPayment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Format results ────────────────────────────────────────────────────────
|
|
||||||
const baseYear = startDate.getFullYear();
|
|
||||||
const baseMo = startDate.getMonth();
|
|
||||||
|
|
||||||
function monthLabel(m) {
|
|
||||||
const d = new Date(baseYear, baseMo + m, 1);
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function monthDisplay(m) {
|
|
||||||
const d = new Date(baseYear, baseMo + m, 1);
|
|
||||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const debtResults = active.map(d => ({
|
|
||||||
id: d.id,
|
|
||||||
name: d.name,
|
|
||||||
payoff_month: d.payoffMonth,
|
|
||||||
payoff_date: d.payoffMonth ? monthLabel(d.payoffMonth) : null,
|
|
||||||
payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null,
|
|
||||||
total_interest: round2(d.totalInterest),
|
|
||||||
months: d.payoffMonth,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0));
|
|
||||||
const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
months_to_freedom: maxMonth || null,
|
|
||||||
total_interest_paid: round2(totalInterest),
|
|
||||||
payoff_date: maxMonth ? monthLabel(maxMonth) : null,
|
|
||||||
payoff_display: maxMonth ? monthDisplay(maxMonth) : null,
|
|
||||||
debts: debtResults,
|
|
||||||
skipped,
|
|
||||||
extra_payment: extra,
|
|
||||||
capped: month >= MAX_MONTHS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snowball: attack the smallest balance first (fast wins, motivational).
|
|
||||||
* Debts must already be in snowball order (sorted by current_balance ASC by the caller).
|
|
||||||
*/
|
|
||||||
function calculateSnowball(debts, extraPayment = 0, startDate = new Date()) {
|
|
||||||
return _simulate(debts, extraPayment, startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Avalanche: attack the highest interest rate first (minimises total interest paid).
|
|
||||||
* Re-sorts debts internally — caller does not need to pre-sort.
|
|
||||||
*/
|
|
||||||
function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) {
|
|
||||||
const sorted = [...debts].sort((a, b) => {
|
|
||||||
const ra = Number(a.interest_rate) || 0;
|
|
||||||
const rb = Number(b.interest_rate) || 0;
|
|
||||||
if (rb !== ra) return rb - ra; // highest rate first
|
|
||||||
// Tiebreak: smallest balance (clears fastest, rolling the payment sooner)
|
|
||||||
return (Number(a.current_balance) || 0) - (Number(b.current_balance) || 0);
|
|
||||||
});
|
|
||||||
return _simulate(sorted, extraPayment, startDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function round2(n) {
|
|
||||||
return Math.round(n * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { calculateSnowball, calculateAvalanche };
|
|
||||||
|
|
@ -29,9 +29,9 @@ const LABEL_PATTERNS = {
|
||||||
|
|
||||||
const HEADER_PATTERNS = {
|
const HEADER_PATTERNS = {
|
||||||
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
|
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
|
||||||
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/i,
|
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|paid|value)$/i,
|
||||||
due_date: /^(?:due\s*date|due|due\s*day)$/i,
|
due_date: /^(?:due\s*date|due|due\s*day)$/i,
|
||||||
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date)$/i,
|
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date|date\s*cleared|cleared\s*date)$/i,
|
||||||
date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i,
|
date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i,
|
||||||
category: /^(?:category|cat|type|group)$/i,
|
category: /^(?:category|cat|type|group)$/i,
|
||||||
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
|
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
|
||||||
|
|
@ -206,9 +206,9 @@ function parseXlsxBuffer(buffer) {
|
||||||
if (!cell) continue;
|
if (!cell) continue;
|
||||||
|
|
||||||
// Strict cell type validation
|
// Strict cell type validation
|
||||||
// Only allow n (number), t (text/string), b (boolean), d (date), s (shared formula)
|
// Only allow n (number), t (text/string), b (boolean), d (date)
|
||||||
// Reject array (a), error (e), formula (f)
|
// Reject array (a), error (e), formula (f), shared formula (s)
|
||||||
if (cell.t && !['n', 't', 'b', 'd', 's'].includes(cell.t)) {
|
if (cell.t && !['n', 't', 'b', 'd'].includes(cell.t)) {
|
||||||
const err = new Error(`Invalid cell type '${cell.t}' found in ${cellRef}. Only numbers and text are supported.`);
|
const err = new Error(`Invalid cell type '${cell.t}' found in ${cellRef}. Only numbers and text are supported.`);
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
|
|
@ -233,13 +233,8 @@ function parseXlsxBuffer(buffer) {
|
||||||
function getSheetRows(workbook, sheetName) {
|
function getSheetRows(workbook, sheetName) {
|
||||||
const sheet = workbook.Sheets[sheetName];
|
const sheet = workbook.Sheets[sheetName];
|
||||||
if (!sheet) return [];
|
if (!sheet) return [];
|
||||||
try {
|
// raw:false → formatted string values; no formula results can leak through
|
||||||
// raw:false → formatted string values; no formula results can leak through
|
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
|
||||||
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[import] sheet="${sheetName}" failed to parse rows — skipping:`, err.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Header Detection ─────────────────────────────────────────────────────────
|
// ─── Header Detection ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -257,120 +252,12 @@ function detectHeaders(firstRow) {
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dual-Column Header Detection ──────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Detect all header sets in a row, handling dual-column layouts.
|
|
||||||
* When a single row contains TWO sets of bill headers (e.g., columns A-E and G-K),
|
|
||||||
* this function returns an array of header groups, each with its own column range.
|
|
||||||
*
|
|
||||||
* Each group has: startCol, endCol, map, defaultDueDay (1 or 15)
|
|
||||||
*/
|
|
||||||
function detectAllHeaderSets(firstRow) {
|
|
||||||
if (!Array.isArray(firstRow)) return [];
|
|
||||||
|
|
||||||
// First, detect header cells and their column indices
|
|
||||||
const headerCells = [];
|
|
||||||
firstRow.forEach((cell, idx) => {
|
|
||||||
if (cell == null) return;
|
|
||||||
const val = String(cell).trim();
|
|
||||||
if (!val) return;
|
|
||||||
for (const field of Object.keys(HEADER_PATTERNS)) {
|
|
||||||
if (HEADER_PATTERNS[field].test(val)) {
|
|
||||||
headerCells.push({ idx, field, val });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (headerCells.length === 0) return [];
|
|
||||||
|
|
||||||
// Group header cells into sets by detecting when a field repeats.
|
|
||||||
// When we see the same field name again (e.g., second "Bill", second "Amount"),
|
|
||||||
// that indicates the start of a new header group (dual-column layout).
|
|
||||||
// Null columns between fields within a group are just empty columns — they
|
|
||||||
// don't split the group (left half has: Due date | Bill | Amount | null | Date Cleared).
|
|
||||||
const seenFields = new Set();
|
|
||||||
const groups = [];
|
|
||||||
let currentGroup = { cells: [headerCells[0]] };
|
|
||||||
seenFields.add(headerCells[0].field);
|
|
||||||
|
|
||||||
for (let i = 1; i < headerCells.length; i++) {
|
|
||||||
const cell = headerCells[i];
|
|
||||||
|
|
||||||
// Start a new group if this field was already seen (repeat = new column set)
|
|
||||||
// or if there's a large column gap (>3 empty columns) between this and previous
|
|
||||||
const prevCell = headerCells[i - 1];
|
|
||||||
const colGap = cell.idx - prevCell.idx;
|
|
||||||
const isRepeatField = seenFields.has(cell.field);
|
|
||||||
const isLargeGap = colGap > 3;
|
|
||||||
|
|
||||||
if (isRepeatField || isLargeGap) {
|
|
||||||
groups.push(currentGroup);
|
|
||||||
currentGroup = { cells: [cell] };
|
|
||||||
seenFields.clear();
|
|
||||||
seenFields.add(cell.field);
|
|
||||||
} else {
|
|
||||||
currentGroup.cells.push(cell);
|
|
||||||
seenFields.add(cell.field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups.push(currentGroup);
|
|
||||||
|
|
||||||
// Convert groups to return format with header maps and default due days
|
|
||||||
const result = [];
|
|
||||||
for (const group of groups) {
|
|
||||||
const map = {};
|
|
||||||
group.cells.forEach(h => map[h.field] = h.idx);
|
|
||||||
|
|
||||||
const startCol = group.cells[0].idx;
|
|
||||||
const endCol = group.cells[group.cells.length - 1].idx;
|
|
||||||
const defaultDueDay = startCol < 5 ? 1 : 15;
|
|
||||||
|
|
||||||
// Require at least 2 header fields (bill_name + amount, or similar) to count as a real header set.
|
|
||||||
// This filters out spurious rows like "Left Over | $3,204.20 | Paid" where
|
|
||||||
// "Paid" alone matches the amount pattern but isn't a real column header.
|
|
||||||
if (Object.keys(map).length >= 2) {
|
|
||||||
result.push({ startCol, endCol, map, defaultDueDay });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Row Classification ───────────────────────────────────────────────────────
|
// ─── Row Classification ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isBlankRow(cells) {
|
function isBlankRow(cells) {
|
||||||
return cells.every((c) => c == null || String(c).trim() === '');
|
return cells.every((c) => c == null || String(c).trim() === '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a row is blank for a specific header set's columns.
|
|
||||||
* For dual-column layouts, a row may be blank on the left but have data on the right.
|
|
||||||
* Uses absolute column indices from the header set map.
|
|
||||||
*/
|
|
||||||
function isBlankRowForHeaderSet(cells, headerSet) {
|
|
||||||
const { map } = headerSet;
|
|
||||||
|
|
||||||
// Check the bill_name column and amount column for this header set
|
|
||||||
const billNameIdx = map.bill_name;
|
|
||||||
const amountIdx = map.amount;
|
|
||||||
|
|
||||||
// If we can't find bill_name or amount columns, fall back to full-row blank check
|
|
||||||
if (billNameIdx === undefined && amountIdx === undefined) {
|
|
||||||
return isBlankRow(cells);
|
|
||||||
}
|
|
||||||
|
|
||||||
const billNameCell = billNameIdx !== undefined ? cells[billNameIdx] : undefined;
|
|
||||||
const amountCell = amountIdx !== undefined ? cells[amountIdx] : undefined;
|
|
||||||
|
|
||||||
const billNameBlank = billNameCell == null || String(billNameCell).trim() === '';
|
|
||||||
const amountBlank = amountCell == null || String(amountCell).trim() === '' || parseAmount(amountCell) === null;
|
|
||||||
|
|
||||||
// If both bill name and amount are blank, this row is empty for this set
|
|
||||||
return billNameBlank && amountBlank;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyHeaderRow(cells) {
|
function isLikelyHeaderRow(cells) {
|
||||||
const nonEmpty = cells.filter((c) => c != null && String(c).trim() !== '');
|
const nonEmpty = cells.filter((c) => c != null && String(c).trim() !== '');
|
||||||
if (nonEmpty.length === 0) return false;
|
if (nonEmpty.length === 0) return false;
|
||||||
|
|
@ -385,17 +272,7 @@ function isLikelyHeaderRow(cells) {
|
||||||
|
|
||||||
function isLikelyTotalRow(cells) {
|
function isLikelyTotalRow(cells) {
|
||||||
return cells.some(
|
return cells.some(
|
||||||
(c) => c != null && /^(?:total|subtotal|sum|grand\s*total|.*total\s*-+>|auto\s+total)/i.test(String(c).trim()),
|
(c) => c != null && /^(?:total|subtotal|sum|grand\s*total)$/i.test(String(c).trim()),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect rows that are financial summaries, not bill entries.
|
|
||||||
* Catches "Paycheck", "Left Over", "Enter how much...", etc.
|
|
||||||
*/
|
|
||||||
function isLikelySummaryRow(cells) {
|
|
||||||
return cells.some(
|
|
||||||
(c) => c != null && /^(?:paycheck|left\s*over|enter\s+how\s+much|starting\s+balance|ending\s+balance|carry\s*over|carried\s*over|balance\s+(?:forward|carried)|bank\s+balance)/i.test(String(c).trim()),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,7 +499,6 @@ function buildRecommendation({
|
||||||
billName,
|
billName,
|
||||||
detectedAmount,
|
detectedAmount,
|
||||||
parsedDate,
|
parsedDate,
|
||||||
parsedPaidDate,
|
|
||||||
dateHeader,
|
dateHeader,
|
||||||
detectedCategory,
|
detectedCategory,
|
||||||
notesText,
|
notesText,
|
||||||
|
|
@ -631,7 +507,6 @@ function buildRecommendation({
|
||||||
warnings,
|
warnings,
|
||||||
errors,
|
errors,
|
||||||
paymentDateIso,
|
paymentDateIso,
|
||||||
defaultDueDay = null,
|
|
||||||
}) {
|
}) {
|
||||||
const recWarnings = [...warnings];
|
const recWarnings = [...warnings];
|
||||||
const topMatch = possibleMatches[0] || null;
|
const topMatch = possibleMatches[0] || null;
|
||||||
|
|
@ -639,15 +514,7 @@ function buildRecommendation({
|
||||||
const mediumMatches = possibleMatches.filter((m) => m.match_confidence === 'medium');
|
const mediumMatches = possibleMatches.filter((m) => m.match_confidence === 'medium');
|
||||||
|
|
||||||
const dateDay = parsedDate?.day;
|
const dateDay = parsedDate?.day;
|
||||||
let dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
|
const dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
|
||||||
// Fall back to the paid-date column's day (e.g. column D), then to defaultDueDay
|
|
||||||
if (dueDay === null) {
|
|
||||||
const paidDay = parsedPaidDate?.day;
|
|
||||||
if (Number.isInteger(paidDay) && paidDay >= 1 && paidDay <= 31) dueDay = paidDay;
|
|
||||||
}
|
|
||||||
if (dueDay === null && defaultDueDay !== null) {
|
|
||||||
dueDay = defaultDueDay;
|
|
||||||
}
|
|
||||||
const paymentDate = isPaymentDateHeader(dateHeader);
|
const paymentDate = isPaymentDateHeader(dateHeader);
|
||||||
if (dueDay && paymentDate && !isDueDateHeader(dateHeader)) {
|
if (dueDay && paymentDate && !isDueDateHeader(dateHeader)) {
|
||||||
recWarnings.push('Date appears to be a payment date, not a due date');
|
recWarnings.push('Date appears to be a payment date, not a due date');
|
||||||
|
|
@ -781,11 +648,8 @@ function findFirstAmountCell(cells, skipIndices) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null) {
|
function collectNotesCells(cells, headerMap, billName) {
|
||||||
const skipIndices = new Set(Object.values(headerMap));
|
const skipIndices = new Set(Object.values(headerMap));
|
||||||
if (allHeaderColumns) {
|
|
||||||
for (const idx of allHeaderColumns) skipIndices.add(idx);
|
|
||||||
}
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (let i = 0; i < cells.length; i++) {
|
||||||
if (skipIndices.has(i) || cells[i] == null) continue;
|
if (skipIndices.has(i) || cells[i] == null) continue;
|
||||||
|
|
@ -802,7 +666,7 @@ function collectNotesCells(cells, headerMap, billName, allHeaderColumns = null)
|
||||||
|
|
||||||
// ─── Single-Row Analyzer ──────────────────────────────────────────────────────
|
// ─── Single-Row Analyzer ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categories, sheetName, sheetYear, sheetMonth, defaultYear, defaultMonth, rowIdPrefix, defaultDueDay = null, headerSetIndex = null, allHeaderColumns = null) {
|
function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categories, sheetName, sheetYear, sheetMonth, defaultYear, defaultMonth, rowIdPrefix) {
|
||||||
const get = (field) => {
|
const get = (field) => {
|
||||||
const idx = headerMap[field];
|
const idx = headerMap[field];
|
||||||
return idx !== undefined ? cells[idx] : undefined;
|
return idx !== undefined ? cells[idx] : undefined;
|
||||||
|
|
@ -811,12 +675,7 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
const rawBillName = get('bill_name') ?? cells[0];
|
const rawBillName = get('bill_name') ?? cells[0];
|
||||||
const billName = rawBillName ? String(rawBillName).trim() || null : null;
|
const billName = rawBillName ? String(rawBillName).trim() || null : null;
|
||||||
|
|
||||||
// Skip indices: own header columns + all other header sets' columns (for dual-column layouts)
|
|
||||||
// This prevents fallback lookups from picking up values from the other column group.
|
|
||||||
const skipIndices = new Set(Object.values(headerMap));
|
const skipIndices = new Set(Object.values(headerMap));
|
||||||
if (allHeaderColumns) {
|
|
||||||
for (const idx of allHeaderColumns) skipIndices.add(idx);
|
|
||||||
}
|
|
||||||
const rawAmount = get('amount') ?? findFirstAmountCell(cells, skipIndices);
|
const rawAmount = get('amount') ?? findFirstAmountCell(cells, skipIndices);
|
||||||
const detectedAmount = parseAmount(rawAmount);
|
const detectedAmount = parseAmount(rawAmount);
|
||||||
|
|
||||||
|
|
@ -839,7 +698,7 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
const detectedPaidDate = resolveDateIso(parsedPaidDate, paidDateYear);
|
const detectedPaidDate = resolveDateIso(parsedPaidDate, paidDateYear);
|
||||||
const rawCategory = get('category');
|
const rawCategory = get('category');
|
||||||
const detectedCategory = rawCategory ? String(rawCategory).trim() || null : null;
|
const detectedCategory = rawCategory ? String(rawCategory).trim() || null : null;
|
||||||
const notesText = collectNotesCells(cells, headerMap, billName, allHeaderColumns);
|
const notesText = collectNotesCells(cells, headerMap, billName);
|
||||||
const allText = cells.filter((c) => c != null && typeof c === 'string').map((c) => c.trim()).join(' ');
|
const allText = cells.filter((c) => c != null && typeof c === 'string').map((c) => c.trim()).join(' ');
|
||||||
const detectedLabels = detectLabels(allText);
|
const detectedLabels = detectLabels(allText);
|
||||||
const rawValues = cells.map((c) => (c != null ? String(c) : null));
|
const rawValues = cells.map((c) => (c != null ? String(c) : null));
|
||||||
|
|
@ -849,32 +708,11 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
if (!billName) errors.push('No bill name detected');
|
if (!billName) errors.push('No bill name detected');
|
||||||
if (detectedAmount === null) warnings.push('No amount detected');
|
if (detectedAmount === null) warnings.push('No amount detected');
|
||||||
|
|
||||||
// ── Diagnostic logging for auto-detected patterns ──────────────────────────
|
|
||||||
const _rawDue = get('due_date') != null ? String(get('due_date')).trim() : '';
|
|
||||||
const _rawPaid = get('paid_date') != null ? String(get('paid_date')).trim() : '';
|
|
||||||
const _loc = `sheet="${sheetName}" row=${rowIndex + 1}${billName ? ` bill="${billName}"` : ''}`;
|
|
||||||
|
|
||||||
if (detectedLabels.includes('autopay') && billName) {
|
|
||||||
if (_rawDue && /auto/i.test(_rawDue) && /\d/.test(_rawDue)) {
|
|
||||||
console.log(`[import] ${_loc} autopay+date in due col: "${_rawDue}" (date portion not extracted)`);
|
|
||||||
} else {
|
|
||||||
console.log(`[import] ${_loc} autopay detected`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (detectedLabels.includes('past_due')) {
|
|
||||||
console.log(`[import] ${_loc} PAST DUE detected`);
|
|
||||||
}
|
|
||||||
if (_rawPaid && !parsedPaidDate) {
|
|
||||||
console.log(`[import] ${_loc} unparseable paid date: "${_rawPaid}"`);
|
|
||||||
}
|
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const possibleMatches = billName ? findBillMatches(billName, userBills) : [];
|
const possibleMatches = billName ? findBillMatches(billName, userBills) : [];
|
||||||
const recommendation = buildRecommendation({
|
const recommendation = buildRecommendation({
|
||||||
billName,
|
billName,
|
||||||
detectedAmount,
|
detectedAmount,
|
||||||
parsedDate,
|
parsedDate,
|
||||||
parsedPaidDate,
|
|
||||||
dateHeader,
|
dateHeader,
|
||||||
detectedCategory,
|
detectedCategory,
|
||||||
notesText,
|
notesText,
|
||||||
|
|
@ -883,7 +721,6 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
warnings,
|
warnings,
|
||||||
errors,
|
errors,
|
||||||
paymentDateIso: detectedPaidDate,
|
paymentDateIso: detectedPaidDate,
|
||||||
defaultDueDay,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const proposedAction = recommendation.action === 'ambiguous' ? 'mark_ambiguous' : recommendation.action;
|
const proposedAction = recommendation.action === 'ambiguous' ? 'mark_ambiguous' : recommendation.action;
|
||||||
|
|
@ -914,8 +751,6 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
errors,
|
errors,
|
||||||
possible_bill_matches: possibleMatches,
|
possible_bill_matches: possibleMatches,
|
||||||
requires_user_decision: requiresUserDecision,
|
requires_user_decision: requiresUserDecision,
|
||||||
due_day: recommendation.due_day,
|
|
||||||
header_set_index: headerSetIndex,
|
|
||||||
recommendation,
|
recommendation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -929,135 +764,29 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, rowIdPrefix }, userBills, categories, defaultYear, defaultMonth) {
|
function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, rowIdPrefix }, userBills, categories, defaultYear, defaultMonth) {
|
||||||
if (!rawRows.length) return { rows: [], headerRow: null };
|
if (!rawRows.length) return { rows: [], headerRow: null };
|
||||||
|
|
||||||
// Detect all header sets in each row to handle dual-column layouts
|
const firstRow = rawRows[0] || [];
|
||||||
let headerRowIndex = 0;
|
const headerMap = detectHeaders(firstRow);
|
||||||
let headerLabels = rawRows[0]?.map((c) => (c != null ? String(c).trim() : null)) || [];
|
const headerLabels = firstRow.map((c) => (c != null ? String(c).trim() : null));
|
||||||
|
const hasHeaders = Object.keys(headerMap).length > 0;
|
||||||
// First try to detect headers in row 0
|
const startRow = hasHeaders ? 1 : 0;
|
||||||
let allHeaderSets = detectAllHeaderSets(rawRows[0]);
|
|
||||||
|
|
||||||
// If no headers in row 0, scan up to 5 rows
|
|
||||||
for (let scanIdx = 1; scanIdx < Math.min(5, rawRows.length); scanIdx++) {
|
|
||||||
const candidateSets = detectAllHeaderSets(rawRows[scanIdx]);
|
|
||||||
if (candidateSets.length > 0) {
|
|
||||||
headerRowIndex = scanIdx;
|
|
||||||
headerLabels = rawRows[scanIdx].map((c) => (c != null ? String(c).trim() : null));
|
|
||||||
allHeaderSets = candidateSets;
|
|
||||||
// Check if this set has all required fields
|
|
||||||
let hasAllRequired = false;
|
|
||||||
for (const set of allHeaderSets) {
|
|
||||||
if (set.map.due_date !== undefined && set.map.bill_name !== undefined && set.map.amount !== undefined) {
|
|
||||||
hasAllRequired = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasAllRequired) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have valid headers (must have due_date, bill_name, amount)
|
|
||||||
let hasValidHeaders = false;
|
|
||||||
for (const set of allHeaderSets) {
|
|
||||||
if (set.map.due_date !== undefined && set.map.bill_name !== undefined && set.map.amount !== undefined) {
|
|
||||||
hasValidHeaders = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHeaders = hasValidHeaders;
|
|
||||||
const startRow = hasHeaders ? headerRowIndex + 1 : 0;
|
|
||||||
|
|
||||||
// Log detected layout for this sheet
|
|
||||||
const _colLetter = (i) => String.fromCharCode(65 + i);
|
|
||||||
if (!hasHeaders) {
|
|
||||||
console.log(`[import] sheet="${name}" no valid headers detected — sheet will be skipped`);
|
|
||||||
} else {
|
|
||||||
for (const [si, set] of allHeaderSets.entries()) {
|
|
||||||
const mapped = Object.entries(set.map).map(([f, i]) => `${f}:${_colLetter(i)}`).join(', ');
|
|
||||||
console.log(`[import] sheet="${name}" group=${si} defaultDueDay=${set.defaultDueDay} columns={${mapped}}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For dual-column layouts, collect ALL column indices across all header sets
|
|
||||||
// so that fallback lookups (findFirstAmountCell, collectNotesCells) don't
|
|
||||||
// accidentally pick up values from the other column set.
|
|
||||||
// This includes the full range [startCol..endCol] for each set, not just
|
|
||||||
// the mapped columns, because gap columns within a set also belong to that side.
|
|
||||||
const allColumnsIndices = new Set();
|
|
||||||
for (const set of allHeaderSets) {
|
|
||||||
for (const idx of Object.values(set.map)) {
|
|
||||||
allColumnsIndices.add(idx);
|
|
||||||
}
|
|
||||||
for (let i = set.startCol; i <= set.endCol; i++) {
|
|
||||||
allColumnsIndices.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
for (let i = startRow; i < rawRows.length; i++) {
|
||||||
// Process each header set independently
|
const cells = rawRows[i] || [];
|
||||||
for (let setIdx = 0; setIdx < allHeaderSets.length; setIdx++) {
|
if (isBlankRow(cells)) continue;
|
||||||
const headerSet = allHeaderSets[setIdx];
|
if (isLikelyHeaderRow(cells) && i > 0) continue;
|
||||||
const headerMap = headerSet.map;
|
if (isLikelyTotalRow(cells)) continue;
|
||||||
const defaultDueDay = headerSet.defaultDueDay;
|
|
||||||
|
|
||||||
for (let i = startRow; i < rawRows.length; i++) {
|
|
||||||
const cells = rawRows[i] || [];
|
|
||||||
|
|
||||||
// For dual-column: skip rows blank in this header set's columns only
|
|
||||||
// For single-column: fall back to regular isBlankRow
|
|
||||||
if (allHeaderSets.length > 1 ? isBlankRowForHeaderSet(cells, headerSet) : isBlankRow(cells)) continue;
|
|
||||||
|
|
||||||
// Skip duplicate header rows (but only if we found headers)
|
|
||||||
if (hasHeaders && isLikelyHeaderRow(cells) && i > headerRowIndex) continue;
|
|
||||||
|
|
||||||
// Skip total rows
|
|
||||||
if (isLikelyTotalRow(cells)) continue;
|
|
||||||
|
|
||||||
// Skip financial summary rows (Paycheck, Left Over, etc.)
|
|
||||||
if (isLikelySummaryRow(cells)) continue;
|
|
||||||
|
|
||||||
// Skip leftover calculation rows: null/blank bill name with negative amount, or dash separators
|
|
||||||
const getBillName = (field) => {
|
|
||||||
const idx = headerMap[field];
|
|
||||||
return idx !== undefined ? cells[idx] : undefined;
|
|
||||||
};
|
|
||||||
const get = (field) => {
|
|
||||||
const idx = headerMap[field];
|
|
||||||
return idx !== undefined ? cells[idx] : undefined;
|
|
||||||
};
|
|
||||||
const rawBillName = getBillName('bill_name') ?? cells[0];
|
|
||||||
const billName = rawBillName ? String(rawBillName).trim() || null : null;
|
|
||||||
const rawAmount = get('amount') ?? findFirstAmountCell(cells, new Set(Object.values(headerMap)));
|
|
||||||
const amount = rawAmount !== null ? parseAmount(rawAmount) : null;
|
|
||||||
|
|
||||||
// Check if bill name is a dash separator (--- or ---->)
|
|
||||||
const isDashSeparator = billName && (billName.match(/^-+>/) || billName.match(/^--+$/));
|
|
||||||
|
|
||||||
// Check if this is a leftover calculation row (null/blank bill name + negative amount)
|
|
||||||
// Skip if bill name is null AND amount is negative
|
|
||||||
const isLeftoverCalcRow = !billName && amount !== null && amount < 0;
|
|
||||||
|
|
||||||
if (isDashSeparator || isLeftoverCalcRow) continue;
|
|
||||||
|
|
||||||
try {
|
rows.push(analyzeRow(
|
||||||
rows.push(analyzeRow(
|
i, cells, headerMap, headerLabels, userBills, categories,
|
||||||
i, cells, headerMap, headerLabels, userBills, categories,
|
name, sheetYear, sheetMonth,
|
||||||
name, sheetYear, sheetMonth,
|
defaultYear, defaultMonth, rowIdPrefix,
|
||||||
defaultYear, defaultMonth, rowIdPrefix,
|
));
|
||||||
defaultDueDay, setIdx, allColumnsIndices,
|
|
||||||
));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[import] sheet="${name}" row=${i + 1} failed to analyze — skipping:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
headerRow: hasHeaders ? headerLabels : null,
|
headerRow: hasHeaders ? firstRow.map((c) => (c != null ? String(c) : null)) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1107,9 +836,7 @@ function pruneExpiredSessions(db) {
|
||||||
|
|
||||||
async function previewSpreadsheet(userId, buffer, options = {}) {
|
async function previewSpreadsheet(userId, buffer, options = {}) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
try { pruneExpiredSessions(db); } catch (err) {
|
pruneExpiredSessions(db);
|
||||||
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
|
|
||||||
}
|
|
||||||
ensureUserDefaultCategories(userId);
|
ensureUserDefaultCategories(userId);
|
||||||
|
|
||||||
const workbook = parseXlsxBuffer(buffer);
|
const workbook = parseXlsxBuffer(buffer);
|
||||||
|
|
@ -1505,12 +1232,11 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
|
|
||||||
const dueDay = decision.due_day ?? 1;
|
const dueDay = decision.due_day ?? 1;
|
||||||
const expectedAmount = decision.expected_amount ?? amount ?? 0;
|
const expectedAmount = decision.expected_amount ?? amount ?? 0;
|
||||||
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
|
|
||||||
|
|
||||||
const ins = db.prepare(`
|
const ins = db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active)
|
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1)
|
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
|
||||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay);
|
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
|
||||||
|
|
||||||
const newBillId = ins.lastInsertRowid;
|
const newBillId = ins.lastInsertRowid;
|
||||||
summary.created++;
|
summary.created++;
|
||||||
|
|
@ -1549,14 +1275,9 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
|
|
||||||
} else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) {
|
} else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) {
|
||||||
const billId = decision.bill_id;
|
const billId = decision.bill_id;
|
||||||
const bill = db.prepare('SELECT id, name, autopay_enabled FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||||
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
||||||
|
|
||||||
if (!bill.autopay_enabled && previewRow?.detected_labels?.includes('autopay')) {
|
|
||||||
db.prepare(`UPDATE bills SET autopay_enabled = 1, updated_at = datetime('now') WHERE id = ?`).run(billId);
|
|
||||||
console.log(`[import] bill id=${billId} "${bill.name}" autopay_enabled upgraded to 1`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!year || !month) {
|
if (!year || !month) {
|
||||||
summary.ambiguous++;
|
summary.ambiguous++;
|
||||||
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });
|
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });
|
||||||
|
|
@ -1718,7 +1439,6 @@ function getImportHistory(userId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
detectAllHeaderSets,
|
|
||||||
previewSpreadsheet,
|
previewSpreadsheet,
|
||||||
applyImportDecisions,
|
applyImportDecisions,
|
||||||
getImportHistory,
|
getImportHistory,
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,7 @@ function getCycleRange(year, month) {
|
||||||
* Returns status for a bill given its payments and due date.
|
* Returns status for a bill given its payments and due date.
|
||||||
*
|
*
|
||||||
* Statuses:
|
* Statuses:
|
||||||
* paid — has a non-deleted payment in this billing cycle
|
* paid — total payments >= expected_amount
|
||||||
* — OR total paid >= expected_amount (fully settled)
|
|
||||||
* autodraft — autopay_enabled and assumed_paid (no confirmed payment yet)
|
* autodraft — autopay_enabled and assumed_paid (no confirmed payment yet)
|
||||||
* upcoming — due_date in the future
|
* upcoming — due_date in the future
|
||||||
* due_soon — due within 3 days
|
* due_soon — due within 3 days
|
||||||
|
|
@ -44,13 +43,10 @@ function getCycleRange(year, month) {
|
||||||
*/
|
*/
|
||||||
function calculateStatus(bill, payments, dueDate, today) {
|
function calculateStatus(bill, payments, dueDate, today) {
|
||||||
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
|
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
|
||||||
const safePayments = Array.isArray(payments) ? payments : [];
|
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
|
const isPaid = totalPaid >= bill.expected_amount;
|
||||||
|
|
||||||
// A recorded payment is the user's confirmation that this cycle is handled.
|
if (isPaid) return 'paid';
|
||||||
// Expected amounts are estimates, so a lower actual payment must not leave a Pay
|
|
||||||
// button visible and invite duplicate payments.
|
|
||||||
if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid';
|
|
||||||
|
|
||||||
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
|
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
|
||||||
return 'autodraft';
|
return 'autodraft';
|
||||||
|
|
@ -72,15 +68,10 @@ function calculateStatus(bill, payments, dueDate, today) {
|
||||||
function buildTrackerRow(bill, payments, year, month, todayStr) {
|
function buildTrackerRow(bill, payments, year, month, todayStr) {
|
||||||
const dueDate = resolveDueDate(bill, year, month);
|
const dueDate = resolveDueDate(bill, year, month);
|
||||||
const bucket = resolveBucket(bill);
|
const bucket = resolveBucket(bill);
|
||||||
const safePayments = Array.isArray(payments) ? payments : [];
|
const status = calculateStatus(bill, payments, dueDate, todayStr);
|
||||||
const status = calculateStatus(bill, safePayments, dueDate, todayStr);
|
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
|
const lastPayment = payments.length
|
||||||
const hasPayment = safePayments.length > 0;
|
? payments.sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
|
||||||
const isSettled = status === 'paid' || status === 'autodraft';
|
|
||||||
const rawBalance = bill.expected_amount - totalPaid;
|
|
||||||
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
|
|
||||||
const lastPayment = hasPayment
|
|
||||||
? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -94,16 +85,14 @@ function buildTrackerRow(bill, payments, year, month, todayStr) {
|
||||||
expected_amount: bill.expected_amount,
|
expected_amount: bill.expected_amount,
|
||||||
notes: bill.notes || null, // Bill-level notes (always available)
|
notes: bill.notes || null, // Bill-level notes (always available)
|
||||||
total_paid: totalPaid,
|
total_paid: totalPaid,
|
||||||
balance,
|
balance: bill.expected_amount - totalPaid,
|
||||||
has_payment: hasPayment,
|
|
||||||
is_settled: isSettled,
|
|
||||||
last_paid_date: lastPayment ? lastPayment.paid_date : null,
|
last_paid_date: lastPayment ? lastPayment.paid_date : null,
|
||||||
last_payment_amount: lastPayment ? lastPayment.amount : null,
|
last_payment_amount: lastPayment ? lastPayment.amount : null,
|
||||||
status,
|
status,
|
||||||
autopay_enabled: !!bill.autopay_enabled,
|
autopay_enabled: !!bill.autopay_enabled,
|
||||||
autodraft_status: bill.autodraft_status,
|
autodraft_status: bill.autodraft_status,
|
||||||
billing_cycle: bill.billing_cycle,
|
billing_cycle: bill.billing_cycle,
|
||||||
payments: safePayments,
|
payments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,10 @@ module.exports = {
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
||||||
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
||||||
'collapsible-down': { from: { height: '0' }, to: { height: 'var(--radix-collapsible-content-height)' } },
|
|
||||||
'collapsible-up': { from: { height: 'var(--radix-collapsible-content-height)' }, to: { height: '0' } },
|
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
'collapsible-down': 'collapsible-down 0.2s ease-out',
|
|
||||||
'collapsible-up': 'collapsible-up 0.2s ease-out',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue