From 4d99334bcfb6970d04c0eb8f0a00424f961e6a48 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 03:44:05 +0100 Subject: [PATCH] feat: add chart annotations Inline annotation markers on the dashboard chart with create/edit/delete UI. Color-coded categories: deploy, campaign, incident, other. --- CHANGELOG.md | 1 + app/sites/[id]/page.tsx | 27 ++++ components/dashboard/Chart.tsx | 221 ++++++++++++++++++++++++++++++++- lib/api/annotations.ts | 55 ++++++++ lib/swr/dashboard.ts | 16 +++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 lib/api/annotations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57de9de..8c16875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. +- **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 diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index f09b824..df8dde6 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -51,7 +51,9 @@ import { useStats, useDailyStats, useCampaigns, + useAnnotations, } from '@/lib/swr/dashboard' +import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations' function loadSavedSettings(): { type?: string @@ -233,6 +235,26 @@ export default function SiteDashboardPage() { const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end) const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval) 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 const site = overview?.site ?? null @@ -521,6 +543,11 @@ export default function SiteDashboardPage() { multiDayInterval={multiDayInterval} setMultiDayInterval={setMultiDayInterval} lastUpdatedAt={lastUpdatedAt} + annotations={annotations} + canManageAnnotations={true} + onCreateAnnotation={handleCreateAnnotation} + onUpdateAnnotation={handleUpdateAnnotation} + onDeleteAnnotation={handleDeleteAnnotation} /> diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 9c3701f..1ee7c8f 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -10,10 +10,11 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, + ReferenceLine, } from 'recharts' import type { TooltipProps } from 'recharts' 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' const COLORS = { @@ -42,6 +43,27 @@ const CHART_COLORS_DARK = { tooltipBorder: 'var(--color-neutral-700)', } +const ANNOTATION_COLORS: Record = { + deploy: '#3b82f6', + campaign: '#22c55e', + incident: '#ef4444', + other: '#a3a3a3', +} + +const ANNOTATION_LABELS: Record = { + deploy: 'Deploy', + campaign: 'Campaign', + incident: 'Incident', + other: 'Note', +} + +interface AnnotationData { + id: string + date: string + text: string + category: string +} + export interface DailyStat { date: string pageviews: number @@ -70,6 +92,11 @@ interface ChartProps { setMultiDayInterval: (interval: 'hour' | 'day') => void onExportChart?: () => void lastUpdatedAt?: number | null + annotations?: AnnotationData[] + canManageAnnotations?: boolean + onCreateAnnotation?: (data: { date: string; text: string; category: string }) => Promise + onUpdateAnnotation?: (id: string, data: { date: string; text: string; category: string }) => Promise + onDeleteAnnotation?: (id: string) => Promise } type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' @@ -208,6 +235,11 @@ export default function Chart({ setMultiDayInterval, onExportChart, lastUpdatedAt, + annotations, + canManageAnnotations, + onCreateAnnotation, + onUpdateAnnotation, + onDeleteAnnotation, }: ChartProps) { const [metric, setMetric] = useState('visitors') const [showComparison, setShowComparison] = useState(false) @@ -269,6 +301,31 @@ export default function Chart({ } }) + const annotationMarkers = useMemo(() => { + if (!annotations?.length) return [] + const byDate = new Map() + 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 ─────────────────────────────────────────────────────── const calculateTrend = (current: number, previous?: number) => { @@ -427,6 +484,16 @@ export default function Chart({ > + + {canManageAnnotations && ( + + )} @@ -535,12 +602,74 @@ export default function Chart({ animationDuration={400} animationEasing="ease-out" /> + + {annotationMarkers.map((marker) => { + const primaryCategory = marker.annotations[0].category + const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other + return ( + + ) + })} )} + {annotationMarkers.length > 0 && ( +
+ Annotations: + {annotationMarkers.map((marker) => { + const primary = marker.annotations[0] + const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other + const count = marker.annotations.length + return ( + + ) + })} +
+ )} + {/* Live indicator */} {lastUpdatedAt != null && (
@@ -553,6 +682,96 @@ export default function Chart({
)} + + {annotationForm.visible && ( +
+
+

+ {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'} +

+
+
+ + 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" + /> +
+
+ + 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 + /> + {annotationForm.text.length}/200 +
+
+ + +
+
+
+
+ {annotationForm.editingId && ( + + )} +
+
+ + +
+
+
+
+ )} ) } diff --git a/lib/api/annotations.ts b/lib/api/annotations.ts new file mode 100644 index 0000000..6f44a12 --- /dev/null +++ b/lib/api/annotations.ts @@ -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 { + 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 { + return apiRequest(`/sites/${siteId}/annotations`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateAnnotation(siteId: string, annotationId: string, data: UpdateAnnotationRequest): Promise { + return apiRequest(`/sites/${siteId}/annotations/${annotationId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +export async function deleteAnnotation(siteId: string, annotationId: string): Promise { + await apiRequest(`/sites/${siteId}/annotations/${annotationId}`, { + method: 'DELETE', + }) +} diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index db2ce84..1911bb8 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -16,6 +16,8 @@ import { getStats, getDailyStats, } from '@/lib/api/stats' +import { listAnnotations } from '@/lib/api/annotations' +import type { Annotation } from '@/lib/api/annotations' import { getSite } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites' import type { @@ -48,6 +50,7 @@ const fetchers = { realtime: (siteId: string) => getRealtime(siteId), campaigns: (siteId: string, start: string, end: string, limit: number) => getCampaigns(siteId, start, end, limit), + annotations: (siteId: string, start: string, end: string) => listAnnotations(siteId, start, end), } // * 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( + siteId && startDate && endDate ? ['annotations', siteId, startDate, endDate] : null, + () => fetchers.annotations(siteId, startDate, endDate), + { + ...dashboardSWRConfig, + refreshInterval: 60 * 1000, + dedupingInterval: 10 * 1000, + } + ) +} + // * Re-export for convenience export { fetchers }