From 5fc6f183db1c91e9663f6a5194f82c12e4742721 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 04:17:58 +0100 Subject: [PATCH] feat: annotation UX improvements - Custom calendar (DatePicker) instead of native date input - Custom dropdown (Select) instead of native select - EU date format (DD/MM/YYYY) in tooltips and form - Right-click context menu on chart to add annotations - Optional time field (HH:MM) for precise timestamps - Escape key to dismiss, loading state on save - Bump @ciphera-net/ui to 0.0.95 --- app/sites/[id]/page.tsx | 4 +- components/dashboard/Chart.tsx | 238 ++++++++++++++++++++++++++------- lib/api/annotations.ts | 3 + package-lock.json | 8 +- package.json | 2 +- 5 files changed, 201 insertions(+), 54 deletions(-) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index df8dde6..cf3398e 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -238,13 +238,13 @@ export default function SiteDashboardPage() { const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end) // Annotation mutation handlers - const handleCreateAnnotation = async (data: { date: string; text: string; category: string }) => { + const handleCreateAnnotation = async (data: { date: string; time?: 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 }) => { + const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => { await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory }) mutateAnnotations() toast.success('Annotation updated') diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 1ee7c8f..3df1850 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useRef, useCallback } from 'react' +import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' import { AreaChart, @@ -13,8 +13,8 @@ import { ReferenceLine, } from 'recharts' import type { TooltipProps } from 'recharts' -import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' -import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon } from '@ciphera-net/ui' +import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' +import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { @@ -57,9 +57,17 @@ const ANNOTATION_LABELS: Record = { 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 } @@ -94,8 +102,8 @@ interface ChartProps { 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 + onCreateAnnotation?: (data: { date: string; time?: string; text: string; category: string }) => Promise + onUpdateAnnotation?: (id: string, data: { date: string; time?: string; text: string; category: string }) => Promise onDeleteAnnotation?: (id: string) => Promise } @@ -103,6 +111,11 @@ type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' // ─── Helpers ───────────────────────────────────────────────────────── +function formatEU(dateStr: string): string { + const [y, m, d] = dateStr.split('-') + return `${d}/${m}/${y}` +} + function formatAxisValue(value: number): string { if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M` if (value >= 1000) return `${+(value / 1000).toFixed(1)}k` @@ -246,12 +259,32 @@ export default function Chart({ const chartContainerRef = useRef(null) const { resolvedTheme } = useTheme() + // ─── 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') - // Resolve the actual background color from the DOM (CSS vars don't work in html-to-image) const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff') const dataUrl = await toPng(chartContainerRef.current, { cacheBust: true, @@ -322,9 +355,55 @@ export default function Chart({ 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' - }) + // ─── Right-click handler ────────────────────────────────────────── + const handleChartContextMenu = useCallback((e: React.MouseEvent) => { + 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 ─────────────────────────────────────────────────────── @@ -350,7 +429,6 @@ export default function Chart({ const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0) - // Count metrics should never show decimal Y-axis ticks const isCountMetric = metric === 'visitors' || metric === 'pageviews' // ─── X-Axis Ticks ───────────────────────────────────────────────── @@ -487,7 +565,7 @@ export default function Chart({ {canManageAnnotations && ( + + + + )} + + {/* ─── Annotation Form Modal ─────────────────────────────────── */} {annotationForm.visible && (
@@ -690,15 +804,45 @@ export default function Chart({ {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
+ {/* Date picker trigger */}
- 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" - /> +
+ {/* Time input */} +
+ +
+ 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 && ( + + )} +
+
+ {/* Note */}
{annotationForm.text.length}/200
+ {/* Category - custom Select */}
- + onChange={(v) => setAnnotationForm((f) => ({ ...f, category: v }))} + options={CATEGORY_OPTIONS} + variant="input" + fullWidth + align="left" + />
@@ -731,13 +874,9 @@ export default function Chart({ {annotationForm.editingId && ( @@ -746,32 +885,37 @@ export default function Chart({
)} + + {/* ─── DatePicker overlay (single mode) ─────────────────────── */} + 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) + }} + /> ) } diff --git a/lib/api/annotations.ts b/lib/api/annotations.ts index 6f44a12..7b88fe4 100644 --- a/lib/api/annotations.ts +++ b/lib/api/annotations.ts @@ -6,6 +6,7 @@ export interface Annotation { id: string site_id: string date: string + time?: string | null text: string category: AnnotationCategory created_by: string @@ -15,12 +16,14 @@ export interface Annotation { export interface CreateAnnotationRequest { date: string + time?: string text: string category?: AnnotationCategory } export interface UpdateAnnotationRequest { date: string + time?: string text: string category: AnnotationCategory } diff --git a/package-lock.json b/package-lock.json index 29f9e05..8eca35a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.13.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.94", + "@ciphera-net/ui": "^0.0.95", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1664,9 +1664,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.94", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.94/e1fd8da171da4fb65c2ea1756793f94a3762178b", - "integrity": "sha512-8xsgLdiCrRgohySlSTcL2RNKA0IYCTsdyYYMY2qleCaHJly3480kEQGfAmckwmNvkOPoaDBuh3C72iFkyfQssw==", + "version": "0.0.95", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.95/ddb41cb4513d4727f38e34f1a8a8d49a7fc9600d", + "integrity": "sha512-Bo0RLetcuPIwU7g5u6oNs9eftlD5Tb82356Ht/wQamx/6egUTZWnouXEEoOlKVhSPRACisys/AGnUQU6JPM+cA==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "clsx": "^2.1.0", diff --git a/package.json b/package.json index f4c5e13..3030cb1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.0.94", + "@ciphera-net/ui": "^0.0.95", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2",