From 88874f3f3d5e5fe9d01435de76dc54c5d46d12a4 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 14:49:09 -0500 Subject: [PATCH] feat(tracker): export/print the month + calendar link (plan C1/C2) - Header "Export" menu: Export CSV (via the existing /api/export?from&to date range for the viewed month) and Print (window.print()). Extract the shared downloadFile blob helper to lib/download.ts (reused by DownloadMyDataSection). - Add api.exportRangeUrl(from, to, fmt). - Tracker-scoped @media print CSS: hide interactive chrome (.tracker-print-hide), keep the month title + summary + buckets, avoid splitting a row across pages. - Header "Calendar" button deep-links to /calendar?year&month; CalendarPage now reads those params as its initial month (falls back to today). Co-Authored-By: Claude Opus 4.8 --- client/api.ts | 3 + .../components/data/DownloadMyDataSection.tsx | 20 +----- client/index.css | 8 ++- client/lib/download.ts | 20 ++++++ client/pages/CalendarPage.tsx | 10 ++- client/pages/TrackerPage.tsx | 70 +++++++++++++++++-- 6 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 client/lib/download.ts diff --git a/client/api.ts b/client/api.ts index 3ceb13b..855b90b 100644 --- a/client/api.ts +++ b/client/api.ts @@ -409,6 +409,9 @@ export const api = { // Export (returns a URL to navigate to, not a fetch) exportUrl: (year: Id, fmt?: string): string => `/api/export?year=${year}&format=${fmt||'csv'}`, + // Export a specific date range (both bounds inclusive) — used to export one + // tracker month. The /api/export route accepts ?from&to in addition to ?year. + exportRangeUrl: (from: string, to: string, fmt?: string): string => `/api/export?from=${from}&to=${to}&format=${fmt||'csv'}`, // Spreadsheet Import previewSpreadsheetImport: async (file: File, options: { parseAllSheets?: boolean; defaultYear?: number | string; defaultMonth?: number | string } = {}) => { diff --git a/client/components/data/DownloadMyDataSection.tsx b/client/components/data/DownloadMyDataSection.tsx index a13341c..50be9c9 100644 --- a/client/components/data/DownloadMyDataSection.tsx +++ b/client/components/data/DownloadMyDataSection.tsx @@ -5,27 +5,9 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn, errMessage } from '@/lib/utils'; +import { downloadFile } from '@/lib/download'; import { SectionCard, type SectionCardProps } from './dataShared'; -// Shared blob download: works whether or not the response sets Content-Disposition -// (the JSON payments export doesn't), falling back to the given name. -async function downloadFile(endpoint: string, fallbackName: string): Promise { - const res = await fetch(endpoint, { credentials: 'include' }); - if (!res.ok) { - let data: { message?: string; error?: string } = {}; - try { data = await res.json(); } catch { /* ignore */ } - throw new Error(data.message || data.error || `HTTP ${res.status}`); - } - const disposition = res.headers.get('Content-Disposition'); - const match = disposition?.match(/filename="?([^"]+)"?/i); - const name = match?.[1] || fallbackName; - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = name; a.click(); - URL.revokeObjectURL(url); -} - function ExportCard({ icon: Icon, title, description, filename, endpoint }: { icon: ComponentType<{ className?: string }>; title: string; diff --git a/client/index.css b/client/index.css index a3cc945..aca0f33 100644 --- a/client/index.css +++ b/client/index.css @@ -229,10 +229,16 @@ .summary-actions, .summary-reorder-controls, .summary-edit-actions, - .summary-income-form { + .summary-income-form, + .tracker-print-hide { display: none !important; } + /* Tracker printout: don't split a bill row across a page boundary. */ + [data-tracker-row] { + break-inside: avoid; + } + main, main > div { max-width: none !important; diff --git a/client/lib/download.ts b/client/lib/download.ts new file mode 100644 index 0000000..f4a69f7 --- /dev/null +++ b/client/lib/download.ts @@ -0,0 +1,20 @@ +// Shared blob download: fetches an endpoint and saves the response as a file. +// Works whether or not the response sets Content-Disposition (some exports +// don't), falling back to the given name. Throws on a non-OK response so callers +// can surface the failure with a toast. +export async function downloadFile(endpoint: string, fallbackName: string): Promise { + const res = await fetch(endpoint, { credentials: 'include' }); + if (!res.ok) { + let data: { message?: string; error?: string } = {}; + try { data = await res.json(); } catch { /* ignore */ } + throw new Error(data.message || data.error || `HTTP ${res.status}`); + } + const disposition = res.headers.get('Content-Disposition'); + const match = disposition?.match(/filename="?([^"]+)"?/i); + const name = match?.[1] || fallbackName; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = name; a.click(); + URL.revokeObjectURL(url); +} diff --git a/client/pages/CalendarPage.tsx b/client/pages/CalendarPage.tsx index 3ae947b..947a9be 100644 --- a/client/pages/CalendarPage.tsx +++ b/client/pages/CalendarPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Banknote, CalendarDays, @@ -902,7 +902,13 @@ function CalendarSubscribeDialog({ open, onOpenChange }: { open: boolean; onOpen } export default function CalendarPage() { - const initial = currentMonth(); + const [searchParams] = useSearchParams(); + // Land on ?year=&month= when deep-linked (e.g. from the tracker), else today. + const initial = (() => { + const y = Number(searchParams.get('year')); + const m = Number(searchParams.get('month')); + return y && m >= 1 && m <= 12 ? { year: y, month: m } : currentMonth(); + })(); const [year, setYear] = useState(initial.year); const [month, setMonth] = useState(initial.month); const [data, setData] = useState(null); diff --git a/client/pages/TrackerPage.tsx b/client/pages/TrackerPage.tsx index 85cec3e..b4a7b18 100644 --- a/client/pages/TrackerPage.tsx +++ b/client/pages/TrackerPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } from '@/hooks/useQueries'; @@ -9,6 +9,7 @@ import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils'; +import { downloadFile } from '@/lib/download'; import { formatUSD, asDollars, type Dollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -21,7 +22,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { - DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, + DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; @@ -263,6 +264,7 @@ const TRACKER_SETTING_DEFAULTS: Record = { export default function TrackerPage() { const [searchParams, setSearchParams] = useSearchParams(); + const navigateTo = useNavigate(); const now = new Date(); // All navigation + filter state lives in the URL so views are bookmarkable/shareable. @@ -452,6 +454,26 @@ export default function TrackerPage() { updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 }); } + // Export the viewed month's activity as CSV via the existing /api/export + // date-range endpoint (both bounds inclusive). + async function handleExportMonthCsv() { + const mm = String(month).padStart(2, '0'); + const lastDay = new Date(year, month, 0).getDate(); + const from = `${year}-${mm}-01`; + const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`; + try { + await downloadFile(api.exportRangeUrl(from, to, 'csv'), `bills-${year}-${mm}.csv`); + toast.success(`Exported ${from} – ${to} to CSV.`); + } catch (err) { + toast.error((err as { message?: string })?.message || 'Export failed'); + } + } + + // Jump to the calendar view for the month currently in view. + function goToCalendar() { + navigateTo(`/calendar?year=${year}&month=${month}`); + } + const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]); const summary = (data?.summary ?? {}) as TrackerSummary; const bankTracking = data?.bank_tracking; @@ -675,7 +697,7 @@ export default function TrackerPage() {

-
+
+ + + + + + + + {new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year} + + + + Export CSV + + window.print()}> + + Print + + + +