From 067e8befd8aa92b4b8066cdea4adf736bea508ca Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Thu, 9 Jul 2026 09:18:32 +0200 Subject: [PATCH] feat(speakers-teaser): randomize home wall order each load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home-page speakers teaser ordered the roster by `order` and always opened on the same first four names, so repeat visitors saw an identical lineup every time. Shuffle a copy of the roster (Fisher-Yates) client-side so the starting window — and the rotation sequence — is random per load. The Firestore query keeps `orderBy('order')` for a stable fetch; the shuffle is memoized on the speaker id set so it only reshuffles when the roster actually changes, not on every re-render or rotation tick. Reduced- motion and small-roster visitors also get a random set rather than a fixed one. Co-Authored-By: Claude Opus 4.8 --- src/components/SpeakersTeaser.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/SpeakersTeaser.tsx b/src/components/SpeakersTeaser.tsx index e14af108..c16885bf 100644 --- a/src/components/SpeakersTeaser.tsx +++ b/src/components/SpeakersTeaser.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { initials, speakerFromDoc, type Speaker } from '../lib/speakers'; import s from './SpeakersTeaser.module.scss'; @@ -9,6 +9,17 @@ type Status = 'loading' | 'ready' | 'empty' | 'error'; const WALL_SIZE = 4; const ROTATE_MS = 5000; +// Fisher-Yates: fresh shuffled copy so the wall starts on a random set each load +// (rather than always the first four by `order`) and rotates in a random sequence. +function shuffle(items: readonly T[]): T[] { + const out = items.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + function usePrefersReducedMotion(): boolean { const [reduce, setReduce] = useState(false); useEffect(() => { @@ -100,7 +111,12 @@ export default function SpeakersTeaser() { }; }, []); - const total = speakers.length; + // Shuffle once per roster so the starting window — and rotation order — is + // random on every load. Keyed on the id set so it only reshuffles when the + // roster actually changes, not on every re-render or rotation tick. + const displaySpeakers = useMemo(() => shuffle(speakers), [speakers.map((sp) => sp.id).join('|')]); + + const total = displaySpeakers.length; const size = Math.min(WALL_SIZE, total); const canRotate = total > WALL_SIZE; @@ -121,7 +137,7 @@ export default function SpeakersTeaser() { if (status !== 'ready' || total === 0) return null; - const shown = Array.from({ length: size }, (_, i) => speakers[(offset + i) % total]); + const shown = Array.from({ length: size }, (_, i) => displaySpeakers[(offset + i) % total]); return (