v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed

- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
  lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
  previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
  admin endpoints gated, path traversal mitigated, XSS safe)
This commit is contained in:
null 2026-05-11 21:42:36 -05:00
parent 98ede20cd3
commit 2ce5328fd2
19 changed files with 1803 additions and 580 deletions

View File

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

View File

@ -1442,3 +1442,55 @@ Rows with an existing payment below the estimated expected amount could still sh
- `docker compose build` passed. - `docker compose build` passed.
--- ---
---
**Last Updated:** 2026-05-11 21:36 CDT
### v0.25.0 — Roadmap Redesign + Import CSRF Fix
**Date:** 2026-05-11 21:36 CDT
**Coordinator:** Bishop
**Agents:** Bishop (subagent verification)
**Status:** ✅ COMPLETED
**Issue:**
RoadmapPage redesign required AdminDashboard replacement, and import functions needed CSRF token fix to resolve "session expired" errors during XLSX/SQLite/backup imports.
**Files modified:**
- `client/pages/RoadmapPage.jsx` — New kanban-style roadmap with collapsible priority lanes
- `client/pages/AdminDashboard.jsx` — Deleted (replaced by RoadmapPage)
- `routes/aboutAdmin.js` — Added `/api/about/roadmap` and `/api/about/dev-log` endpoints
- `client/api.js` — Added `x-csrf-token: getCsrfToken()` header to import functions
- `client/lib/version.js` — Version bumped to 0.25.0, RELEASE_NOTES updated
- `package.json` — Version bumped to 0.25.0, added @radix-ui/react-collapsible dependency
**Changes:**
- RoadmapPage: Kanban-style priority lanes with shadcn Collapsible + Tabs
- RoadmapPage: Admin-only roadmap and activity log with lazy-loaded activity feed
- API: Added `/api/about/roadmap` and `/api/about/dev-log` endpoints (admin-only)
- CSRF: Import functions (`importAdminBackup`, `previewSpreadsheetImport`, `previewUserDbImport`) now include CSRF token header
- Dependencies: Added @radix-ui/react-collapsible for collapsible UI components
**Verification:**
- Docker build passed: `docker build -t bill-tracker:local .` completed successfully
- Container started with all 46 migrations applied
- Login works: admin/admin123 ✅
- RoadmapPage loads correctly at admin menu → Roadmap ✅
- TrackerPage still functional (basic navigation verified) ✅
- Import CSRF header present in fetch calls ✅
**API Endpoints Added:**
- `GET /api/about/roadmap` — Admin-only, returns roadmap items from FUTURE.md
- `GET /api/about/dev-log` — Admin-only, returns development log from DEVELOPMENT_LOG.md
**Security Notes:**
- RoadmapPage uses existing requireAuth + requireAdmin middleware
- API endpoints return 401/403 appropriately for unauthenticated/non-admin users
- Markdown content uses rehype-sanitize for XSS protection
**Release Highlights:**
- 🗺️ Roadmap Page — Kanban-style priority lanes with collapsible items, admin-only roadmap and activity log APIs
- 🛡️ Import CSRF Fix — XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)
- 🧹 AdminDashboard replaced by RoadmapPage
---

368
FUTURE.md
View File

@ -2,7 +2,7 @@
**This document tracks potential future enhancements for Bill Tracker.** **This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-10 **Last Updated:** 2026-05-11
**Current Version:** v0.24.3 **Current Version:** v0.24.3
## How to Use This Document ## How to Use This Document
@ -12,7 +12,7 @@ This file is a living document. Agents should:
2. Add new recommendations with priority levels 2. Add new recommendations with priority levels
3. Never add completed items — move those to HISTORY.md instead 3. Never add completed items — move those to HISTORY.md instead
4. Reference this file when dispatching improvement tasks 4. Reference this file when dispatching improvement tasks
5. Only Ripley can remove items from this list. 5. Only Ripley can remove items from this list. Notify Ripley if something neds to be removed.
### Priority Format ### Priority Format
@ -33,47 +33,243 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
### 🔴 CRITICAL ### 🔴 CRITICAL
### 🔴 Import XLSX / SQLite / Backup CSRF Failure — CRITICAL
**Priority:** CRITICAL
**Added:** 2026-05-11 by Ripley
### ~~🔴 Notification Runner Leaks Bill Details Across Users — CRITICAL~~ ✅ FIXED (v0.23.2) **Description:**
**Moved to HISTORY.md** All three file-upload import endpoints (`/api/import/spreadsheet/preview`, `/api/import/user-db/preview`, `/api/admin/backups/import`) return "Your session has expired or this request may be fraudulent" because the frontend raw `fetch()` calls don't include the `x-csrf-token` header.
**Rationale:**
- The `_fetch()` helper in `client/api.js` automatically adds `x-csrf-token` from the cookie for all state-changing requests
- Three import functions bypass `_fetch()` and use raw `fetch()` directly for file uploads: `previewSpreadsheetImport`, `previewUserDbImport`, `importAdminBackup`
- None of them include the CSRF token header
- The CSRF middleware rejects these requests with 403 `CSRF_INVALID`
- Import is completely broken — users cannot import XLSX, SQLite, or backup files
- This affects a core feature (data import) and produces a confusing error message
**Implementation Notes:**
- Add `x-csrf-token` header to all three raw `fetch()` calls in `client/api.js`
- Use the existing `getCsrfToken()` function (already defined at the top of `client/api.js`)
- Lines to fix: ~L204 (previewSpreadsheetImport), ~L234 (previewUserDbImport), ~L93 (importAdminBackup)
- Example: `headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken(), ... }`
- No backend changes needed — CSRF middleware already works correctly
- Files to modify: `client/api.js`
- Estimated effort: 15 minutes
### 🔴 Import XLSX Dual-Column Layout Not Parsed — CRITICAL
**Priority:** CRITICAL
**Added:** 2026-05-11 by Ripley
**Description:**
The real-world spreadsheet (`backups/monthly bills.xlsx`) uses a **dual-column layout** — each monthly sheet is split into two halves representing two payment periods:
- **Left half (columns A-E):** Bills due around the **1st** of the month
- **Right half (columns G-K):** Bills due around the **15th** of the month
Each half has its own `Due Date | Bill | Amount | Paid Date | Date Cleared` headers. The current parser only detects headers in the first row and processes columns linearly, so it captures the 1st-of-month bills but completely misses all 15th-of-month bills (roughly half the data).
Example from Apr 2026 sheet:
```
Left (1st): auto | Roadrunner ATV | $225.64 | paid 2026-03-01 (due ~1st)
Right (15th): | Amazon chase card | $366 | paid 2026-04-20 (due ~15th)
```
**Rationale:**
- This is the actual production spreadsheet the app needs to import
- ~100 monthly sheets spanning 20172026, each with two payment periods
- The 15th-of-month bills (credit cards, loans, subscriptions) are completely lost during import
- Without dual-column support, the import feature is broken for real data
- The "1st vs 15th" split is semantically meaningful — it maps to `due_day` in the bill model
**Additional Issues in This Spreadsheet:**
- Rows 12 contain paycheck/leftover summary data, not bills — parser must skip these
- Non-numeric amount values: "double pay", blank amounts, "past due" — need graceful handling
- Due date column contains non-date values: "auto" (autopay indicator), "24th" (day-of-month shorthand), account numbers like "9522104"
- Some sheets have slight column layout variations (extra column, merged cells)
- Sheet names have typos: "Januaru 2021", "Novevmber 2019", "Febuary 2023" — parser already handles these
- 3 non-month sheets ("2018 taxes", "debt totoals", "home ownership expenses") should be skipped — already handled by `NON_MONTH_SHEET_RE`
- "auto" in the Due Date column is an autopay flag, not a date — should be detected as a label, not parsed as a date
**Implementation Notes:**
- Modify `spreadsheetImportService.js` to detect dual-column headers in a single sheet row
- When two sets of bill headers are found (A-E and G-K), process each half independently
- Left half rows should default `due_day` to ~1, right half rows should default `due_day` to ~15
- Each half produces its own set of rows with the same sheet name/month context
- Handle non-numeric amount cells gracefully (null amount, "double pay" as a note/label)
- "auto" in Due Date column → set `autopay` label/detected label, don't try to parse as date
- "24th" in Due Date column → parse as day-of-month (24)
- Skip rows where the bill name cell is blank AND the amount cell is blank or non-numeric
- Filter out paycheck/summary rows (Row 1: Paycheck amounts, Row 2: Left Over calculations)
- The `backups/monthly bills.xlsx` file is in `backups/` (gitignored) for testing
- Files to modify: `services/spreadsheetImportService.js`, possibly `routes/import.js`
- Estimated effort: 4-6 hours
### 🔴 No Confirmation Before Destructive Actions — CRITICAL
**Priority:** CRITICAL
**Added:** 2026-05-11 by Ripley
**Description:**
Deleting a bill or payment is one click with no confirmation dialog and no undo. For a financial app, accidental data loss destroys user trust.
**Rationale:**
- Bills and payments represent real financial commitments and history
- No "are you sure?" prompt before delete
- No undo mechanism, no soft delete, no recovery
- One misclick = gone forever — unacceptable for financial data
- This is a trust and data integrity issue, not a UX nicety
**Implementation Notes:**
- Add confirmation dialog to all delete actions (bills, payments, categories)
- Consider soft delete (`deleted_at` column) with a grace period before permanent removal
- Add undo toast after delete that allows restoration within a short window
- Files to modify: `BillModal.jsx`, `BillsTableInner.jsx`, `TrackerPage.jsx`, `CategoriesPage.jsx`
- Estimated effort: 3-4 hours
### 🟠 HIGH ### 🟠 HIGH
### ~~🟠 Admin Can Toggle Payments on Any User Bill — HIGH~~ ✅ FIXED (v0.24.0) ### 🟠 No Search or Filter Across Bills — HIGH
**Moved to HISTORY.md** **Priority:** HIGH
**Added:** 2026-05-11 by Ripley
### ~~🟠 Analytics Validation Errors Crash Instead of Returning 400 — HIGH~~ ✅ FIXED (v0.24.0) **Description:**
**Moved to HISTORY.md** No way to find a bill by name, category, or amount. Users must scroll through the entire list to find anything. Every bill tracker on the market has instant search.
### ~~🟠 User Export Drops Recurrence and History-Range Data — HIGH~~ ✅ FIXED (v0.24.0) **Rationale:**
**Moved to HISTORY.md** - With dozens of bills, scrolling is slow and error-prone
- No search bar on BillsPage, TrackerPage, or CalendarPage
- Can't filter by category, amount range, autopay status, or billing cycle
- Can't quickly find "where's that electric bill?" without visually scanning
- Table stakes for any list-based app
### ~~🟠 Single-User Mode Can Lock Itself Out When Expired Sessions Exist — HIGH~~ ✅ FIXED (v0.24.0) **Implementation Notes:**
**Moved to HISTORY.md** - Add search input to BillsPage (filter by name, category, notes)
- Add filter chips/dropdowns for category, billing cycle, autopay, active/inactive
- Add search to TrackerPage (filter visible rows by bill name)
- Consider a global `Cmd+K` / `Ctrl+K` command palette for instant bill lookup
- Files to modify: `BillsPage.jsx`, `TrackerPage.jsx`, `client/api.js`
- Estimated effort: 6-8 hours
### 🟠 No Visible Overdue Indicators — HIGH
**Priority:** HIGH
**Added:** 2026-05-11 by Ripley
**Description:**
Overdue bills aren't visually flagged on the tracker. An unpaid past-due bill looks the same as one not due yet. The `notify_overdue` setting exists but there's no visual distinction in the UI.
**Rationale:**
- The whole point of a bill tracker is to not miss payments
- Overdue bills should be impossible to overlook — red highlight, badge count, sticky alert
- Data model supports overdue notifications but tracker grid shows no overdue state
- Users scanning the tracker won't notice a missed bill buried in the list
**Implementation Notes:**
- Add overdue detection: if today > due date for current month and no payment logged, mark overdue
- Red/amber background on overdue tracker rows
- Overdue count badge in Sidebar next to Tracker nav link
- Optional: overdue summary banner at top of TrackerPage
- Files to modify: `TrackerPage.jsx`, `Sidebar.jsx`, `routes/tracker.js` (add overdue count to API response)
- Estimated effort: 4-6 hours
### 🟠 Filtered Export for Reports — HIGH
**Priority:** HIGH
**Added:** 2026-05-11 by Ripley (upgraded from LOW)
**Description:**
No way to export filtered data (e.g., "all bills in category X for last 6 months", "everything overdue in 2026"). Export dumps everything or nothing.
**Rationale:**
- Exporting filtered reports is core functionality for a bill tracker, not a nice-to-have
- Users need "all Q1 utility bills" or "overdue payments this year" for reconciliation and tax prep
- `/api/export/user-excel` exports everything — no query params for date range, category, or status
- This is how people actually use financial data outside the app
**Implementation Notes:**
- Add query params to export endpoints: `category_id`, `start`, `end`, `status` (paid/unpaid/overdue)
- Files to modify: `routes/export.js`, `client/pages/DataPage.jsx`
- Estimated effort: 6 hours
### 🟡 MEDIUM ### 🟡 MEDIUM
### 🟡 No Bill Template / Duplicate Bill — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-11 by Ripley
### ~~🟡 Password Change Rate Limiter Applies to Every Profile Endpoint — MEDIUM~~ ✅ FIXED (v0.24.0) **Description:**
**Moved to HISTORY.md** Creating a new bill means filling 10+ fields every time. No way to duplicate an existing bill or use a template. If you have 3 utilities from the same provider, you're retyping everything.
### ~~🟡 Profile Password Change Does Not Invalidate Other Sessions — MEDIUM~~ ✅ FIXED (v0.24.0) **Rationale:**
**Moved to HISTORY.md** - Bill creation has many fields (name, category, due day, amount, autopay, website, account, 2FA, notes)
- Common pattern: similar bills from same provider or same category with slight variations
- "Duplicate bill" is table stakes in every bill tracker
- Reduces friction and errors during bill setup
### ~~🟡 CSRF Defaults Conflict with SPA Token Loading — MEDIUM~~ ✅ FIXED (v0.24.0) **Implementation Notes:**
**Moved to HISTORY.md** - Add "Duplicate" button/action on each bill row and in BillModal
- Pre-fill all fields from source bill, clear `name` and set "(Copy)" suffix
- Files to modify: `BillModal.jsx`, `BillsPage.jsx`, `routes/bills.js` (POST endpoint can accept `source_bill_id` param)
- Estimated effort: 3-4 hours
### ~~🟡 Change-Password Routes Are Globally Exempted from CSRF — MEDIUM~~ ✅ FIXED (v0.24.0) ### 🟡 No Partial Payment Support — MEDIUM
**Moved to HISTORY.md** **Priority:** MEDIUM
**Added:** 2026-05-11 by Ripley
### ~~🟡 Notification Due-Day Math Can Miss Same-Day Reminders — MEDIUM~~ ✅ FIXED (v0.24.0) **Description:**
**Moved to HISTORY.md** The UI only supports logging a single payment per bill per month. The `payments` table schema supports multiple entries per bill, but the frontend doesn't surface this. Split payments (half now, half later) can't be tracked.
### ~~🟡 Upcoming Bills Allows Negative Day Windows — MEDIUM~~ ✅ FIXED (v0.24.0) **Rationale:**
**Moved to HISTORY.md** - Many bills get paid in installments (medical, tuition, large utilities)
- Payment plan arrangements require tracking multiple payments against one bill
- The data model already supports it — it's purely a frontend gap
- Without this, users either over-record or under-record partial payments
**Implementation Notes:**
- Show payment history per bill in tracker (expandable row or modal tab)
- Allow "Add partial payment" with amount + date, summing to bill total
- Display remaining balance on partially-paid bills
- Files to modify: `TrackerPage.jsx`, `routes/payments.js`, possibly `BillModal.jsx`
- Estimated effort: 6-8 hours
### 🟡 No Year-Over-Year Comparison in Analytics — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-11 by Ripley
**Description:**
Analytics shows monthly trends within a single year but there's no "this month vs same month last year" view. Users can't evaluate whether spending is improving.
**Rationale:**
- The whole point of analytics is answering "am I doing better or worse?"
- Within-year trends are useful but don't show long-term improvement
- Comparing April 2026 to April 2025 is the natural question people ask
- Available in every competing app (YNAB, Monarch, etc.)
**Implementation Notes:**
- Add YoY comparison toggle or tab to AnalyticsPage
- Query: same month range across current and previous year, diff the totals
- Show percentage change and absolute change per category
- Files to modify: `AnalyticsPage.jsx`, `routes/analytics.js` (add YoY endpoint or params)
- Estimated effort: 6-8 hours
### 🟡 No Bulk Actions — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-11 by Ripley
**Description:**
Every action is one-at-a-time. Can't select multiple bills and mark them paid, skip them for a month, or change their category.
**Rationale:**
- End-of-month reconciliation means marking many bills as paid in a row
- Category reorganization affects multiple bills at once
- Skipping seasonal bills for summer/winter requires individual clicks
- Bulk actions are standard in any list-based management UI
**Implementation Notes:**
- Add checkbox selection to BillsPage rows (with select-all toggle)
- Bulk action toolbar: Mark Paid, Skip This Month, Change Category, Delete
- Backend: batch endpoints or loop with progress indicator
- Files to modify: `BillsPage.jsx`, `BillsTableInner.jsx`, `routes/bills.js`, `routes/payments.js`
- Estimated effort: 8-10 hours
### Architecture: Business Logic Mixed with Route Handlers ### Architecture: Business Logic Mixed with Route Handlers
**Priority:** MEDIUM **Priority:** MEDIUM
@ -102,38 +298,48 @@ Many routes contain business logic that should be extracted to service layers.
``` ```
- Route handlers should call services, not contain business logic - 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 ### 🔵 LOW
### 🔵 Payment Method Tracking and Summary — LOW
**Priority:** LOW
**Added:** 2026-05-11 by Ripley
### ~~🔵 Export Formats Include Sensitive Bill Credential Fields by Default — LOW~~ ✅ FIXED (v0.24.1) **Description:**
**Moved to HISTORY.md** The `payments` table has a `method` column (free-text) but no way to see "how much did I pay via autopay vs manual vs credit card this month." No standardized method options, no summary.
### ~~🔵 Duplicate Local Login Route Increases Auth Drift Risk — LOW~~ ✅ FIXED (v0.23.2) **Rationale:**
**Moved to HISTORY.md** - Useful for reconciling credit card statements vs bank statements
- Autopay vs manual tracking helps identify bills that should be switched to autopay
- Payment method breakdown is a common analytics view in financial apps
- Current `method` field is unvalidated free text — no consistency
**Implementation Notes:**
- Standardize payment methods: enum or controlled list (autopay, bank_transfer, credit_card, check, cash, other)
- Add payment method breakdown to analytics or summary page
- Files to modify: `routes/payments.js`, `AnalyticsPage.jsx` or `SummaryPage.jsx`, schema migration for method validation
- Estimated effort: 4-6 hours
### 🔵 No Keyboard Navigation or Shortcuts — LOW
**Priority:** LOW
**Added:** 2026-05-11 by Ripley
**Description:**
Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid. Power users and accessibility need keyboard support.
**Rationale:**
- Keyboard accessibility is required for WCAG compliance
- Power users navigate faster with keyboard shortcuts
- Modal dismiss on `Esc` is expected behavior in any modern app
- Command palette (`Cmd+K`) pairs with the search feature (also missing)
**Implementation Notes:**
- `Esc` closes any open modal/dialog
- `Cmd+K` / `Ctrl+K` opens search/command palette
- Arrow keys navigate tracker rows when grid is focused
- Tab order follows logical flow, not DOM order
- Files to modify: `App.jsx`, `BillModal.jsx`, `TrackerPage.jsx`, all dialog components
- Estimated effort: 6-8 hours
### Add comprehensive unit and integration tests ### Add comprehensive unit and integration tests
**Priority:** LOW **Priority:** LOW
@ -155,27 +361,6 @@ Code quality and maintainability. Unit tests catch regressions and document comp
- Files likely to be modified: Add `client/test/` directory, add `jest.config.cjs` - Files likely to be modified: Add `client/test/` directory, add `jest.config.cjs`
- Estimated effort: 8-12 hours for baseline coverage - 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 ### Features: Missing Bill Grouping and Reorganization API
**Priority:** LOW **Priority:** LOW
**Added:** 2026-05-08 by Neo **Added:** 2026-05-08 by Neo
@ -197,7 +382,6 @@ No way to reorder bills, drag-and-drop, or group by custom criteria.
- `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}` - `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}`
- `PUT /api/bills/:id/archived` to soft-dearchive (sets `archived` flag) - `PUT /api/bills/:id/archived` to soft-dearchive (sets `archived` flag)
---
### 💭 NICE TO HAVE ### 💭 NICE TO HAVE
@ -217,43 +401,3 @@ Consistency and maintainability. A consistent pattern makes it easier to add new
- Standardize validation approach - Standardize validation approach
- Files likely to be modified: `client/components/*.jsx` - Files likely to be modified: `client/components/*.jsx`
- Estimated effort: 4-6 hours for migration - 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`.
---

View File

@ -35,6 +35,7 @@ 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'));
@ -126,7 +127,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<AdminShell> <AdminShell>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<AboutPage admin /> <AboutPage />
</Suspense> </Suspense>
</AdminShell> </AdminShell>
</ErrorBoundary> </ErrorBoundary>
@ -140,7 +141,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<AdminShell> <AdminShell>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<AboutPage admin /> <RoadmapPage />
</Suspense> </Suspense>
</AdminShell> </AdminShell>
</ErrorBoundary> </ErrorBoundary>

View File

@ -92,7 +92,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' }, headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() },
body: file, body: file,
}); });
const data = await res.json(); const data = await res.json();
@ -186,6 +186,8 @@ 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'),
@ -204,6 +206,7 @@ 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,
@ -229,6 +232,7 @@ export const api = {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
...(file.name ? { 'X-Filename': file.name } : {}), ...(file.name ? { 'X-Filename': file.name } : {}),
}, },
body: file, body: file,

View File

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

View File

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

View File

@ -1,12 +1,12 @@
export const APP_VERSION = '0.24.6'; export const APP_VERSION = '0.25.0';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.24.6', version: '0.25.0',
date: '2026-05-11', date: '2026-05-11',
highlights: [ highlights: [
{ icon: '🛡️', title: 'Duplicate Payment Fix', desc: 'Partial payments below the estimated amount are now correctly treated as paid — no more phantom Pay button after recording a payment.' }, { icon: '🗺️', title: 'Roadmap Page Redesign', desc: 'Kanban-style priority lanes with collapsible items, admin-only roadmap and activity log APIs replacing AdminDashboard' },
{ icon: '🔧', title: 'Starting Amounts Fix', desc: 'Paid deductions now correctly factor in the "other" bucket for remaining balance calculations.' }, { icon: '🛡️', title: 'Import CSRF Fix', desc: 'XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)' },
{ icon: '🎨', title: 'Pay Badge Alignment', desc: 'Amount input and Pay button now stay inline and centered, no more wrapping on tight layouts.' }, { icon: '🧹', title: 'AdminDashboard Replaced', desc: 'RoadmapPage now handles admin roadmap and development log display' },
], ],
}; };

View File

@ -4,20 +4,19 @@ 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({ admin = false }) { export default function AboutPage() {
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(admin ? await api.aboutAdmin() : await api.about()); setAbout(await api.about());
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [admin]); }, []);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@ -33,12 +32,6 @@ export default function AboutPage({ admin = false }) {
</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">
@ -90,4 +83,4 @@ export default function AboutPage({ admin = false }) {
</main> </main>
</div> </div>
); );
} }

View File

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

View File

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

227
docs/ROADMAP_UI_AUDIT.md Normal file
View File

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

35
package-lock.json generated
View File

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

View File

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

View File

@ -14,6 +14,358 @@ 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
@ -45,7 +397,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 // Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat)
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
@ -71,4 +423,36 @@ router.get('/', requireAuth, requireAdmin, (req, res) => {
} }
}); });
// Admin-only endpoint: parsed roadmap items from FUTURE.md
router.get('/roadmap', requireAuth, requireAdmin, (req, res) => {
try {
const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8');
const sanitized = redactSensitiveContent(futureContent);
const result = parseFutureMd(sanitized);
res.json(result);
} catch (err) {
console.error('[aboutAdmin] Error reading FUTURE.md for roadmap');
res.status(500).json({
error: 'Failed to read roadmap data',
code: 'FILE_READ_ERROR'
});
}
});
// Admin-only endpoint: parsed dev log entries from DEVELOPMENT_LOG.md
router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
try {
const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8');
const sanitized = redactSensitiveContent(devLogContent);
const entries = parseDevLogMd(sanitized);
res.json({ entries, version: pkg.version });
} catch (err) {
console.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log');
res.status(500).json({
error: 'Failed to read dev log data',
code: 'FILE_READ_ERROR'
});
}
});
module.exports = router; module.exports = router;

View File

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

17
scripts/docker-push.sh Executable file
View File

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

27
scripts/docker-test.sh Executable file
View File

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

View File

@ -61,10 +61,14 @@ 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',
}, },
}, },
}, },