diff --git a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx index 68299cb..a8dd5ee 100644 --- a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx +++ b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx @@ -26,7 +26,7 @@ * be lifted later without rewrites. */ -import { useEffect, useReducer, useCallback } from "react"; +import { useEffect, useReducer, useState, useCallback } from "react"; import { getSmartsheetConfig, saveSmartsheetConfig, @@ -58,6 +58,18 @@ const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [ "logged_at", ]; +// IDs for the in-page section nav. Hoisted to module scope so the +// IntersectionObserver effect can list it once in its deps array. +const SECTION_IDS = [ + "smartsheet-enable", + "smartsheet-connection", + "smartsheet-frequency", + "smartsheet-mapping", + "smartsheet-backfill", + "smartsheet-activity", +] as const; +type SectionId = (typeof SECTION_IDS)[number]; + const FREQUENCY_LABELS: Record = { realtime: "Real-time (after every entry)", every_15_minutes: "Every 15 minutes", @@ -295,6 +307,69 @@ export default function SmartsheetIntegrationCard({ return () => window.removeEventListener("keydown", onKey); }, [state.setupModalOpen]); + // ── In-page section nav: which section is currently visible ──────────── + // The "active zone" is the top 20% of the viewport (under the sticky nav). + // When a section enters that zone, it becomes active. We keep the previous + // active section during the gap between sections so the highlight doesn't + // blink off when scrolling between two adjacent cards. + const [activeSection, setActiveSection] = useState( + "smartsheet-enable", + ); + + useEffect(() => { + const elements = SECTION_IDS.map((id) => document.getElementById(id)).filter( + (el): el is HTMLElement => el !== null, + ); + if (elements.length === 0) return; + + const visible = new Set(); + let lastActive: SectionId | null = null; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + visible.add(entry.target.id); + } else { + visible.delete(entry.target.id); + } + } + // Among visible sections, prefer the earliest (topmost) in DOM order. + const ordered = SECTION_IDS.filter((id) => visible.has(id)); + const nextActive = (ordered[0] ?? lastActive) as SectionId | null; + if (nextActive && nextActive !== lastActive) { + lastActive = nextActive; + setActiveSection(nextActive); + } + }, + { + // Top 20% of the viewport = the visible "reading zone" under the nav. + rootMargin: "0px 0px -80% 0px", + threshold: 0, + }, + ); + + for (const el of elements) observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Smooth scroll on pill click. `scroll-behavior: smooth` is set globally on + // and the prefers-reduced-motion media query in globals.css flips + // it to `auto`, so we don't need a JS-side matchMedia check here. + const handleSectionClick = useCallback( + (e: React.MouseEvent, id: string) => { + const el = document.getElementById(id); + if (!el) return; + e.preventDefault(); + el.scrollIntoView({ block: "start" }); + // Reflect the destination in the URL without triggering a jump. + if (typeof window !== "undefined" && window.history) { + window.history.replaceState(null, "", `#${id}`); + } + }, + [], + ); + async function handleSave(e: React.FormEvent) { e.preventDefault(); dispatch({ type: "SAVE_START" }); @@ -400,6 +475,7 @@ export default function SmartsheetIntegrationCard({ return (
+ {state.message && (
@@ -456,6 +533,7 @@ export default function SmartsheetIntegrationCard({ @@ -626,6 +705,7 @@ export default function SmartsheetIntegrationCard({ @@ -717,9 +797,15 @@ export default function SmartsheetIntegrationCard({
- dispatch({ type: "BACKFILL_RANGE", value: v })} /> + dispatch({ type: "BACKFILL_RANGE", value: v })} + /> @@ -808,19 +894,78 @@ export default function SmartsheetIntegrationCard({ // ── Subcomponents (mirror the parent settings page style) ────────────────── +function SectionNav({ + activeId, + onNavigate, +}: { + activeId: string; + onNavigate: (e: React.MouseEvent, id: string) => void; +}) { + const sections: Array<{ id: string; label: string }> = [ + { id: "smartsheet-enable", label: "Enable" }, + { id: "smartsheet-connection", label: "Connection" }, + { id: "smartsheet-frequency", label: "Frequency" }, + { id: "smartsheet-mapping", label: "Mapping" }, + { id: "smartsheet-backfill", label: "Backfill" }, + { id: "smartsheet-activity", label: "Activity" }, + ]; + + return ( + + ); +} + function Card({ + id, title, subtitle, action, children, }: { + id?: string; title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; }) { return ( -
+

@@ -943,10 +1088,12 @@ type BackfillState = { }; function BackfillCard({ + id, state, onRun, onRangeChange, }: { + id?: string; state: BackfillState; onRun: () => void; onRangeChange: (value: "all" | "30d" | "7d") => void; @@ -958,6 +1105,7 @@ function BackfillCard({ return (