Skip to content

Commit 6905236

Browse files
omarkohlclaude
andcommitted
feat: single scroll surface for schedule & move user select to header
The grid page had three competing scrollbars (window, inner vertical, horizontal) that chained into each other. The grid view now owns the viewport below the nav: a slim pinned toolbar (title, view toggle, name select, event details behind an About toggle) sits above one container that scrolls both axes, so the sticky day/location headers and time gutter keep working. Wide grids can be dragged to pan from anywhere (movement threshold + click suppression, since empty slots are full-cell links). The global footer is replaced by an inline copy at the end of the schedule content. Chosen over window-scroll header syncing and fit-to-width variants after HTML prototyping (throwaway prototype deleted). Surface the acting attendee as an always-visible header chip on every page so it's clear who "you" are and switching is possible on shared devices, replacing the per-page name selectors. On the schedule, move event details and the proposals link into the view-toggle bar (details behind a popup). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ee7abb0 commit 6905236

24 files changed

Lines changed: 564 additions & 294 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1010

1111
- **Email notifications**: attendees are emailed when a session they've RSVP'd to changes time or location, hosts are emailed when a session they're hosting changes time or location, and guests are emailed when they're added as a co-host of a session. Each notification can be turned on or off individually from the profile page (requires SMTP and `SITE_URL` to be configured)
1212

13+
### Changed
14+
15+
- **Your name is always visible**: the attendee you're acting as now shows as a chip in the header on every page — proposals, voting, and schedule — so it's always clear who "you" are, and you can switch attendee from there (handy for a shared device)
16+
- **Smoother schedule scrolling**: the grid view now has a single scroll area instead of nested scrollbars, and wide schedules can be dragged sideways with the mouse. The view controls (Grid, Text, RSVP'd) sit on one bar alongside an "Event details" popup and a "Proposals" link; the bar scrolls away with the schedule while the room headers stay pinned. The redundant schedule title is gone, since the header already shows the current event
17+
1318
### Fixed
1419

1520
- **Host RSVPs cleared on edit**: adding an attendee as a session host now removes their RSVP to that session, including when an organizer edits the session from the admin panel

app/(site)/[eventSlug]/event.tsx

Lines changed: 70 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
11
"use client";
2-
import { ScheduleSettings } from "./schedule-settings";
2+
import { ScheduleToolbar } from "./schedule-toolbar";
33
import { DayGrid } from "./day-grid";
4-
import {
5-
CalendarIcon,
6-
LinkIcon,
7-
ClipboardDocumentListIcon,
8-
ChevronRightIcon,
9-
ChevronDownIcon,
10-
} from "@heroicons/react/24/outline";
4+
import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
115
import { DateTime } from "luxon";
126
import { useSearchParams } from "next/navigation";
137
import { DayText } from "./day-text";
148
import { Input } from "@/app/input";
15-
import { useState, useContext } from "react";
9+
import { useState, useContext, useRef } from "react";
1610
import { EventContext } from "../context";
17-
import { hasPhases } from "@/app/(site)/utils/events";
18-
import Link from "next/link";
1911
import { getDefaultFoldedDayIds } from "@/utils/schedule-fold";
2012
import { KioskController, useKioskMode } from "./kiosk";
2113
import { SessionModal } from "./session-modal";
2214
import type { DayWithSessions } from "../context";
23-
import { Markdown } from "@/app/(site)/markdown";
15+
import { useDragToPan } from "./use-drag-to-pan";
16+
import Footer from "@/app/footer";
2417

2518
export function EventDisplay() {
2619
const { event, days, locations, guests, rsvps, now } =
@@ -33,6 +26,8 @@ export function EventDisplay() {
3326
const [unfoldedDayIds, setUnfoldedDayIds] = useState<Set<string>>(
3427
() => new Set()
3528
);
29+
const scrollerRef = useRef<HTMLDivElement>(null);
30+
useDragToPan(scrollerRef, view === "grid");
3631

3732
if (!event) return <div>No event data available</div>;
3833

@@ -48,101 +43,64 @@ export function EventDisplay() {
4843
return next;
4944
});
5045
const locationsForEvent = locations;
51-
const multipleDays = event.start.getTime() !== event.end.getTime();
5246

53-
return (
54-
<div className="flex flex-col items-start w-full">
55-
<div className="mx-2">
56-
<h1 className="sm:text-4xl text-3xl font-bold mt-20">
57-
{event.name} Schedule
58-
</h1>
59-
<div className="flex text-gray-500 text-sm mt-1 gap-5 font-medium">
60-
<span className="flex gap-1 items-center">
61-
<CalendarIcon className="h-4 w-4 stroke-2" />
62-
<span>
63-
{DateTime.fromJSDate(event.start)
64-
.setZone(event.timezone)
65-
.toFormat("LLL d")}
66-
{multipleDays && (
67-
<>
68-
{" - "}
69-
{DateTime.fromJSDate(event.end)
70-
.setZone(event.timezone)
71-
.toFormat("LLL d")}
72-
</>
73-
)}
74-
{" · "}
75-
{event.timezone}
76-
</span>
77-
</span>
78-
<a
79-
className="flex gap-1 items-center hover:underline"
80-
href={`https://${event.website}`}
81-
>
82-
<LinkIcon className="h-4 w-4 stroke-2" />
83-
<span>{event.website}</span>
84-
</a>
85-
</div>
86-
<div className="text-gray-900 mt-3 mb-5">
87-
<Markdown>{event.description}</Markdown>
88-
</div>
89-
{hasPhases(event) && (
90-
<div className="mb-5">
91-
<Link
92-
href={`/${event.slug}/proposals`}
93-
className={`bg-rose-400 hover:bg-rose-500 transition-colors text-white px-4 py-2 rounded-md flex items-center gap-2 max-w-fit`}
94-
>
95-
<ClipboardDocumentListIcon className="h-4 w-4" />
96-
View Session Proposals
97-
</Link>
47+
const toolbar = <ScheduleToolbar event={event} />;
48+
49+
// Both views own the viewport below the nav bar via the same fixed frame, so
50+
// the toolbar rests in the same spot and doesn't jump when switching views.
51+
// The frame's inner container is the only scroll surface; the toolbar lives
52+
// inside it (as the first item) so it scrolls away with the content, leaving
53+
// only the sticky room headers pinned in the grid view. globals.css locks
54+
// window scrolling and hides the site footer while [data-schedule-frame] is
55+
// mounted; a footer copy ends the schedule content instead.
56+
const scheduleBody =
57+
view === "grid" ? (
58+
<div
59+
data-testid="schedule-scroll"
60+
ref={scrollerRef}
61+
// `grid` with a single minmax(max-content, 1fr) column (rather than
62+
// block flow) so the toolbar, fold bars and footer stretch to the
63+
// widest day's grid when it overflows — instead of falling short when
64+
// scrolled horizontally — yet still fill the viewport when the grid is
65+
// narrower than it.
66+
className="flex-1 w-full overflow-auto cursor-grab grid content-start"
67+
style={{ gridTemplateColumns: "minmax(max-content, 1fr)" }}
68+
>
69+
{toolbar}
70+
{daysForEvent.map((day) => (
71+
<div key={day.id} className="contents">
72+
{defaultFoldedDayIds.has(day.id) && (
73+
<DayFoldBar
74+
day={day}
75+
timezone={event.timezone}
76+
folded={isFolded(day.id)}
77+
onToggle={() => toggleDayFold(day.id)}
78+
/>
79+
)}
80+
{!isFolded(day.id) && (
81+
<DayGrid
82+
day={day}
83+
locations={locationsForEvent}
84+
guests={guests}
85+
/>
86+
)}
9887
</div>
99-
)}
100-
</div>
101-
<div className="mb-10 w-full">
102-
<ScheduleSettings guests={guests} />
88+
))}
89+
<Footer inline />
10390
</div>
104-
{view !== "grid" && (
91+
) : (
92+
<div
93+
data-testid="schedule-scroll"
94+
ref={scrollerRef}
95+
className="flex-1 w-full overflow-auto flex flex-col items-stretch"
96+
>
97+
{toolbar}
10598
<Input
106-
className="max-w-3xl w-full mb-5 mx-auto"
99+
className="max-w-3xl w-full my-5 mx-auto"
107100
placeholder="Search sessions"
108101
value={search}
109102
onChange={(event) => setSearch(event.target.value)}
110103
/>
111-
)}
112-
{view === "grid" ? (
113-
// One large scroll container for everything:
114-
// Time, room name and day are sticky, and every new day day+room names get replaced.
115-
// - `dvh` (not `vh`) so the mobile address bar showing/hiding doesn't change the height.
116-
<div
117-
data-testid="schedule-scroll"
118-
// `grid` with a single max-content column (rather than block flow)
119-
// so the fold bar's `w-full` stretches to match the widest day's
120-
// grid instead of the container's own (viewport-bound) width —
121-
// otherwise its background falls short when scrolled horizontally.
122-
className="w-full overflow-auto sticky top-16 max-h-[calc(100dvh-6rem)] sm:max-h-[calc(100dvh-8rem)] rounded-lg border border-gray-200 grid"
123-
style={{ gridTemplateColumns: "max-content" }}
124-
>
125-
{daysForEvent.map((day) => (
126-
<div key={day.id} className="contents">
127-
{defaultFoldedDayIds.has(day.id) && (
128-
<DayFoldBar
129-
day={day}
130-
timezone={event.timezone}
131-
folded={isFolded(day.id)}
132-
onToggle={() => toggleDayFold(day.id)}
133-
/>
134-
)}
135-
{!isFolded(day.id) && (
136-
<DayGrid
137-
day={day}
138-
locations={locationsForEvent}
139-
guests={guests}
140-
/>
141-
)}
142-
</div>
143-
))}
144-
</div>
145-
) : (
146104
<div className="flex flex-col gap-12 w-full">
147105
{daysForEvent.map((day) => (
148106
<div key={day.id}>
@@ -168,12 +126,23 @@ export function EventDisplay() {
168126
</div>
169127
))}
170128
</div>
171-
)}
129+
<Footer inline />
130+
</div>
131+
);
132+
133+
return (
134+
<>
135+
<div
136+
data-schedule-frame
137+
className="fixed inset-x-0 top-16 bottom-0 flex flex-col bg-white"
138+
>
139+
{scheduleBody}
140+
</div>
172141
{viewSession && (
173142
<SessionModal sessionId={viewSession} eventSlug={event.slug} />
174143
)}
175144
{kiosk && <KioskController />}
176-
</div>
145+
</>
177146
);
178147
}
179148

app/(site)/[eventSlug]/kiosk.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ const INITIAL_SCROLL_DELAY_MS = 1000;
1717
const INTERACTION_IDLE_MS = 60 * 1000;
1818
/** How often the event data is refetched so the display never goes stale. */
1919
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
20-
/** The scroll container's `sticky top-16` offset (see EventDisplay). */
21-
const STICKY_TOP_PX = 64;
2220

2321
export function useKioskMode(): boolean {
2422
const searchParams = useSearchParams();
@@ -86,9 +84,10 @@ export function KioskController() {
8684
};
8785
}, []);
8886

89-
// Periodically bring the now line back into view: scroll the window so the
90-
// grid is at its sticky resting position and the grid so the line sits a
91-
// third from the top. Paused while someone is interacting with the display.
87+
// Periodically bring the now line back into view: scroll the grid — pinned
88+
// below the toolbar, it is the page's only scroll surface — so the line
89+
// sits a third from the top. Paused while someone is interacting with the
90+
// display.
9291
useEffect(() => {
9392
let lastInteraction = -Infinity;
9493
const markInteraction = () => {
@@ -112,10 +111,6 @@ export function KioskController() {
112111
const line = document.querySelector('[data-testid="now-line"]');
113112
if (!container || !line) return;
114113
const containerTop = container.getBoundingClientRect().top;
115-
window.scrollTo({
116-
top: window.scrollY + containerTop - STICKY_TOP_PX,
117-
behavior: "smooth",
118-
});
119114
container.scrollTo({
120115
top:
121116
container.scrollTop +

app/(site)/[eventSlug]/proposals/page.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import Link from "next/link";
33
import { getRepositories } from "@/db/container";
44
import { ProposalActionBar } from "./proposal-action-bar";
55
import { ProposalTable } from "./proposal-table";
6-
import { UserSelect } from "@/app/(site)/user-select";
76
import { ProposalModal } from "./proposal-modal";
87

98
export const dynamic = "force-dynamic";
@@ -25,8 +24,7 @@ export default async function ProposalsPage({
2524
return <div>Event not found</div>;
2625
}
2726

28-
const [guests, proposals, sessions] = await Promise.all([
29-
repos.guests.list(),
27+
const [proposals, sessions] = await Promise.all([
3028
repos.sessionProposals.listByEvent(event.id),
3129
viewProposal ? repos.sessions.listByEvent(event.id) : Promise.resolve([]),
3230
]);
@@ -36,13 +34,7 @@ export default async function ProposalsPage({
3634

3735
return (
3836
<div className="container mx-auto px-4 py-8">
39-
<div className="flex flex-col gap-1">
40-
<label htmlFor="user-selection" className="text-gray-500">
41-
My name is:
42-
</label>
43-
<UserSelect guests={guests} />
44-
</div>
45-
<div className="mb-6 mt-6">
37+
<div className="mb-6">
4638
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
4739
<div>
4840
<h1 className="text-3xl font-bold">

app/(site)/[eventSlug]/schedule-settings.tsx

Lines changed: 0 additions & 91 deletions
This file was deleted.

0 commit comments

Comments
 (0)