Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/components/prototype/PrototypeSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// PROTOTYPE (timeline nav & filtering) — throwaway, delete with
// src/pages/EditionView/tabs/ScheduleTab/horizontal/prototype/.
//
// Floating variant switcher: arrows (and ←/→ keys) cycle the `?variant=`
// search param. Renders only when a variant is already active in the URL —
// normal users never see it, and preview deploys (production builds) can
// still flip variants when sharing the prototype.
import { useEffect } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { ChevronLeft, ChevronRight } from "lucide-react";

const ROUTE =
"/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline" as const;

const VARIANTS = [
{ key: "a", name: "Slim jump bar" },
{ key: "b", name: "Segmented rail" },
{ key: "c", name: "Mini-map" },
] as const;

export function PrototypeSwitcher() {
const { variant } = useSearch({ from: ROUTE });
const navigate = useNavigate({ from: ROUTE });

const index = Math.max(
0,
VARIANTS.findIndex((v) => v.key === variant),
);
const current = VARIANTS[index];

useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return;
}
if (e.key === "ArrowLeft") cycle(-1);
if (e.key === "ArrowRight") cycle(1);
}

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
});

if (!variant) return null;

return (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-full border border-white/20 bg-gray-950 px-3 py-1.5 shadow-xl">
<button
type="button"
onClick={() => cycle(-1)}
className="text-white/70 hover:text-white"
aria-label="Previous variant"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="whitespace-nowrap font-mono text-xs text-white">
{current.key.toUpperCase()} — {current.name}
</span>
<button
type="button"
onClick={() => cycle(1)}
className="text-white/70 hover:text-white"
aria-label="Next variant"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);

function cycle(delta: number) {
const next = VARIANTS[(index + delta + VARIANTS.length) % VARIANTS.length];
navigate({
to: ".",
search: (prev) => ({ ...prev, variant: next.key }),
replace: true,
});
}
}
3 changes: 3 additions & 0 deletions src/lib/searchSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const timelineSearchSchema = z.object({
day: z.string().catch("all"),
time: z.enum(["all", "morning", "afternoon", "evening"]).catch("all"),
stages: z.array(z.string()).catch([]),
// PROTOTYPE (timeline nav & filtering) — remove with src/pages/EditionView/tabs/ScheduleTab/horizontal/prototype/
variant: z.enum(["a", "b", "c"]).optional(),
scrollTo: z.string().optional(),
});
Comment on lines +43 to 46

export type TimelineSearch = z.infer<typeof timelineSearchSchema>;
Expand Down
19 changes: 18 additions & 1 deletion src/pages/EditionView/tabs/ScheduleTab/horizontal/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useRouteContext, useSearch } from "@tanstack/react-router";
import { TimelinePrototype } from "./prototype/TimelinePrototype";
import { useScheduleData } from "@/hooks/useScheduleData";
import { calculateTimelineData } from "@/lib/timelineCalculator";
import { StageLabels } from "./StageLabels";
Expand Down Expand Up @@ -33,6 +34,10 @@ export function Timeline() {
time: selectedTime,
stages: selectedStages,
} = useTimelineUrlState("timeline");
// PROTOTYPE (timeline nav & filtering) — remove with ./prototype/
const { variant: prototypeVariant } = useSearch({
from: "/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline",
});

const timelineData = useMemo(() => {
if (!edition.start_date || !edition.end_date) {
Expand Down Expand Up @@ -127,6 +132,18 @@ export function Timeline() {
return <ScheduleNotRevealedPlaceholder />;
}

if (prototypeVariant) {
return (
<TimelinePrototype
variant={prototypeVariant}
scheduleDays={scheduleDays}
stages={stages}
edition={edition}
timezone={festival.timezone}
/>
);
}

return (
<div className="space-y-8">
<div className="relative bg-white/5 rounded-lg p-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# PROTOTYPE — timeline navigation, filtering & my-vote chips

**Throwaway code.** Three variants of timeline navigation + my-vote filtering,
switchable via `?variant=`, on the existing
`/festivals/$festivalSlug/editions/$editionSlug/schedule/timeline` route.
Delete this folder (plus `src/components/prototype/` and the two
PROTOTYPE-marked blocks in `src/lib/searchSchemas.ts` and
`../Timeline.tsx`) once the verdicts below are captured.

## How to run

```
pnpm run dev
```

Open any edition's timeline with a variant param, e.g.:

```
/festivals/<festival>/editions/<edition>/schedule/timeline?variant=a
```

Flip variants with the floating bottom bar or the ←/→ keys. Without
`?variant=`, the production timeline renders untouched (switcher included —
it only mounts when a variant is active in the URL), so preview deploys can
be shared safely.

Demo affordances (prototype-only, the real implementation drops both):

- "Now" is **faked** to ~60% into the edition window (still ticks every 60s)
so the Now button + current-time indicator are always demoable.
- Logged out, "my votes" are **fake** (deterministic per set); logged in, your
real votes are used. The real implementation hides the chips when logged out.

## The variants

| | Navigation | Scroll on jump | My-vote chips | Now indicator |
|---|---|---|---|---|
| **a — Slim jump bar** | sticky ghost day buttons + Now pill (agreed design, literal) | smooth | inside filters drawer | thin fuchsia line + dot |
| **b — Segmented rail** | segmented control highlighting the day at viewport center | instant | inline row above strip, with counts | gradient line + "now HH:mm" bubble |
| **c — Mini-map** | collapsed by default to a slim day-button strip (same as a); an optional "Show overview" toggle reveals the draggable density map | smooth | compact icon chips in map header | dashed white line |

All variants share the agreed `scrollTo` URL mechanics (`useScrollToUrl.ts`):
debounced ~300ms scroll-idle write, 5-minute rounding, history replace,
one-way ownership, mount precedence `scrollTo` → day filter → now−1h →
festival start. Back-from-a-set-page restores position in every variant.

### Round 2 changes, from first-round feedback

Two users tried round 1: one preferred **a**, the other loved **c**'s mini-map
for being visual — but flagged the always-visible 3-variant switcher as
intimidating without a label. Fed back in:

- **Filters moved into a bottom sheet** (`PrototypeFilters.tsx`, via shadcn
`Sheet`) instead of an inline expanding panel, in all three variants — the
schedule stays the visual focus instead of competing with an expanded
filter block for space.
- **Mini-map (c) now collapses to a's slim day-strip by default**, with a
"Show overview" toggle to reveal the full colored density map. What the
tester actually valued was one control doing "jump to a day" + "filter by
my votes" together — not the density map being visually loud all the time.
- **Day labels now include the date** (`"Thu 13"`, not bare `"Thu"`) — a real
bug, not a preference: multi-weekend festivals (Tomorrowland's two
long weekends) repeat weekday names, and the bare-weekday jump bar/segmented
rail/mini-map ticks had no way to tell the two Thursdays apart.

## Questions to answer (fill in verdicts, then delete the folder)

1. **Does URL-driven jumping feel right?** Smooth (a, c) vs instant (b) on
day/Now clicks; jank while swiping from the debounced `scrollTo` writes;
does back-restoration land where you expect?
- VERDICT:
2. **Does the nav-vs-filter split read?** Do test users reach for the jump bar
to move and the Filters panel to narrow, or do they tap "Fri" expecting a
filter? (b's active-day highlight may make it read *more* like a filter —
that's deliberate, to probe the confusion.)
- VERDICT:
3. **Vote-chip placement and form.** In-panel (a) vs inline with counts (b) vs
compact-in-minimap (c)? Does "my schedule" (Must Go + Interested) feel like
a two-tap primary use case?
- VERDICT:
4. **Current-time indicator + jump bar visual treatment.** Which of the three
treatments has enough contrast without shouting?
- VERDICT:

Open sub-question surfaced while building: when the day *filter* is active,
the jump bar only shows the filtered day's button (nav operates on what's
rendered). Right call, or should all days stay and clear the filter on jump?
- VERDICT:

## After verdicts

Fold amendments into the drafted PRD, publish it to GitHub Issues with
`ready-for-agent`, and delete the prototype code (see the deletion list at
the top).
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// PROTOTYPE (timeline nav & filtering) — throwaway, delete with this folder.
//
// Three visual treatments for the current-time line, one per variant, to
// answer "enough contrast against the purple/white-on-dark styling without
// shouting?"
import { formatInTimeZone } from "date-fns-tz";

interface NowIndicatorProps {
left: number;
now: Date;
timezone: string;
treatment: "line" | "bubble" | "dashed";
}

export function NowIndicator({
left,
now,
timezone,
treatment,
}: NowIndicatorProps) {
if (treatment === "bubble") {
return (
<div
className="absolute inset-y-0 z-20 pointer-events-none"
style={{ left: `${left}px` }}
>
<div className="absolute inset-y-0 w-0.5 bg-gradient-to-b from-fuchsia-400 via-fuchsia-400/60 to-transparent" />
<div className="absolute top-0 -translate-x-1/2 rounded-full bg-fuchsia-500 px-2 py-0.5 text-[10px] font-semibold text-white whitespace-nowrap shadow-lg">
now {formatInTimeZone(now, timezone, "HH:mm")}
</div>
</div>
);
}

if (treatment === "dashed") {
return (
<div
className="absolute inset-y-0 z-20 pointer-events-none border-l-2 border-dashed border-white/50"
style={{ left: `${left}px` }}
>
<div className="absolute top-0 h-2 w-2 -translate-x-[5px] rounded-full bg-white/80" />
</div>
);
}

return (
<div
className="absolute inset-y-0 z-20 pointer-events-none"
style={{ left: `${left}px` }}
>
<div className="absolute inset-y-0 w-0.5 bg-fuchsia-400/90" />
<div className="absolute top-0 h-2 w-2 -translate-x-[3px] rounded-full bg-fuchsia-400" />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// PROTOTYPE (timeline nav & filtering) — throwaway, delete with this folder.
//
// The timeline scroll surface: production TimeScale + StageRows, but with the
// scroll container ref owned by the variant (for scrollTo/jump control) and a
// current-time indicator overlaid on the strip.
import type { RefObject } from "react";
import { TimeScale } from "../TimeScale";
import { StageRow } from "../StageRow";
import { StageLabels } from "../StageLabels";
import { NowIndicator } from "./NowIndicator";
import { PX_PER_MINUTE, CONTENT_OFFSET_PX } from "./useScrollToUrl";
import type { TimelineData } from "@/lib/timelineCalculator";

interface PrototypeCanvasProps {
timelineData: TimelineData;
timezone: string;
scrollRef: RefObject<HTMLDivElement>;
now: Date | null;
nowTreatment: "line" | "bubble" | "dashed";
}

export function PrototypeCanvas({
timelineData,
timezone,
scrollRef,
now,
nowTreatment,
}: PrototypeCanvasProps) {
const showNow =
now !== null &&
now >= timelineData.festivalStart &&
now <= timelineData.festivalEnd;
const nowLeft = now
? ((now.getTime() - timelineData.festivalStart.getTime()) / 60_000) *
PX_PER_MINUTE +
CONTENT_OFFSET_PX
: 0;

return (
<div className="relative bg-white/5 rounded-lg p-4">
<StageLabels stages={timelineData.stages} />
<div
ref={scrollRef}
className="overflow-x-auto overflow-y-hidden pb-20"
>
<div
className="relative"
style={{ minWidth: timelineData.totalWidth }}
>
<TimeScale
timeSlots={timelineData.timeSlots}
totalWidth={timelineData.totalWidth}
scrollContainerRef={scrollRef}
timezone={timezone}
/>
<div className="space-y-12 mt-28">
{timelineData.stages.map((stage) => (
<StageRow
key={stage.name}
stage={stage}
totalWidth={timelineData.totalWidth}
timezone={timezone}
/>
))}
</div>
{showNow && (
<NowIndicator
left={nowLeft}
now={now}
timezone={timezone}
treatment={nowTreatment}
/>
)}
</div>
</div>
</div>
);
}
Loading
Loading