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
+
+
+
+