Skip to content

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.

Continue to Chapter 5: Deployment →

Released under the MIT License.