diff --git a/.gitignore b/.gitignore index 62860276..a4f7bbe5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ performance/results/*.md next-env.d.ts -bun.lock \ No newline at end of file +bun.lock + +# Project issues file +vrickish.md \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index da9824ed..00858cbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,7 +120,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -354,7 +353,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -377,7 +375,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -400,7 +397,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -417,7 +413,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -434,7 +429,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -451,7 +445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -468,7 +461,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -485,7 +477,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -502,7 +493,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -519,7 +509,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -536,7 +525,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -553,7 +541,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -570,7 +557,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -593,7 +579,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -616,7 +601,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -639,7 +623,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -662,7 +645,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -685,7 +667,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -708,7 +689,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -731,7 +711,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -754,7 +733,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -774,7 +752,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -794,7 +771,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -814,7 +790,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/public/sw.js b/public/sw.js index 17e0ac16..3f964a5e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,6 @@ -// PetChain Service Worker — Cache-first for assets, network-first for API -const CACHE_NAME = "petchain-v1"; -const STATIC_ASSETS = ["/", "/favicon.ico"]; +// PetChain Service Worker — Offline-first with IndexedDB sync +const CACHE_NAME = "petchain-v2"; +const STATIC_ASSETS = ["/", "/favicon.ico", "/offline"]; // ── Install: pre-cache essential assets ── self.addEventListener("install", (event) => { @@ -25,6 +25,27 @@ self.addEventListener("activate", (event) => { ); }); +// ── Background Sync: flush offline queue when connection restores ── +self.addEventListener("sync", (event) => { + if (event.tag === "flush-sync-queue") { + event.waitUntil(flushSyncQueue()); + } +}); + +async function flushSyncQueue() { + const clients = await self.clients.matchAll(); + clients.forEach((client) => { + client.postMessage({ type: "BACKGROUND_SYNC_TRIGGERED" }); + }); +} + +// ── Message handling ── +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); + // ── Fetch: strategy per request type ── self.addEventListener("fetch", (event) => { const { request } = event; @@ -35,7 +56,13 @@ self.addEventListener("fetch", (event) => { return; } - // Network-first for API calls + // Stale-while-revalidate for pet profile and medical data APIs + if (url.pathname.startsWith("/api/v1/pets") || url.pathname.startsWith("/api/v1/medical")) { + event.respondWith(staleWhileRevalidate(request)); + return; + } + + // Network-first for other API calls if (url.pathname.startsWith("/api")) { event.respondWith(networkFirst(request)); return; @@ -80,3 +107,23 @@ async function networkFirst(request) { return cached || new Response("", { status: 408, statusText: "Offline" }); } } + +async function staleWhileRevalidate(request) { + const cached = await caches.match(request); + const fetchPromise = fetch(request).then((response) => { + if (response.ok) { + const cache = caches.open(CACHE_NAME); + cache.then((c) => c.put(request, response.clone())); + } + return response; + }).catch(() => cached); + + // Return cached immediately if available, otherwise wait for network + if (cached) { + // Don't block on the network update + fetchPromise.catch(() => {}); + return cached; + } + + return fetchPromise; +} diff --git a/src/components/LabResults/ResultList.tsx b/src/components/LabResults/ResultList.tsx index 9b867f57..e39495a7 100644 --- a/src/components/LabResults/ResultList.tsx +++ b/src/components/LabResults/ResultList.tsx @@ -1,75 +1,258 @@ -import React from 'react'; +import React, { useState, useMemo } from 'react'; import { LabResultItem } from '@/types/lab-results'; -import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { + AlertCircle, CheckCircle2, ChevronDown, ChevronUp, + ChevronLeft, ChevronRight, Syringe, Microscope, + Droplets, Stethoscope, Bone, FlaskConical, +} from 'lucide-react'; +import { SkeletonLine } from '@/components/Skeleton'; + +type SortKey = 'date' | 'name' | 'status'; +type SortDir = 'asc' | 'desc'; interface ResultListProps { results: LabResultItem[]; onSelectTest: (testName: string) => void; selectedTest: string | null; + loading?: boolean; } -export default function ResultList({ results, onSelectTest, selectedTest }: ResultListProps) { - if (results.length === 0) { - return ( -
- No results found for this category. -
- ); +const ITEMS_PER_PAGE = 10; + +function getCategoryIcon(category: string) { + switch (category) { + case 'Blood Work': return ; + case 'Urinalysis': return ; + case 'Imaging': return ; + case 'Cytology': return ; + case 'Microbiology': return ; + default: return ; } +} + +function ResultListSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ ))} +
+ ); +} + +function EmptyState() { + return ( +
+ +

No results found

+

Try a different category or upload new lab results.

+
+ ); +} + +function ResultDetailPanel({ result }: { result: LabResultItem }) { + return ( +
+
+
+ Category +

{result.category}

+
+
+ Date +

+ {new Date(result.date).toLocaleDateString(undefined, { + weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', + })} +

+
+
+ Value +

+ {result.value} + {result.referenceRange?.unit && ( + {result.referenceRange.unit} + )} +

+
+
+ Reference Range + {result.referenceRange ? ( +

+ {result.referenceRange.min} – {result.referenceRange.max} {result.referenceRange.unit} +

+ ) : ( +

N/A

+ )} +
+
+ Status +
+ {result.isAbnormal ? ( + + Abnormal – needs attention + + ) : ( + + Normal + + )} +
+
+
+
+ ); +} + +export default function ResultList({ + results, onSelectTest, selectedTest, loading = false, +}: ResultListProps) { + const [sortKey, setSortKey] = useState('date'); + const [sortDir, setSortDir] = useState('desc'); + const [expandedId, setExpandedId] = useState(null); + const [page, setPage] = useState(1); + + const sorted = useMemo(() => { + const arr = [...results]; + arr.sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case 'date': cmp = new Date(a.date).getTime() - new Date(b.date).getTime(); break; + case 'name': cmp = a.testName.localeCompare(b.testName); break; + case 'status': cmp = Number(a.isAbnormal) - Number(b.isAbnormal); break; + } + return sortDir === 'asc' ? cmp : -cmp; + }); + return arr; + }, [results, sortKey, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE)); + const paginated = sorted.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE); + + const toggleSort = (key: SortKey) => { + if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + else { setSortKey(key); setSortDir(key === 'date' ? 'desc' : 'asc'); } + setPage(1); + }; + + const handleClick = (result: LabResultItem) => { + onSelectTest(result.testName); + setExpandedId((prev) => (prev === result.id ? null : result.id)); + }; + + if (loading) return ; + if (results.length === 0) return ; return (
- {results.map((result) => { - const isSelected = selectedTest === result.testName; +
+ Sort by: + {(['date', 'name', 'status'] as SortKey[]).map((key) => ( + + ))} + {results.length} result{results.length !== 1 && 's'} +
+ + {paginated.map((result) => { + const isExpanded = expandedId === result.id; return (
onSelectTest(result.testName)} - className={`p-4 rounded-xl border cursor-pointer transition-all flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 ${ - isSelected - ? 'border-blue-400 bg-blue-50 shadow-sm' - : 'border-gray-100 bg-white hover:border-blue-200 hover:bg-gray-50' + onClick={() => handleClick(result)} + className={`p-4 rounded-xl border cursor-pointer transition-all ${ + result.isAbnormal + ? 'border-red-200 bg-red-50/40 hover:border-red-300' + : isExpanded + ? 'border-blue-400 bg-blue-50 shadow-sm' + : 'border-gray-100 bg-white hover:border-blue-200 hover:bg-gray-50' }`} + role="button" tabIndex={0} aria-expanded={isExpanded} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(result); } }} > - {/* Left side: Test Info */} -
-

{result.testName}

-

- {new Date(result.date).toLocaleDateString()} -

-
- - {/* Right side: Values & Badges */} -
-
-
- +
+
+
+ {getCategoryIcon(result.category)} +
+
+

{result.testName}

+
+ {new Date(result.date).toLocaleDateString()} + · + {result.category} +
+
+
+
+
+ {result.value} + {result.referenceRange?.unit && ( + {result.referenceRange.unit} + )} - {result.referenceRange?.unit && ( - {result.referenceRange.unit} - )}
- {result.referenceRange && ( - - Range: {result.referenceRange.min} - {result.referenceRange.max} - - )} -
- -
{result.isAbnormal ? ( - + ) : ( - + )} +
+ {isExpanded ? : } +
+ {isExpanded && }
); })} + + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ )}
); } diff --git a/src/components/OfflineIndicator.tsx b/src/components/OfflineIndicator.tsx new file mode 100644 index 00000000..97136b6e --- /dev/null +++ b/src/components/OfflineIndicator.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Wifi, WifiOff, RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react'; +import { useOffline } from '@/hooks/useOffline'; +import type { SyncStatus } from '@/lib/offline/syncManager'; + +function getStatusConfig(status: SyncStatus) { + switch (status) { + case 'syncing': + return { bg: 'bg-blue-100', text: 'text-blue-700', icon: RefreshCw, label: 'Syncing...' }; + case 'success': + return { bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircle2, label: 'Synced' }; + case 'conflict': + return { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: AlertTriangle, label: 'Sync conflicts' }; + case 'error': + return { bg: 'bg-red-100', text: 'text-red-700', icon: AlertTriangle, label: 'Sync error' }; + default: + return { bg: 'bg-gray-100', text: 'text-gray-600', icon: Wifi, label: 'Connected' }; + } +} + +interface OfflineIndicatorProps { + showLabel?: boolean; + className?: string; +} + +export default function OfflineIndicator({ showLabel = true, className = '' }: OfflineIndicatorProps) { + const { isOnline, syncStatus, pendingSyncCount, syncDetails, triggerSync } = useOffline(); + const statusConfig = getStatusConfig(syncStatus); + + if (isOnline && syncStatus === 'idle' && pendingSyncCount === 0) { + // Online and idle - show minimal indicator or nothing + return ( +
+ + {showLabel && Online} +
+ ); + } + + return ( +
+ {!isOnline ? ( +
+ + {showLabel && Offline} + {pendingSyncCount > 0 && ( + + {pendingSyncCount} + + )} +
+ ) : syncStatus !== 'idle' ? ( +
+ + {showLabel && ( + + {statusConfig.label} + {syncDetails ? `: ${syncDetails}` : ''} + + )} + {syncStatus === 'error' || syncStatus === 'conflict' ? ( + + ) : null} +
+ ) : pendingSyncCount > 0 ? ( +
+ + {showLabel && {pendingSyncCount} pending} + +
+ ) : null} +
+ ); +} diff --git a/src/components/analytics/DataVisualizationDashboard.tsx b/src/components/analytics/DataVisualizationDashboard.tsx new file mode 100644 index 00000000..16b50462 --- /dev/null +++ b/src/components/analytics/DataVisualizationDashboard.tsx @@ -0,0 +1,548 @@ +/** + * Data Visualization Dashboard + * Interactive charts and graphs for pet health trends, vaccination schedules, + * weight tracking, and geographic vet clinic locations. + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + LineChart, Line, BarChart, Bar, AreaChart, Area, + XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, + PieChart, Pie, Cell, ComposedChart, RadialBarChart, RadialBar, +} from 'recharts'; +import { + Download, Calendar, Weight, Activity, Syringe, + MapPin, TrendingUp, Filter, RefreshCw, +} from 'lucide-react'; + +// ── Types ── + +export interface WeightDataPoint { + date: string; + weight: number; + petName?: string; +} + +export interface VaccinationDataPoint { + date: string; + vaccine: string; + status: 'completed' | 'upcoming' | 'overdue'; + petName?: string; +} + +export interface HealthMetric { + metric: string; + value: number; + unit: string; + normalRange: { min: number; max: number }; + timestamp: string; +} + +export interface VetLocation { + name: string; + city: string; + region: string; + pets: number; + rating: number; +} + +export interface DashboardData { + weightHistory: WeightDataPoint[]; + vaccinations: VaccinationDataPoint[]; + healthMetrics: HealthMetric[]; + vetLocations: VetLocation[]; + summary: { + totalPets: number; + upcomingVaccinations: number; + averageWeight: number; + abnormalResults: number; + }; +} + +// ── Time Range Filter ── + +type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'ALL'; + +const TIME_RANGES: { label: string; value: TimeRange }[] = [ + { label: '1 Month', value: '1M' }, + { label: '3 Months', value: '3M' }, + { label: '6 Months', value: '6M' }, + { label: '1 Year', value: '1Y' }, + { label: 'All Time', value: 'ALL' }, +]; + +function filterByTimeRange( + data: T[], + range: TimeRange +): T[] { + if (range === 'ALL') return data; + const now = Date.now(); + const msMap: Record = { + '1M': 30 * 24 * 60 * 60 * 1000, + '3M': 90 * 24 * 60 * 60 * 1000, + '6M': 180 * 24 * 60 * 60 * 1000, + '1Y': 365 * 24 * 60 * 60 * 1000, + ALL: Infinity, + }; + const cutoff = now - (msMap[range] || msMap['1Y']); + return data.filter((item) => { + const ts = item.date || item.timestamp; + return ts ? new Date(ts).getTime() >= cutoff : true; + }); +} + +// ── Export Utilities ── + +function exportToCSV(filename: string, headers: string[], rows: unknown[][]): void { + const headerRow = headers.join(','); + const dataRows = rows.map((row) => row.map((cell) => JSON.stringify(cell)).join(',')); + const csv = [headerRow, ...dataRows].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${filename}_${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + +// ── Custom Tooltip ── + +const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry, idx) => ( +

+ {entry.name}: {entry.value} +

+ ))} +
+ ); +}; + +// ── Sub-Components ── + +interface WeightChartProps { + data: WeightDataPoint[]; + timeRange: TimeRange; + onExport: () => void; +} + +function WeightChart({ data, timeRange, onExport }: WeightChartProps) { + const filtered = useMemo(() => filterByTimeRange(data, timeRange), [data, timeRange]); + const chartData = useMemo( + () => + filtered.map((d) => ({ + ...d, + dateFormatted: new Date(d.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + })), + [filtered] + ); + + if (!chartData.length) { + return ( +
+ No weight data available for this period. +
+ ); + } + + return ( +
+
+

Weight & Growth Tracking

+ +
+
+ + + + + + + + + + + + } /> + + + +
+
+ ); +} + +interface VaccinationScheduleChartProps { + data: VaccinationDataPoint[]; + timeRange: TimeRange; + onExport: () => void; +} + +function VaccinationScheduleChart({ data, timeRange, onExport }: VaccinationScheduleChartProps) { + const filtered = useMemo(() => filterByTimeRange(data as any, timeRange) as VaccinationDataPoint[], [data, timeRange]); + + const chartData = useMemo(() => { + const grouped: Record = {}; + filtered.forEach((d) => { + if (!grouped[d.vaccine]) { + grouped[d.vaccine] = { completed: 0, upcoming: 0, overdue: 0 }; + } + grouped[d.vaccine][d.status]++; + }); + return Object.entries(grouped).map(([vaccine, counts]) => ({ vaccine, ...counts })); + }, [filtered]); + + if (!chartData.length) { + return ( +
+ No vaccination data available. +
+ ); + } + + return ( +
+
+

Vaccination Schedule

+ +
+
+ + + + + + + + + + + + +
+
+ ); +} + +interface HealthMetricsChartProps { + data: HealthMetric[]; + timeRange: TimeRange; + onExport: () => void; +} + +function HealthMetricsChart({ data, timeRange, onExport }: HealthMetricsChartProps) { + const filtered = useMemo(() => filterByTimeRange(data as any, timeRange) as HealthMetric[], [data, timeRange]); + const [selectedMetric, setSelectedMetric] = useState(null); + + const metrics = useMemo(() => { + const unique = [...new Set(filtered.map((d) => d.metric))]; + return unique; + }, [filtered]); + + const activeMetric = selectedMetric || metrics[0] || ''; + + const metricData = useMemo( + () => + filtered + .filter((d) => d.metric === activeMetric) + .map((d) => ({ + ...d, + dateFormatted: new Date(d.timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + aboveRange: d.value > d.normalRange.max, + belowRange: d.value < d.normalRange.min, + })), + [filtered, activeMetric] + ); + + if (!metrics.length) { + return ( +
+ No health metrics available. +
+ ); + } + + return ( +
+
+

Health Metrics

+ +
+
+ {metrics.map((m) => ( + + ))} +
+
+ + + + + + } /> + + { + const { cx, cy, payload } = props; + if (payload.aboveRange) return ; + if (payload.belowRange) return ; + return ; + }} + /> + + +
+
+ Above range + Below range + Normal +
+
+ ); +} + +interface VetLocationChartProps { + data: VetLocation[]; + onExport: () => void; +} + +function VetLocationChart({ data, onExport }: VetLocationChartProps) { + const chartData = useMemo(() => { + const grouped: Record = {}; + data.forEach((loc) => { + grouped[loc.region] = (grouped[loc.region] || 0) + loc.pets; + }); + return Object.entries(grouped) + .map(([region, pets]) => ({ region, pets })) + .sort((a, b) => b.pets - a.pets); + }, [data]); + + const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + + if (!chartData.length) { + return ( +
+ No geographic data available. +
+ ); + } + + return ( +
+
+

Vet Clinic Distribution

+ +
+
+ + + `${region} ${(percent * 100).toFixed(0)}%`} + > + {chartData.map((_, index) => ( + + ))} + + + + + +
+
+ ); +} + +// ── Summary Cards ── + +interface SummaryCardsProps { + summary: DashboardData['summary']; +} + +function SummaryCards({ summary }: SummaryCardsProps) { + const cards = [ + { label: 'Total Pets', value: summary.totalPets, icon: Activity, color: 'bg-blue-500' }, + { label: 'Upcoming Vaccinations', value: summary.upcomingVaccinations, icon: Syringe, color: 'bg-green-500' }, + { label: 'Avg Weight', value: `${summary.averageWeight.toFixed(1)} kg`, icon: Weight, color: 'bg-purple-500' }, + { label: 'Abnormal Results', value: summary.abnormalResults, icon: TrendingUp, color: 'bg-red-500' }, + ]; + + return ( +
+ {cards.map((card) => ( +
+
+
+ +
+
+

{card.label}

+

{card.value}

+
+
+
+ ))} +
+ ); +} + +// ── Main Dashboard Component ── + +interface DataVisualizationDashboardProps { + data: DashboardData; + className?: string; +} + +export default function DataVisualizationDashboard({ data, className = '' }: DataVisualizationDashboardProps) { + const [timeRange, setTimeRange] = useState('6M'); + + const handleExportWeight = useCallback(() => { + exportToCSV('weight_tracking', ['Date', 'Weight (kg)'], data.weightHistory.map((d) => [d.date, d.weight])); + }, [data.weightHistory]); + + const handleExportVaccination = useCallback(() => { + exportToCSV('vaccination_schedule', ['Date', 'Vaccine', 'Status'], data.vaccinations.map((d) => [d.date, d.vaccine, d.status])); + }, [data.vaccinations]); + + const handleExportHealthMetrics = useCallback(() => { + exportToCSV('health_metrics', ['Metric', 'Value', 'Unit', 'Timestamp'], data.healthMetrics.map((d) => [d.metric, d.value, d.unit, d.timestamp])); + }, [data.healthMetrics]); + + const handleExportVetLocations = useCallback(() => { + exportToCSV('vet_clinics', ['Name', 'City', 'Region', 'Pets', 'Rating'], data.vetLocations.map((d) => [d.name, d.city, d.region, d.pets, d.rating])); + }, [data.vetLocations]); + + const handleExportAll = useCallback(() => { + handleExportWeight(); + setTimeout(handleExportVaccination, 500); + setTimeout(handleExportHealthMetrics, 1000); + setTimeout(handleExportVetLocations, 1500); + }, [handleExportWeight, handleExportVaccination, handleExportHealthMetrics, handleExportVetLocations]); + + return ( +
+ {/* Header */} +
+
+

Data Visualization Dashboard

+

+ Interactive charts for pet health trends and insights +

+
+
+ {/* Time Range Filter */} +
+ + {TIME_RANGES.map((r) => ( + + ))} +
+ +
+
+ + {/* Summary Cards */} + + + {/* Charts Grid */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+

Quick Actions

+
+
+ + + + +
+
+
+
+ ); +} diff --git a/src/hooks/useOffline.ts b/src/hooks/useOffline.ts new file mode 100644 index 00000000..33a862c7 --- /dev/null +++ b/src/hooks/useOffline.ts @@ -0,0 +1,126 @@ +/** + * useOffline hook + * Provides offline status, sync capabilities, and cached data access. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { syncManager, SyncStatus } from '@/lib/offline/syncManager'; +import { getCachedData, cacheData, enqueueSyncAction, clearExpiredCache } from '@/lib/offline/indexedDB'; + +interface OfflineState { + isOnline: boolean; + syncStatus: SyncStatus; + pendingSyncCount: number; + lastSyncTime: Date | null; + syncDetails?: string; +} + +interface OfflineActions { + triggerSync: () => Promise; + getCached: (key: string) => Promise; + setCached: (key: string, data: T, ttl?: number) => Promise; + queueAction: (action: 'create' | 'update' | 'delete', endpoint: string, payload: unknown, idempotencyKey?: string) => Promise; + clearOldCache: () => Promise; +} + +export function useOffline(): OfflineState & OfflineActions { + const [state, setState] = useState({ + isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, + syncStatus: 'idle', + pendingSyncCount: 0, + lastSyncTime: null, + }); + const unsubscribeRef = useRef<(() => void) | null>(null); + + useEffect(() => { + // Subscribe to sync status changes + unsubscribeRef.current = syncManager.subscribe((status, details) => { + setState((prev) => ({ + ...prev, + syncStatus: status, + syncDetails: details, + lastSyncTime: status === 'success' || status === 'conflict' ? new Date() : prev.lastSyncTime, + })); + }); + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + } + }; + }, []); + + useEffect(() => { + const handleOnline = () => { + setState((prev) => ({ ...prev, isOnline: true })); + // Auto-sync when coming back online + syncManager.processQueue().then((result) => { + setState((prev) => ({ + ...prev, + pendingSyncCount: 0, + syncStatus: result.conflicts > 0 ? 'conflict' : result.failed > 0 ? 'error' : 'idle', + })); + }); + }; + + const handleOffline = () => { + setState((prev) => ({ ...prev, isOnline: false, syncStatus: 'idle' })); + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + const triggerSync = useCallback(async () => { + if (!navigator.onLine) return; + const result = await syncManager.processQueue(); + setState((prev) => ({ + ...prev, + pendingSyncCount: 0, + syncStatus: result.conflicts > 0 ? 'conflict' : result.failed > 0 ? 'error' : 'idle', + lastSyncTime: new Date(), + })); + }, []); + + const getCached = useCallback(async (key: string): Promise => { + return getCachedData(key); + }, []); + + const setCached = useCallback(async (key: string, data: T, ttl?: number): Promise => { + return cacheData(key, data, ttl); + }, []); + + const queueAction = useCallback( + async (action: 'create' | 'update' | 'delete', endpoint: string, payload: unknown, idempotencyKey?: string): Promise => { + const key = idempotencyKey || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const id = await enqueueSyncAction(action, endpoint, payload, key); + setState((prev) => ({ ...prev, pendingSyncCount: prev.pendingSyncCount + 1 })); + + // If online, process immediately + if (navigator.onLine) { + syncManager.processQueue().catch(() => {}); + } + + return id; + }, + [] + ); + + const clearOldCache = useCallback(async (): Promise => { + return clearExpiredCache(); + }, []); + + return { + ...state, + triggerSync, + getCached, + setCached, + queueAction, + clearOldCache, + }; +} diff --git a/src/lib/offline/indexedDB.ts b/src/lib/offline/indexedDB.ts new file mode 100644 index 00000000..644118ff --- /dev/null +++ b/src/lib/offline/indexedDB.ts @@ -0,0 +1,217 @@ +/** + * IndexedDB helper for offline-first data storage. + */ + +const DB_NAME = 'petchain-offline'; +const DB_VERSION = 1; + +export interface OfflineCacheEntry { + key: string; + data: T; + timestamp: number; + ttl?: number; +} + +export interface SyncQueueItem { + id?: number; + action: 'create' | 'update' | 'delete'; + endpoint: string; + payload: unknown; + createdAt: number; + retryCount: number; + maxRetries: number; + idempotencyKey: string; +} + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('cache')) { + const cacheStore = db.createObjectStore('cache', { keyPath: 'key' }); + cacheStore.createIndex('timestamp', 'timestamp', { unique: false }); + cacheStore.createIndex('ttl', 'ttl', { unique: false }); + } + if (!db.objectStoreNames.contains('syncQueue')) { + const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id', autoIncrement: true }); + syncStore.createIndex('createdAt', 'createdAt', { unique: false }); + syncStore.createIndex('idempotencyKey', 'idempotencyKey', { unique: true }); + syncStore.createIndex('retryCount', 'retryCount', { unique: false }); + } + if (!db.objectStoreNames.contains('records')) { + const recordsStore = db.createObjectStore('records', { keyPath: 'key' }); + recordsStore.createIndex('timestamp', 'timestamp', { unique: false }); + } + }; + request.onsuccess = (event) => resolve((event.target as IDBOpenDBRequest).result); + request.onerror = (event) => reject((event.target as IDBOpenDBRequest).error); + }); +} + +export async function cacheData(key: string, data: T, ttl?: number): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('cache', 'readwrite'); + const store = tx.objectStore('cache'); + const entry: OfflineCacheEntry = { key, data, timestamp: Date.now(), ...(ttl !== undefined ? { ttl } : {}) }; + const request = store.put(entry); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function getCachedData(key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('cache', 'readonly'); + const store = tx.objectStore('cache'); + const request = store.get(key); + request.onsuccess = () => { + const entry = request.result as OfflineCacheEntry | undefined; + if (!entry) { db.close(); resolve(null); return; } + if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { + const deleteTx = db.transaction('cache', 'readwrite'); + deleteTx.objectStore('cache').delete(key); + deleteTx.oncomplete = () => db.close(); + resolve(null); + return; + } + db.close(); + resolve(entry.data); + }; + request.onerror = () => reject(request.error); + }); +} + +export async function removeCachedData(key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('cache', 'readwrite'); + const request = tx.objectStore('cache').delete(key); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function clearExpiredCache(): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('cache', 'readwrite'); + const store = tx.objectStore('cache'); + const index = store.index('ttl'); + const range = IDBKeyRange.lowerBound(0); + const request = index.openCursor(range); + let cleared = 0; + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result; + if (cursor) { + const entry = cursor.value as OfflineCacheEntry; + if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { + cursor.delete(); + cleared++; + } + cursor.continue(); + } else { + resolve(cleared); + } + }; + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function enqueueSyncAction( + action: SyncQueueItem['action'], + endpoint: string, + payload: unknown, + idempotencyKey: string, + maxRetries = 3 +): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('syncQueue', 'readwrite'); + const store = tx.objectStore('syncQueue'); + const item: Omit = { action, endpoint, payload, createdAt: Date.now(), retryCount: 0, maxRetries, idempotencyKey }; + const request = store.add(item); + request.onsuccess = () => resolve(request.result as number); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function getPendingSyncActions(): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('syncQueue', 'readonly'); + const store = tx.objectStore('syncQueue'); + const index = store.index('createdAt'); + const request = index.getAll(); + request.onsuccess = () => { db.close(); resolve(request.result as SyncQueueItem[]); }; + request.onerror = () => reject(request.error); + }); +} + +export async function removeSyncAction(id: number): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('syncQueue', 'readwrite'); + const request = tx.objectStore('syncQueue').delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function incrementRetry(id: number): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('syncQueue', 'readwrite'); + const store = tx.objectStore('syncQueue'); + const getRequest = store.get(id); + getRequest.onsuccess = () => { + const item = getRequest.result as SyncQueueItem | undefined; + if (item) { item.retryCount += 1; store.put(item); } + }; + getRequest.onerror = () => reject(getRequest.error); + tx.oncomplete = () => db.close(); + resolve(); + }); +} + +export async function storeOfflineRecord(key: string, data: T): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('records', 'readwrite'); + const store = tx.objectStore('records'); + const entry: OfflineCacheEntry = { key, data, timestamp: Date.now() }; + const request = store.put(entry); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function getOfflineRecord(key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('records', 'readonly'); + const store = tx.objectStore('records'); + const request = store.get(key); + request.onsuccess = () => { db.close(); const entry = request.result as OfflineCacheEntry | undefined; resolve(entry ? entry.data : null); }; + request.onerror = () => reject(request.error); + }); +} + +export async function getAllOfflineRecordKeys(): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction('records', 'readonly'); + const store = tx.objectStore('records'); + const request = store.getAllKeys(); + request.onsuccess = () => { db.close(); resolve(request.result as string[]); }; + request.onerror = () => reject(request.error); + }); +} diff --git a/src/lib/offline/syncManager.ts b/src/lib/offline/syncManager.ts new file mode 100644 index 00000000..8a0aa7ec --- /dev/null +++ b/src/lib/offline/syncManager.ts @@ -0,0 +1,215 @@ +/** + * Sync Queue Manager + * Processes pending offline actions when the application comes back online. + * Includes conflict resolution logic. + */ + +import { + getPendingSyncActions, + removeSyncAction, + incrementRetry, + SyncQueueItem, +} from './indexedDB'; + +const SYNC_API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1'; + +export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error' | 'conflict'; +export type SyncEventCallback = (status: SyncStatus, details?: string) => void; + +interface SyncManagerConfig { + onStatusChange?: SyncEventCallback; + maxConcurrent?: number; + retryDelay?: number; +} + +class SyncManager { + private isSyncing = false; + private status: SyncStatus = 'idle'; + private listeners: Set = new Set(); + private config: Required; + + constructor(config: SyncManagerConfig = {}) { + this.config = { + onStatusChange: config.onStatusChange || (() => {}), + maxConcurrent: config.maxConcurrent || 3, + retryDelay: config.retryDelay || 2000, + }; + if (this.config.onStatusChange) { + this.listeners.add(this.config.onStatusChange); + } + } + + subscribe(callback: SyncEventCallback): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + private notify(status: SyncStatus, details?: string) { + this.status = status; + this.listeners.forEach((cb) => cb(status, details)); + } + + getStatus(): SyncStatus { + return this.status; + } + + /** + * Process all pending sync queue items. + */ + async processQueue(): Promise<{ synced: number; failed: number; conflicts: number }> { + if (this.isSyncing) return { synced: 0, failed: 0, conflicts: 0 }; + this.isSyncing = true; + this.notify('syncing'); + + const pending = await getPendingSyncActions(); + let synced = 0; + let failed = 0; + let conflicts = 0; + + // Process in batches to avoid overwhelming the server + const batches: SyncQueueItem[][] = []; + for (let i = 0; i < pending.length; i += this.config.maxConcurrent) { + batches.push(pending.slice(i, i + this.config.maxConcurrent)); + } + + for (const batch of batches) { + const results = await Promise.allSettled( + batch.map((item) => this.executeSync(item)) + ); + + for (const result of results) { + if (result.status === 'fulfilled') { + if (result.value === 'synced') synced++; + else if (result.value === 'conflict') conflicts++; + else failed++; + } else { + failed++; + } + } + } + + this.isSyncing = false; + + if (conflicts > 0) { + this.notify('conflict', `${conflicts} item(s) have conflicts`); + } else if (failed > 0) { + this.notify('error', `${failed} item(s) failed to sync`); + } else if (synced > 0) { + this.notify('success', `${synced} item(s) synced successfully`); + } else { + this.notify('idle'); + } + + return { synced, failed, conflicts }; + } + + /** + * Execute a single sync action with conflict resolution. + */ + private async executeSync(item: SyncQueueItem): Promise<'synced' | 'conflict' | 'failed'> { + try { + const { id, action, endpoint, payload, idempotencyKey, maxRetries, retryCount } = item; + + if (retryCount >= maxRetries) { + // Remove from queue after max retries + if (id !== undefined) await removeSyncAction(id); + return 'failed'; + } + + const url = `${SYNC_API_BASE}${endpoint}`; + const response = await fetch(url, { + method: action === 'delete' ? 'DELETE' : action === 'create' ? 'POST' : 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Idempotency-Key': idempotencyKey, + 'X-Sync-Mode': 'offline', + }, + body: action !== 'delete' ? JSON.stringify(payload) : undefined, + }); + + if (response.ok) { + if (id !== undefined) await removeSyncAction(id); + return 'synced'; + } + + // Handle conflict (409) - apply conflict resolution strategy + if (response.status === 409) { + const serverData = await response.json().catch(() => ({})); + const resolved = await this.resolveConflict(item, serverData); + + if (resolved) { + // Send resolved data + const resolveResponse = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Idempotency-Key': `${idempotencyKey}-resolved`, + 'X-Conflict-Resolution': 'true', + }, + body: JSON.stringify(resolved), + }); + + if (resolveResponse.ok) { + if (id !== undefined) await removeSyncAction(id); + return 'conflict'; + } + } + + if (id !== undefined) await incrementRetry(id); + return 'conflict'; + } + + // Other errors: retry later + if (id !== undefined) await incrementRetry(id); + return 'failed'; + } catch (error) { + // Network or other error: will retry + if (item.id !== undefined) await incrementRetry(item.id); + return 'failed'; + } + } + + /** + * Conflict resolution strategy: + * - For medical records: last-write-wins based on timestamp + * - For appointments: server version wins + * - For general data: merge with server data keeping latest values + */ + private async resolveConflict( + localItem: SyncQueueItem, + serverData: Record + ): Promise | null> { + const localPayload = localItem.payload as Record; + + if (!localPayload || !serverData) return null; + + // For endpoints containing 'appointment', server wins + if (localItem.endpoint.includes('appointment')) { + return null; // Skip, server data takes precedence + } + + // For medical records, last-write-wins based on timestamp + const localTimestamp = (localPayload.updatedAt || localPayload.createdAt || localItem.createdAt) as number; + const serverTimestamp = (serverData.updatedAt || serverData.createdAt || 0) as number; + + if (localTimestamp >= serverTimestamp) { + // Local changes are newer, send them + return { ...localPayload, conflictResolvedAt: Date.now() } as Record; + } + + // Server data is newer; merge non-conflicting fields + const merged = { ...serverData }; + for (const [key, value] of Object.entries(localPayload)) { + // Only merge if server doesn't have the field or if it's a local-only field + if (!(key in serverData) || key.startsWith('local_')) { + merged[key] = value; + } + } + return merged as Record; + } +} + +// Singleton instance +export const syncManager = new SyncManager(); + +export default SyncManager;