diff --git a/HISTORY.md b/HISTORY.md index a359551..b5c09c9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,17 @@ # Bill Tracker β€” Changelog +## v0.35.1 + +### πŸ”§ Changed + +- **Bump** β€” `0.35.0` β†’ `0.35.1` + +- **Roadmap pulls from Forgejo issues** β€” The Admin Roadmap tab now fetches live open issues from the Forgejo repository instead of parsing `FUTURE.md`. Issues are grouped into the same priority lanes (`CRITICAL` β†’ `NICE TO HAVE`) using `priority:*` labels. Each card shows the issue title (priority prefix stripped), a 2-line body preview, all label chips rendered in their actual Forgejo colors, creation time, comment count, and a click-through link to the issue. Results are cached server-side for 5 minutes; a ↻ refresh button bypasses the cache on demand. On fetch failure the last cached result is served with a stale indicator. + +- **OIDC login error logging improved** β€” `Issuer.discover()` failures previously produced a blank log line because the error was an `AggregateError` (empty `.message`, real causes in `.errors[]`). Both the `/login` and `/callback` handlers now log the full error, expand `err.errors[]` entries, and surface `err.cause` so network-level failures (e.g. `ETIMEDOUT`, `ENOTFOUND`) are visible in the server log. + +--- + ## v0.35.0 ### πŸ”§ Changed diff --git a/client/api.js b/client/api.js index 1d0f8cb..155924f 100644 --- a/client/api.js +++ b/client/api.js @@ -255,7 +255,7 @@ export const api = { about: () => get('/about'), privacy: () => get('/privacy'), aboutAdmin: () => get('/about-admin'), - roadmap: () => get('/about-admin/roadmap'), + roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), updateStatus: () => get('/version/update-status'), checkForUpdates: () => post('/about-admin/check-updates'), devLog: () => get('/about-admin/dev-log'), diff --git a/client/pages/RoadmapPage.jsx b/client/pages/RoadmapPage.jsx index 987f700..94921b3 100644 --- a/client/pages/RoadmapPage.jsx +++ b/client/pages/RoadmapPage.jsx @@ -7,11 +7,21 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; -import { ChevronDown, ChevronsUpDown, Map, FileText, Loader2, Users, FileCode, Clock } from 'lucide-react'; +import { + AlertCircle, + ChevronDown, + CircleDot, + Clock, + ExternalLink, + FileCode, + FileText, + Loader2, + MessageCircle, + RefreshCw, +} from 'lucide-react'; import { api } from '@/api'; -import { APP_VERSION } from '@/lib/version'; -/* ─── Priority lanes ────────────────────────────────────────────────────────── */ +/* ─── Priority lanes ─────────────────────────────────────────────────────── */ const PRIORITY_LANES = [ { key: 'critical', emoji: 'πŸ”΄', label: 'CRITICAL', borderColor: 'border-t-red-500', textColor: 'text-red-500', badgeClass: 'bg-red-500/15 text-red-500 border-red-500/20' }, @@ -21,127 +31,210 @@ const PRIORITY_LANES = [ { key: 'niceToHave', emoji: 'πŸ’­', label: 'NICE TO HAVE', borderColor: 'border-t-border', textColor: 'text-muted-foreground', badgeClass: 'bg-muted/50 text-muted-foreground border-border/50' }, ]; -// Normalise any priority string to a lane key. -function laneForPriority(priority) { - const normalised = String(priority || '').toLowerCase().replace(/[\s_-]+/g, ''); - const map = { - critical: 'critical', - high: 'high', - medium: 'medium', - low: 'low', - nicetohave: 'niceToHave', - }; - return map[normalised] ?? 'low'; +/* ─── Helpers ────────────────────────────────────────────────────────────── */ + +function priorityFromLabels(labels = []) { + for (const l of labels) { + if (l.name === 'priority:critical') return 'critical'; + if (l.name === 'priority:high') return 'high'; + if (l.name === 'priority:medium') return 'medium'; + if (l.name === 'priority:low') return 'low'; + if (l.name === 'priority:nice-to-have') return 'niceToHave'; + } + return 'low'; } -/* ─── Roadmap item card ─────────────────────────────────────────────────────── */ +function cleanTitle(title) { + return title.replace(/^(CRITICAL|HIGH|MEDIUM|LOW|NICE[\s-]TO[\s-]HAVE)\s*:\s*/i, '').trim(); +} -function RoadmapItemCard({ item, defaultOpen }) { - const lane = PRIORITY_LANES.find(l => l.key === laneForPriority(item.priority)) ?? PRIORITY_LANES[3]; - const [open, setOpen] = useState(defaultOpen); +function stripMarkdown(text) { + if (!text) return ''; + return text + .replace(/#{1,6}\s+[^\n]*/g, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/^[-*+]\s+/gm, '') + .replace(/\n\n+/g, ' ') + .replace(/\n/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); +} - // Sync when parent toggles all cards via forceKey remount (no extra effect needed) +function timeAgo(dateStr) { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 30) return `${days}d ago`; + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function labelStyle(hex) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return { + backgroundColor: `rgba(${r},${g},${b},0.12)`, + color: `#${hex}`, + border: `1px solid rgba(${r},${g},${b},0.28)`, + }; +} + +/* ─── Label chip ─────────────────────────────────────────────────────────── */ + +function LabelChip({ label }) { return ( - -
- - {/* Trigger β€” always visible header */} - - - - - {/* Meta row β€” always visible */} - {(item.added || item.addedBy || item.effort) && ( -
- {item.added && ( - - - {item.added} - - )} - {item.addedBy && ( - <> - - - - {item.addedBy} - - - )} - {item.effort && ( - <> - - {item.effort} - - )} -
- )} - - {/* Expandable detail */} - -
- {item.description && ( -
-

Description

-

{item.description}

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

Rationale

-

{item.rationale}

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

Implementation Notes

-
- {item.implementationNotes} -
-
- )} -
-
-
-
+ + {label.name} + ); } -/* ─── Priority lane column ──────────────────────────────────────────────────── */ +/* ─── Issue card ─────────────────────────────────────────────────────────── */ -function PriorityLane({ lane, items, defaultOpenCards, forceKey }) { - if (items.length === 0) return null; +function IssueCard({ issue }) { + const typeLabels = issue.labels || []; + const title = cleanTitle(issue.title); + const preview = stripMarkdown(issue.body); return ( -
+ + {/* Title row */} +
+

+ {title} +

+ + #{issue.number} + +
+ + {/* Body preview */} + {preview && ( +

+ {preview} +

+ )} + + {/* Type labels */} + {typeLabels.length > 0 && ( +
+ {typeLabels.map(label => ( + + ))} +
+ )} + + {/* Footer */} +
+
+ + + {timeAgo(issue.created_at)} + + {issue.comments > 0 && ( + + + {issue.comments} + + )} +
+ +
+
+ ); +} + +/* ─── Priority lane ──────────────────────────────────────────────────────── */ + +function PriorityLane({ lane, items }) { + return ( +
-

{lane.label}

- {items.length} +

+ {lane.label} +

+ + {items.length} +
- {items.map(item => ( - + {items.map(issue => ( + ))}
); } -/* ─── Dev log entry ─────────────────────────────────────────────────────────── */ +/* ─── Stats bar ──────────────────────────────────────────────────────────── */ + +function StatsBar({ issues, fetchedAt, stale, onRefresh, refreshing }) { + const counts = Object.fromEntries(PRIORITY_LANES.map(l => [l.key, 0])); + issues.forEach(issue => { + const p = priorityFromLabels(issue.labels); + counts[p] = (counts[p] || 0) + 1; + }); + const nonEmpty = PRIORITY_LANES.filter(l => counts[l.key] > 0); + + return ( +
+ {issues.length} open + + {nonEmpty.map(lane => ( + + Β· + + {lane.emoji} + {counts[lane.key]} + {lane.label} + + + ))} + +
+ {fetchedAt && ( + + {stale && ⚠ stale ·} + {timeAgo(fetchedAt)} + + )} + +
+
+ ); +} + +/* ─── Dev log entry ──────────────────────────────────────────────────────── */ function DevLogEntry({ entry }) { const [open, setOpen] = useState(false); @@ -234,56 +327,35 @@ function DevLogEntry({ entry }) { ); } -/* ─── Main page ─────────────────────────────────────────────────────────────── */ +/* ─── 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 [roadmapData, setRoadmapData] = useState(null); + const [devLogData, setDevLogData] = useState(null); + const [roadmapLoading, setRoadmapLoading] = useState(true); + const [roadmapRefreshing,setRoadmapRefreshing]= useState(false); + const [devLogLoading, setDevLogLoading] = useState(false); + const [roadmapError, setRoadmapError] = useState(null); + const [devLogError, setDevLogError] = useState(null); - // Expand/collapse all β€” forceKey causes cards to remount with the new default - const [allExpanded, setAllExpanded] = useState(true); - const [forceKey, setForceKey] = useState(0); - - const handleExpandToggle = () => { - setAllExpanded(prev => !prev); - setForceKey(prev => prev + 1); - }; - - const getIsDesktop = () => ( - typeof window !== 'undefined' - && typeof window.matchMedia === 'function' - && window.matchMedia('(min-width: 1024px)').matches - ); - const [isDesktop, setIsDesktop] = useState(getIsDesktop); - useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined; - const mq = window.matchMedia('(min-width: 1024px)'); - const handler = (e) => setIsDesktop(e.matches); - setIsDesktop(mq.matches); - if (typeof mq.addEventListener === 'function') { - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - } - mq.addListener(handler); - return () => mq.removeListener(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'); }) + .catch(err => { if (!cancelled) setRoadmapError(err.message || 'Failed to load issues'); }) .finally(() => { if (!cancelled) setRoadmapLoading(false); }); return () => { cancelled = true; }; }, []); - // Lazy-load dev log when the Activity tab is first opened + const handleRefresh = () => { + setRoadmapRefreshing(true); + api.roadmap(true) + .then(data => setRoadmapData(data)) + .catch(() => {}) + .finally(() => setRoadmapRefreshing(false)); + }; + const fetchDevLog = useCallback(() => { if (devLogData || devLogLoading) return; let cancelled = false; @@ -292,25 +364,22 @@ export default function RoadmapPage() { .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, devLogLoading]); - const version = roadmapData?.version || APP_VERSION; - const items = roadmapData?.items || []; - const grouped = PRIORITY_LANES.map(lane => ({ + const issues = roadmapData?.issues || []; + const grouped = PRIORITY_LANES.map(lane => ({ ...lane, - items: items.filter(item => laneForPriority(item.priority) === lane.key), + items: issues.filter(issue => priorityFromLabels(issue.labels) === lane.key), })); const visibleLanes = grouped.filter(lane => lane.items.length > 0); - const laneGridClass = { - 1: 'grid-cols-1', - 2: 'grid-cols-1 lg:grid-cols-2', - 3: 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3', - 4: 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4', - 5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 min-[1400px]:grid-cols-5', - }[visibleLanes.length] || 'grid-cols-1'; - - const defaultOpenCards = isDesktop && allExpanded; - const laneProps = { defaultOpenCards, forceKey }; + const cols = visibleLanes.length; + const laneGridClass = + cols <= 1 ? 'grid-cols-1' : + cols === 2 ? 'grid-cols-1 lg:grid-cols-2' : + cols === 3 ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3' : + cols === 4 ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4' : + 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 min-[1400px]:grid-cols-5'; return (
@@ -319,24 +388,36 @@ export default function RoadmapPage() {
- +
-

Roadmap

-

Current and upcoming features by priority

+

Issues & Roadmap

+

+ Open issues Β·{' '} + + BillTracker β†— + +

- - v{version} - + {issues.length > 0 && ( + + {issues.length} open + + )}
{/* Tabs */} { if (v === 'activity') fetchDevLog(); }} className="min-w-0"> - - Roadmap + + Issues @@ -344,38 +425,40 @@ export default function RoadmapPage() { - {/* ── Roadmap tab ── */} + {/* ── Issues tab ── */} {roadmapLoading ? (
- Loading roadmap… + Loading issues…
) : roadmapError ? ( -
-

Failed to load roadmap

-

{roadmapError}

+
+
+ + Failed to load issues +
+

{roadmapError}

- ) : items.length === 0 ? ( + ) : issues.length === 0 ? (
- No roadmap items found. + No open issues.
) : ( - <> -
- -
- - {/* Size the board to its populated lanes so sparse roadmaps stay readable. */} +
+
{visibleLanes.map(lane => ( - + ))}
- +
)} diff --git a/package.json b/package.json index ba5e9b3..7a24f87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.35.0", + "version": "0.35.1", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..f9b2ee5 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,154 @@ +# Bill Tracker Roadmap + +This document tracks the planned features and enhancements for Bill Tracker, organized by priority and implementation status. + +## 🟑 MEDIUM Priority Items + +### 🟑 Projected Cash Flow β€” MEDIUM +**Status:** Not implemented + +**Description:** +Show users what's coming: "You'll have $X left before the 15th", "Upcoming bills before next paycheck", and a "Safe-to-spend" estimate based on starting amount, unpaid bills, and scheduled income. Fits naturally with the existing 1st/15th bucket model. + +**Scope:** +- "Remaining after bills" projection per bucket (1st half / 15th half) +- "Upcoming bills before next paycheck" list +- "Safe-to-spend" estimate based on starting balance minus unpaid bills +- Scheduled income support (payday amounts) + +**Rationale:** +- The 1st/15th bucket model is already built β€” cash flow projection is the natural next step +- Most valuable feature for day-to-day money management +- Turns a bill tracker into a financial planning tool + +**Implementation Notes:** +- Requires user to enter starting balance and payday amounts (new settings fields) +- Calculate: starting amount - unpaid bills due before next payday = safe-to-spend +- Files to modify: `TrackerPage.jsx`, `routes/tracker.js`, `user_settings` table (new fields) +- Estimated effort: 8-10 hours + +--- + +### 🟑 Recurring Payment Rules β€” MEDIUM +**Status:** Partially implemented + +**Description:** +Auto-mark certain bills as paid on due date if `autodraft_status = assumed_paid`. Or create suggested payments awaiting confirmation. Good for autopay-heavy users. + +**Scope:** +- Bills with autopay/autodraft get a "suggested payment" on their due date +- User confirms or dismisses the suggestion +- Auto-mark option: bills can be set to automatically mark as paid on due date + +**Implementation Status:** +- βœ… `auto_mark_paid` column + bill edit checkbox +- βœ… `applyAutopaySuggestions()` in trackerService handles auto-mark + suggestion generation +- βœ… Confirm (`POST /api/payments/autopay-suggestions/:billId/confirm`) and dismiss (`POST /.../dismiss`) endpoints +- βœ… Suggestion UI in TrackerPage with badge + confirm/dismiss buttons +- ❌ No proactive suggestion engine β€” only runs when tracker loads +- ❌ No scheduled task/cron to evaluate bills and create suggestions on due date + +**Remaining Work:** +- Implement scheduled task/cron to evaluate bills and create suggestions on due date +- Estimated effort remaining: 2-3 hours + +--- + +### 🟑 Calendar Agenda Mode β€” MEDIUM +**Status:** Not implemented + +**Description:** +Replace the month-grid calendar with an agenda view: Today / This Week / Next 14 Days. Group bills by "needs action," "autopay," "already paid." More useful when actually paying bills. + +**Rationale:** +- Month grids are pretty but not actionable +- Agenda mode answers "what do I need to do right now?" +- Groups by status makes it immediately clear what needs attention + +**Implementation Notes:** +- New view toggle on CalendarPage: Grid vs Agenda +- Agenda shows: Overdue β†’ Today β†’ This Week β†’ Next 14 Days +- Each group sorted by due date, with action status badges +- Files to modify: `CalendarPage.jsx`, `routes/calendar.js` +- Estimated effort: 6-8 hours + +--- + +### 🟑 Filtered Exports β€” MEDIUM +**Status:** Not implemented + +**Description:** +Export only utilities, debts, overdue, date range, tax-relevant categories. Currently exports everything with no filtering. + +**Rationale:** +- 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 + +**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 + +--- + +## πŸ”΅ LOW Priority Items + +### πŸ”΅ Payment Method Tracking and Summary β€” LOW +**Status:** Not implemented + +**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." + +**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`, schema migration +- Estimated effort: 4-6 hours + +--- + +### πŸ”΅ No Keyboard Navigation or Shortcuts β€” LOW +**Status:** Partially implemented + +**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. + +**Implementation Status:** +- βœ… `Esc` closes any open modal/dialog (via Radix Dialog default) +- βœ… `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`) +- ❌ Arrow keys navigate tracker rows when grid is focused + +**Remaining Work:** +- Implement arrow key navigation for tracker rows +- Estimated effort: 1-2 hours + +--- + +### πŸ”΅ Add comprehensive unit and integration tests +**Status:** Not implemented + +**Description:** +Currently no unit tests exist for components or hooks. The only testing is functional tests in `test-functional.js`. + +**Implementation Notes:** +- Set up Jest + React Testing Library (or vitest) +- Test key components: BillModal, TrackerPage row, BillsTableInner +- Test hooks: useAuth, custom form hooks +- Test utility functions in `client/lib/utils.js` +- Estimated effort: 8-12 hours for baseline coverage + +--- + +## πŸ’­ NICE TO HAVE Items + +### πŸ’­ Add consistent form state management pattern +**Status:** Not implemented + +**Description:** +Form state management is inconsistent across components. Some use `useState` for each field, others use form libraries. + +**Implementation Notes:** +- Consider react-hook-form for complex forms +- Create reusable form field components (InputField, SelectField, etc.) +- Standardize validation approach +- Estimated effort: 4-6 hours \ No newline at end of file diff --git a/routes/aboutAdmin.js b/routes/aboutAdmin.js index d6711d6..73390d4 100644 --- a/routes/aboutAdmin.js +++ b/routes/aboutAdmin.js @@ -397,6 +397,23 @@ function redactSensitiveContent(content) { .replace(/\bpassword\s*=\s*['"][^'"\s]+['"]/gi, 'password=[REDACTED]') } +// ── Forgejo issues cache ────────────────────────────────────────────────────── +const FORGEJO_BASE = 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker'; +let _forgejoCache = null; +let _forgejoCacheTs = 0; +const FORGEJO_TTL_MS = 5 * 60 * 1000; + +async function fetchForgejoIssues() { + const res = await fetch( + `${FORGEJO_BASE}/issues?type=issues&state=open&limit=50&page=1`, + { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000) }, + ); + if (!res.ok) throw new Error(`Forgejo API returned ${res.status}`); + const issues = await res.json(); + if (!Array.isArray(issues)) throw new Error('Unexpected Forgejo response shape'); + return issues; +} + // Admin-only endpoint to serve FUTURE.md and DEVELOPMENT_LOG.md content (raw markdown, backward compat) router.get('/', requireAuth, requireAdmin, (req, res) => { try { @@ -423,19 +440,22 @@ router.get('/', requireAuth, requireAdmin, (req, res) => { } }); -// Admin-only endpoint: parsed roadmap items from FUTURE.md -router.get('/roadmap', requireAuth, requireAdmin, (req, res) => { +// Admin-only endpoint: open issues from Forgejo (5-min cache, ?refresh=1 to bypass) +router.get('/roadmap', requireAuth, requireAdmin, async (req, res) => { + const now = Date.now(); + const refresh = req.query.refresh === '1'; try { - const futureContent = fs.readFileSync(ALLOWED_FILES['FUTURE.md'], 'utf-8'); - const sanitized = redactSensitiveContent(futureContent); - const result = parseFutureMd(sanitized); - res.json(result); + if (!refresh && _forgejoCache && now - _forgejoCacheTs < FORGEJO_TTL_MS) { + return res.json(_forgejoCache); + } + const issues = await fetchForgejoIssues(); + _forgejoCache = { issues, fetchedAt: new Date().toISOString() }; + _forgejoCacheTs = now; + res.json(_forgejoCache); } 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' - }); + console.error('[aboutAdmin] Forgejo issues error:', err.message); + if (_forgejoCache) return res.json({ ..._forgejoCache, stale: true }); + res.status(502).json({ error: 'Failed to fetch issues from repository', code: 'FORGEJO_ERROR' }); } });