feat(water-log/settings): sticky in-page section nav for Smartsheet card
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
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:
@@ -26,7 +26,7 @@
|
|||||||
* be lifted later without rewrites.
|
* be lifted later without rewrites.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useReducer, useCallback } from "react";
|
import { useEffect, useReducer, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
getSmartsheetConfig,
|
getSmartsheetConfig,
|
||||||
saveSmartsheetConfig,
|
saveSmartsheetConfig,
|
||||||
@@ -58,6 +58,18 @@ const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [
|
|||||||
"logged_at",
|
"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> = {
|
const FREQUENCY_LABELS: Record<SmartsheetFrequency, string> = {
|
||||||
realtime: "Real-time (after every entry)",
|
realtime: "Real-time (after every entry)",
|
||||||
every_15_minutes: "Every 15 minutes",
|
every_15_minutes: "Every 15 minutes",
|
||||||
@@ -295,6 +307,69 @@ export default function SmartsheetIntegrationCard({
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [state.setupModalOpen]);
|
}, [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) {
|
async function handleSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatch({ type: "SAVE_START" });
|
dispatch({ type: "SAVE_START" });
|
||||||
@@ -400,6 +475,7 @@ export default function SmartsheetIntegrationCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSave} className="space-y-6">
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
<SectionNav activeId={activeSection} onNavigate={handleSectionClick} />
|
||||||
{state.message && (
|
{state.message && (
|
||||||
<div
|
<div
|
||||||
role={state.message.type === "error" ? "alert" : "status"}
|
role={state.message.type === "error" ? "alert" : "status"}
|
||||||
@@ -444,6 +520,7 @@ export default function SmartsheetIntegrationCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
id="smartsheet-enable"
|
||||||
title="Smartsheet Integration"
|
title="Smartsheet Integration"
|
||||||
subtitle="Push every new water log entry to a Smartsheet sheet automatically."
|
subtitle="Push every new water log entry to a Smartsheet sheet automatically."
|
||||||
>
|
>
|
||||||
@@ -456,6 +533,7 @@ export default function SmartsheetIntegrationCard({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
id="smartsheet-connection"
|
||||||
title="Connection"
|
title="Connection"
|
||||||
subtitle="Smartsheet API token + target sheet."
|
subtitle="Smartsheet API token + target sheet."
|
||||||
action={
|
action={
|
||||||
@@ -590,6 +668,7 @@ export default function SmartsheetIntegrationCard({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
id="smartsheet-frequency"
|
||||||
title="Sync frequency"
|
title="Sync frequency"
|
||||||
subtitle="How often new water log entries are pushed to Smartsheet."
|
subtitle="How often new water log entries are pushed to Smartsheet."
|
||||||
>
|
>
|
||||||
@@ -626,6 +705,7 @@ export default function SmartsheetIntegrationCard({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
|
id="smartsheet-mapping"
|
||||||
title="Column mapping"
|
title="Column mapping"
|
||||||
subtitle="Map our water log fields to columns in your Smartsheet. entry_id and logged_at are required."
|
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>
|
</div>
|
||||||
</Card>
|
</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
|
<Card
|
||||||
|
id="smartsheet-activity"
|
||||||
title="Recent activity"
|
title="Recent activity"
|
||||||
subtitle="Last 10 sync attempts. Use this to spot token / sheet issues quickly."
|
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) ──────────────────
|
// ── 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({
|
function Card({
|
||||||
|
id,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
action,
|
action,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<header className="mb-5 flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
|
<h2 className="text-[15px] font-semibold tracking-tight text-[#1d1d1f]">
|
||||||
@@ -943,10 +1088,12 @@ type BackfillState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function BackfillCard({
|
function BackfillCard({
|
||||||
|
id,
|
||||||
state,
|
state,
|
||||||
onRun,
|
onRun,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string;
|
||||||
state: BackfillState;
|
state: BackfillState;
|
||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
onRangeChange: (value: "all" | "30d" | "7d") => void;
|
onRangeChange: (value: "all" | "30d" | "7d") => void;
|
||||||
@@ -958,6 +1105,7 @@ function BackfillCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
id={id}
|
||||||
title="Backfill historical entries"
|
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."
|
subtitle="Push existing water log entries to your Smartsheet. Safe to re-run — already-pushed entries are deduped by Entry ID."
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user