feat: add chart annotations

Inline annotation markers on the dashboard chart with create/edit/delete UI.
Color-coded categories: deploy, campaign, incident, other.
This commit is contained in:
Usman Baig
2026-03-09 03:44:05 +01:00
parent 3002c4f58c
commit 4d99334bcf
5 changed files with 319 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Added ### Added
- **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations. - **Hide unknown locations.** New toggle in Site Settings under Data & Privacy to hide "Unknown" entries from your Locations panel. When geographic data can't be determined for a visitor, it normally shows as "Unknown" in countries, cities, and regions. Turn this on to keep your location stats clean and only show resolved locations.
- **Chart annotations.** Mark events on your dashboard timeline — like deploys, campaigns, or incidents — so you always know why traffic changed. Click the + button on the chart to add a note on any date. Annotations appear as colored markers on the chart: blue for deploys, green for campaigns, red for incidents. Hover to see the details. Team owners and admins can add, edit, and delete annotations; everyone else (including public dashboard viewers) can see them.
### Improved ### Improved

View File

@@ -51,7 +51,9 @@ import {
useStats, useStats,
useDailyStats, useDailyStats,
useCampaigns, useCampaigns,
useAnnotations,
} from '@/lib/swr/dashboard' } from '@/lib/swr/dashboard'
import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations'
function loadSavedSettings(): { function loadSavedSettings(): {
type?: string type?: string
@@ -233,6 +235,26 @@ export default function SiteDashboardPage() {
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end) const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end)
// Annotation mutation handlers
const handleCreateAnnotation = async (data: { date: string; text: string; category: string }) => {
await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory })
mutateAnnotations()
toast.success('Annotation added')
}
const handleUpdateAnnotation = async (id: string, data: { date: string; text: string; category: string }) => {
await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory })
mutateAnnotations()
toast.success('Annotation updated')
}
const handleDeleteAnnotation = async (id: string) => {
await deleteAnnotation(siteId, id)
mutateAnnotations()
toast.success('Annotation deleted')
}
// Derive typed values from SWR data // Derive typed values from SWR data
const site = overview?.site ?? null const site = overview?.site ?? null
@@ -521,6 +543,11 @@ export default function SiteDashboardPage() {
multiDayInterval={multiDayInterval} multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval} setMultiDayInterval={setMultiDayInterval}
lastUpdatedAt={lastUpdatedAt} lastUpdatedAt={lastUpdatedAt}
annotations={annotations}
canManageAnnotations={true}
onCreateAnnotation={handleCreateAnnotation}
onUpdateAnnotation={handleUpdateAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
/> />
</div> </div>

View File

@@ -10,10 +10,11 @@ import {
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
ReferenceLine,
} from 'recharts' } from 'recharts'
import type { TooltipProps } from 'recharts' import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon } from '@ciphera-net/ui' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui'
const COLORS = { const COLORS = {
@@ -42,6 +43,27 @@ const CHART_COLORS_DARK = {
tooltipBorder: 'var(--color-neutral-700)', tooltipBorder: 'var(--color-neutral-700)',
} }
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',
}
interface AnnotationData {
id: string
date: string
text: string
category: string
}
export interface DailyStat { export interface DailyStat {
date: string date: string
pageviews: number pageviews: number
@@ -70,6 +92,11 @@ interface ChartProps {
setMultiDayInterval: (interval: 'hour' | 'day') => void setMultiDayInterval: (interval: 'hour' | 'day') => void
onExportChart?: () => void onExportChart?: () => void
lastUpdatedAt?: number | null lastUpdatedAt?: number | null
annotations?: AnnotationData[]
canManageAnnotations?: boolean
onCreateAnnotation?: (data: { date: string; text: string; category: string }) => Promise<void>
onUpdateAnnotation?: (id: string, data: { date: string; text: string; category: string }) => Promise<void>
onDeleteAnnotation?: (id: string) => Promise<void>
} }
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
@@ -208,6 +235,11 @@ export default function Chart({
setMultiDayInterval, setMultiDayInterval,
onExportChart, onExportChart,
lastUpdatedAt, lastUpdatedAt,
annotations,
canManageAnnotations,
onCreateAnnotation,
onUpdateAnnotation,
onDeleteAnnotation,
}: ChartProps) { }: ChartProps) {
const [metric, setMetric] = useState<MetricType>('visitors') const [metric, setMetric] = useState<MetricType>('visitors')
const [showComparison, setShowComparison] = useState(false) const [showComparison, setShowComparison] = useState(false)
@@ -269,6 +301,31 @@ export default function Chart({
} }
}) })
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])
const [annotationForm, setAnnotationForm] = useState<{ visible: boolean; editingId?: string; date: string; text: string; category: string }>({
visible: false, date: new Date().toISOString().slice(0, 10), text: '', category: 'other'
})
// ─── Metrics ─────────────────────────────────────────────────────── // ─── Metrics ───────────────────────────────────────────────────────
const calculateTrend = (current: number, previous?: number) => { const calculateTrend = (current: number, previous?: number) => {
@@ -427,6 +484,16 @@ export default function Chart({
> >
<DownloadIcon className="w-4 h-4" /> <DownloadIcon className="w-4 h-4" />
</button> </button>
{canManageAnnotations && (
<button
onClick={() => setAnnotationForm({ visible: true, date: new Date().toISOString().slice(0, 10), 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>
</div> </div>
@@ -535,12 +602,74 @@ export default function Chart({
animationDuration={400} animationDuration={400}
animationEasing="ease-out" animationEasing="ease-out"
/> />
{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}
/>
)
})}
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)} )}
</div> </div>
{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">
<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,
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>}
{/* Hover tooltip */}
<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'} &middot; {a.date}</span>
<p className="text-neutral-900 dark:text-white">{a.text}</p>
</div>
</div>
))}
</div>
</div>
</button>
)
})}
</div>
)}
{/* Live indicator */} {/* Live indicator */}
{lastUpdatedAt != null && ( {lastUpdatedAt != null && (
<div className="px-4 sm:px-6 pb-3 flex justify-end"> <div className="px-4 sm:px-6 pb-3 flex justify-end">
@@ -553,6 +682,96 @@ export default function Chart({
</div> </div>
</div> </div>
)} )}
{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>
<input
type="date"
value={annotationForm.date}
onChange={(e) => setAnnotationForm((f) => ({ ...f, date: e.target.value }))}
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"
/>
</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={(e) => setAnnotationForm((f) => ({ ...f, category: e.target.value }))}
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"
>
<option value="deploy">Deploy</option>
<option value="campaign">Campaign</option>
<option value="incident">Incident</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div className="flex items-center justify-between mt-4">
<div>
{annotationForm.editingId && (
<button
type="button"
onClick={async () => {
if (annotationForm.editingId && onDeleteAnnotation) {
await onDeleteAnnotation(annotationForm.editingId)
setAnnotationForm({ visible: false, date: '', text: '', category: 'other' })
}
}}
className="text-xs text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 font-medium cursor-pointer"
>
Delete
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setAnnotationForm({ visible: false, date: '', 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}
onClick={async () => {
const data = { date: annotationForm.date, text: annotationForm.text.trim(), category: annotationForm.category }
if (annotationForm.editingId && onUpdateAnnotation) {
await onUpdateAnnotation(annotationForm.editingId, data)
} else if (onCreateAnnotation) {
await onCreateAnnotation(data)
}
setAnnotationForm({ visible: false, date: '', text: '', category: 'other' })
}}
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"
>
{annotationForm.editingId ? 'Save' : 'Add'}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

55
lib/api/annotations.ts Normal file
View File

@@ -0,0 +1,55 @@
import apiRequest from './client'
export type AnnotationCategory = 'deploy' | 'campaign' | 'incident' | 'other'
export interface Annotation {
id: string
site_id: string
date: string
text: string
category: AnnotationCategory
created_by: string
created_at: string
updated_at: string
}
export interface CreateAnnotationRequest {
date: string
text: string
category?: AnnotationCategory
}
export interface UpdateAnnotationRequest {
date: string
text: string
category: AnnotationCategory
}
export async function listAnnotations(siteId: string, startDate?: string, endDate?: string): Promise<Annotation[]> {
const params = new URLSearchParams()
if (startDate) params.set('start_date', startDate)
if (endDate) params.set('end_date', endDate)
const qs = params.toString()
const res = await apiRequest<{ annotations: Annotation[] }>(`/sites/${siteId}/annotations${qs ? `?${qs}` : ''}`)
return res?.annotations ?? []
}
export async function createAnnotation(siteId: string, data: CreateAnnotationRequest): Promise<Annotation> {
return apiRequest<Annotation>(`/sites/${siteId}/annotations`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateAnnotation(siteId: string, annotationId: string, data: UpdateAnnotationRequest): Promise<Annotation> {
return apiRequest<Annotation>(`/sites/${siteId}/annotations/${annotationId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteAnnotation(siteId: string, annotationId: string): Promise<void> {
await apiRequest(`/sites/${siteId}/annotations/${annotationId}`, {
method: 'DELETE',
})
}

View File

@@ -16,6 +16,8 @@ import {
getStats, getStats,
getDailyStats, getDailyStats,
} from '@/lib/api/stats' } from '@/lib/api/stats'
import { listAnnotations } from '@/lib/api/annotations'
import type { Annotation } from '@/lib/api/annotations'
import { getSite } from '@/lib/api/sites' import { getSite } from '@/lib/api/sites'
import type { Site } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites'
import type { import type {
@@ -48,6 +50,7 @@ const fetchers = {
realtime: (siteId: string) => getRealtime(siteId), realtime: (siteId: string) => getRealtime(siteId),
campaigns: (siteId: string, start: string, end: string, limit: number) => campaigns: (siteId: string, start: string, end: string, limit: number) =>
getCampaigns(siteId, start, end, limit), getCampaigns(siteId, start, end, limit),
annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end),
} }
// * Standard SWR config for dashboard data // * Standard SWR config for dashboard data
@@ -247,5 +250,18 @@ export function useCampaigns(siteId: string, start: string, end: string, limit =
) )
} }
// * Hook for annotations data
export function useAnnotations(siteId: string, startDate: string, endDate: string) {
return useSWR<Annotation[]>(
siteId && startDate && endDate ? ['annotations', siteId, startDate, endDate] : null,
() => fetchers.annotations(siteId, startDate, endDate),
{
...dashboardSWRConfig,
refreshInterval: 60 * 1000,
dedupingInterval: 10 * 1000,
}
)
}
// * Re-export for convenience // * Re-export for convenience
export { fetchers } export { fetchers }