Swap the main site dashboard chart from a bar chart to a line chart using 21st.dev's line-charts-6 component with dot grid background, glow shadow, and animated active dots. Add Badge trend indicators on metric cards using Phosphor icons. All existing features preserved (annotations, comparison, export, live indicator, interval controls). New UI primitives: line-charts-6, badge-2, card, button-1, avatar. Added shadcn-compatible CSS variables and Tailwind color mappings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
736 lines
32 KiB
TypeScript
736 lines
32 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
import { useTheme } from '@ciphera-net/ui'
|
|
import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts'
|
|
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6'
|
|
import { Badge } from '@/components/ui/badge-2'
|
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
|
import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui'
|
|
import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui'
|
|
import { Checkbox } from '@ciphera-net/ui'
|
|
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const ANNOTATION_COLORS: Record<string, string> = {
|
|
deploy: '#3b82f6',
|
|
campaign: '#22c55e',
|
|
incident: '#ef4444',
|
|
other: '#a3a3a3',
|
|
}
|
|
|
|
const ANNOTATION_LABELS: Record<string, string> = {
|
|
deploy: 'Deploy',
|
|
campaign: 'Campaign',
|
|
incident: 'Incident',
|
|
other: 'Note',
|
|
}
|
|
|
|
const CATEGORY_OPTIONS = [
|
|
{ value: 'deploy', label: 'Deploy' },
|
|
{ value: 'campaign', label: 'Campaign' },
|
|
{ value: 'incident', label: 'Incident' },
|
|
{ value: 'other', label: 'Other' },
|
|
]
|
|
|
|
interface AnnotationData {
|
|
id: string
|
|
date: string
|
|
time?: string | null
|
|
text: string
|
|
category: string
|
|
}
|
|
|
|
export interface DailyStat {
|
|
date: string
|
|
pageviews: number
|
|
visitors: number
|
|
bounce_rate: number
|
|
avg_duration: number
|
|
}
|
|
|
|
interface Stats {
|
|
pageviews: number
|
|
visitors: number
|
|
bounce_rate: number
|
|
avg_duration: number
|
|
}
|
|
|
|
interface ChartProps {
|
|
data: DailyStat[]
|
|
prevData?: DailyStat[]
|
|
stats: Stats
|
|
prevStats?: Stats
|
|
interval: 'minute' | 'hour' | 'day' | 'month'
|
|
dateRange: { start: string, end: string }
|
|
todayInterval: 'minute' | 'hour'
|
|
setTodayInterval: (interval: 'minute' | 'hour') => void
|
|
multiDayInterval: 'hour' | 'day'
|
|
setMultiDayInterval: (interval: 'hour' | 'day') => void
|
|
onExportChart?: () => void
|
|
lastUpdatedAt?: number | null
|
|
annotations?: AnnotationData[]
|
|
canManageAnnotations?: boolean
|
|
onCreateAnnotation?: (data: { date: string; time?: string; text: string; category: string }) => Promise<void>
|
|
onUpdateAnnotation?: (id: string, data: { date: string; time?: string; text: string; category: string }) => Promise<void>
|
|
onDeleteAnnotation?: (id: string) => Promise<void>
|
|
}
|
|
|
|
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
function formatEU(dateStr: string): string {
|
|
const [y, m, d] = dateStr.split('-')
|
|
return `${d}/${m}/${y}`
|
|
}
|
|
|
|
// ─── Metric configurations ──────────────────────────────────────────
|
|
|
|
const METRIC_CONFIGS: {
|
|
key: MetricType
|
|
label: string
|
|
format: (v: number) => string
|
|
isNegative?: boolean
|
|
}[] = [
|
|
{ key: 'visitors', label: 'Unique Visitors', format: (v) => formatNumber(v) },
|
|
{ key: 'pageviews', label: 'Total Pageviews', format: (v) => formatNumber(v) },
|
|
{ key: 'bounce_rate', label: 'Bounce Rate', format: (v) => `${Math.round(v)}%`, isNegative: true },
|
|
{ key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) },
|
|
]
|
|
|
|
const chartConfig = {
|
|
visitors: { label: 'Unique Visitors', color: '#FD5E0F' },
|
|
pageviews: { label: 'Total Pageviews', color: '#3b82f6' },
|
|
bounce_rate: { label: 'Bounce Rate', color: '#a855f7' },
|
|
avg_duration: { label: 'Visit Duration', color: '#10b981' },
|
|
} satisfies ChartConfig
|
|
|
|
// ─── Custom Tooltip ─────────────────────────────────────────────────
|
|
|
|
interface TooltipProps {
|
|
active?: boolean
|
|
payload?: Array<{ dataKey: string; value: number; color: string }>
|
|
label?: string
|
|
metric: MetricType
|
|
}
|
|
|
|
function CustomTooltip({ active, payload, metric }: TooltipProps) {
|
|
if (active && payload && payload.length) {
|
|
const entry = payload[0]
|
|
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
|
|
|
if (config) {
|
|
return (
|
|
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 p-3 shadow-sm shadow-black/5 min-w-[120px]">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="size-1.5 rounded-full" style={{ backgroundColor: entry.color }}></div>
|
|
<span className="text-neutral-500 dark:text-neutral-400">{config.label}:</span>
|
|
<span className="font-semibold text-neutral-900 dark:text-white">{config.format(entry.value)}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ─── Chart Component ─────────────────────────────────────────────────
|
|
|
|
export default function Chart({
|
|
data,
|
|
prevData,
|
|
stats,
|
|
prevStats,
|
|
interval,
|
|
dateRange,
|
|
todayInterval,
|
|
setTodayInterval,
|
|
multiDayInterval,
|
|
setMultiDayInterval,
|
|
onExportChart,
|
|
lastUpdatedAt,
|
|
annotations,
|
|
canManageAnnotations,
|
|
onCreateAnnotation,
|
|
onUpdateAnnotation,
|
|
onDeleteAnnotation,
|
|
}: ChartProps) {
|
|
const [metric, setMetric] = useState<MetricType>('visitors')
|
|
const chartContainerRef = useRef<HTMLDivElement>(null)
|
|
const { resolvedTheme } = useTheme()
|
|
const [showComparison, setShowComparison] = useState(false)
|
|
|
|
// ─── Annotation state ─────────────────────────────────────────────
|
|
const [annotationForm, setAnnotationForm] = useState<{
|
|
visible: boolean; editingId?: string; date: string; time: string; text: string; category: string
|
|
}>({ visible: false, date: new Date().toISOString().slice(0, 10), time: '', text: '', category: 'other' })
|
|
const [calendarOpen, setCalendarOpen] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; date: string } | null>(null)
|
|
|
|
// Close context menu and annotation form on Escape
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (calendarOpen) { setCalendarOpen(false); return }
|
|
if (contextMenu) { setContextMenu(null); return }
|
|
if (annotationForm.visible) { setAnnotationForm(f => ({ ...f, visible: false })); return }
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [calendarOpen, contextMenu, annotationForm.visible])
|
|
|
|
const handleExportChart = useCallback(async () => {
|
|
if (onExportChart) { onExportChart(); return }
|
|
if (!chartContainerRef.current) return
|
|
try {
|
|
const { toPng } = await import('html-to-image')
|
|
const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff')
|
|
const dataUrl = await toPng(chartContainerRef.current, {
|
|
cacheBust: true,
|
|
backgroundColor: bg,
|
|
})
|
|
const link = document.createElement('a')
|
|
link.download = `chart-${dateRange.start}-${dateRange.end}.png`
|
|
link.href = dataUrl
|
|
link.click()
|
|
} catch { /* noop */ }
|
|
}, [onExportChart, dateRange, resolvedTheme])
|
|
|
|
// ─── Data ──────────────────────────────────────────────────────────
|
|
|
|
const chartData = data.map((item) => {
|
|
let formattedDate: string
|
|
if (interval === 'minute') {
|
|
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
|
} else if (interval === 'hour') {
|
|
const d = new Date(item.date)
|
|
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0
|
|
formattedDate = isMidnight
|
|
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM'
|
|
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
|
|
} else {
|
|
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
return {
|
|
date: formattedDate,
|
|
originalDate: item.date,
|
|
pageviews: item.pageviews,
|
|
visitors: item.visitors,
|
|
bounce_rate: item.bounce_rate,
|
|
avg_duration: item.avg_duration,
|
|
}
|
|
})
|
|
|
|
const annotationMarkers = useMemo(() => {
|
|
if (!annotations?.length) return []
|
|
const byDate = new Map<string, AnnotationData[]>()
|
|
for (const a of annotations) {
|
|
const existing = byDate.get(a.date) || []
|
|
existing.push(a)
|
|
byDate.set(a.date, existing)
|
|
}
|
|
const markers: { x: string; annotations: AnnotationData[] }[] = []
|
|
for (const [date, anns] of byDate) {
|
|
const match = chartData.find((d) => {
|
|
const orig = d.originalDate
|
|
return orig.startsWith(date) || orig === date
|
|
})
|
|
if (match) {
|
|
markers.push({ x: match.date, annotations: anns })
|
|
}
|
|
}
|
|
return markers
|
|
}, [annotations, chartData])
|
|
|
|
// ─── Right-click handler ──────────────────────────────────────────
|
|
const handleChartContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!canManageAnnotations) return
|
|
e.preventDefault()
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
const relX = e.clientX - rect.left
|
|
const leftMargin = 48
|
|
const rightMargin = 16
|
|
const plotWidth = rect.width - leftMargin - rightMargin
|
|
const fraction = Math.max(0, Math.min(1, (relX - leftMargin) / plotWidth))
|
|
const index = Math.min(Math.round(fraction * (chartData.length - 1)), chartData.length - 1)
|
|
const point = chartData[index]
|
|
if (point) {
|
|
setContextMenu({ x: e.clientX, y: e.clientY, date: point.originalDate.slice(0, 10) })
|
|
}
|
|
}, [canManageAnnotations, chartData])
|
|
|
|
// ─── Annotation form handlers ─────────────────────────────────────
|
|
const handleSaveAnnotation = useCallback(async () => {
|
|
if (saving) return
|
|
const payload = {
|
|
date: annotationForm.date,
|
|
time: annotationForm.time || undefined,
|
|
text: annotationForm.text.trim(),
|
|
category: annotationForm.category,
|
|
}
|
|
setSaving(true)
|
|
try {
|
|
if (annotationForm.editingId && onUpdateAnnotation) {
|
|
await onUpdateAnnotation(annotationForm.editingId, payload)
|
|
} else if (onCreateAnnotation) {
|
|
await onCreateAnnotation(payload)
|
|
}
|
|
setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [annotationForm, saving, onCreateAnnotation, onUpdateAnnotation])
|
|
|
|
const handleDeleteAnnotation = useCallback(async () => {
|
|
if (!annotationForm.editingId || !onDeleteAnnotation) return
|
|
setSaving(true)
|
|
try {
|
|
await onDeleteAnnotation(annotationForm.editingId)
|
|
setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' })
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [annotationForm.editingId, onDeleteAnnotation])
|
|
|
|
// ─── Metrics with trends ──────────────────────────────────────────
|
|
|
|
const metricsWithTrends = METRIC_CONFIGS.map((m) => {
|
|
const value = stats[m.key]
|
|
const previousValue = prevStats?.[m.key]
|
|
const change = previousValue != null && previousValue > 0
|
|
? ((value - previousValue) / previousValue) * 100
|
|
: null
|
|
const isPositive = change !== null ? (m.isNegative ? change < 0 : change > 0) : null
|
|
|
|
return {
|
|
...m,
|
|
value,
|
|
previousValue,
|
|
change,
|
|
isPositive,
|
|
}
|
|
})
|
|
|
|
const hasData = data.length > 0
|
|
const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0
|
|
)
|
|
|
|
// ─── Render ────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div ref={chartContainerRef} className="relative">
|
|
<Card className="@container w-full overflow-hidden rounded-2xl">
|
|
<CardHeader className="p-0 mb-0">
|
|
{/* Metrics Grid - 21st.dev style */}
|
|
<div className="grid grid-cols-2 @2xl:grid-cols-2 @3xl:grid-cols-4 grow w-full">
|
|
{metricsWithTrends.map((m) => (
|
|
<button
|
|
key={m.key}
|
|
onClick={() => setMetric(m.key)}
|
|
className={cn(
|
|
'cursor-pointer flex-1 text-start p-4 last:border-b-0 border-b @2xl:border-b @2xl:even:border-r @3xl:border-b-0 @3xl:border-r @3xl:last:border-r-0 border-neutral-200 dark:border-neutral-800 transition-all',
|
|
metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40',
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-neutral-500 dark:text-neutral-400">{m.label}</span>
|
|
{m.change !== null && (
|
|
<Badge variant={m.isPositive ? 'success' : 'destructive'} appearance="outline">
|
|
{m.isPositive ? <ArrowUp weight="bold" className="size-3" /> : <ArrowDown weight="bold" className="size-3" />}
|
|
{Math.abs(m.change).toFixed(1)}%
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-2xl font-bold text-neutral-900 dark:text-white">{m.format(m.value)}</div>
|
|
{m.previousValue != null && (
|
|
<div className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">from {m.format(m.previousValue)}</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="px-2.5 py-4">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between gap-3 mb-4 px-2">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
|
{METRIC_CONFIGS.find((m) => m.key === metric)?.label}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{dateRange.start === dateRange.end ? (
|
|
<Select
|
|
value={todayInterval}
|
|
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
|
|
options={[
|
|
{ value: 'minute', label: '1 min' },
|
|
{ value: 'hour', label: '1 hour' },
|
|
]}
|
|
className="min-w-[90px]"
|
|
/>
|
|
) : (
|
|
<Select
|
|
value={multiDayInterval}
|
|
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
|
|
options={[
|
|
{ value: 'hour', label: '1 hour' },
|
|
{ value: 'day', label: '1 day' },
|
|
]}
|
|
className="min-w-[90px]"
|
|
/>
|
|
)}
|
|
|
|
{prevData?.length ? (
|
|
<Checkbox
|
|
checked={showComparison}
|
|
onCheckedChange={setShowComparison}
|
|
label="Compare"
|
|
/>
|
|
) : null}
|
|
|
|
<button
|
|
onClick={handleExportChart}
|
|
disabled={!hasData}
|
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
|
|
title="Export chart as PNG"
|
|
>
|
|
<DownloadIcon className="w-4 h-4" />
|
|
</button>
|
|
|
|
{canManageAnnotations && (
|
|
<button
|
|
onClick={() => setAnnotationForm({ visible: true, date: new Date().toISOString().slice(0, 10), time: '', text: '', category: 'other' })}
|
|
className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors cursor-pointer"
|
|
title="Add annotation"
|
|
>
|
|
<PlusIcon className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!hasData || !hasAnyNonZero ? (
|
|
<div className="flex h-96 flex-col items-center justify-center gap-2">
|
|
<p className="text-sm text-neutral-400 dark:text-neutral-500">
|
|
{!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="w-full" onContextMenu={handleChartContextMenu}>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="h-96 w-full overflow-visible [&_.recharts-curve.recharts-tooltip-cursor]:stroke-[initial]"
|
|
>
|
|
<LineChart
|
|
data={chartData}
|
|
margin={{ top: 20, right: 20, left: 5, bottom: 20 }}
|
|
style={{ overflow: 'visible' }}
|
|
>
|
|
<defs>
|
|
<pattern id="dotGrid" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
|
|
<circle cx="10" cy="10" r="1" fill="var(--chart-grid)" fillOpacity="1" />
|
|
</pattern>
|
|
<filter id="lineShadow" x="-100%" y="-100%" width="300%" height="300%">
|
|
<feDropShadow
|
|
dx="4"
|
|
dy="6"
|
|
stdDeviation="25"
|
|
floodColor={`${chartConfig[metric]?.color}60`}
|
|
/>
|
|
</filter>
|
|
<filter id="dotShadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feDropShadow dx="2" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.5)" />
|
|
</filter>
|
|
</defs>
|
|
|
|
<XAxis
|
|
dataKey="date"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
|
|
tickMargin={10}
|
|
minTickGap={32}
|
|
/>
|
|
|
|
<YAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fontSize: 11, fill: 'var(--chart-axis)' }}
|
|
tickMargin={10}
|
|
tickCount={6}
|
|
tickFormatter={(value) => {
|
|
const config = METRIC_CONFIGS.find((m) => m.key === metric)
|
|
return config ? config.format(value) : value.toString()
|
|
}}
|
|
/>
|
|
|
|
<ChartTooltip content={<CustomTooltip metric={metric} />} cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} />
|
|
|
|
{/* Background dot grid pattern */}
|
|
<rect
|
|
x="60px"
|
|
y="-20px"
|
|
width="calc(100% - 75px)"
|
|
height="calc(100% - 10px)"
|
|
fill="url(#dotGrid)"
|
|
style={{ pointerEvents: 'none' }}
|
|
/>
|
|
|
|
{/* Annotation reference lines */}
|
|
{annotationMarkers.map((marker) => {
|
|
const primaryCategory = marker.annotations[0].category
|
|
const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other
|
|
return (
|
|
<ReferenceLine
|
|
key={`ann-${marker.x}`}
|
|
x={marker.x}
|
|
stroke={color}
|
|
strokeDasharray="4 4"
|
|
strokeWidth={1.5}
|
|
strokeOpacity={0.6}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
<Line
|
|
type="monotone"
|
|
dataKey={metric}
|
|
stroke={chartConfig[metric]?.color}
|
|
strokeWidth={2}
|
|
filter="url(#lineShadow)"
|
|
dot={false}
|
|
activeDot={{
|
|
r: 6,
|
|
fill: chartConfig[metric]?.color,
|
|
stroke: 'white',
|
|
strokeWidth: 2,
|
|
filter: 'url(#dotShadow)',
|
|
}}
|
|
/>
|
|
</LineChart>
|
|
</ChartContainer>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Annotation tags */}
|
|
{annotationMarkers.length > 0 && (
|
|
<div className="px-4 sm:px-6 flex items-center gap-1 flex-wrap py-1.5 border-t border-neutral-100 dark:border-neutral-800 -mt-px rounded-b-2xl bg-white dark:bg-neutral-900 border-x border-b border-neutral-200 dark:border-neutral-800">
|
|
<span className="text-[10px] font-medium text-neutral-400 dark:text-neutral-500 mr-1">Annotations:</span>
|
|
{annotationMarkers.map((marker) => {
|
|
const primary = marker.annotations[0]
|
|
const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other
|
|
const count = marker.annotations.length
|
|
return (
|
|
<button
|
|
key={`ann-btn-${marker.x}`}
|
|
type="button"
|
|
className="relative group inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
|
|
onClick={() => {
|
|
if (canManageAnnotations) {
|
|
setAnnotationForm({
|
|
visible: true,
|
|
editingId: primary.id,
|
|
date: primary.date,
|
|
time: primary.time || '',
|
|
text: primary.text,
|
|
category: primary.category,
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
|
<span className="max-w-[120px] truncate">{primary.text}</span>
|
|
{count > 1 && <span className="text-neutral-400">+{count - 1}</span>}
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block z-50 pointer-events-none">
|
|
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-2 min-w-[180px] max-w-[240px]">
|
|
{marker.annotations.map((a) => (
|
|
<div key={a.id} className="flex items-start gap-1.5 text-[11px] mb-1 last:mb-0">
|
|
<span className="w-1.5 h-1.5 rounded-full mt-1 shrink-0" style={{ backgroundColor: ANNOTATION_COLORS[a.category] || ANNOTATION_COLORS.other }} />
|
|
<div>
|
|
<span className="font-medium text-neutral-400 dark:text-neutral-500">
|
|
{ANNOTATION_LABELS[a.category] || 'Note'} · {formatEU(a.date)}{a.time ? ` at ${a.time}` : ''}
|
|
</span>
|
|
<p className="text-neutral-900 dark:text-white">{a.text}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Live indicator */}
|
|
{lastUpdatedAt != null && (
|
|
<div className="px-4 sm:px-6 pb-3 pt-2 flex justify-end">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
|
|
<span className="relative flex h-1.5 w-1.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
|
|
</span>
|
|
Live · {formatUpdatedAgo(lastUpdatedAt)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Right-click Context Menu ──────────────────────────────── */}
|
|
{contextMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null) }} />
|
|
<div
|
|
className="fixed z-50 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg py-1 min-w-[180px]"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
>
|
|
<button
|
|
className="w-full text-left px-3 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
|
onClick={() => {
|
|
setAnnotationForm({ visible: true, date: contextMenu.date, time: '', text: '', category: 'other' })
|
|
setContextMenu(null)
|
|
}}
|
|
>
|
|
<PlusIcon className="w-3.5 h-3.5" />
|
|
Add annotation ({formatEU(contextMenu.date)})
|
|
</button>
|
|
<button
|
|
className="w-full text-left px-3 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
|
onClick={() => {
|
|
handleExportChart()
|
|
setContextMenu(null)
|
|
}}
|
|
>
|
|
<DownloadIcon className="w-3.5 h-3.5" />
|
|
Export chart
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ─── Annotation Form Modal ─────────────────────────────────── */}
|
|
{annotationForm.visible && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/20 dark:bg-black/40 rounded-2xl">
|
|
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl p-5 w-[340px] max-w-[90%]">
|
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-white mb-3">
|
|
{annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Date</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCalendarOpen(true)}
|
|
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30 text-left flex items-center justify-between"
|
|
>
|
|
<span>{annotationForm.date ? formatEU(annotationForm.date) : 'Select date'}</span>
|
|
<svg className="w-4 h-4 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
|
Time <span className="text-neutral-400 dark:text-neutral-500">(optional)</span>
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="time"
|
|
value={annotationForm.time}
|
|
onChange={(e) => setAnnotationForm((f) => ({ ...f, time: e.target.value }))}
|
|
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
|
|
/>
|
|
{annotationForm.time && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setAnnotationForm((f) => ({ ...f, time: '' }))}
|
|
className="p-1.5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
|
title="Clear time"
|
|
>
|
|
<XIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Note</label>
|
|
<input
|
|
type="text"
|
|
value={annotationForm.text}
|
|
onChange={(e) => setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))}
|
|
placeholder="e.g. Launched new homepage"
|
|
maxLength={200}
|
|
className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30"
|
|
autoFocus
|
|
/>
|
|
<span className="text-[10px] text-neutral-400 mt-0.5 block text-right">{annotationForm.text.length}/200</span>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">Category</label>
|
|
<Select
|
|
value={annotationForm.category}
|
|
onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))}
|
|
options={CATEGORY_OPTIONS}
|
|
variant="input"
|
|
fullWidth
|
|
align="left"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div>
|
|
{annotationForm.editingId && (
|
|
<button
|
|
type="button"
|
|
onClick={handleDeleteAnnotation}
|
|
disabled={saving}
|
|
className="text-xs text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 font-medium cursor-pointer disabled:opacity-50"
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' })}
|
|
className="px-3 py-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!annotationForm.text.trim() || !annotationForm.date || saving}
|
|
onClick={handleSaveAnnotation}
|
|
className="px-3 py-1.5 text-xs font-medium text-white bg-brand-orange hover:bg-brand-orange/90 rounded-lg disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{saving ? 'Saving...' : annotationForm.editingId ? 'Save' : 'Add'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── DatePicker overlay ─────────────────────── */}
|
|
<DatePicker
|
|
isOpen={calendarOpen}
|
|
onClose={() => setCalendarOpen(false)}
|
|
onApply={() => {}}
|
|
initialRange={{ start: annotationForm.date || new Date().toISOString().slice(0, 10), end: annotationForm.date || new Date().toISOString().slice(0, 10) }}
|
|
mode="single"
|
|
onSelect={(date) => {
|
|
setAnnotationForm((f) => ({ ...f, date }))
|
|
setCalendarOpen(false)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|