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:
parent
98ede20cd3
commit
2ce5328fd2
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Bill Tracker — Scarlett's Active Notes
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-11
|
||||||
|
|
||||||
|
## Task 2: RoadmapPage UI — Kanban Priority Lanes
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `client/pages/RoadmapPage.jsx` | **NEW** | Standalone kanban-style roadmap page with 2 tabs (Roadmap + Activity Log) |
|
||||||
|
| `client/App.jsx` | **MODIFIED** | Added lazy import for RoadmapPage; `/admin/roadmap` route now renders `<RoadmapPage />`; `/admin/about` route uses `<AboutPage />` without admin prop |
|
||||||
|
| `client/pages/AboutPage.jsx` | **MODIFIED** | Removed `admin` prop, removed `AdminDashboard` import, removed conditional render block — AboutPage is now public-only |
|
||||||
|
| `client/components/AdminDashboard.jsx` | **DELETED** | Replaced entirely by RoadmapPage |
|
||||||
|
| `client/components/ui/collapsible.jsx` | **NEW** | shadcn Collapsible component (Radix-based) |
|
||||||
|
| `tailwind.config.js` | **MODIFIED** | Added `collapsible-down`/`collapsible-up` keyframes and animations |
|
||||||
|
| `package.json` | **MODIFIED** | Added `@radix-ui/react-collapsible` dependency |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **RoadmapPage** is a standalone page rendered at `/admin/roadmap` (behind `<RequireAuth role="admin">` + `<AdminShell>`)
|
||||||
|
- Uses **shadcn Tabs** for Roadmap / Activity Log tab switching
|
||||||
|
- **Roadmap tab**: 5-column kanban grid on desktop (`lg+`), 2-column on tablet (`sm–lg`), single column on mobile (`<sm`)
|
||||||
|
- **Activity Log tab**: Lazy-loaded — only fetches `/api/about-admin/dev-log` when the tab is selected
|
||||||
|
- **Collapsible cards**: shadcn `Collapsible` with `CollapsibleTrigger` + `CollapsibleContent` — no more `SimpleCollapsible`
|
||||||
|
- **Expand All / Collapse All** toggle button above the lane grid
|
||||||
|
- **Page-level scroll only** — no nested `max-h-[500px]` overflow containers
|
||||||
|
|
||||||
|
### API Endpoints Used
|
||||||
|
|
||||||
|
- `GET /api/about-admin/roadmap` → `{ version, items: [...], counts: { critical, high, medium, low, niceToHave } }`
|
||||||
|
- `GET /api/about-admin/dev-log` → `{ version, entries: [...] }`
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- All collapsible triggers are `<button>` elements (via shadcn Collapsible)
|
||||||
|
- `aria-expanded` on all collapsible triggers (Radix handles this)
|
||||||
|
- `aria-label` on priority badges (e.g., "Critical priority")
|
||||||
|
- `role="region"` + `aria-label` on each priority lane section
|
||||||
|
- Keyboard-focusable throughout
|
||||||
|
|
||||||
|
### Responsive
|
||||||
|
|
||||||
|
- Desktop (`lg+`): 5-column grid
|
||||||
|
- Tablet (`sm–lg`): 2-column grid (CRITICAL+HIGH | MEDIUM+LOW+NICE TO HAVE)
|
||||||
|
- Mobile (`<sm`): single column, lanes stack vertically
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- `api.roadmap()` and `api.devLog()` were already present in `client/api.js`
|
||||||
|
- AboutPage's `/admin/about` route now shows the same public content (no admin dashboard appended)
|
||||||
|
- The `aboutAdmin()` API endpoint is still available but no longer called by the frontend for the roadmap view
|
||||||
|
|
@ -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
368
FUTURE.md
|
|
@ -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 2017–2026, 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 1–2 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`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import { ChevronDown, ChevronsUpDown, Map, FileText, Loader2, Users, FileCode, Clock } from 'lucide-react';
|
||||||
|
import { api } from '@/api';
|
||||||
|
import { APP_VERSION } from '@/lib/version';
|
||||||
|
|
||||||
|
/* ─── Priority Configuration ───────────────────────────── */
|
||||||
|
|
||||||
|
const PRIORITY_LANES = [
|
||||||
|
{ key: 'critical', emoji: '🔴', label: 'CRITICAL', borderColor: 'border-t-red-500', bgColor: 'bg-red-500/10', textColor: 'text-red-600 dark:text-red-400', badgeClass: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20' },
|
||||||
|
{ key: 'high', emoji: '🟠', label: 'HIGH', borderColor: 'border-t-orange-500', bgColor: 'bg-orange-500/10', textColor: 'text-orange-600 dark:text-orange-400', badgeClass: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/20' },
|
||||||
|
{ key: 'medium', emoji: '🟡', label: 'MEDIUM', borderColor: 'border-t-yellow-500', bgColor: 'bg-yellow-500/10', textColor: 'text-yellow-600 dark:text-yellow-400', badgeClass: 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20' },
|
||||||
|
{ key: 'low', emoji: '🔵', label: 'LOW', borderColor: 'border-t-blue-500', bgColor: 'bg-blue-500/10', textColor: 'text-blue-600 dark:text-blue-400', badgeClass: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20' },
|
||||||
|
{ key: 'niceToHave', emoji: '💭', label: 'NICE TO HAVE', borderColor: 'border-t-gray-400', bgColor: 'bg-gray-400/10', textColor: 'text-gray-600 dark:text-gray-400', badgeClass: 'bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function laneForPriority(priority) {
|
||||||
|
const key = typeof priority === 'string'
|
||||||
|
? priority.toLowerCase().replace(/\s+/g, '').replace(/to/ig, 'To')
|
||||||
|
: '';
|
||||||
|
// Map API priority keys to lane keys
|
||||||
|
const mapping = {
|
||||||
|
critical: 'critical',
|
||||||
|
high: 'high',
|
||||||
|
medium: 'medium',
|
||||||
|
low: 'low',
|
||||||
|
nicetohave: 'niceToHave',
|
||||||
|
'nice to have': 'niceToHave',
|
||||||
|
};
|
||||||
|
return mapping[key] || 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Roadmap Item Card ────────────────────────────────── */
|
||||||
|
|
||||||
|
function RoadmapItemCard({ item, defaultOpen, onToggle }) {
|
||||||
|
const lane = PRIORITY_LANES.find(l => l.key === laneForPriority(item.priority)) || PRIORITY_LANES[3];
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((value) => {
|
||||||
|
setOpen(value);
|
||||||
|
onToggle?.(value);
|
||||||
|
}, [onToggle]);
|
||||||
|
|
||||||
|
const effortLabel = item.effort || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={handleOpenChange} className="group">
|
||||||
|
<Card className="border-border/70 bg-card/95 transition-shadow hover:shadow-md">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-2xl">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${lane.badgeClass} border text-[11px] font-semibold px-1.5 py-0 mb-1.5`}
|
||||||
|
aria-label={`${lane.label} priority`}
|
||||||
|
>
|
||||||
|
{lane.emoji} {lane.label}
|
||||||
|
</Badge>
|
||||||
|
<h4 className="font-semibold text-sm leading-snug line-clamp-3">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<div className="px-6 pb-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{item.added && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{item.added}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.addedBy && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{item.addedBy}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{effortLabel && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{effortLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0 pb-4 space-y-3">
|
||||||
|
{item.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Description</p>
|
||||||
|
<p className="text-sm leading-relaxed">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.rationale && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Rationale</p>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">{item.rationale}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.implementationNotes && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Implementation Notes</p>
|
||||||
|
<div className="rounded-xl bg-muted/50 border border-border/50 p-3 text-sm font-mono leading-relaxed whitespace-pre-wrap">
|
||||||
|
{item.implementationNotes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Priority Lane ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
function PriorityLane({ lane, items, defaultOpenCards }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="region"
|
||||||
|
aria-label={`${lane.label} priority lane`}
|
||||||
|
className={`rounded-2xl border ${lane.borderColor} border-t-4 bg-background/50`}
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/50">
|
||||||
|
<span className="text-lg" aria-hidden="true">{lane.emoji}</span>
|
||||||
|
<h3 className={`font-bold text-sm ${lane.textColor}`}>{lane.label}</h3>
|
||||||
|
<Badge variant="secondary" className="ml-auto text-[11px]">{items.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<RoadmapItemCard key={item.id} item={item} defaultOpen={defaultOpenCards} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dev Log Entry ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
function DevLogEntry({ entry }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen} className="group">
|
||||||
|
<div className="relative flex gap-4">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-primary border-2 border-background shrink-0 mt-1.5" />
|
||||||
|
<div className="w-px flex-1 bg-border/70" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 pb-6 min-w-0">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-sm">{entry.version}</span>
|
||||||
|
{entry.date && (
|
||||||
|
<span className="text-xs text-muted-foreground">{entry.date}</span>
|
||||||
|
)}
|
||||||
|
{entry.status && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[11px] ${
|
||||||
|
entry.status.includes('COMPLETED')
|
||||||
|
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
|
||||||
|
: 'bg-muted/50 text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{entry.agents?.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{entry.agents.map(a => a.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(entry.filesModified?.length > 0 || entry.workCompleted?.length > 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
<FileCode className="inline h-3 w-3 mr-0.5" />
|
||||||
|
{entry.filesModified?.length || 0} files
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180 ml-auto" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{entry.agents?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Agents</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{entry.agents.map((agent, idx) => (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[11px] ${
|
||||||
|
agent.status === 'COMPLETED'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
|
||||||
|
: agent.status === 'IN PROGRESS'
|
||||||
|
? 'bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20'
|
||||||
|
: 'bg-muted/50 text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{agent.status === 'COMPLETED' ? '✅' : agent.status === 'IN PROGRESS' ? '⏳' : '❓'}{' '}
|
||||||
|
{agent.name}
|
||||||
|
{agent.time ? ` · ${agent.time}` : ''}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.filesModified?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">Files Modified</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{entry.filesModified.map((file, idx) => (
|
||||||
|
<code key={idx} className="text-[11px] bg-muted/50 px-1.5 py-0.5 rounded border border-border/50 text-muted-foreground">
|
||||||
|
{file}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.workCompleted?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">Work Completed</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{entry.workCompleted.map((work, idx) => (
|
||||||
|
<li key={idx} className="text-sm text-muted-foreground flex items-start gap-1.5">
|
||||||
|
<span className="text-emerald-500 mt-0.5 shrink-0">✓</span>
|
||||||
|
{work}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main Page ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export default function RoadmapPage() {
|
||||||
|
const [roadmapData, setRoadmapData] = useState(null);
|
||||||
|
const [devLogData, setDevLogData] = useState(null);
|
||||||
|
const [roadmapLoading, setRoadmapLoading] = useState(true);
|
||||||
|
const [devLogLoading, setDevLogLoading] = useState(false);
|
||||||
|
const [roadmapError, setRoadmapError] = useState(null);
|
||||||
|
const [devLogError, setDevLogError] = useState(null);
|
||||||
|
const [allExpanded, setAllExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Detect desktop for default expand state
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : true
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(min-width: 1024px)');
|
||||||
|
const handler = (e) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
setIsDesktop(mq.matches);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch roadmap on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setRoadmapLoading(true);
|
||||||
|
api.roadmap()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setRoadmapData(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setRoadmapError(err.message || 'Failed to load roadmap');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setRoadmapLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchDevLog = useCallback(() => {
|
||||||
|
if (devLogData) return; // Already loaded
|
||||||
|
let cancelled = false;
|
||||||
|
setDevLogLoading(true);
|
||||||
|
api.devLog()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setDevLogData(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setDevLogError(err.message || 'Failed to load activity log');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setDevLogLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [devLogData]);
|
||||||
|
|
||||||
|
const version = roadmapData?.version || APP_VERSION;
|
||||||
|
const items = roadmapData?.items || [];
|
||||||
|
const counts = roadmapData?.counts || {};
|
||||||
|
const devLogEntries = devLogData?.entries || [];
|
||||||
|
|
||||||
|
// Group items by priority lane
|
||||||
|
const grouped = PRIORITY_LANES.map(lane => ({
|
||||||
|
...lane,
|
||||||
|
items: items.filter(item => laneForPriority(item.priority) === lane.key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultOpenCards = isDesktop && allExpanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
|
||||||
|
<Map className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Roadmap</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Current and upcoming features by priority</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="font-mono text-sm self-start sm:self-auto">
|
||||||
|
v{version}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="roadmap" onValueChange={(value) => { if (value === 'activity') fetchDevLog(); }}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="roadmap" className="gap-1.5">
|
||||||
|
<Map className="h-3.5 w-3.5" />
|
||||||
|
Roadmap
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" className="gap-1.5">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
Activity Log
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ─── Roadmap Tab ─── */}
|
||||||
|
<TabsContent value="roadmap">
|
||||||
|
{roadmapLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-3 text-muted-foreground">Loading roadmap…</span>
|
||||||
|
</div>
|
||||||
|
) : roadmapError ? (
|
||||||
|
<Card className="border-destructive/50 bg-destructive/5">
|
||||||
|
<CardContent className="py-8 text-center">
|
||||||
|
<p className="text-destructive font-medium">Failed to load roadmap</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{roadmapError}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
No roadmap items found.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Expand/Collapse All toggle */}
|
||||||
|
<div className="flex justify-end mb-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAllExpanded(prev => !prev)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||||
|
{allExpanded ? 'Collapse All' : 'Expand All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: 5-column grid */}
|
||||||
|
<div className="hidden lg:grid lg:grid-cols-5 gap-4">
|
||||||
|
{grouped.map(lane => (
|
||||||
|
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tablet: 2-column grid */}
|
||||||
|
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-4">
|
||||||
|
{/* Left column: Critical + High */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => (
|
||||||
|
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Right column: Medium + Low + Nice to Have */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane => (
|
||||||
|
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: single column */}
|
||||||
|
<div className="sm:hidden space-y-4">
|
||||||
|
{grouped.map(lane => (
|
||||||
|
<PriorityLane key={lane.key} lane={lane} items={lane.items} defaultOpenCards={defaultOpenCards} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ─── Activity Log Tab ─── */}
|
||||||
|
<TabsContent value="activity">
|
||||||
|
{devLogLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-3 text-muted-foreground">Loading activity log…</span>
|
||||||
|
</div>
|
||||||
|
) : devLogError ? (
|
||||||
|
<Card className="border-destructive/50 bg-destructive/5">
|
||||||
|
<CardContent className="py-8 text-center">
|
||||||
|
<p className="text-destructive font-medium">Failed to load activity log</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{devLogError}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : devLogEntries.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
No activity log entries found.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="pt-2">
|
||||||
|
{devLogEntries.map((entry, idx) => (
|
||||||
|
<DevLogEntry key={entry.version || idx} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
# Roadmap Page Redesign — Execution Plan
|
||||||
|
|
||||||
|
**Created:** 2026-05-11
|
||||||
|
**Scope:** Replace AdminDashboard with a standalone RoadmapPage using kanban-style priority lanes
|
||||||
|
**Reference:** `docs/ROADMAP_UI_AUDIT.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — Neo: Backend API Split & Parsing Fix
|
||||||
|
|
||||||
|
**Agent:** Neo
|
||||||
|
**Priority:** Must complete before Task 2
|
||||||
|
**Estimated time:** 2-3 hours
|
||||||
|
|
||||||
|
### What
|
||||||
|
Split `/api/about-admin` into two endpoints so the dev log (54KB) isn't shipped on page load, and add structured FUTURE.md parsing on the backend.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
**1. New endpoint: `GET /api/roadmap`**
|
||||||
|
- Reads `FUTURE.md`
|
||||||
|
- Returns parsed JSON array of roadmap items (not raw markdown)
|
||||||
|
- Each item: `{ id, priority, priorityLabel, title, description, rationale, implementationNotes, effort, added, addedBy, status }`
|
||||||
|
- Parse `**Description:**`, `**Rationale:**`, `**Implementation Notes:**` into separate fields
|
||||||
|
- Extract effort estimate from Implementation Notes (regex: `Estimated effort: X-Y hours` → `effort: "X-Yh"`)
|
||||||
|
- Filter out strikethrough/completed items server-side
|
||||||
|
- Group counts by priority tier in response: `{ items: [...], counts: { critical: 1, high: 3, medium: 4, low: 3, niceToHave: 1 } }`
|
||||||
|
|
||||||
|
**2. New endpoint: `GET /api/dev-log`**
|
||||||
|
- Reads `DEVELOPMENT_LOG.md`
|
||||||
|
- Returns parsed JSON array of log entries (not raw markdown)
|
||||||
|
- Each entry: `{ version, date, status, agents: [{name, status, time, notes}], filesModified: [...] }`
|
||||||
|
- Called lazily — frontend only fetches when Activity Log tab is selected
|
||||||
|
|
||||||
|
**3. Keep `/api/about-admin` unchanged**
|
||||||
|
- Still returns `version`, `future` (raw), `developmentLog` (raw) for backward compatibility
|
||||||
|
- AdminDashboard continues to work until we swap it out
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `routes/aboutAdmin.js` — add `/api/roadmap` and `/api/dev-log` routes
|
||||||
|
- `client/api.js` — add `roadmap()` and `devLog()` functions
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
- `GET /api/roadmap` returns JSON with structured items and counts
|
||||||
|
- `GET /api/dev-log` returns parsed log entries
|
||||||
|
- `GET /api/about-admin` still works unchanged
|
||||||
|
- Completed/strikethrough items are excluded from `/api/roadmap`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — Scarlett: RoadmapPage UI (Kanban Lanes + Tabs)
|
||||||
|
|
||||||
|
**Agent:** Scarlett
|
||||||
|
**Priority:** Depends on Task 1
|
||||||
|
**Estimated time:** 6-8 hours
|
||||||
|
**Stack mandate:** Vite + React (NOT Next.js). All UI components must use shadcn/ui primitives. Styling via Tailwind CSS only.
|
||||||
|
|
||||||
|
### What
|
||||||
|
Build a standalone `RoadmapPage.jsx` with kanban-style priority lanes and a tab for the Activity Log. Replace the current AdminDashboard component.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
**1. New file: `client/pages/RoadmapPage.jsx`**
|
||||||
|
- Fetch data from `/api/roadmap` on mount
|
||||||
|
- Lazy-fetch `/api/dev-log` only when Activity Log tab is selected
|
||||||
|
- Page-level scroll only (no nested scroll containers)
|
||||||
|
- Page header: "🗺️ Roadmap" title + version badge (from `/api/roadmap` response or `APP_VERSION`)
|
||||||
|
|
||||||
|
**2. Kanban lane layout (Roadmap tab)**
|
||||||
|
- Desktop (`lg+`): 5-column grid — one lane per priority (CRITICAL, HIGH, MEDIUM, LOW, NICE TO HAVE)
|
||||||
|
- Tablet (`sm–lg`): 2-column grid (CRITICAL+HIGH | MEDIUM+LOW+NICE TO HAVE)
|
||||||
|
- Mobile (`< sm`): single column, lanes stack vertically as collapsible sections
|
||||||
|
- Each lane header: priority emoji + label + item count badge (e.g., "🔴 Critical (1)")
|
||||||
|
- Lane header has colored top border from PRIORITY_COLORS map
|
||||||
|
|
||||||
|
**3. Roadmap item cards**
|
||||||
|
- Compact card: priority badge, title (bold, 2-3 line clamp), date added, effort estimate
|
||||||
|
- Click to expand via shadcn `Collapsible` (Radix-based, accessible, `aria-expanded`)
|
||||||
|
- Expanded view shows three labeled sections: Description, Rationale, Implementation Notes — properly styled, not raw markdown
|
||||||
|
- "Expand All / Collapse All" toggle button above the lane grid
|
||||||
|
|
||||||
|
**4. Activity Log tab**
|
||||||
|
- shadcn `Tabs` component with two tabs: "Roadmap" | "Activity Log"
|
||||||
|
- Activity Log shows parsed dev log entries in vertical timeline format
|
||||||
|
- Each entry: version, date, agent badges with status icons, files modified count
|
||||||
|
- Expandable details (click to see full entry content)
|
||||||
|
- Lazy-loaded — only fetch when tab is selected
|
||||||
|
|
||||||
|
**5. Replace shadcn/ui components (not custom)**
|
||||||
|
- `SimpleCollapsible` → shadcn `Collapsible` (`Collapsible`, `CollapsibleTrigger`, `CollapsibleContent`)
|
||||||
|
- `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent` for the tab switcher
|
||||||
|
- Keep existing `Card`, `Badge`, `Button` usage
|
||||||
|
- Use shadcn `Accordion` for mobile lane fallback if needed
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- **NEW:** `client/pages/RoadmapPage.jsx` — the entire new page
|
||||||
|
- **MODIFY:** `client/App.jsx` — update `/admin/roadmap` route to render `<RoadmapPage />` instead of `<AboutPage admin />`; add lazy import
|
||||||
|
- **MODIFY:** `client/pages/AboutPage.jsx` — remove `admin` prop, remove `AdminDashboard` import, revert to public-only about page
|
||||||
|
- **DELETE:** `client/components/AdminDashboard.jsx` — replaced entirely by RoadmapPage
|
||||||
|
- Check if shadcn `Collapsible` and `Tabs` are already installed; if not, add via `npx shadcn@latest add collapsible`
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
- `/admin/roadmap` renders RoadmapPage with kanban lanes
|
||||||
|
- `/admin` and `/about` no longer show the admin dashboard
|
||||||
|
- Desktop: 5 priority lanes side by side
|
||||||
|
- Mobile: lanes stack vertically
|
||||||
|
- Each item card expands to show Description/Rationale/Notes as separate styled sections
|
||||||
|
- Activity Log tab lazy-loads dev log data
|
||||||
|
- No `SimpleCollapsible` usage — all shadcn `Collapsible`
|
||||||
|
- All interactive elements keyboard-focusable with `aria-expanded`
|
||||||
|
- Dark mode and light mode both render correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — Private_Hudson: Security Review
|
||||||
|
|
||||||
|
**Agent:** Private_Hudson
|
||||||
|
**Priority:** After Task 2
|
||||||
|
**Estimated time:** 1-2 hours
|
||||||
|
|
||||||
|
### What
|
||||||
|
Review the new endpoints and page for security issues.
|
||||||
|
|
||||||
|
### Current CSRF Security Context
|
||||||
|
|
||||||
|
Bill Tracker uses a **double-submit cookie pattern** for CSRF protection:
|
||||||
|
|
||||||
|
- **Cookie:** `bt_csrf_token` (set by `csrfTokenProvider` middleware on every response)
|
||||||
|
- **Header:** Frontend reads token from `document.cookie` and sends it as `x-csrf-token` header on all state-changing requests (POST, PUT, DELETE, PATCH)
|
||||||
|
- **Validation:** `csrfMiddleware` compares cookie value to header/query/body value — must match exactly
|
||||||
|
- **Token generation:** `crypto.randomBytes(32).toString('hex')` (256-bit)
|
||||||
|
|
||||||
|
**Configuration (env vars):**
|
||||||
|
- `CSRF_HTTP_ONLY` — defaults to `false` (SPA needs JS to read cookie for double-submit)
|
||||||
|
- `CSRF_SAME_SITE` — defaults to `strict`
|
||||||
|
- `CSRF_SECURE` — defaults to `true` (HTTPS only)
|
||||||
|
- `CSRF_COOKIE_NAME` — defaults to `bt_csrf_token`
|
||||||
|
|
||||||
|
**CSRF-exempt routes (via `req.csrfSkip`):**
|
||||||
|
- `POST /api/auth/login` — no session exists yet, nothing to hijack
|
||||||
|
- `POST /api/auth/logout-all` — uses session cookie directly
|
||||||
|
|
||||||
|
**All other state-changing routes have CSRF enforced**, including:
|
||||||
|
- `POST /api/auth/change-password` — covered by `csrfMiddleware` on `/api/auth` mount
|
||||||
|
- `POST /api/profile/change-password` — covered by `csrfMiddleware` on `/api/profile` mount
|
||||||
|
- All `/api/bills`, `/api/payments`, `/api/categories`, `/api/tracker`, `/api/analytics`, etc.
|
||||||
|
|
||||||
|
⚠️ **Known stale comment:** `routes/auth.js` line 120 has a comment saying "Exempt from CSRF" on the change-password route, but there is NO `req.csrfSkip` set — the route IS protected. The comment is wrong and should be removed.
|
||||||
|
|
||||||
|
### Checks for New Endpoints
|
||||||
|
- `/api/roadmap` and `/api/dev-log` are GET routes — CSRF middleware only validates POST/PUT/DELETE/PATCH, so they're safe by default. But confirm they still require admin auth.
|
||||||
|
- No FUTURE.md internal file paths leak through the API (the `redactSensitiveContent` function from `aboutAdmin.js` is applied)
|
||||||
|
- `/api/roadmap` doesn't expose implementation details that could aid an attacker (file paths, internal IPs, etc.)
|
||||||
|
- `/api/dev-log` doesn't expose agent names/tokens that shouldn't be visible
|
||||||
|
- XSS check: all parsed content rendered through React's JSX (auto-escaped) or sanitized
|
||||||
|
- Route: confirm `/admin/roadmap` is behind `<RequireAuth role="admin">`
|
||||||
|
- Fix stale comment in `routes/auth.js` line 120 (remove or correct the "Exempt from CSRF" note)
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `routes/aboutAdmin.js` — review new routes
|
||||||
|
- `client/pages/RoadmapPage.jsx` — review rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — Bishop: Verification + Docs Update
|
||||||
|
|
||||||
|
**Agent:** Bishop
|
||||||
|
**Priority:** After Tasks 2 and 3
|
||||||
|
**Estimated time:** 2-3 hours
|
||||||
|
|
||||||
|
### What
|
||||||
|
Build, test, verify the redesign works, update docs.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
1. Run `scripts/docker-test.sh` — fresh build on port 3036
|
||||||
|
2. Test: admin login → navigate to `/admin/roadmap`
|
||||||
|
3. Verify: 5 priority lanes render on desktop
|
||||||
|
4. Verify: lanes stack on mobile viewport
|
||||||
|
5. Verify: click item card → expands to show Description/Rationale/Notes
|
||||||
|
6. Verify: Activity Log tab loads data on click (not on page load)
|
||||||
|
7. Verify: `/about` and `/admin` no longer show admin dashboard
|
||||||
|
8. Verify: `/admin/roadmap` requires admin auth (non-admin gets redirect)
|
||||||
|
9. Verify: dark mode + light mode both look correct
|
||||||
|
10. Verify: keyboard navigation works (Tab, Enter/Space to expand)
|
||||||
|
11. Update `client/lib/version.js` — bump patch version
|
||||||
|
12. Update `STRUCTURE.md` — add RoadmapPage, remove AdminDashboard, update AboutPage description
|
||||||
|
13. Update Engineering Reference Manual — grep headings, update relevant sections only
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `client/lib/version.js` — version bump
|
||||||
|
- `package.json` — version bump
|
||||||
|
- `STRUCTURE.md` — add RoadmapPage, remove AdminDashboard
|
||||||
|
- `docs/Engineering_Reference_Manual.md` — targeted section updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 — Ripley: Final Commit & Push
|
||||||
|
|
||||||
|
**Agent:** Ripley
|
||||||
|
**Priority:** After Task 4
|
||||||
|
|
||||||
|
### What
|
||||||
|
Final review, commit, push, deploy.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
1. Review all changes
|
||||||
|
2. `git add -A && git commit -m "feat: redesign roadmap page as kanban-style priority lanes"`
|
||||||
|
3. `git push origin dev`
|
||||||
|
4. `scripts/docker-test.sh` — rebuild and redeploy
|
||||||
|
5. Update HISTORY.md with the change
|
||||||
|
6. Update FUTURE.md — add "Roadmap page redesign" if not already there, or reference this work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (Neo: API split)
|
||||||
|
└──→ Task 2 (Scarlett: UI) ──→ Task 3 (Hudson: Security) ──→ Task 4 (Bishop: Verify) ──→ Task 5 (Ripley: Commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks 1 and 2 are sequential. Tasks 3 and 4 are sequential after 2. Task 5 is final.
|
||||||
|
|
||||||
|
## Estimated Total Time
|
||||||
|
|
||||||
|
| Task | Agent | Time |
|
||||||
|
|------|-------|------|
|
||||||
|
| 1 | Neo | 2-3h |
|
||||||
|
| 2 | Scarlett | 6-8h |
|
||||||
|
| 3 | Hudson | 1-2h |
|
||||||
|
| 4 | Bishop | 2-3h |
|
||||||
|
| 5 | Ripley | 30m |
|
||||||
|
| **Total** | | **12-17h** |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If the redesign has issues in production:
|
||||||
|
- Revert `App.jsx` route to `<AboutPage admin />`
|
||||||
|
- Restore `AdminDashboard.jsx` from git
|
||||||
|
- Roadmap page works again in the old format
|
||||||
|
- New `/api/roadmap` and `/api/dev-log` endpoints are additive — no data loss
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
# Roadmap Page — UI Audit & Redesign Proposal
|
||||||
|
|
||||||
|
**Audited by:** Ripley
|
||||||
|
**Date:** 2026-05-11
|
||||||
|
**Component:** `client/components/AdminDashboard.jsx`
|
||||||
|
**Route:** `/admin/roadmap` (rendered via `AboutPage admin` prop)
|
||||||
|
**Framework:** Vite + React + Tailwind CSS + shadcn/ui + Radix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The Roadmap page is an admin-only dashboard embedded inside `AboutPage.jsx`. It parses `FUTURE.md` and `DEVELOPMENT_LOG.md` via the `/api/about-admin` endpoint and renders two sections: a Roadmap card and a Development Activity Log card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problems
|
||||||
|
|
||||||
|
### 1. It's Not a Real Page — It's an Appendix to About
|
||||||
|
|
||||||
|
The roadmap is rendered *inside* `AboutPage.jsx` with an `admin` prop. The `/admin/roadmap` route literally renders `<AboutPage admin />`. This means:
|
||||||
|
|
||||||
|
- The standard About page content (version cards, "Produced with AI" blurb, Sign In button) renders **below** the admin dashboard. An admin sees both the dashboard *and* the public about page stacked vertically.
|
||||||
|
- The "Back" button links to `/login` — wrong for an admin navigating from the sidebar.
|
||||||
|
- No dedicated page identity. It doesn't feel like a destination, it feels like a data dump tacked onto another page.
|
||||||
|
|
||||||
|
### 2. Two Giant Scrollboxes Trapped in Cards
|
||||||
|
|
||||||
|
Both Roadmap and Activity Log are `max-h-[500px]` scroll containers nested inside `Card` components. This creates:
|
||||||
|
|
||||||
|
- **Scroll-in-scroll**: The page itself scrolls, and then each card has its own internal scroll. Users fight nested scroll areas.
|
||||||
|
- **500px is arbitrary and too short** — on a 1080p screen with browser chrome, you see maybe 5-6 roadmap items before needing to scroll inside the card. With 10+ items now, most are hidden.
|
||||||
|
- **No visual indicator that content is scrollable** — no fade-out gradient, no scroll shadow, nothing signals "there's more below."
|
||||||
|
|
||||||
|
### 3. Collapsible Everything = Nothing Visible at a Glance
|
||||||
|
|
||||||
|
Every roadmap item is a `SimpleCollapsible` (custom, not shadcn). CRITICAL and HIGH start expanded, but MEDIUM/LOW/NICE-TO-HAVE are collapsed. This means:
|
||||||
|
|
||||||
|
- **6 out of 10 items are invisible by default** — you see 4 items, then 6 collapsed headers you have to click one by one.
|
||||||
|
- The collapsible headers show a priority badge + title, but no description, no effort estimate, no status beyond "PENDING" — you have to click each one to learn anything.
|
||||||
|
- No way to expand all / collapse all.
|
||||||
|
- `SimpleCollapsible` is a custom component when shadcn has `Collapsible` (Radix-based, accessible, animated).
|
||||||
|
|
||||||
|
### 4. No Priority Grouping or Visual Hierarchy
|
||||||
|
|
||||||
|
All roadmap items render as a flat list inside a single scroll container. The priority emoji/badge is the only differentiator:
|
||||||
|
|
||||||
|
- No section headers (CRITICAL / HIGH / MEDIUM / LOW) — items from different priorities blend together.
|
||||||
|
- No count indicators ("2 Critical, 3 High, 4 Medium...").
|
||||||
|
- No way to filter by priority or toggle visibility of entire tiers.
|
||||||
|
- The `PRIORITY_COLORS` object defines `border-l-4` left borders but the visual weight difference between orange and yellow on a dark theme is subtle.
|
||||||
|
|
||||||
|
### 5. Description Content Is Raw Markdown Dump
|
||||||
|
|
||||||
|
The `parseFutureMarkdown` function concatenates description, rationale, and implementation notes into a single `description` string with `whitespace-pre-wrap`. This means:
|
||||||
|
|
||||||
|
- Markdown formatting (`**Description:**`, `**Rationale:**`, bullet points) renders as literal text, not styled content.
|
||||||
|
- No visual separation between Description, Rationale, and Implementation Notes.
|
||||||
|
- Long implementation notes (the business logic item has code blocks) just dump as plain text.
|
||||||
|
- The markdown headers (`**Description:**`, etc.) show as bold text but with no structure — looks like a raw file view.
|
||||||
|
|
||||||
|
### 6. Activity Log Is Broken / Useless
|
||||||
|
|
||||||
|
The `parseDevLogMarkdown` function splits on `---` horizontal rules and tries to parse the development log. Problems:
|
||||||
|
|
||||||
|
- The actual `DEVELOPMENT_LOG.md` format doesn't consistently use `---` separators between entries — it uses `###` headers. The parser misses most entries.
|
||||||
|
- `devLogEntries` often comes back nearly empty or with badly parsed data.
|
||||||
|
- Each entry is a `DevLogEntry` component that's also collapsible (collapsed by default), so you're clicking to expand... inside a scrollbox... inside a card. Three layers of hiding.
|
||||||
|
- The dev log is 54KB of data being shipped to the frontend on every page load. Most admins never look at it.
|
||||||
|
|
||||||
|
### 7. No Interactivity or Actionability
|
||||||
|
|
||||||
|
This is a read-only data wall. There's no:
|
||||||
|
|
||||||
|
- Way to reorder priorities
|
||||||
|
- Way to mark an item as "in progress" or "started"
|
||||||
|
- Link to create a dispatch for an agent
|
||||||
|
- Progress indicator (how many items done vs pending)
|
||||||
|
- Filter or search
|
||||||
|
- Sorting (by priority, by date added, by effort)
|
||||||
|
|
||||||
|
### 8. Version Badge Is Orphaned
|
||||||
|
|
||||||
|
A lone `Badge` with the version number floats at the top of the component with no label, no context, no styling weight. It looks like it fell out of another component.
|
||||||
|
|
||||||
|
### 9. No Responsive Consideration
|
||||||
|
|
||||||
|
The component renders the same way at every breakpoint. On mobile:
|
||||||
|
|
||||||
|
- The 500px scroll containers are worse (less visible content).
|
||||||
|
- Collapsible headers with badges + long titles overflow or wrap poorly.
|
||||||
|
- No card reflow for small screens.
|
||||||
|
|
||||||
|
### 10. Accessibility Issues
|
||||||
|
|
||||||
|
- `SimpleCollapsible` uses a `div` with `onClick` — not a button, no `aria-expanded`, no keyboard activation.
|
||||||
|
- The scroll containers have no `role` or `aria-label`.
|
||||||
|
- No skip links within the dashboard sections.
|
||||||
|
- The priority emojis (🔴🟠🟡🔵💭) have no text alternatives for screen readers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redesign Proposal
|
||||||
|
|
||||||
|
### Core Concept: Kanban-Style Priority Lanes
|
||||||
|
|
||||||
|
Replace the single flat scrollbox with a **horizontal lane layout** — one column per priority tier. Each lane shows its items as compact cards. This gives admins an at-a-glance view of the entire roadmap without scrolling or clicking.
|
||||||
|
|
||||||
|
### Architecture Changes
|
||||||
|
|
||||||
|
1. **Make it a standalone page** — `RoadmapPage.jsx`, not `AboutPage admin`. The `/admin/roadmap` route should render its own component with its own layout, header, and identity.
|
||||||
|
2. **Use shadcn Tabs** for the two sections (Roadmap / Activity Log) instead of stacking two cards.
|
||||||
|
3. **Separate the About page** — admins who navigate to `/admin/roadmap` shouldn't see the public about page below it.
|
||||||
|
|
||||||
|
### Roadmap Tab Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 🗺️ Roadmap v0.24.4 │
|
||||||
|
│ ┌─────────┬─────────┬─────────┬─────────┬─────────┐ │
|
||||||
|
│ │CRITICAL │ HIGH │ MEDIUM │ LOW │ NICE │ │
|
||||||
|
│ │ (1) │ (3) │ (4) │ (3) │ (1) │ │
|
||||||
|
│ ├─────────┼─────────┼─────────┼─────────┼─────────┤ │
|
||||||
|
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │
|
||||||
|
│ │ │Item │ │ │Item │ │ │Item │ │ │Item │ │ │Item │ │ │
|
||||||
|
│ │ │card │ │ │card │ │ │card │ │ │card │ │ │card │ │ │
|
||||||
|
│ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │
|
||||||
|
│ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ │
|
||||||
|
│ │ │ │ │ │card │ │ │card │ │ │card │ │ │ │
|
||||||
|
│ │ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │ │
|
||||||
|
│ │ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ │
|
||||||
|
│ │ │ │card │ │ │card │ │ │card │ │ │ │
|
||||||
|
│ │ │ └─────┘ │ └─────┘ │ └─────┘ │ │ │
|
||||||
|
│ └─────────┴─────────┴─────────┴─────────┴─────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
On mobile (below `lg` breakpoint): lanes stack vertically with each lane as a collapsible section (using shadcn `Collapsible` or `Accordion`).
|
||||||
|
|
||||||
|
### Item Card Design (compact, scannable)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ 🔴 CRITICAL │ ← Priority badge
|
||||||
|
│ No Confirmation Before │ ← Title (bold, 2-3 lines max)
|
||||||
|
│ Destructive Actions │
|
||||||
|
│ │
|
||||||
|
│ Added: 2026-05-11 │ ← Meta line (date, agent)
|
||||||
|
│ Est: 3-4h │ ← Effort estimate
|
||||||
|
│ │
|
||||||
|
│ [Expand ▸] │ ← Click to see full details
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Expanded card shows Description, Rationale, Implementation Notes as **properly styled sections** (not raw markdown dump).
|
||||||
|
|
||||||
|
### Activity Log Tab
|
||||||
|
|
||||||
|
- Replace the broken `parseDevLogMarkdown` with a **simpler timeline format** — just show version, date, agents involved, files modified. No full content dump.
|
||||||
|
- Consider lazy-loading: only fetch `developmentLog` when the Activity Log tab is selected (it's 54KB of data nobody needs on page load).
|
||||||
|
- Timeline format (vertical):
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.24.4 — 2026-05-11
|
||||||
|
Scarlett ✅ 12m | Neo ✅ 3m | Bishop ✅ 7m
|
||||||
|
5 files modified
|
||||||
|
[▸ Expand details]
|
||||||
|
|
||||||
|
v0.23.2 — 2026-05-10
|
||||||
|
Neo ✅ | Bishop ✅
|
||||||
|
3 files modified
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Inventory (what to use)
|
||||||
|
|
||||||
|
| Need | Use |
|
||||||
|
|------|-----|
|
||||||
|
| Page container | Standalone `RoadmapPage.jsx` |
|
||||||
|
| Priority lanes | CSS Grid (`grid-cols-5` on `lg`, `grid-cols-1` on mobile) |
|
||||||
|
| Lane sections | `Card` with colored top border from `PRIORITY_COLORS` |
|
||||||
|
| Item cards | `Card` with `CardHeader`/`CardContent` |
|
||||||
|
| Item expand/collapse | shadcn `Collapsible` (Radix, accessible) |
|
||||||
|
| Tab switching | shadcn `Tabs` / `TabsList` / `TabsTrigger` |
|
||||||
|
| Priority badges | Keep current `Badge` + emoji approach, add `aria-label` |
|
||||||
|
| Scroll | Page-level scroll only, no nested scroll containers |
|
||||||
|
| Expand All / Collapse All | `Button` at top of Roadmap tab |
|
||||||
|
| Item count per lane | `Badge` variant="outline" in lane header |
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `client/components/AdminDashboard.jsx` | **Delete** (replaced by RoadmapPage) |
|
||||||
|
| `client/pages/AboutPage.jsx` | Remove `admin` prop, remove AdminDashboard import — AboutPage goes back to being a public-only page |
|
||||||
|
| `client/pages/RoadmapPage.jsx` | **New** — standalone roadmap page |
|
||||||
|
| `client/App.jsx` | Update `/admin/roadmap` route to render `<RoadmapPage />` instead of `<AboutPage admin />`; possibly add `/admin/about` route if admins need the about page |
|
||||||
|
| `client/api.js` | No changes needed (same endpoint) |
|
||||||
|
|
||||||
|
### Data Parsing Improvements
|
||||||
|
|
||||||
|
- **Parse FUTURE.md into structured sections** — separate Description, Rationale, Implementation Notes into distinct fields on the item object instead of concatenating into one `description` blob.
|
||||||
|
- **Extract effort estimate** from Implementation Notes (`Estimated effort: 3-4 hours` → `effort: "3-4h"`).
|
||||||
|
- **Lazy-load dev log** — only call `/api/about-admin` with `developmentLog` when the Activity Log tab is active, or split the API into two endpoints.
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
|
||||||
|
| Breakpoint | Layout |
|
||||||
|
|-----------|--------|
|
||||||
|
| `< sm` (mobile) | Single column, lanes stack vertically as collapsible sections |
|
||||||
|
| `sm–lg` (tablet) | 2 columns (CRITICAL+HIGH | MEDIUM+LOW+NICE) |
|
||||||
|
| `lg+` (desktop) | 5 columns, one per priority tier |
|
||||||
|
|
||||||
|
### Accessibility Fixes
|
||||||
|
|
||||||
|
- Replace `SimpleCollapsible` div+onClick with shadcn `Collapsible` (button trigger, `aria-expanded`, keyboard support)
|
||||||
|
- Add `aria-label` to priority badges (e.g., `aria-label="Critical priority"`)
|
||||||
|
- Add `role="region"` and `aria-label` to lane sections
|
||||||
|
- Ensure all interactive elements are keyboard-focusable
|
||||||
|
- Add `aria-live="polite"` to expand/collapse regions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority for Implementation
|
||||||
|
|
||||||
|
This is a **MEDIUM** priority redesign. The current page works for data display but fails at being a useful admin tool. The kanban-style layout and proper parsing would make it genuinely useful for planning.
|
||||||
|
|
||||||
|
**Estimated effort:** 8-12 hours (Scarlett for UI, Neo for API split if lazy-loading)
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker-push.sh — Tag and push dev image to Forgejo registry
|
||||||
|
# Usage: ./scripts/docker-push.sh
|
||||||
|
# Requires: ~/.openclaw/docker-registry.env (chmod 600)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
source ~/.openclaw/docker-registry.env
|
||||||
|
|
||||||
|
echo "$FORGEJO_REGISTRY_TOKEN" | docker login "$FORGEJO_REGISTRY" -u "$FORGEJO_REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
docker tag bill-tracker:local "${FORGEJO_REGISTRY}/null/bill-tracker:dev"
|
||||||
|
docker push "${FORGEJO_REGISTRY}/null/bill-tracker:dev"
|
||||||
|
|
||||||
|
docker logout "$FORGEJO_REGISTRY"
|
||||||
|
echo "✓ Pushed dev image"
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker-test.sh — Build and run bill-tracker in Docker for testing
|
||||||
|
# Usage: ./scripts/docker-test.sh
|
||||||
|
# Access: http://localhost:3036
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
docker stop bill-tracker 2>/dev/null || true
|
||||||
|
docker rm bill-tracker 2>/dev/null || true
|
||||||
|
rm -rf dist node_modules/.vite 2>/dev/null
|
||||||
|
|
||||||
|
docker build --no-cache -t bill-tracker:local .
|
||||||
|
|
||||||
|
docker run -d --name bill-tracker -p 3036:3000 --restart unless-stopped \
|
||||||
|
-e INIT_ADMIN_USER=admin \
|
||||||
|
-e INIT_ADMIN_PASS=admin123 \
|
||||||
|
-e INIT_TEST_USER=testuser \
|
||||||
|
-e INIT_TEST_PASS=testpass123 \
|
||||||
|
-e INIT_REGULAR_USER=regularuser \
|
||||||
|
-e INIT_REGULAR_PASS=regularpass123 \
|
||||||
|
-e CSRF_HTTP_ONLY=false \
|
||||||
|
-e CSRF_SAME_SITE=lax \
|
||||||
|
-v /tmp/bill-tracker-test/data:/data \
|
||||||
|
bill-tracker:local
|
||||||
|
|
||||||
|
echo "✓ Running on http://localhost:3036"
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue