From 2ce5328fd2dc3932dc42de17293b49a62185ec84 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 11 May 2026 21:42:36 -0500 Subject: [PATCH] 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) --- .learnings/scarlett/BILL_TRACKER_ACTIVE.md | 52 +++ DEVELOPMENT_LOG.md | 52 +++ FUTURE.md | 368 +++++++++++----- client/App.jsx | 5 +- client/api.js | 6 +- client/components/AdminDashboard.jsx | 444 ------------------- client/components/ui/collapsible.jsx | 17 + client/lib/version.js | 10 +- client/pages/AboutPage.jsx | 15 +- client/pages/RoadmapPage.jsx | 472 +++++++++++++++++++++ docs/ROADMAP_REDESIGN_PLAN.md | 241 +++++++++++ docs/ROADMAP_UI_AUDIT.md | 227 ++++++++++ package-lock.json | 35 +- package.json | 3 +- routes/aboutAdmin.js | 386 ++++++++++++++++- routes/auth.js | 2 +- scripts/docker-push.sh | 17 + scripts/docker-test.sh | 27 ++ tailwind.config.js | 4 + 19 files changed, 1803 insertions(+), 580 deletions(-) create mode 100644 .learnings/scarlett/BILL_TRACKER_ACTIVE.md delete mode 100644 client/components/AdminDashboard.jsx create mode 100644 client/components/ui/collapsible.jsx create mode 100644 client/pages/RoadmapPage.jsx create mode 100644 docs/ROADMAP_REDESIGN_PLAN.md create mode 100644 docs/ROADMAP_UI_AUDIT.md create mode 100755 scripts/docker-push.sh create mode 100755 scripts/docker-test.sh diff --git a/.learnings/scarlett/BILL_TRACKER_ACTIVE.md b/.learnings/scarlett/BILL_TRACKER_ACTIVE.md new file mode 100644 index 0000000..8166859 --- /dev/null +++ b/.learnings/scarlett/BILL_TRACKER_ACTIVE.md @@ -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 ``; `/admin/about` route uses `` 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 `` + ``) +- 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 (`` 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 (` 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 +### 🟡 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) -**Moved to HISTORY.md** +**Description:** +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) -**Moved to HISTORY.md** +**Rationale:** +- 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) -**Moved to HISTORY.md** +**Implementation Notes:** +- 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) -**Moved to HISTORY.md** +### 🟡 No Partial Payment Support — MEDIUM +**Priority:** MEDIUM +**Added:** 2026-05-11 by Ripley -### ~~🟡 Notification Due-Day Math Can Miss Same-Day Reminders — MEDIUM~~ ✅ FIXED (v0.24.0) -**Moved to HISTORY.md** +**Description:** +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) -**Moved to HISTORY.md** +**Rationale:** +- 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 **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 -### ~~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 +### 🔵 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) -**Moved to HISTORY.md** +**Description:** +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) -**Moved to HISTORY.md** +**Rationale:** +- 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 **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` - Estimated effort: 8-12 hours for baseline coverage -### Features: Missing Export for User-Specific Reports -**Priority:** LOW -**Added:** 2026-05-08 by Neo - -**Description:** -No built-in way to export filtered data (e.g., "all bills in category X for last 6 months"). - -**Rationale:** -- `/api/analytics/summary` exists but returns JSON only -- Users cannot generate Excel/PDF reports -- No programmatic way to get export links for specific filters -- `/api/export/user-excel` exports everything, not filtered views - -**Implementation Notes:** -- Files to modify: `/home/kaspa/.openclaw/Projects/bill-tracker/routes/export.js` -- Estimated effort: 6 hours -- Add endpoints: - - `GET /api/export/user-excel?category_id=1&start=2026-01&end=2026-06` - - `GET /api/export/user-json?filter=bills&status=missed` - - Add report title/description to export metadata - ### Features: Missing Bill Grouping and Reorganization API **Priority:** LOW **Added:** 2026-05-08 by Neo @@ -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/:id/archived` to soft-dearchive (sets `archived` flag) ---- ### 💭 NICE TO HAVE @@ -217,43 +401,3 @@ Consistency and maintainability. A consistent pattern makes it easier to add new - Standardize validation approach - Files likely to be modified: `client/components/*.jsx` - Estimated effort: 4-6 hours for migration - ---- - -## Template for New Recommendations - -```markdown -### [Feature Name] -**Priority:** CRITICAL / HIGH / MEDIUM / LOW / MEH -**Added:** YYYY-MM-DD by [Agent] - -**Description:** -Brief description of the improvement. - -**Rationale:** -Why this matters. - -**Implementation Notes:** -- Technical approach -- Files likely to be modified -- Estimated effort - -**Depends On:** -Any prerequisites or blocking issues. -``` - -## Completed Items - -### ✅ Security: Rate Limiting on /api/about-admin — MEDIUM -**Completed:** 2026-05-09 (v0.19.0) -**Fix:** `adminActionLimiter` (30 req/15min) applied to `/api/about-admin` route. - -### ✅ Security: Markdown Sanitization in AboutPage — MEDIUM -**Completed:** 2026-05-09 (v0.19.0) -**Fix:** `rehype-sanitize` added to `AboutPage.jsx` ReactMarkdown component. - -### ✅ Security: aboutAdmin() in API Client — LOW -**Completed:** 2026-05-09 (v0.19.0) -**Fix:** `aboutAdmin` endpoint function added to `client/api.js`. - ---- diff --git a/client/App.jsx b/client/App.jsx index 89e25d1..5aefb68 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -35,6 +35,7 @@ const StatusPage = lazy(() => import('@/pages/StatusPage')); const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage')); const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage')); const AboutPage = lazy(() => import('@/pages/AboutPage')); +const RoadmapPage = lazy(() => import('@/pages/RoadmapPage')); const DataPage = lazy(() => import('@/pages/DataPage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage')); @@ -126,7 +127,7 @@ export default function App() { }> - + @@ -140,7 +141,7 @@ export default function App() { }> - + diff --git a/client/api.js b/client/api.js index 2b330d3..16afff3 100644 --- a/client/api.js +++ b/client/api.js @@ -92,7 +92,7 @@ export const api = { const res = await fetch('/api/admin/backups/import', { method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/octet-stream' }, + headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() }, body: file, }); const data = await res.json(); @@ -186,6 +186,8 @@ export const api = { // Version (public) about: () => get('/about'), aboutAdmin: () => get('/about-admin'), + roadmap: () => get('/about-admin/roadmap'), + devLog: () => get('/about-admin/dev-log'), version: () => get('/version'), releaseHistory: () => get('/version/history'), @@ -204,6 +206,7 @@ export const api = { credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', + 'x-csrf-token': getCsrfToken(), ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, @@ -229,6 +232,7 @@ export const api = { credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', + 'x-csrf-token': getCsrfToken(), ...(file.name ? { 'X-Filename': file.name } : {}), }, body: file, diff --git a/client/components/AdminDashboard.jsx b/client/components/AdminDashboard.jsx deleted file mode 100644 index 146d8d4..0000000 --- a/client/components/AdminDashboard.jsx +++ /dev/null @@ -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 ( -
-
setIsOpen(!isOpen)} - > -
- {title} -
- -
- {isOpen && ( -
- {children} -
- )} -
- ); -} - -// 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 ( - - {emoji} {label} - - ); -} - -/** - * Roadmap Card Component - */ -function RoadmapCard({ item }) { - const colors = PRIORITY_COLORS[item.priority] || PRIORITY_COLORS['💭']; - const isHighPriority = item.priority === '🔴' || item.priority === '🟠'; - - return ( - - - {item.title} - - }> -
-
- {item.status && ( - - Status: {item.status} - - )} - {item.added && ( - - Added: {item.added} - - )} - {item.addedBy && ( - - by {item.addedBy} - - )} -
- -
-
- {item.description} -
-
-
-
- ); -} - -/** - * Development Log Entry Component - */ -function DevLogEntry({ entry }) { - const [isOpen, setIsOpen] = useState(false); - - return ( -
-
setIsOpen(!isOpen)} - > -
- {entry.version} - {entry.date} -
- -
- {entry.status && ( - - {entry.status} - - )} - -
-
- - {isOpen && ( -
- {entry.agents && entry.agents.length > 0 && ( -
- {entry.agents.map((agent, idx) => ( - - {agent.status === 'COMPLETED' && '✅ '} - {agent.name}: {agent.notes} - - ))} -
- )} - - {entry.filesModified && entry.filesModified.length > 0 && ( -
-

Files Modified:

-
- {entry.filesModified.map((file, idx) => ( - - {file} - - ))} -
-
- )} - - {entry.details && ( -
-
- {entry.details} -
-
- )} -
- )} -
- ); -} - -/** - * 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 ( -
-
-
-
-
- ); - } - - return ( -
- {/* Version Badge */} -
- - v{version} - -
- - {/* Roadmap Section */} - - - - - 🗺️ - - Roadmap - - - Current and upcoming features organized by priority - - - -
- {roadmapItems.length === 0 ? ( -
- No roadmap items found -
- ) : ( -
-
- {roadmapItems.map((item, idx) => ( - - ))} -
-
- )} -
-
-
- - {/* Activity Log Section */} - - - - - 📝 - - Development Activity Log - - - Recent development work and completed tasks - - - -
- {devLogEntries.length === 0 ? ( -
- No activity log entries found -
- ) : ( -
-
- {devLogEntries.map((entry, idx) => ( - - ))} -
-
- )} -
-
-
-
- ); -} diff --git a/client/components/ui/collapsible.jsx b/client/components/ui/collapsible.jsx new file mode 100644 index 0000000..7ffc051 --- /dev/null +++ b/client/components/ui/collapsible.jsx @@ -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) => ( + +)); +CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; \ No newline at end of file diff --git a/client/lib/version.js b/client/lib/version.js index fdc4949..29bec42 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -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 RELEASE_NOTES = { - version: '0.24.6', + version: '0.25.0', date: '2026-05-11', 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: 'Starting Amounts Fix', desc: 'Paid deductions now correctly factor in the "other" bucket for remaining balance calculations.' }, - { icon: '🎨', title: 'Pay Badge Alignment', desc: 'Amount input and Pay button now stay inline and centered, no more wrapping on tight layouts.' }, + { icon: '🗺️', title: 'Roadmap Page Redesign', desc: 'Kanban-style priority lanes with collapsible items, admin-only roadmap and activity log APIs replacing AdminDashboard' }, + { icon: '🛡️', title: 'Import CSRF Fix', desc: 'XLSX, SQLite, and backup imports now include CSRF token (previously blocked with "session expired" error)' }, + { icon: '🧹', title: 'AdminDashboard Replaced', desc: 'RoadmapPage now handles admin roadmap and development log display' }, ], }; \ No newline at end of file diff --git a/client/pages/AboutPage.jsx b/client/pages/AboutPage.jsx index 91b7ee7..646e603 100644 --- a/client/pages/AboutPage.jsx +++ b/client/pages/AboutPage.jsx @@ -4,20 +4,19 @@ import { ArrowLeft, Info, Sparkles } from 'lucide-react'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import AdminDashboard from '@/components/AdminDashboard'; -export default function AboutPage({ admin = false }) { +export default function AboutPage() { const [about, setAbout] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { setLoading(true); try { - setAbout(admin ? await api.aboutAdmin() : await api.about()); + setAbout(await api.about()); } finally { setLoading(false); } - }, [admin]); + }, []); useEffect(() => { load(); }, [load]); @@ -33,12 +32,6 @@ export default function AboutPage({ admin = false }) { - {/* Admin Dashboard (visible to admin only) */} - {admin && about?.future && about?.developmentLog && ( - - )} - - {/* Standard About Page (visible to all users) */}
@@ -90,4 +83,4 @@ export default function AboutPage({ admin = false }) {
); -} +} \ No newline at end of file diff --git a/client/pages/RoadmapPage.jsx b/client/pages/RoadmapPage.jsx new file mode 100644 index 0000000..729eeb0 --- /dev/null +++ b/client/pages/RoadmapPage.jsx @@ -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 ( + + + + + + +
+ {item.added && ( + + + {item.added} + + )} + {item.addedBy && ( + <> + + + + {item.addedBy} + + + )} + {effortLabel && ( + <> + + + + {effortLabel} + + + )} +
+ + + + {item.description && ( +
+

Description

+

{item.description}

+
+ )} + {item.rationale && ( +
+

Rationale

+

{item.rationale}

+
+ )} + {item.implementationNotes && ( +
+

Implementation Notes

+
+ {item.implementationNotes} +
+
+ )} +
+
+
+
+ ); +} + +/* ─── Priority Lane ─────────────────────────────────────── */ + +function PriorityLane({ lane, items, defaultOpenCards }) { + if (items.length === 0) return null; + + return ( +
+
+ +

{lane.label}

+ {items.length} +
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +/* ─── Dev Log Entry ─────────────────────────────────────── */ + +function DevLogEntry({ entry }) { + const [open, setOpen] = useState(false); + + return ( + +
+ {/* Timeline line */} +
+
+
+
+ +
+ + + + + +
+ {entry.agents?.length > 0 && ( +
+

Agents

+
+ {entry.agents.map((agent, idx) => ( + + {agent.status === 'COMPLETED' ? '✅' : agent.status === 'IN PROGRESS' ? '⏳' : '❓'}{' '} + {agent.name} + {agent.time ? ` · ${agent.time}` : ''} + + ))} +
+
+ )} + + {entry.filesModified?.length > 0 && ( +
+

Files Modified

+
+ {entry.filesModified.map((file, idx) => ( + + {file} + + ))} +
+
+ )} + + {entry.workCompleted?.length > 0 && ( +
+

Work Completed

+
    + {entry.workCompleted.map((work, idx) => ( +
  • + + {work} +
  • + ))} +
+
+ )} +
+
+
+
+ + ); +} + +/* ─── 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 ( +
+ {/* Page Header */} +
+
+
+ +
+
+

Roadmap

+

Current and upcoming features by priority

+
+
+ + v{version} + +
+ + {/* Tabs */} + { if (value === 'activity') fetchDevLog(); }}> + + + + Roadmap + + + + Activity Log + + + + {/* ─── Roadmap Tab ─── */} + + {roadmapLoading ? ( +
+ + Loading roadmap… +
+ ) : roadmapError ? ( + + +

Failed to load roadmap

+

{roadmapError}

+
+
+ ) : items.length === 0 ? ( + + + No roadmap items found. + + + ) : ( + <> + {/* Expand/Collapse All toggle */} +
+ +
+ + {/* Desktop: 5-column grid */} +
+ {grouped.map(lane => ( + + ))} +
+ + {/* Tablet: 2-column grid */} +
+ {/* Left column: Critical + High */} +
+ {grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => ( + + ))} +
+ {/* Right column: Medium + Low + Nice to Have */} +
+ {grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane => ( + + ))} +
+
+ + {/* Mobile: single column */} +
+ {grouped.map(lane => ( + + ))} +
+ + )} +
+ + {/* ─── Activity Log Tab ─── */} + + {devLogLoading ? ( +
+ + Loading activity log… +
+ ) : devLogError ? ( + + +

Failed to load activity log

+

{devLogError}

+
+
+ ) : devLogEntries.length === 0 ? ( + + + No activity log entries found. + + + ) : ( +
+ {devLogEntries.map((entry, idx) => ( + + ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/docs/ROADMAP_REDESIGN_PLAN.md b/docs/ROADMAP_REDESIGN_PLAN.md new file mode 100644 index 0000000..613a72c --- /dev/null +++ b/docs/ROADMAP_REDESIGN_PLAN.md @@ -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 `` instead of ``; 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 `` +- 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 `` +- 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 \ No newline at end of file diff --git a/docs/ROADMAP_UI_AUDIT.md b/docs/ROADMAP_UI_AUDIT.md new file mode 100644 index 0000000..7fc7cdc --- /dev/null +++ b/docs/ROADMAP_UI_AUDIT.md @@ -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 ``. 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 `` instead of ``; 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) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7d8ad07..e6a5c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "bill-tracker", - "version": "0.21.1", + "version": "0.24.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bill-tracker", - "version": "0.21.1", + "version": "0.24.6", "license": "ISC", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", @@ -969,6 +970,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index b353415..ffe1efd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.24.6", + "version": "0.25.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { @@ -13,6 +13,7 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", diff --git a/routes/aboutAdmin.js b/routes/aboutAdmin.js index 2448c5d..bcb72e5 100644 --- a/routes/aboutAdmin.js +++ b/routes/aboutAdmin.js @@ -14,6 +14,358 @@ const ALLOWED_FILES = { 'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'), }; +// Priority emoji to label mapping +const PRIORITY_MAP = { + '🔴': 'CRITICAL', + '🟠': 'HIGH', + '🟡': 'MEDIUM', + '🔵': 'LOW', + '💭': 'NICE_TO_HAVE', +}; + +/** + * Generate a slug from a title: lowercase, hyphens, strip emojis + */ +function slugify(title) { + return title + .replace(/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1FAFF}]/gu, '') // strip emojis + .replace(/[^a-zA-Z0-9]+/g, '-') // non-alphanumeric → hyphens + .replace(/^-+|-+$/g, '') // trim leading/trailing hyphens + .toLowerCase(); +} + +/** + * Extract effort estimate from implementation notes text. + * Matches patterns like "Estimated effort: 3-4 hours", "Estimated effort: 8 hours" + */ +function extractEffort(text) { + if (!text) return null; + const match = text.match(/Estimated effort:\s*(\d+(?:\s*-\s*\d+)?\s*hours?)/i); + if (!match) return null; + // Normalize: "3-4 hours" → "3-4h", "8 hours" → "8h" + return match[1].replace(/\s*hours?/i, 'h').replace(/\s*/g, ''); +} + +/** + * Parse FUTURE.md into structured roadmap items. + * Filters out completed/strikethrough items and template/meta sections. + */ +function parseFutureMd(content) { + if (!content) return { items: [], counts: {} }; + + const items = []; + const counts = { critical: 0, high: 0, medium: 0, low: 0, niceToHave: 0 }; + + const lines = content.split('\n'); + let skipSection = false; + let currentSectionLines = []; + let currentPriorityEmoji = null; + let currentPriorityLabel = null; + let currentTitle = null; + let inItem = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Skip template/meta sections + if (/^##\s+How to Use This Document/i.test(line) || /^###\s+Priority Format/i.test(line)) { + skipSection = true; + continue; + } + // Completed items section + if (/^##\s+Completed/i.test(line)) { + skipSection = true; + continue; + } + // Stop skipping at ## or ### headings that aren't skipped sections + if (skipSection) { + if (/^(?:##|###)\s/.test(line) && !/^(?:##|###)\s+(How to Use|Priority Format|Completed)/i.test(line)) { + skipSection = false; + // Don't continue — process this heading line below + } else { + continue; + } + } + + // Skip table rows (Priority Format table) + if (/^\|/.test(line)) continue; + + // Strikethrough items: ### ~~Title~~ — PRIORITY + if (/^###\s+~~/.test(line)) { + // Save previous item and skip completed/strikethrough items + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + inItem = false; + currentTitle = null; + currentSectionLines = []; + continue; + } + + // Priority section headings: ### 🔴 CRITICAL, ### 🟠 HIGH, etc. + const sectionMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE)\s*$/); + if (sectionMatch) { + // Save previous item + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + currentPriorityEmoji = sectionMatch[1]; + currentPriorityLabel = sectionMatch[2]; + inItem = false; + currentTitle = null; + currentSectionLines = []; + continue; + } + + // Item headings: ### 🔴 Title — CRITICAL or ### Title — HIGH etc. + const headingMatch = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/); + const headingMatchNoEmoji = line.match(/^###\s+(.+?)\s*—\s*(CRITICAL|HIGH|MEDIUM|LOW|NICE TO HAVE|NICE_TO_HAVE|MEH)\s*$/); + + if (headingMatch) { + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + currentPriorityEmoji = headingMatch[1]; + currentPriorityLabel = headingMatch[3]; + currentTitle = headingMatch[2].trim(); + currentSectionLines = []; + inItem = true; + continue; + } + + if (!headingMatch && headingMatchNoEmoji) { + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + currentPriorityEmoji = currentPriorityEmoji || null; // inherit from section + currentPriorityLabel = headingMatchNoEmoji[2]; + currentTitle = headingMatchNoEmoji[1].trim(); + currentSectionLines = []; + inItem = true; + continue; + } + + // Also handle items with emoji but no trailing priority: ### 🔴 Title + const headingEmojiOnly = line.match(/^###\s+(🔴|🟠|🟡|🔵|💭)\s+(.+?)\s*$/); + if (headingEmojiOnly && !headingMatch) { + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + currentPriorityEmoji = headingEmojiOnly[1]; + // Use section-level priority if available + currentPriorityLabel = currentPriorityLabel || PRIORITY_MAP[headingEmojiOnly[1]] || 'MEDIUM'; + currentTitle = headingEmojiOnly[2].trim(); + currentSectionLines = []; + inItem = true; + continue; + } + + // Generic ### headings without emoji or priority label (items in a section context) + if (/^###\s+/.test(line) && !headingMatch && !headingMatchNoEmoji && !headingEmojiOnly) { + // Plain ### heading within a known section + if (currentPriorityLabel) { + // Save previous item + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + currentTitle = line.replace(/^###\s+/, '').trim(); + currentSectionLines = []; + inItem = true; + continue; + } + } + + // ## Pending Recommendations heading — skip + if (/^##\s+Pending Recommendations/.test(line)) { + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + inItem = false; + currentTitle = null; + currentSectionLines = []; + continue; + } + + // Collect body lines for current item + if (inItem) { + currentSectionLines.push(line); + } + } + + // Save last item + if (inItem && currentTitle) { + _addItem(items, counts, currentPriorityEmoji, currentPriorityLabel, currentTitle, currentSectionLines); + } + + return { items, counts, version: pkg.version }; +} + +/** + * Add a parsed item to the items array and update counts. + */ +function _addItem(items, counts, emoji, label, title, bodyLines) { + const body = bodyLines.join('\n'); + const description = _extractField(body, 'Description'); + const rationale = _extractField(body, 'Rationale'); + const implementationNotes = _extractField(body, 'Implementation Notes'); + const effort = extractEffort(implementationNotes); + + // Extract Added and AddedBy metadata + const addedMatch = body.match(/\*\*Added:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+by\s+(.+))?/); + const added = addedMatch ? addedMatch[1] : null; + const addedBy = addedMatch ? (addedMatch[2] || null) : null; + + // Determine status — if not specified, default to PENDING + const statusMatch = body.match(/\*\*Status:\*\*\s*(.+)/); + const status = statusMatch ? statusMatch[1].trim().toUpperCase() : 'PENDING'; + + // Map priority label to count key + const countKey = { + 'CRITICAL': 'critical', + 'HIGH': 'high', + 'MEDIUM': 'medium', + 'LOW': 'low', + 'NICE TO HAVE': 'niceToHave', + 'NICE_TO_HAVE': 'niceToHave', + 'MEH': 'niceToHave', + }[label] || 'medium'; + counts[countKey]++; + + items.push({ + id: slugify(title), + priority: emoji || '', + priorityLabel: label, + title, + description, + rationale, + implementationNotes, + effort, + added, + addedBy, + status, + }); +} + +/** + * Extract a named field from markdown body text. + * Looks for **Field Name:** and captures everything until the next ** field or ### heading or end. + */ +function _extractField(body, fieldName) { + // Match **FieldName:** followed by content until next ** or ### heading + const regex = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*\n([\\s\\S]*?)(?=\\n\\*\\*[^*]|\\n###|$)`, 'i'); + const match = body.match(regex); + if (!match) return null; + return match[1].trim(); +} + +/** + * Parse DEVELOPMENT_LOG.md into structured log entries. + * Returns entries sorted by date descending. + */ +function parseDevLogMd(content) { + if (!content) return []; + + const entries = []; + // Split on version headings: ### v0.24.4 - Title + const versionRegex = /^###\s+(v[\d.]+(?:-[\w]+)?)\s+-\s+(.+)$/gm; + const splits = []; + let match; + while ((match = versionRegex.exec(content)) !== null) { + splits.push({ + version: match[1], + title: match[2].trim(), + index: match.index, + }); + } + + for (let i = 0; i < splits.length; i++) { + const start = splits[i].index; + const end = i + 1 < splits.length ? splits[i + 1].index : content.length; + const block = content.substring(start, end); + const entry = _parseDevLogEntry(block, splits[i].version, splits[i].title); + if (entry) entries.push(entry); + } + + // Sort by date descending + entries.sort((a, b) => { + const dateA = a.date ? new Date(a.date) : new Date(0); + const dateB = b.date ? new Date(b.date) : new Date(0); + return dateB - dateA; + }); + + return entries; +} + +/** + * Parse a single dev log entry block. + */ +function _parseDevLogEntry(block, version, title) { + // Status + const statusMatch = block.match(/\*\*Status:\*\*\s*(.+)/); + const status = statusMatch ? statusMatch[1].trim() : null; + + // Date + const dateMatch = block.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/); + const date = dateMatch ? dateMatch[1] : null; + + // Priority + const priorityMatch = block.match(/\*\*Priority:\*\*\s*(.+)/); + const priority = priorityMatch ? priorityMatch[1].trim() : null; + + // Agents table + const agents = []; + const agentTableRegex = /\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/g; + let inAgentTable = false; + const blockLines = block.split('\n'); + for (const line of blockLines) { + if (/^\|\s*Agent\s*\|/i.test(line)) { + inAgentTable = true; + continue; + } + if (/^\|\s*[-:]+\s*\|/.test(line)) continue; // separator row + if (inAgentTable && /^\|/.test(line)) { + const row = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|/); + if (row) { + agents.push({ + name: row[1].trim(), + status: row[2].trim(), + time: row[3].trim(), + notes: row[4].trim(), + }); + } + } else if (inAgentTable && !/^\|/.test(line) && line.trim() !== '') { + inAgentTable = false; + } + } + + // Files modified + const filesMatch = block.match(/\*\*Files modified:\*\*\s*(.+)/); + const filesModified = filesMatch + ? filesMatch[1].split(',').map(f => f.trim().replace(/^`|`$/g, '')).filter(Boolean) + : []; + + // Work completed (checklist items) + const workCompleted = []; + const workMatch = block.match(/\*\*Work Completed:\*\*\n([\s\S]*?)(?=\n---|\n###|$)/); + if (workMatch) { + const items = workMatch[1].match(/- \[[ x]\] .+/g); + if (items) { + workCompleted.push(...items.map(item => item.replace(/^- \[[ x]\]\s*/, '').trim())); + } + } + + return { + version, + title, + date, + status, + priority, + agents, + filesModified, + workCompleted, + }; +} + /** * Redact sensitive information from file content * @param {string} content - The content to redact @@ -45,7 +397,7 @@ function redactSensitiveContent(content) { .replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]') } -// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content +// Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat) router.get('/', requireAuth, requireAdmin, (req, res) => { try { // Read both files directly from the allowlist @@ -71,4 +423,36 @@ router.get('/', requireAuth, requireAdmin, (req, res) => { } }); +// Admin-only endpoint: parsed roadmap items from FUTURE.md +router.get('/roadmap', requireAuth, requireAdmin, (req, res) => { + try { + const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8'); + const sanitized = redactSensitiveContent(futureContent); + const result = parseFutureMd(sanitized); + res.json(result); + } catch (err) { + console.error('[aboutAdmin] Error reading FUTURE.md for roadmap'); + res.status(500).json({ + error: 'Failed to read roadmap data', + code: 'FILE_READ_ERROR' + }); + } +}); + +// Admin-only endpoint: parsed dev log entries from DEVELOPMENT_LOG.md +router.get('/dev-log', requireAuth, requireAdmin, (req, res) => { + try { + const devLogContent = fs.readFileSync(ALLOWED_FILES['DEVELOPMENT_LOG.md'], 'utf-8'); + const sanitized = redactSensitiveContent(devLogContent); + const entries = parseDevLogMd(sanitized); + res.json({ entries, version: pkg.version }); + } catch (err) { + console.error('[aboutAdmin] Error reading DEVELOPMENT_LOG.md for dev-log'); + res.status(500).json({ + error: 'Failed to read dev log data', + code: 'FILE_READ_ERROR' + }); + } +}); + module.exports = router; diff --git a/routes/auth.js b/routes/auth.js index 0b4dc3f..9d7492f 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -117,7 +117,7 @@ router.post('/acknowledge-privacy', requireAuth, (req, res) => { // POST /api/auth/change-password // Password change endpoint with dedicated rate limiter -// Exempt from CSRF - session-based auth is primary protection (pre-middleware sets csrfSkip) +// CSRF protected via csrfMiddleware on /api/auth mount router.post('/change-password', passwordLimiter, requireAuth, async (req, res) => { const { current_password, new_password } = req.body; diff --git a/scripts/docker-push.sh b/scripts/docker-push.sh new file mode 100755 index 0000000..cf83c64 --- /dev/null +++ b/scripts/docker-push.sh @@ -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" \ No newline at end of file diff --git a/scripts/docker-test.sh b/scripts/docker-test.sh new file mode 100755 index 0000000..9c99f05 --- /dev/null +++ b/scripts/docker-test.sh @@ -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" \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index c4d9dd5..e59e319 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -61,10 +61,14 @@ module.exports = { keyframes: { 'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } }, 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } }, + 'collapsible-down': { from: { height: '0' }, to: { height: 'var(--radix-collapsible-content-height)' } }, + 'collapsible-up': { from: { height: 'var(--radix-collapsible-content-height)' }, to: { height: '0' } }, }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'collapsible-down': 'collapsible-down 0.2s ease-out', + 'collapsible-up': 'collapsible-up 0.2s ease-out', }, }, },