Dashboard Tutorial: Analytics Page
Build an analytics page with shared state between islands.
Shared State Between Islands
Multiple islands can share state using signals:
tsx
// app/lib/analytics-store.ts
import { signal, computed } from '@ereo/state'
export interface DateRange {
start: Date
end: Date
}
export const dateRange = signal<DateRange>({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
end: new Date()
})
export const selectedMetric = signal<'views' | 'users' | 'revenue'>('views')
export const dateRangeLabel = computed(() => {
const range = dateRange.get()
const days = Math.ceil((range.end.getTime() - range.start.getTime()) / (24 * 60 * 60 * 1000))
return `Last ${days} days`
}, [dateRange])Date Range Picker Island
Create app/components/DateRangePicker.tsx:
tsx
'use client'
import { useState, useEffect } from 'react'
import { createIsland } from '@ereo/client'
import { dateRange, type DateRange } from '~/lib/analytics-store'
const presets = [
{ label: 'Last 7 days', days: 7 },
{ label: 'Last 30 days', days: 30 },
{ label: 'Last 90 days', days: 90 },
{ label: 'This year', days: 365 }
]
function DateRangePicker({ initialRange }: { initialRange: DateRange }) {
const [range, setRange] = useState(initialRange)
const [showCustom, setShowCustom] = useState(false)
useEffect(() => {
// Subscribe to global state changes
return dateRange.subscribe(setRange)
}, [])
const applyPreset = (days: number) => {
const newRange = {
start: new Date(Date.now() - days * 24 * 60 * 60 * 1000),
end: new Date()
}
setRange(newRange)
dateRange.set(newRange)
setShowCustom(false)
}
const applyCustom = (start: string, end: string) => {
const newRange = {
start: new Date(start),
end: new Date(end)
}
setRange(newRange)
dateRange.set(newRange)
setShowCustom(false)
}
return (
<div className="relative">
<button
onClick={() => setShowCustom(!showCustom)}
className="flex items-center gap-2 px-4 py-2 bg-white border rounded-lg hover:bg-gray-50"
>
<span>
{range.start.toLocaleDateString()} - {range.end.toLocaleDateString()}
</span>
</button>
{showCustom && (
<div className="absolute top-full left-0 mt-2 p-4 bg-white rounded-lg shadow-lg border z-10">
<div className="flex flex-wrap gap-2 mb-4">
{presets.map((preset) => (
<button
key={preset.days}
onClick={() => applyPreset(preset.days)}
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
>
{preset.label}
</button>
))}
</div>
<div className="border-t pt-4">
<p className="text-sm text-gray-500 mb-2">Custom Range</p>
<form
onSubmit={(e) => {
e.preventDefault()
const form = e.currentTarget
const start = (form.elements.namedItem('start') as HTMLInputElement).value
const end = (form.elements.namedItem('end') as HTMLInputElement).value
applyCustom(start, end)
}}
className="flex gap-2"
>
<input
type="date"
name="start"
defaultValue={range.start.toISOString().split('T')[0]}
className="px-2 py-1 border rounded text-sm"
/>
<span className="self-center">to</span>
<input
type="date"
name="end"
defaultValue={range.end.toISOString().split('T')[0]}
className="px-2 py-1 border rounded text-sm"
/>
<button
type="submit"
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Apply
</button>
</form>
</div>
</div>
)}
</div>
)
}
export default createIsland(DateRangePicker, 'DateRangePicker')Metric Selector Island
Create app/components/MetricSelector.tsx:
tsx
'use client'
import { useState, useEffect } from 'react'
import { createIsland } from '@ereo/client'
import { selectedMetric } from '~/lib/analytics-store'
const metrics = [
{ id: 'views', label: 'Page Views', icon: '👁' },
{ id: 'users', label: 'Active Users', icon: '👤' },
{ id: 'revenue', label: 'Revenue', icon: '💰' }
] as const
function MetricSelector({ initial }: { initial: 'views' | 'users' | 'revenue' }) {
const [selected, setSelected] = useState(initial)
useEffect(() => {
return selectedMetric.subscribe(setSelected)
}, [])
const handleSelect = (id: typeof selected) => {
setSelected(id)
selectedMetric.set(id)
}
return (
<div className="flex gap-2">
{metrics.map((metric) => (
<button
key={metric.id}
onClick={() => handleSelect(metric.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition ${
selected === metric.id
? 'bg-blue-600 text-white'
: 'bg-white border hover:bg-gray-50'
}`}
>
<span>{metric.icon}</span>
<span>{metric.label}</span>
</button>
))}
</div>
)
}
export default createIsland(MetricSelector, 'MetricSelector')Analytics Chart Island
Create app/components/AnalyticsChart.tsx:
tsx
'use client'
import { useState, useEffect, useMemo } from 'react'
import { createIsland } from '@ereo/client'
import { dateRange, selectedMetric } from '~/lib/analytics-store'
interface DataSet {
views: Array<{ date: string; value: number }>
users: Array<{ date: string; value: number }>
revenue: Array<{ date: string; value: number }>
}
function AnalyticsChart({ data }: { data: DataSet }) {
const [range, setRange] = useState(dateRange.get())
const [metric, setMetric] = useState(selectedMetric.get())
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null)
useEffect(() => {
const unsubRange = dateRange.subscribe(setRange)
const unsubMetric = selectedMetric.subscribe(setMetric)
return () => {
unsubRange()
unsubMetric()
}
}, [])
const filteredData = useMemo(() => {
return data[metric].filter(d => {
const date = new Date(d.date)
return date >= range.start && date <= range.end
})
}, [data, metric, range])
const maxValue = Math.max(...filteredData.map(d => d.value))
const getY = (value: number) => 100 - (value / maxValue) * 80 - 10
const points = filteredData.map((d, i) => ({
x: (i / (filteredData.length - 1 || 1)) * 100,
y: getY(d.value),
data: d
}))
const formatValue = (value: number) => {
if (metric === 'revenue') return `$${value.toLocaleString()}`
return value.toLocaleString()
}
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="font-semibold text-lg">
{metric === 'views' && 'Page Views'}
{metric === 'users' && 'Active Users'}
{metric === 'revenue' && 'Revenue'}
</h3>
{hoveredPoint !== null && points[hoveredPoint] && (
<div className="text-right">
<p className="text-2xl font-bold">{formatValue(points[hoveredPoint].data.value)}</p>
<p className="text-sm text-gray-500">{points[hoveredPoint].data.date}</p>
</div>
)}
</div>
<svg
viewBox="0 0 100 100"
className="w-full h-64"
preserveAspectRatio="none"
onMouseLeave={() => setHoveredPoint(null)}
>
{/* Grid */}
{[0, 25, 50, 75, 100].map((y) => (
<line key={y} x1="0" y1={y} x2="100" y2={y} stroke="#e5e7eb" strokeWidth="0.2" />
))}
{/* Area */}
<polygon
points={`0,100 ${points.map(p => `${p.x},${p.y}`).join(' ')} 100,100`}
fill="url(#analyticsGradient)"
/>
{/* Line */}
<polyline
points={points.map(p => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke="#3b82f6"
strokeWidth="0.5"
/>
{/* Interactive points */}
{points.map((point, i) => (
<circle
key={i}
cx={point.x}
cy={point.y}
r={hoveredPoint === i ? 1.5 : 0.5}
fill="#3b82f6"
className="cursor-pointer"
onMouseEnter={() => setHoveredPoint(i)}
/>
))}
<defs>
<linearGradient id="analyticsGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
<div className="flex justify-between text-sm text-gray-500 mt-2">
<span>{range.start.toLocaleDateString()}</span>
<span>{range.end.toLocaleDateString()}</span>
</div>
</div>
)
}
export default createIsland(AnalyticsChart, 'AnalyticsChart')Analytics Page
Create app/routes/dashboard/analytics.tsx:
tsx
import { createLoader } from '@ereo/data'
import DateRangePicker from '~/components/DateRangePicker'
import MetricSelector from '~/components/MetricSelector'
import AnalyticsChart from '~/components/AnalyticsChart'
export const loader = createLoader(async () => {
// Generate mock analytics data
const generateData = (base: number, variance: number) => {
return Array.from({ length: 365 }, (_, i) => {
const date = new Date()
date.setDate(date.getDate() - (364 - i))
return {
date: date.toISOString().split('T')[0],
value: Math.floor(base + Math.random() * variance - variance / 2)
}
})
}
return {
data: {
views: generateData(5000, 2000),
users: generateData(1200, 500),
revenue: generateData(15000, 5000)
},
initialRange: {
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end: new Date().toISOString()
}
}
})
export default function AnalyticsPage({ loaderData }: { loaderData: any }) {
const { data, initialRange } = loaderData
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Analytics</h2>
{/* Date Range Picker */}
<DateRangePicker
client:load
initialRange={{
start: new Date(initialRange.start),
end: new Date(initialRange.end)
}}
/>
</div>
{/* Metric Selector */}
<MetricSelector client:load initial="views" />
{/* Main Chart */}
<AnalyticsChart client:load data={data} />
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500">Total Views</p>
<p className="text-3xl font-bold">1.2M</p>
<p className="text-sm text-green-600">↑ 12% from last period</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500">Avg. Daily Users</p>
<p className="text-3xl font-bold">4,521</p>
<p className="text-sm text-green-600">↑ 8% from last period</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500">Conversion Rate</p>
<p className="text-3xl font-bold">3.2%</p>
<p className="text-sm text-red-600">↓ 0.5% from last period</p>
</div>
</div>
</div>
)
}How State Sharing Works
┌─────────────────────────────────────────────────────────────┐
│ Analytics Page │
├─────────────────────────────────────────────────────────────┤
│ ┌───────────────┐ ┌─────────────────┐ │
│ │ DateRange │────▶│ │ │
│ │ Picker │ │ Shared │ │
│ │ (Island) │ │ Signals │ │
│ └───────────────┘ │ │ │
│ │ - dateRange │ │
│ ┌───────────────┐ │ - selectedMetric│ │
│ │ Metric │────▶│ │ │
│ │ Selector │ │ │ │
│ │ (Island) │ └────────┬────────┘ │
│ └───────────────┘ │ │
│ │ │
│ ┌───────────────────────────────▼───────────────────────┐ │
│ │ Analytics Chart (Island) │ │
│ │ │ │
│ │ Subscribes to both signals, re-renders on change │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘Next Steps
In the final chapter, we'll deploy the dashboard to production.