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;