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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 14:49:09 -05:00
parent 313aafcb79
commit 88874f3f3d
6 changed files with 105 additions and 26 deletions

View File

@ -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 } = {}) => {

View File

@ -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<void> {
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;

View File

@ -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;

20
client/lib/download.ts Normal file
View File

@ -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<void> {
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);
}

View File

@ -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<CalendarData | null>(null);

View File

@ -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<string, string> = {
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() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 tracker-print-hide">
<Button
size="sm"
variant={pinUpcoming ? 'default' : 'outline'}
@ -713,6 +735,45 @@ export default function TrackerPage() {
<span className="hidden sm:inline">Add Bill</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={goToCalendar}
className="h-9 gap-1.5 px-3 tracker-print-hide"
aria-label="Open calendar for this month"
title="View this month on the calendar"
>
<CalendarDays className="h-4 w-4" />
<span className="hidden sm:inline">Calendar</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5 px-3 tracker-print-hide"
aria-label="Export or print this month"
title="Export or print this month"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Export</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleExportMonthCsv}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => window.print()}>
<Printer className="mr-2 h-4 w-4" />
Print
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-1 bg-muted/50 border border-border rounded-lg p-1">
<Button
size="icon" variant="ghost"
@ -751,6 +812,7 @@ export default function TrackerPage() {
{showSearchSort && (
<SearchFilterPanel
title="Search & sort"
className="tracker-print-hide"
collapsed={searchPanelCollapsed}
onCollapsedChange={setSearchPanelCollapsed}
hasFilters={hasFilters}