feat(water-log/settings): sticky in-page section nav for Smartsheet card
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s

The Smartsheet card has six stacked sections (Enable, Connection,
Sync frequency, Column mapping, Backfill, Recent activity) — at
1100px viewport that's a long scroll. Add a sticky 'On this page'
pill nav at the top of the card that highlights the active section
as the user scrolls, with anchor jump on click.

- Each <Card> gets a stable id (smartsheet-enable, -connection,
  -frequency, -mapping, -backfill, -activity) and scroll-mt-20 so
  anchor jumps clear the sticky nav.
- IntersectionObserver (rootMargin 0 0 -80% 0) tracks which section
  is in the top 20% of the viewport and updates activeSection state.
- Pill click uses native scrollIntoView, which honors the global
  scroll-behavior: smooth on <html> (auto-flipped to instant under
  prefers-reduced-motion by globals.css — no JS matchMedia needed).
- URL hash updates via history.replaceState so the destination is
  shareable without triggering a second jump.
- Apple HIG polish: rounded-full pills, focus-visible ring,
  active:scale-[0.96], motion-safe: spring transitions, bg-[#fdfaf2]/92
  with backdrop-blur for the sticky chrome.
- All changes additive — no existing Card/ToggleRow behavior modified.

Verified: npx tsc --noEmit clean, npm run build clean,
npm run lint on this file clean.
This commit is contained in:
Nora
2026-07-03 15:56:16 -06:00
parent 61d5e3c6fa
commit a470b6fe9d
@@ -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<SmartsheetFrequency, string> = {
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<SectionId>(
"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<string>();
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
// <html> 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<HTMLAnchorElement>, 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 (
<form onSubmit={handleSave} className="space-y-6">
<SectionNav activeId={activeSection} onNavigate={handleSectionClick} />
{state.message && (
<div
role={state.message.type === "error" ? "alert" : "status"}
@@ -444,6 +520,7 @@ export default function SmartsheetIntegrationCard({
)}
<Card
id="smartsheet-enable"
title="Smartsheet Integration"
subtitle="Push every new water log entry to a Smartsheet sheet automatically."
>
@@ -456,6 +533,7 @@ export default function SmartsheetIntegrationCard({
</Card>
<Card
id="smartsheet-connection"
title="Connection"
subtitle="Smartsheet API token + target sheet."
action={
@@ -590,6 +668,7 @@ export default function SmartsheetIntegrationCard({
</Card>
<Card
id="smartsheet-frequency"
title="Sync frequency"
subtitle="How often new water log entries are pushed to Smartsheet."
>
@@ -626,6 +705,7 @@ export default function SmartsheetIntegrationCard({
</Card>
<Card
id="smartsheet-mapping"
title="Column mapping"
subtitle="Map our water log fields to columns in your Smartsheet. entry_id and logged_at are required."
>
@@ -717,9 +797,15 @@ export default function SmartsheetIntegrationCard({
</div>
</Card>
<BackfillCard state={state.backfill} onRun={handleBackfill} onRangeChange={(v) => dispatch({ type: "BACKFILL_RANGE", value: v })} />
<BackfillCard
id="smartsheet-backfill"
state={state.backfill}
onRun={handleBackfill}
onRangeChange={(v) => dispatch({ type: "BACKFILL_RANGE", value: v })}
/>
<Card
id="smartsheet-activity"
title="Recent activity"
subtitle="Last 10 sync attempts. Use this to spot token / sheet issues quickly."
>
@@ -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<HTMLAnchorElement>, 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 (
<nav
aria-label="On this page"
className="smartsheet-fade-in sticky top-2 z-20 -mx-1 flex flex-wrap items-center gap-1 rounded-xl border border-[#d4d9d3] bg-[#fdfaf2]/92 px-3 py-2 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)] backdrop-blur-md"
>
<span className="mr-1 hidden text-[10px] font-mono uppercase tracking-[0.22em] text-[#8a6b3b] sm:inline">
On this page
</span>
<ul className="flex flex-wrap items-center gap-1">
{sections.map((s) => {
const active = activeId === s.id;
return (
<li key={s.id}>
<a
href={`#${s.id}`}
aria-current={active ? "true" : undefined}
onClick={(e) => onNavigate(e, s.id)}
className={`inline-flex min-h-[28px] items-center justify-center rounded-full px-2.5 text-[12px] font-semibold tracking-tight
motion-safe:transition-[background-color,color,transform,box-shadow] motion-safe:duration-150 motion-safe:ease-out
focus:outline-none focus-visible:ring-4 focus-visible:ring-[#1a4d2e]/25
active:scale-[0.96]
${
active
? "bg-[#1a4d2e] text-white shadow-[0_1px_2px_rgba(15,23,18,0.1)]"
: "text-[#5a5d5a] hover:bg-[#f0ebde] hover:text-[#1d1d1f]"
}`}
>
{s.label}
</a>
</li>
);
})}
</ul>
</nav>
);
}
function Card({
id,
title,
subtitle,
action,
children,
}: {
id?: string;
title: string;
subtitle?: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[#d4d9d3] bg-white/95 p-6 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)] motion-safe:transition-shadow">
<section
id={id}
// `scroll-mt-20` keeps anchor jumps clear of the sticky "On this page" nav.
className="scroll-mt-20 rounded-xl border border-[#d4d9d3] bg-white/95 p-6 shadow-[0_1px_2px_rgba(15,23,18,0.04),0_2px_6px_-2px_rgba(15,23,18,0.06)] motion-safe:transition-shadow"
>
<header className="mb-5 flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
@@ -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 (
<Card
id={id}
title="Backfill historical entries"
subtitle="Push existing water log entries to your Smartsheet. Safe to re-run — already-pushed entries are deduped by Entry ID."
>