diff --git a/HISTORY.md b/HISTORY.md index c7d9a64..c626b18 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -15,6 +15,14 @@ - **Bank payments override provisional manual tracker payments** — Manual payments entered from the Tracker count immediately while waiting for bank sync. When a matching bank-backed payment clears for the same bill cycle, the bank payment becomes the accounting source of truth and the manual payment is preserved as history only with override metadata and a BillModal badge/note. Overridden manual payments are excluded consistently from Tracker, Summary, Calendar, Analytics, Categories, starting amount summaries, drift checks, notifications, status counts, bank pending deductions, trends, overdue checks, and debt balance deltas. If the bank match is undone, the provisional manual payment is reactivated. +- **Animated page, modal, and tracker reorder transitions** — Added `framer-motion` and a shared `PageTransition` wrapper for user/admin route content. Dialog and AlertDialog content now use Framer entry motion, while Tracker bucket rows/cards use layout animation so sorted and reordered bill groups move smoothly instead of snapping. + +- **Remembered collapsible search/filter panels** — Added a shared `SearchFilterPanel` and `useSearchPanelPreference` hook backed by the per-user `search_bars_collapsed` setting. Tracker, Bills, and Subscriptions search/filter areas can now be collapsed, default open, and the user's expanded/collapsed preference is remembered across pages and sessions. + +- **Profile display-name persistence** — Profile name updates now save to `users.display_name` in the database and are returned consistently as `display_name`, `displayName`, and `name` aliases from auth/profile responses. The Profile page and Sidebar now display the saved name reliably after reloads and new sessions, with profile route coverage added. + +- **Private calendar subscription feed** — Added a token-protected `feed.ics` calendar subscription flow for Apple Calendar, Google Calendar/Android, Outlook, and generic ICS clients. Settings now has a Calendar Feed card with create, copy, regenerate, revoke, and preview actions, plus platform guidance and bearer-link privacy copy. The Calendar page includes an Add All entry point that routes users to the subscription setup. The public feed endpoint does not require session cookies, uses stable per-bill-cycle event UIDs to avoid duplicate subscribed events, emits all-day DATE events with CRLF/no-BOM/no-VTIMEZONE ICS output, and is backed by the new idempotent `calendar_tokens` migration. + - **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction. - **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account. @@ -40,6 +48,10 @@ ### 🔧 Changed +- **Mobile tracker view polish** — The mobile bill tracker now mirrors desktop drift context with the "Changed" badge and compact amount sparkline, prevents duplicate quick-pay taps while a save is in progress, and lets bucket headers wrap cleanly on narrow screens. The old unused mobile monthly-state dialog path was removed so the row actions stay focused on the supported tracker flows. + +- **Empty state polish** — Every dashed-border centered text empty state across the app has been replaced. The `border border-dashed border-border/...` treatment read as a broken UI rather than an intentional empty state. All instances now use a `bg-muted/20` filled card with no border. Empty states that were purely informational also received a CTA or action hint: the BillModal payment history empty state now reads "No payments yet — use the form below to record the first payment"; the PaymentLedgerDialog shows "No payments yet — add one below"; the Summary page "no bills" state links to `/bills` with an "Add a bill →" arrow. Filter-state empty copy in TrackerBucket was reworded from the passive "No bills match this bucket and filter set" to the directive "No bills match — try adjusting your filters." The Categories page empty-category row already had an "Open Bills" button — its label was updated to "Add a bill →" for consistency. Dashed borders were stripped (content preserved) on SnowballPage, PayoffPage (both `EmptyDebts` and the chart placeholder), CalendarPage day panel, and RoadmapPage issue/activity-log empties. + - **React 19 upgrade** — Upgraded from React 18.3.1 to React 19. The `useOptimistic` polyfill in `client/hooks/useOptimistic.js` was deleted in favour of the native React 19 hook. `BillModal` was refactored from a manual `handleSubmit` + `useState(busy)` pattern to `useActionState` with a form `action` prop — the async action handles validation, bill creation/update, template save, and error toasts without a separate busy flag. All 15 shadcn/ui component files (`button`, `badge`, `card`, `checkbox`, `collapsible`, `dialog`, `input`, `label`, `select`, `separator`, `Skeleton`, `table`, `tabs`, `theme-toggle`, `tooltip`) had `React.forwardRef(...)` replaced with plain function components accepting `ref` as a named prop, removing the deprecated pattern. - **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount. diff --git a/client/api.js b/client/api.js index 781cf17..378405b 100644 --- a/client/api.js +++ b/client/api.js @@ -174,6 +174,11 @@ export const api = { // Calendar calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`), + calendarFeed: () => get('/calendar/feed'), + createCalendarFeed: () => post('/calendar/feed', {}), + regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}), + revokeCalendarFeed: () => del('/calendar/feed'), + calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }), // Summary summary: (y, m) => get(`/summary?year=${y}&month=${m}`), diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx index b86c9a0..2565f24 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.jsx @@ -836,6 +836,12 @@ export default function CalendarPage() { + diff --git a/client/pages/SettingsPage.jsx b/client/pages/SettingsPage.jsx index 819b4a7..ea18db5 100644 --- a/client/pages/SettingsPage.jsx +++ b/client/pages/SettingsPage.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react'; +import { AlertCircle, CalendarDays, Copy, Eye, KeyRound, Moon, RefreshCw, ShieldOff, Sun, Users } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -237,6 +237,193 @@ function LinkImportToggle() { ); } +function CalendarFeedSection() { + const [feed, setFeed] = useState(null); + const [preview, setPreview] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + + const loadFeed = useCallback(async () => { + setLoading(true); + try { + const data = await api.calendarFeed(); + setFeed(data); + if (data?.active) { + const nextPreview = await api.calendarFeedPreview(10); + setPreview(nextPreview.events || []); + } else { + setPreview([]); + } + } catch (err) { + toast.error(err.message || 'Failed to load calendar feed.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadFeed(); }, [loadFeed]); + + const active = !!feed?.active && !!feed?.feed_url; + + async function createFeed() { + setBusy('create'); + try { + const data = await api.createCalendarFeed(); + setFeed(data); + const nextPreview = await api.calendarFeedPreview(10); + setPreview(nextPreview.events || []); + toast.success('Calendar feed created.'); + } catch (err) { + toast.error(err.message || 'Failed to create calendar feed.'); + } finally { + setBusy(null); + } + } + + async function copyFeedUrl() { + if (!feed?.feed_url) return; + try { + await navigator.clipboard.writeText(feed.feed_url); + toast.success('Calendar feed URL copied.'); + } catch { + toast.error('Copy failed. Select the URL and copy it manually.'); + } + } + + async function regenerateFeed() { + setBusy('regenerate'); + try { + const data = await api.regenerateCalendarFeed(); + setFeed(data); + const nextPreview = await api.calendarFeedPreview(10); + setPreview(nextPreview.events || []); + toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.'); + } catch (err) { + toast.error(err.message || 'Failed to regenerate calendar feed.'); + } finally { + setBusy(null); + } + } + + async function revokeFeed() { + setBusy('revoke'); + try { + const data = await api.revokeCalendarFeed(); + setFeed(data); + setPreview([]); + toast.success('Calendar feed revoked.'); + } catch (err) { + toast.error(err.message || 'Failed to revoke calendar feed.'); + } finally { + setBusy(null); + } + } + + return ( + +
+
+
+
+ +

Subscribe from Apple Calendar, Google Calendar, Android, or Outlook

+
+

+ This creates a private ICS feed URL. Calendar apps refresh subscribed feeds on their own schedule, so Google and Apple may take time to show updates. +

+
+ + {!loading && !active && ( + + )} +
+ + {loading && ( +
+ )} + + {!loading && active && ( +
+
+ Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it was shared somewhere it should not be. +
+ +
+ + + +
+ +
+
+

Apple Calendar

+

Add a calendar subscription using the copied URL. The feed uses all-day DATE events to avoid timezone shifts.

+
+
+

Google Calendar

+

Use Google Calendar on the web: Other calendars, From URL. Android sync follows your Google Calendar settings.

+
+
+

Outlook

+

Subscribe from Outlook on the web with this URL. Imported copies will not update; subscribed calendars will.

+
+
+

Duplicate Safety

+

Bill Tracker emits stable event IDs per bill cycle, so subscribed calendars can update without double-adding events.

+
+
+ +
+
+

Next events

+ + Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'} + +
+
+ {preview.length === 0 && ( +

No upcoming bill events in the preview window.

+ )} + {preview.map(event => ( +
+
+

{event.name}

+

{event.due_date} · {event.cycle_type}

+
+ ${Number(event.amount || 0).toFixed(2)} +
+ ))} +
+
+ +
+ + +
+
+ )} +
+ + ); +} + // ─── SettingsPage ───────────────────────────────────────────────────────────── export default function SettingsPage() { @@ -388,7 +575,6 @@ export default function SettingsPage() { - {/* Save button — right-aligned below all cards */}
+
+ +
+
); } diff --git a/db/database.js b/db/database.js index e0820bf..453b3f2 100644 --- a/db/database.js +++ b/db/database.js @@ -3323,6 +3323,27 @@ function runMigrations() { console.log('[v0.99] autopay trust indicators + lifecycle fields added'); } }, + { + version: 'v1.00', + description: 'calendar feed subscription tokens', + run() { + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + label TEXT, + active INTEGER NOT NULL DEFAULT 1, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_calendar_tokens_token ON calendar_tokens(token) WHERE active = 1; + CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user_active ON calendar_tokens(user_id, active); + `); + console.log('[v1.00] calendar feed token table ensured'); + } + }, ]; // ── users: notification columns ─────────────────────────────────────────── diff --git a/db/schema.sql b/db/schema.sql index bbedec7..d123ead 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -300,3 +300,16 @@ CREATE TABLE IF NOT EXISTS snowball_plans ( CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at); + +CREATE TABLE IF NOT EXISTS calendar_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + label TEXT, + active INTEGER NOT NULL DEFAULT 1, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_calendar_tokens_token ON calendar_tokens(token) WHERE active = 1; +CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user_active ON calendar_tokens(user_id, active); diff --git a/routes/calendar.js b/routes/calendar.js index 758ac14..904af6f 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -5,6 +5,14 @@ const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { getUserSettings } = require('../services/userSettings'); const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { + feedUrlForToken, + getActiveToken, + getOrCreateToken, + previewFeed, + regenerateToken, + revokeToken, +} = require('../services/calendarFeedService'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); @@ -32,6 +40,58 @@ function emptyDay(year, month, day) { }; } +function tokenPayload(req, tokenRow) { + if (!tokenRow) { + return { + active: false, + token: null, + feed_url: null, + created_at: null, + last_used_at: null, + revoked_at: null, + }; + } + return { + active: !!tokenRow.active, + token: tokenRow.token, + feed_url: feedUrlForToken(req, tokenRow.token), + created_at: tokenRow.created_at, + last_used_at: tokenRow.last_used_at, + revoked_at: tokenRow.revoked_at, + }; +} + +// GET /api/calendar/feed — current user's calendar feed token and URL +router.get('/feed', (req, res) => { + res.json(tokenPayload(req, getActiveToken(req.user.id))); +}); + +// POST /api/calendar/feed — create the feed token if one does not exist +router.post('/feed', (req, res) => { + const token = getOrCreateToken(req.user.id); + res.status(201).json(tokenPayload(req, token)); +}); + +// POST /api/calendar/feed/regenerate — revoke old token and issue a new URL +router.post('/feed/regenerate', (req, res) => { + const token = regenerateToken(req.user.id); + res.json(tokenPayload(req, token)); +}); + +// DELETE /api/calendar/feed — revoke the active feed URL +router.delete('/feed', (req, res) => { + revokeToken(req.user.id); + res.json(tokenPayload(req, null)); +}); + +// GET /api/calendar/feed/preview — next generated events shown before subscribing +router.get('/feed/preview', (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 50); + res.json({ + events: previewFeed(req.user.id, { limit }), + }); +}); + // GET /api/calendar?year=2026&month=5 router.get('/', (req, res) => { const db = getDb(); diff --git a/routes/calendarFeed.js b/routes/calendarFeed.js new file mode 100644 index 0000000..9bb3796 --- /dev/null +++ b/routes/calendarFeed.js @@ -0,0 +1,34 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const { + buildCalendarFeed, + getTokenRecord, + markTokenUsed, +} = require('../services/calendarFeedService'); + +// GET /api/calendar/feed.ics?token=... +// Public by design: calendar clients cannot use the app's session cookies. +router.get('/feed.ics', (req, res) => { + const token = String(req.query.token || ''); + const tokenRow = getTokenRecord(token); + if (!tokenRow) { + return res.status(404).type('text/plain').send('Calendar feed not found or revoked.'); + } + + const { ics } = buildCalendarFeed(tokenRow.user_id, { + name: tokenRow.label || 'Bill Tracker', + }); + markTokenUsed(tokenRow.id); + + res.status(200); + res.set({ + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': 'inline; filename="bill-tracker.ics"', + 'Cache-Control': 'private, max-age=300', + }); + return res.send(ics); +}); + +module.exports = router; diff --git a/server.js b/server.js index 658002e..cce6152 100644 --- a/server.js +++ b/server.js @@ -90,6 +90,7 @@ app.use('/api/matches', csrfMiddleware, requireAuth, requireUser, require( app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories')); app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings')); app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user')); +app.use('/api/calendar', require('./routes/calendarFeed')); app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require('./routes/calendar')); app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary')); app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts')); diff --git a/services/calendarFeedService.js b/services/calendarFeedService.js new file mode 100644 index 0000000..d51c3ff --- /dev/null +++ b/services/calendarFeedService.js @@ -0,0 +1,358 @@ +'use strict'; + +const crypto = require('crypto'); +const { getDb } = require('../db/database'); +const { normalizeCycleType, resolveDueDate } = require('./statusService'); + +const PRODID = '-//Bill Tracker//Calendar Feed//EN'; +const FEED_PAST_MONTHS = 12; +const FEED_FUTURE_MONTHS = 24; + +const WEEKDAY_CODES = { + sunday: 'SU', + monday: 'MO', + tuesday: 'TU', + wednesday: 'WE', + thursday: 'TH', + friday: 'FR', + saturday: 'SA', +}; + +function ensureCalendarTokenSchema(db = getDb()) { + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + label TEXT, + active INTEGER NOT NULL DEFAULT 1, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_calendar_tokens_token ON calendar_tokens(token) WHERE active = 1; + CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user_active ON calendar_tokens(user_id, active); + `); +} + +function generateToken() { + return crypto.randomBytes(32).toString('base64url'); +} + +function sanitizeLabel(label) { + const value = String(label || 'Bill Tracker Calendar').trim(); + return value.slice(0, 80) || 'Bill Tracker Calendar'; +} + +function getActiveToken(userId, db = getDb()) { + ensureCalendarTokenSchema(db); + return db.prepare(` + SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at + FROM calendar_tokens + WHERE user_id = ? AND active = 1 + ORDER BY created_at DESC, id DESC + LIMIT 1 + `).get(userId) || null; +} + +function createToken(userId, label = 'Bill Tracker Calendar', db = getDb()) { + ensureCalendarTokenSchema(db); + const token = generateToken(); + const result = db.prepare(` + INSERT INTO calendar_tokens (user_id, token, label, active, created_at) + VALUES (?, ?, ?, 1, datetime('now')) + `).run(userId, token, sanitizeLabel(label)); + return db.prepare(` + SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at + FROM calendar_tokens + WHERE id = ? + `).get(result.lastInsertRowid); +} + +function getOrCreateToken(userId, db = getDb()) { + return getActiveToken(userId, db) || createToken(userId, 'Bill Tracker Calendar', db); +} + +function regenerateToken(userId, db = getDb()) { + ensureCalendarTokenSchema(db); + let next; + db.transaction(() => { + db.prepare(` + UPDATE calendar_tokens + SET active = 0, revoked_at = datetime('now') + WHERE user_id = ? AND active = 1 + `).run(userId); + next = createToken(userId, 'Bill Tracker Calendar', db); + })(); + return next; +} + +function revokeToken(userId, db = getDb()) { + ensureCalendarTokenSchema(db); + db.prepare(` + UPDATE calendar_tokens + SET active = 0, revoked_at = datetime('now') + WHERE user_id = ? AND active = 1 + `).run(userId); +} + +function getTokenRecord(token, db = getDb()) { + ensureCalendarTokenSchema(db); + if (!token || typeof token !== 'string') return null; + return db.prepare(` + SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at + FROM calendar_tokens + WHERE token = ? AND active = 1 + `).get(token) || null; +} + +function markTokenUsed(tokenId, db = getDb()) { + ensureCalendarTokenSchema(db); + db.prepare('UPDATE calendar_tokens SET last_used_at = datetime(\'now\') WHERE id = ?').run(tokenId); +} + +function originFromRequest(req) { + const host = req.get?.('x-forwarded-host') || req.get?.('host') || 'localhost'; + const proto = req.get?.('x-forwarded-proto') || req.protocol || 'http'; + return `${String(proto).split(',')[0]}://${String(host).split(',')[0]}`; +} + +function feedUrlForToken(req, token) { + return `${originFromRequest(req)}/api/calendar/feed.ics?token=${encodeURIComponent(token)}`; +} + +function ymd(date) { + return [ + date.getUTCFullYear(), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0'), + ].join('-'); +} + +function icsDate(dateString) { + return String(dateString).replaceAll('-', ''); +} + +function utcStamp(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +function addDays(dateString, days) { + const [year, month, day] = String(dateString).split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() + days); + return ymd(date); +} + +function addMonths(date, months) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1)); +} + +function monthIterator(startDate, endDate) { + const months = []; + let cursor = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), 1)); + const end = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), 1)); + while (cursor <= end) { + months.push({ year: cursor.getUTCFullYear(), month: cursor.getUTCMonth() + 1 }); + cursor = addMonths(cursor, 1); + } + return months; +} + +// RFC 5545 §3.3.11 TEXT: escape backslash, semicolon, comma, and line breaks. +function escapeText(value) { + return String(value ?? '') + .replace(/\\/g, '\\\\') + .replace(/\r\n|\r|\n/g, '\\n') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,'); +} + +// RFC 5545 §3.1 content lines: fold at 75 octets, not JavaScript characters. +function foldLine(line) { + const value = String(line); + if (Buffer.byteLength(value, 'utf8') <= 75) return value; + + const lines = []; + let current = ''; + let limit = 75; + + for (const char of value) { + const next = current + char; + if (Buffer.byteLength(next, 'utf8') > limit) { + lines.push(current); + current = char; + limit = 74; // continuation lines include one leading WSP octet + } else { + current = next; + } + } + + if (current) lines.push(current); + return lines.map((part, index) => (index === 0 ? part : ` ${part}`)).join('\r\n'); +} + +function weekdayForCycleDay(value) { + const normalized = String(value || 'monday').trim().toLowerCase(); + return WEEKDAY_CODES[normalized] || 'MO'; +} + +function monthForCycleDay(value) { + const parsed = parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 12 ? parsed : 1; +} + +function dayForBill(bill) { + const parsed = parseInt(bill?.due_day, 10); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 31 ? parsed : 1; +} + +// RRULE values follow RFC 5545 §3.3.10 and mirror the app's five cycle types. +function rruleForCycle(bill = {}) { + const cycleType = normalizeCycleType(bill); + if (cycleType === 'weekly') { + return `FREQ=WEEKLY;BYDAY=${weekdayForCycleDay(bill.cycle_day)}`; + } + if (cycleType === 'biweekly') { + return `FREQ=WEEKLY;INTERVAL=2;BYDAY=${weekdayForCycleDay(bill.cycle_day)}`; + } + if (cycleType === 'quarterly') { + return `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=${dayForBill(bill)}`; + } + if (cycleType === 'annual') { + return `FREQ=YEARLY;BYMONTH=${monthForCycleDay(bill.cycle_day)};BYMONTHDAY=${dayForBill(bill)}`; + } + return `FREQ=MONTHLY;BYMONTHDAY=${dayForBill(bill)}`; +} + +function eventUid(bill, dueDate) { + return `bill-tracker-bill-${bill.id}-${dueDate}@bill-tracker`; +} + +function eventSummary(bill, detailLevel = 'standard') { + if (detailLevel === 'private') return 'Bill due'; + if (detailLevel === 'full') return `${bill.name} due - $${Number(bill.expected_amount || 0).toFixed(2)}`; + return `${bill.name} due`; +} + +function eventDescription(bill, dueDate, detailLevel = 'standard') { + const lines = ['Bill Tracker reminder']; + if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`); + if (detailLevel === 'full') lines.push(`Expected amount: $${Number(bill.expected_amount || 0).toFixed(2)}`); + lines.push(`Due date: ${dueDate}`); + if (bill.category_name) lines.push(`Category: ${bill.category_name}`); + if (bill.autopay_enabled) lines.push('Autopay: enabled'); + return lines.join('\n'); +} + +function buildEvent({ bill, dueDate, detailLevel = 'standard', dtstamp = utcStamp() }) { + const dtEnd = addDays(dueDate, 1); + const lines = [ + 'BEGIN:VEVENT', + `UID:${eventUid(bill, dueDate)}`, + `DTSTAMP:${dtstamp}`, + `DTSTART;VALUE=DATE:${icsDate(dueDate)}`, + `DTEND;VALUE=DATE:${icsDate(dtEnd)}`, + `SUMMARY:${escapeText(eventSummary(bill, detailLevel))}`, + `DESCRIPTION:${escapeText(eventDescription(bill, dueDate, detailLevel))}`, + `CATEGORIES:${escapeText(bill.category_name || 'Bills')}`, + 'TRANSP:TRANSPARENT', + 'STATUS:CONFIRMED', + 'END:VEVENT', + ]; + return lines; +} + +function loadActiveBillsForFeed(userId, db = getDb()) { + return db.prepare(` + SELECT b.*, c.name AS category_name + FROM bills b + LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL + WHERE b.user_id = ? + AND b.active = 1 + AND b.deleted_at IS NULL + ORDER BY b.name ASC + `).all(userId); +} + +function buildFeedEvents(userId, options = {}, db = getDb()) { + const now = options.now || new Date(); + const start = options.startDate || addMonths(now, -(options.pastMonths ?? FEED_PAST_MONTHS)); + const end = options.endDate || addMonths(now, options.futureMonths ?? FEED_FUTURE_MONTHS); + const detailLevel = options.detailLevel || 'standard'; + const dtstamp = options.dtstamp || utcStamp(now); + const events = []; + const seen = new Set(); + + for (const bill of loadActiveBillsForFeed(userId, db)) { + for (const { year, month } of monthIterator(start, end)) { + const dueDate = resolveDueDate(bill, year, month); + if (!dueDate) continue; + const uid = eventUid(bill, dueDate); + if (seen.has(uid)) continue; + seen.add(uid); + events.push({ bill, dueDate, uid, lines: buildEvent({ bill, dueDate, detailLevel, dtstamp }) }); + } + } + + events.sort((a, b) => a.dueDate.localeCompare(b.dueDate) || a.bill.name.localeCompare(b.bill.name)); + return events; +} + +function buildIcsCalendar({ name = 'Bill Tracker', events = [] } = {}) { + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + `PRODID:${PRODID}`, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + `X-WR-CALNAME:${escapeText(name)}`, + 'X-WR-TIMEZONE:UTC', + ]; + + for (const event of events) { + lines.push(...event.lines); + } + + lines.push('END:VCALENDAR'); + return `${lines.map(foldLine).join('\r\n')}\r\n`; +} + +function buildCalendarFeed(userId, options = {}, db = getDb()) { + const events = buildFeedEvents(userId, options, db); + return { + ics: buildIcsCalendar({ name: options.name || 'Bill Tracker', events }), + events, + }; +} + +function previewFeed(userId, options = {}, db = getDb()) { + const events = buildFeedEvents(userId, { ...options, pastMonths: 0, futureMonths: 6 }, db); + return events.slice(0, options.limit || 10).map(event => ({ + uid: event.uid, + bill_id: event.bill.id, + name: event.bill.name, + due_date: event.dueDate, + amount: Number(event.bill.expected_amount || 0), + cycle_type: normalizeCycleType(event.bill), + category_name: event.bill.category_name || null, + })); +} + +module.exports = { + buildCalendarFeed, + buildFeedEvents, + createToken, + ensureCalendarTokenSchema, + escapeText, + feedUrlForToken, + foldLine, + getActiveToken, + getOrCreateToken, + getTokenRecord, + markTokenUsed, + previewFeed, + regenerateToken, + revokeToken, + rruleForCycle, +}; diff --git a/tests/calendarFeedService.test.js b/tests/calendarFeedService.test.js new file mode 100644 index 0000000..82e63a6 --- /dev/null +++ b/tests/calendarFeedService.test.js @@ -0,0 +1,160 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-calendar-feed-test-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { + buildCalendarFeed, + createToken, + escapeText, + foldLine, + rruleForCycle, +} = require('../services/calendarFeedService'); +const { getDb, closeDb } = require('../db/database'); + +function createUser(db, username = 'calendar-user') { + return db.prepare(` + INSERT INTO users (username, password_hash, role, active, created_at, updated_at) + VALUES (?, 'x', 'user', 1, datetime('now'), datetime('now')) + `).run(username).lastInsertRowid; +} + +function createBill(db, userId, overrides = {}) { + return db.prepare(` + INSERT INTO bills ( + user_id, name, due_day, expected_amount, cycle_type, cycle_day, + billing_cycle, active, autopay_enabled, autodraft_status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, 'none', datetime('now'), datetime('now')) + `).run( + userId, + overrides.name || 'Water, Power; Internet', + overrides.due_day || 15, + overrides.expected_amount || 123.45, + overrides.cycle_type || 'monthly', + overrides.cycle_day || '1', + overrides.billing_cycle || 'monthly', + overrides.autopay_enabled || 0, + ).lastInsertRowid; +} + +function callFeedRoute(query) { + const router = require('../routes/calendarFeed'); + const layer = router.stack.find(item => item.route?.path === '/feed.ics' && item.route.methods.get); + assert.ok(layer, 'GET /feed.ics route should exist'); + const handler = layer.route.stack[0].handle; + + return new Promise((resolve, reject) => { + const req = { + query, + }; + const headers = {}; + const res = { + statusCode: 200, + body: '', + headers, + status(code) { + this.statusCode = code; + return this; + }, + type(value) { + headers['Content-Type'] = value; + return this; + }, + set(values) { + Object.assign(headers, values); + return this; + }, + send(body) { + this.body = body; + resolve({ status: this.statusCode, headers, body }); + }, + }; + try { + handler(req, res); + } catch (err) { + reject(err); + } + }); +} + +test.after(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + fs.rmSync(`${dbPath}${suffix}`, { force: true }); + } +}); + +test('escapeText follows RFC 5545 TEXT escaping', () => { + assert.equal( + escapeText('Comma, semicolon; slash\\ newline\nnext'), + 'Comma\\, semicolon\\; slash\\\\ newline\\nnext', + ); +}); + +test('foldLine folds at 75 octets without splitting UTF-8 characters', () => { + const folded = foldLine(`SUMMARY:${'Long title '.repeat(12)}😀`); + const physicalLines = folded.split('\r\n'); + + assert.ok(physicalLines.length > 1); + for (const line of physicalLines) { + assert.ok(Buffer.byteLength(line, 'utf8') <= 75); + } + assert.ok(physicalLines.slice(1).every(line => line.startsWith(' '))); + assert.equal(folded.replace(/\r\n /g, ''), `SUMMARY:${'Long title '.repeat(12)}😀`); +}); + +test('rruleForCycle maps all five bill cycle types', () => { + assert.equal(rruleForCycle({ cycle_type: 'monthly', due_day: 12 }), 'FREQ=MONTHLY;BYMONTHDAY=12'); + assert.equal(rruleForCycle({ cycle_type: 'weekly', cycle_day: 'friday', due_day: 12 }), 'FREQ=WEEKLY;BYDAY=FR'); + assert.equal(rruleForCycle({ cycle_type: 'biweekly', cycle_day: 'monday', due_day: 12 }), 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO'); + assert.equal(rruleForCycle({ cycle_type: 'quarterly', cycle_day: '2', due_day: 28 }), 'FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=28'); + assert.equal(rruleForCycle({ cycle_type: 'annual', cycle_day: '11', due_day: 30 }), 'FREQ=YEARLY;BYMONTH=11;BYMONTHDAY=30'); +}); + +test('buildCalendarFeed emits CRLF ICS without BOM or VTIMEZONE', () => { + const db = getDb(); + const userId = createUser(db, 'feed-build'); + createBill(db, userId, { name: 'Rent' }); + + const { ics, events } = buildCalendarFeed(userId, { + now: new Date(Date.UTC(2026, 5, 7)), + pastMonths: 0, + futureMonths: 1, + dtstamp: '20260607T120000Z', + }, db); + + assert.ok(events.length >= 1); + assert.equal(ics.charCodeAt(0), 'B'.charCodeAt(0)); + assert.ok(ics.includes('\r\n')); + assert.ok(!ics.includes('\nBEGIN:VEVENT') || ics.includes('\r\nBEGIN:VEVENT')); + assert.ok(!ics.includes('BEGIN:VTIMEZONE')); + assert.match(ics, /DTSTART;VALUE=DATE:\d{8}\r\n/); + assert.match(ics, /DTEND;VALUE=DATE:\d{8}\r\n/); + assert.match(ics, /UID:bill-tracker-bill-\d+-\d{4}-\d{2}-\d{2}@bill-tracker\r\n/); +}); + +test('public feed endpoint serves ICS for valid token and rejects invalid tokens', async () => { + const db = getDb(); + const userId = createUser(db, 'feed-http'); + createBill(db, userId, { name: 'Phone Plan', due_day: 8 }); + const token = createToken(userId, 'Test Feed', db); + + const missing = await callFeedRoute({ token: 'missing' }); + assert.equal(missing.status, 404); + + const response = await callFeedRoute({ token: token.token }); + assert.equal(response.status, 200); + assert.equal(response.headers['Content-Type'], 'text/calendar; charset=utf-8'); + assert.ok(response.body.includes('BEGIN:VCALENDAR\r\n')); + assert.ok(response.body.includes('SUMMARY:Phone Plan due\r\n')); + + const updated = db.prepare('SELECT last_used_at FROM calendar_tokens WHERE id = ?').get(token.id); + assert.ok(updated.last_used_at); +});