Quick Start
- Monarch Sync is a CLI tool that syncs your retail purchases with Monarch Money. Here's how to get started.
+ Retail Sync is a CLI tool that syncs your retail purchases with Monarch Money. Here's how to get started.
diff --git a/web/src/app/(app)/sync/page.tsx b/web/src/app/(app)/sync/page.tsx
new file mode 100644
index 0000000..6cac259
--- /dev/null
+++ b/web/src/app/(app)/sync/page.tsx
@@ -0,0 +1,541 @@
+'use client'
+
+import { Badge } from '@/components/badge'
+import { Button } from '@/components/button'
+import { Checkbox, CheckboxField } from '@/components/checkbox'
+import { Divider } from '@/components/divider'
+import { Fieldset, Label, Legend } from '@/components/fieldset'
+import { Heading, Subheading } from '@/components/heading'
+import { Input } from '@/components/input'
+import { Select } from '@/components/select'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
+import { Text } from '@/components/text'
+import {
+ cancelSyncJob,
+ getActiveSyncJobs,
+ getSyncJobs,
+ startSync,
+ type SyncJob,
+ type StartSyncRequest,
+} from '@/lib/api'
+import {
+ ArrowPathIcon,
+ ExclamationTriangleIcon,
+ InformationCircleIcon,
+ PlayIcon,
+ ShieldCheckIcon,
+ XMarkIcon,
+} from '@heroicons/react/16/solid'
+import { useCallback, useEffect, useState } from 'react'
+
+// Helper text component for form fields
+function HelpText({ children }: { children: React.ReactNode }) {
+ return
{children}
+}
+
+function formatDate(dateString: string): string {
+ const date = new Date(dateString)
+ return date.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+function formatRelativeTime(dateString: string): string {
+ const date = new Date(dateString)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMins / 60)
+ const diffDays = Math.floor(diffHours / 24)
+
+ if (diffMins < 1) return 'just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ return `${diffDays}d ago`
+}
+
+function StatusBadge({ status }: { status: string }) {
+ const colorMap: Record
= {
+ completed: 'green',
+ failed: 'red',
+ running: 'cyan',
+ pending: 'amber',
+ cancelled: 'zinc',
+ }
+ const color = colorMap[status] || 'zinc'
+ return (
+
+ {status}
+
+ )
+}
+
+function ProviderBadge({ provider }: { provider: string }) {
+ const colorMap: Record = {
+ walmart: 'blue',
+ costco: 'red',
+ amazon: 'orange',
+ }
+ const color = colorMap[provider] || 'zinc'
+ return {provider}
+}
+
+function ProgressBar({ current, total }: { current: number; total: number }) {
+ const percentage = total > 0 ? (current / total) * 100 : 0
+ return (
+
+
0 ? 10 : 0)}%` }}
+ >
+ {total > 0 && `${current}/${total}`}
+
+
+ )
+}
+
+// Mobile-friendly job card component
+function JobCard({ job, onCancel }: { job: SyncJob; onCancel: (jobId: string) => void }) {
+ return (
+
+
+
+
+
+ {job.dry_run && (
+
Dry
+ )}
+
+ {job.status === 'running' && (
+
+ )}
+
+
+ {job.job_id.substring(0, 8)}
+ {formatRelativeTime(job.started_at)}
+
+ {job.status === 'running' && (
+
+
+
+ {job.progress.current_phase}
+ {job.progress.errored_orders > 0 && ` (${job.progress.errored_orders} errors)`}
+
+
+ )}
+ {job.status !== 'running' && job.result && (
+
+
+ {job.result.orders_processed} / {job.result.orders_found} orders
+ {job.result.orders_errored > 0 && ` (${job.result.orders_errored} errors)`}
+
+
+ )}
+
+ )
+}
+
+export default function SyncPage() {
+ const [jobs, setJobs] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+ const [lastUpdated, setLastUpdated] = useState(null)
+ const [showAdvanced, setShowAdvanced] = useState(false)
+
+ // Form state - load provider from localStorage
+ const [provider, setProvider] = useState<'walmart' | 'costco' | 'amazon'>(() => {
+ if (typeof window !== 'undefined') {
+ const saved = localStorage.getItem('sync_provider')
+ if (saved === 'walmart' || saved === 'costco' || saved === 'amazon') {
+ return saved
+ }
+ }
+ return 'walmart'
+ })
+ const [dryRun, setDryRun] = useState(true)
+ const [lookbackDays, setLookbackDays] = useState(14)
+ const [maxOrders, setMaxOrders] = useState(undefined)
+ const [verbose, setVerbose] = useState(false)
+ const [force, setForce] = useState(false)
+ const [orderId, setOrderId] = useState('')
+
+ // Save provider preference to localStorage
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('sync_provider', provider)
+ }
+ }, [provider])
+
+ // Auto-dismiss success/error messages after 5 seconds
+ useEffect(() => {
+ if (success || error) {
+ const timer = setTimeout(() => {
+ setSuccess(null)
+ setError(null)
+ }, 5000)
+ return () => clearTimeout(timer)
+ }
+ }, [success, error])
+
+ // Auto-refresh active jobs
+ useEffect(() => {
+ loadJobs()
+ const interval = setInterval(() => {
+ loadActiveJobs()
+ }, 3000) // Poll every 3 seconds
+ return () => clearInterval(interval)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ async function loadJobs() {
+ try {
+ setLoading(true)
+ const data = await getSyncJobs()
+ setJobs(data.jobs)
+ setLastUpdated(new Date())
+ setError(null)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load sync jobs')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const loadActiveJobs = useCallback(async () => {
+ try {
+ const data = await getActiveSyncJobs()
+ setLastUpdated(new Date())
+ // Update only active jobs to avoid flickering
+ if (data.jobs.length > 0) {
+ setJobs((prevJobs) => {
+ const activeJobIds = new Set(data.jobs.map((j) => j.job_id))
+ const inactiveJobs = prevJobs.filter((j) => !activeJobIds.has(j.job_id))
+ return [...data.jobs, ...inactiveJobs]
+ })
+ }
+ } catch {
+ // Silently fail on polling errors to avoid noise
+ }
+ }, [])
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setSubmitting(true)
+ setError(null)
+ setSuccess(null)
+
+ const request: StartSyncRequest = {
+ provider,
+ dry_run: dryRun,
+ lookback_days: lookbackDays,
+ max_orders: maxOrders,
+ verbose,
+ force,
+ order_id: orderId || undefined,
+ }
+
+ try {
+ const response = await startSync(request)
+ setSuccess(response.message)
+ // Reload jobs to show the new one
+ await loadJobs()
+ // Reset form to defaults (keep provider)
+ setDryRun(true)
+ setForce(false)
+ setOrderId('')
+ setMaxOrders(undefined)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to start sync')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function handleCancel(jobId: string) {
+ const confirmed = window.confirm(
+ 'Are you sure you want to cancel this sync job? This action cannot be undone.'
+ )
+ if (!confirmed) {
+ return
+ }
+
+ try {
+ await cancelSyncJob(jobId)
+ setSuccess('Job cancelled successfully')
+ await loadJobs()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to cancel job')
+ }
+ }
+
+ return (
+ <>
+ Sync
+ Start a new sync job to import orders from your providers.
+
+ {error && (
+
+ )}
+
+ {success && (
+
+ )}
+
+
+
+
+
+
+
+ Sync Jobs
+ {lastUpdated && (
+
+ Updated {formatRelativeTime(lastUpdated.toISOString())}
+
+ )}
+
+
+
+
+ {/* Desktop Table View */}
+
+
+
+
+ Job ID
+ Provider
+ Status
+ Progress
+ Started
+ Actions
+
+
+
+ {jobs.map((job) => (
+
+ {job.job_id.substring(0, 8)}
+
+
+
+
+
+
+ {job.dry_run && (
+ Dry
+ )}
+
+
+
+ {job.status === 'running' ? (
+
+
+
+ {job.progress.current_phase}
+ {job.progress.errored_orders > 0 && ` (${job.progress.errored_orders} errors)`}
+
+
+ ) : job.result ? (
+
+ {job.result.orders_processed} / {job.result.orders_found}
+ {job.result.orders_errored > 0 && ` (${job.result.orders_errored} errors)`}
+
+ ) : (
+ -
+ )}
+
+ {formatDate(job.started_at)}
+
+ {job.status === 'running' && (
+
+ )}
+
+
+ ))}
+
+
+
+
+ {/* Mobile Card View */}
+
+ {jobs.map((job) => (
+
+ ))}
+
+
+ {jobs.length === 0 && !loading && (
+
+
+
No sync jobs found yet.
+
+ Configure your sync settings above and click Start Sync.
+
+
+ )}
+ >
+ )
+}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 7d34965..b807f70 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -4,10 +4,13 @@ import { ThemeProvider } from '@/lib/theme-context'
export const metadata: Metadata = {
title: {
- template: '%s - Monarch Sync',
- default: 'Monarch Sync',
+ template: '%s - Retail Sync',
+ default: 'Retail Sync',
+ },
+ description: 'Sync your Walmart, Costco, and Amazon orders with Monarch Money. Third-party tool, not affiliated with Monarch Money Inc.',
+ icons: {
+ icon: '/favicon.svg',
},
- description: 'Sync your Walmart, Costco, and Amazon orders with Monarch Money',
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
index 7b3b971..76c811f 100644
--- a/web/src/lib/api/client.ts
+++ b/web/src/lib/api/client.ts
@@ -1,17 +1,42 @@
-import { Order, OrderFilters, OrderListResponse, SyncRun, SyncRunListResponse, HealthResponse, StatsResponse } from './types'
-
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8085'
-
-async function fetchJSON(url: string): Promise {
+import {
+ Order,
+ OrderFilters,
+ OrderListResponse,
+ SyncRun,
+ SyncRunListResponse,
+ HealthResponse,
+ StatsResponse,
+ StartSyncRequest,
+ StartSyncResponse,
+ SyncJob,
+ SyncJobListResponse,
+} from './types'
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'
+
+async function fetchJSON(url: string, options?: RequestInit): Promise {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
+ ...options,
})
if (!response.ok) {
- throw new Error(`API error: ${response.status} ${response.statusText}`)
+ // Try to parse error message from response body
+ let errorMessage = `API error: ${response.status} ${response.statusText}`
+ try {
+ const errorBody = await response.json()
+ if (errorBody.error) {
+ errorMessage = errorBody.error
+ } else if (errorBody.message) {
+ errorMessage = errorBody.message
+ }
+ } catch {
+ // If we can't parse the body, use the default message
+ }
+ throw new Error(errorMessage)
}
return response.json()
@@ -85,3 +110,29 @@ export async function getOrderStats(): Promise {
totalAmount,
}
}
+
+// Sync Job API functions
+export async function startSync(request: StartSyncRequest): Promise {
+ return fetchJSON(`${API_BASE_URL}/api/sync`, {
+ method: 'POST',
+ body: JSON.stringify(request),
+ })
+}
+
+export async function getSyncJobs(): Promise {
+ return fetchJSON(`${API_BASE_URL}/api/sync`)
+}
+
+export async function getActiveSyncJobs(): Promise {
+ return fetchJSON(`${API_BASE_URL}/api/sync/active`)
+}
+
+export async function getSyncJob(jobId: string): Promise {
+ return fetchJSON(`${API_BASE_URL}/api/sync/${encodeURIComponent(jobId)}`)
+}
+
+export async function cancelSyncJob(jobId: string): Promise {
+ await fetchJSON(`${API_BASE_URL}/api/sync/${encodeURIComponent(jobId)}`, {
+ method: 'DELETE',
+ })
+}
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 7f63df8..4c26ff3 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -93,3 +93,53 @@ export interface StatsResponse {
total_splits: number
provider_stats: ProviderStats[]
}
+
+// Sync Job Types
+export interface StartSyncRequest {
+ provider: 'walmart' | 'costco' | 'amazon'
+ dry_run?: boolean
+ lookback_days?: number
+ max_orders?: number
+ verbose?: boolean
+ order_id?: string
+ force?: boolean
+}
+
+export interface StartSyncResponse {
+ job_id: string
+ message: string
+}
+
+export interface SyncJobProgress {
+ current_phase: string
+ total_orders: number
+ processed_orders: number
+ skipped_orders: number
+ errored_orders: number
+}
+
+export interface SyncJobResult {
+ orders_found: number
+ orders_processed: number
+ orders_skipped: number
+ orders_errored: number
+ dry_run: boolean
+}
+
+export interface SyncJob {
+ job_id: string
+ provider: string
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
+ dry_run: boolean
+ progress: SyncJobProgress
+ request?: StartSyncRequest
+ started_at: string
+ completed_at?: string
+ result?: SyncJobResult
+ error?: string
+}
+
+export interface SyncJobListResponse {
+ jobs: SyncJob[]
+ count: number
+}