From 2df45fe61ebddd57a251e2c659c833f7b50db7b3 Mon Sep 17 00:00:00 2001 From: Jeff Chen Date: Wed, 12 Aug 2026 09:16:51 -0600 Subject: [PATCH 1/2] Add a 200m/400m lane draw converter Replaces the 200m-only converter with one that covers both sprints in lanes for both sexes. Each event and sex gets its own lane slope, pooled by inverse-variance weighting from a Diamond League estimate (2015-2025) and an NCAA estimate (2016-2026), and the pooled confidence interval drives an uncertainty range under the converted time. The old slug redirects to the new one with its query params intact, and stays out of the utilities index via a `hidden` export that getAllPages() filters on. Unlike its siblings, the page waits for router.isReady before writing the URL so that a shared link's params survive a cold load. Co-Authored-By: Claude Fable 5 --- lib/track/laneDraw.ts | 112 ++++++++ lib/track/pages.ts | 17 +- pages/projects/track/200m-lane-draw.tsx | 196 ++------------ pages/projects/track/lane-draw.tsx | 335 ++++++++++++++++++++++++ scripts/generate-search-index.ts | 4 +- 5 files changed, 481 insertions(+), 183 deletions(-) create mode 100644 lib/track/laneDraw.ts create mode 100644 pages/projects/track/lane-draw.tsx diff --git a/lib/track/laneDraw.ts b/lib/track/laneDraw.ts new file mode 100644 index 0000000..aa1ac6b --- /dev/null +++ b/lib/track/laneDraw.ts @@ -0,0 +1,112 @@ +// Lane-draw effects for the sprints run entirely in lanes on a curve. +// +// A slope is the change in finishing time per lane moved outward, in seconds, +// so a negative slope means outer lanes are faster. Each source estimate comes +// from a fixed-effects regression of time on lane number with athlete and race +// fixed effects, which compares an athlete to themselves across lanes instead +// of comparing the (better) athletes seeded into middle lanes with everyone +// else. + +export type LaneEvent = "200m" | "400m"; +export type Gender = "men" | "women"; + +export interface LaneSlope { + /** seconds per lane moved outward; negative means outer lanes are faster */ + slope: number; + /** 95% confidence interval on the slope, [low, high] */ + ci95: [number, number]; +} + +export interface LaneSlopeSource extends LaneSlope { + event: LaneEvent; + gender: Gender; + dataset: "elite" | "college"; +} + +// The two datasets behind the pooled constants: Diamond League results +// (2015-2025) and NCAA results scraped from FlashResults (2016-2026). +export const SOURCE_ESTIMATES: LaneSlopeSource[] = [ + { + dataset: "elite", + event: "200m", + gender: "men", + slope: -0.0256, + ci95: [-0.041, -0.01], + }, + { + dataset: "elite", + event: "200m", + gender: "women", + slope: -0.0081, + ci95: [-0.024, 0.008], + }, + { + dataset: "elite", + event: "400m", + gender: "men", + slope: -0.0183, + ci95: [-0.043, 0.006], + }, + { + dataset: "elite", + event: "400m", + gender: "women", + slope: -0.0236, + ci95: [-0.06, 0.013], + }, + { + dataset: "college", + event: "200m", + gender: "men", + slope: -0.0227, + ci95: [-0.03, -0.0154], + }, + { + dataset: "college", + event: "200m", + gender: "women", + slope: -0.0137, + ci95: [-0.0221, -0.0053], + }, + { + dataset: "college", + event: "400m", + gender: "men", + slope: -0.0265, + ci95: [-0.0429, -0.0101], + }, + { + dataset: "college", + event: "400m", + gender: "women", + slope: -0.0396, + ci95: [-0.0605, -0.0187], + }, +]; + +// One pooled slope per event and gender: the inverse-variance-weighted mean of +// the two source estimates above, where each standard error is the width of +// that estimate's 95% interval divided by 3.92, the pooled standard error is +// 1/sqrt(sum of weights), and the pooled interval is the mean +/- 1.96 pooled +// standard errors. The college estimates carry most of the weight because +// their intervals are the tighter of the two, especially for the women. +export const LANE_SLOPES: Record> = { + "200m": { + men: { slope: -0.0232, ci95: [-0.0298, -0.0166] }, + women: { slope: -0.0125, ci95: [-0.0199, -0.0051] }, + }, + "400m": { + men: { slope: -0.024, ci95: [-0.0376, -0.0103] }, + women: { slope: -0.0356, ci95: [-0.0538, -0.0175] }, + }, +}; + +export function laneSlope({ + event, + gender, +}: { + event: LaneEvent; + gender: Gender; +}): LaneSlope { + return LANE_SLOPES[event][gender]; +} diff --git a/lib/track/pages.ts b/lib/track/pages.ts index e8f1042..98fb4e4 100644 --- a/lib/track/pages.ts +++ b/lib/track/pages.ts @@ -7,6 +7,13 @@ const dir = join(process.cwd(), "pages/projects/track"); export type TrackPage = Metas & { page: string }; +// A page that exports `hidden` stays reachable but unlisted, e.g. the redirect +// left behind by a renamed slug. +interface PageExports { + metas?: Metas; + hidden?: boolean; +} + export function getAllPages(): TrackPage[] { return fs .readdirSync(dir) @@ -14,15 +21,17 @@ export function getAllPages(): TrackPage[] { .map(fn => { // remove extension const page = fn.split(".")[0]; - const { metas } = require(`pages/projects/track/${page}`) as { - metas?: Metas; - }; + const { metas, hidden } = require( + `pages/projects/track/${page}`, + ) as PageExports; if (!metas) { throw new Error( `File ${page} does not export required export \`metas\`!`, ); } - return { ...metas, page }; + return { metas, hidden, page }; }) + .filter(({ hidden }) => !hidden) + .map(({ metas, page }) => ({ ...metas, page })) .sort((a, b) => a.page.localeCompare(b.page)); } diff --git a/pages/projects/track/200m-lane-draw.tsx b/pages/projects/track/200m-lane-draw.tsx index ef68244..8d6c58b 100644 --- a/pages/projects/track/200m-lane-draw.tsx +++ b/pages/projects/track/200m-lane-draw.tsx @@ -1,206 +1,48 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { useRouter } from "next/router"; -import { useInitialQueryParams } from "lib/hooks"; -import { getAllPages } from "lib/track/pages"; -import type { TrackPage } from "lib/track/pages"; - -import Meta from "components/Meta"; -import RelatedPosts from "components/RelatedPosts"; import Title from "components/Title"; -import UnitInput from "components/UnitInput"; import blogStyles from "styles/components/Blog.module.scss"; -import styles from "styles/pages/track-calculators.module.scss"; -import type { GetStaticProps } from "next"; import type { Metas } from "lib/types"; -const LANE_EFFECT = 0.018; -const LANES = [1, 2, 3, 4, 5, 6, 7, 8, 9]; - -function convertLaneTime( - time: string | number, - currentLane: string | number, - targetLane: string | number, -) { - const timeNum = parseFloat(time as string); - const currentNum = parseInt(currentLane as string, 10); - const targetNum = parseInt(targetLane as string, 10); - - if (isNaN(timeNum) || isNaN(currentNum) || isNaN(targetNum)) { - return null; - } - - return timeNum + (currentNum - targetNum) * LANE_EFFECT; -} +// The 200m-only converter this slug used to serve now lives at /lane-draw/, +// which covers the 400m and both sexes as well. +const TARGET = "/projects/track/lane-draw/"; export const metas: Metas = { title: "200m Lane Draw Converter", - description: - "Converts 200m times between lanes based on lane draw advantage.", + description: "Moved to the Lane Draw Converter.", }; -interface LaneDrawConverterProps { - pages: TrackPage[]; -} +// keeps the slug out of the utilities index and the "Other Utilities" lists +export const hidden = true; -export default function LaneDrawConverter({ pages }: LaneDrawConverterProps) { +export default function Moved200mLaneDraw() { const router = useRouter(); - const [time, setTime] = useState(19.79); - const [currentLane, setCurrentLane] = useState(5); - const [targetLane, setTargetLane] = useState(5); - const [hasShared, setHasShared] = useState(false); - - const loadedFromUrl = useInitialQueryParams(params => { - const urlTime = parseFloat(params.get("time") ?? ""); - if (!isNaN(urlTime)) { - setTime(urlTime); - } - - const urlCurrentLane = parseInt(params.get("currentLane") ?? "", 10); - if (LANES.includes(urlCurrentLane)) { - setCurrentLane(urlCurrentLane); - } - - const urlTargetLane = parseInt(params.get("targetLane") ?? "", 10); - if (LANES.includes(urlTargetLane)) { - setTargetLane(urlTargetLane); - } - }); - useEffect(() => { - if (!loadedFromUrl) { - return; + const existing = new URLSearchParams(window.location.search); + const params = new URLSearchParams({ event: "200m", gender: "men" }); + for (const key of ["time", "currentLane", "targetLane"]) { + const value = existing.get(key); + if (value) { + params.set(key, value); + } } - const params = new URLSearchParams(); - if (time) params.set("time", time.toString()); - if (currentLane) params.set("currentLane", currentLane.toString()); - if (targetLane) params.set("targetLane", targetLane.toString()); - - const newUrl = `${window.location.pathname}?${params.toString()}`; - if (window.location.pathname + window.location.search !== newUrl) { - router.replace(newUrl, undefined, { shallow: true }); - setHasShared(false); - } - }, [loadedFromUrl, time, currentLane, targetLane, router]); - - const handleShare = useCallback(() => { - navigator.clipboard.writeText(window.location.href); - setHasShared(true); - setTimeout(() => setHasShared(false), 2000); - }, []); - - const convertedTime = convertLaneTime(time, currentLane, targetLane); + router.replace(`${TARGET}?${params.toString()}`); + }, [router]); return (
- <p> - Converts outdoor 200m times between lanes. Outside lanes have an - advantage of approximately 0.018 seconds per lane, based on{" "} - <a - href="/posts/Effect-of-Lane-Draw-In-200m-Sprinters/" - target="_blank" - rel="noreferrer" - > - analysis of Diamond League results from 2015-2021 - </a> - . + This calculator moved to the <a href={TARGET}>Lane Draw Converter</a>, + which also handles the 400m and women's races. </p> - <label className={styles.formContainer}> - <strong>200m Time</strong> - <UnitInput - className={styles.input} - type="number" - step="0.01" - value={time} - onChange={v => setTime(v)} - unit="s" - /> - </label> - <label className={styles.formContainer}> - <strong>Current Lane</strong> - <div className={styles.selectWrapper}> - <select - className={styles.select} - value={currentLane} - onChange={e => - setCurrentLane( - parseInt((e.target as HTMLSelectElement).value, 10), - ) - } - > - {LANES.map(lane => ( - <option value={lane} key={lane}> - Lane {lane} - </option> - ))} - </select> - </div> - <small>The lane the time was run in.</small> - </label> - <label className={styles.formContainer}> - <strong>Target Lane</strong> - <div className={styles.selectWrapper}> - <select - className={styles.select} - value={targetLane} - onChange={e => - setTargetLane(parseInt((e.target as HTMLSelectElement).value, 10)) - } - > - {LANES.map(lane => ( - <option value={lane} key={lane}> - Lane {lane} - </option> - ))} - </select> - </div> - <small>The lane to convert the time to.</small> - </label> - <label className={styles.formContainer}> - <strong>Converted Time</strong> - <UnitInput - className={styles.input} - disabled={true} - type="number" - value={convertedTime?.toFixed(2)} - unit="s" - /> - </label> - <button - className={styles.shareButton} - onClick={handleShare} - aria-label="Copy link to clipboard" - disabled={hasShared} - > - {hasShared ? "Copied link!" : "Share"} - </button> - <RelatedPosts - title="Other Utilities" - posts={pages - .filter(({ title }) => title !== metas.title) - .map(({ title, page }) => ({ - fullSlug: `/projects/track/${page}`, - title, - }))} - /> </article> ); } - -export const getStaticProps: GetStaticProps< - LaneDrawConverterProps -> = async () => { - const pages = getAllPages(); - return { - props: { - pages, - }, - }; -}; diff --git a/pages/projects/track/lane-draw.tsx b/pages/projects/track/lane-draw.tsx new file mode 100644 index 0000000..a9fd7db --- /dev/null +++ b/pages/projects/track/lane-draw.tsx @@ -0,0 +1,335 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import { useRouter } from "next/router"; + +import { getAllPages } from "lib/track/pages"; +import type { TrackPage } from "lib/track/pages"; +import { laneSlope } from "lib/track/laneDraw"; +import type { Gender, LaneEvent } from "lib/track/laneDraw"; + +import Meta from "components/Meta"; +import RelatedPosts from "components/RelatedPosts"; +import Title from "components/Title"; +import UnitInput from "components/UnitInput"; + +import blogStyles from "styles/components/Blog.module.scss"; +import styles from "styles/pages/track-calculators.module.scss"; + +import type { GetStaticProps } from "next"; +import type { Metas } from "lib/types"; + +function useSearchParams() { + const router = useRouter(); + return useMemo( + () => new URLSearchParams(router.query as Record<string, string>), + [router.query], + ); +} + +const LANES = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + +// World-class marks, used as the starting point for each cohort +const defaultTimes: Record<LaneEvent, Record<Gender, string>> = { + "200m": { men: "19.79", women: "21.87" }, + "400m": { men: "44.15", women: "49.50" }, +}; + +interface LaneDrawState { + event: LaneEvent; + gender: Gender; + time: string; + currentLane: number; + targetLane: number; +} + +function stateFromParams(params: URLSearchParams): LaneDrawState { + const event: LaneEvent = params.get("event") === "400m" ? "400m" : "200m"; + const gender: Gender = params.get("gender") === "women" ? "women" : "men"; + + return { + event, + gender, + time: params.get("time") || defaultTimes[event][gender], + currentLane: parseInt(params.get("currentLane") ?? "", 10) || 5, + targetLane: parseInt(params.get("targetLane") ?? "", 10) || 5, + }; +} + +interface Conversion { + time: number; + /** half-width of the converted time's 95% interval, seconds */ + uncertainty: number; +} + +function convertLaneTime( + event: LaneEvent, + gender: Gender, + time: string | number, + currentLane: number, + targetLane: number, +): Conversion | null { + const timeNum = parseFloat(time as string); + if (isNaN(timeNum)) { + return null; + } + + const { slope, ci95 } = laneSlope({ event, gender }); + const lanes = targetLane - currentLane; + + return { + time: timeNum + slope * lanes, + uncertainty: (Math.abs(lanes) * (ci95[1] - ci95[0])) / 2, + }; +} + +export const metas: Metas = { + title: "Lane Draw Converter", + description: + "Converts 200m and 400m times between lanes based on lane draw advantage.", +}; + +interface LaneDrawConverterProps { + pages: TrackPage[]; +} + +export default function LaneDrawConverter({ pages }: LaneDrawConverterProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const initial = stateFromParams(searchParams); + const [event, setEvent] = useState<LaneEvent>(initial.event); + const [gender, setGender] = useState<Gender>(initial.gender); + const [time, setTime] = useState<string | number>(initial.time); + // a time that came from the user or a link is never replaced by a cohort + // default + const [timeIsUserSet, setTimeIsUserSet] = useState( + () => !!searchParams.get("time"), + ); + const [currentLane, setCurrentLane] = useState(initial.currentLane); + const [targetLane, setTargetLane] = useState(initial.targetLane); + const [adoptedParams, setAdoptedParams] = useState(false); + const [hasShared, setHasShared] = useState(false); + + // On a statically generated page router.query is empty until the router is + // ready, so the query a shared link carries is adopted as soon as it arrives. + useEffect(() => { + if (adoptedParams || !router.isReady) { + return; + } + + const incoming = stateFromParams(searchParams); + setEvent(incoming.event); + setGender(incoming.gender); + setTime(incoming.time); + setTimeIsUserSet(!!searchParams.get("time")); + setCurrentLane(incoming.currentLane); + setTargetLane(incoming.targetLane); + setAdoptedParams(true); + }, [adoptedParams, router.isReady, searchParams]); + + // Writing the URL before the query has been adopted would replace a shared + // link's params with the defaults rendered ahead of them. + useEffect(() => { + if (!adoptedParams) { + return; + } + + const params = new URLSearchParams(); + params.set("event", event); + params.set("gender", gender); + if (time) params.set("time", time.toString()); + if (currentLane) params.set("currentLane", currentLane.toString()); + if (targetLane) params.set("targetLane", targetLane.toString()); + + const newUrl = `${window.location.pathname}?${params.toString()}`; + if (window.location.pathname + window.location.search !== newUrl) { + router.replace(newUrl, undefined, { shallow: true }); + setHasShared(false); + } + }, [adoptedParams, event, gender, time, currentLane, targetLane, router]); + + const handleShare = useCallback(() => { + navigator.clipboard.writeText(window.location.href); + setHasShared(true); + setTimeout(() => setHasShared(false), 2000); + }, []); + + const handleEvent = useCallback( + (value: LaneEvent) => { + setEvent(value); + if (!timeIsUserSet) { + setTime(defaultTimes[value][gender]); + } + }, + [gender, timeIsUserSet], + ); + const handleGender = useCallback( + (value: Gender) => { + setGender(value); + if (!timeIsUserSet) { + setTime(defaultTimes[event][value]); + } + }, + [event, timeIsUserSet], + ); + const handleTime = useCallback((value: string) => { + setTime(value); + setTimeIsUserSet(true); + }, []); + + const conversion = convertLaneTime( + event, + gender, + time, + currentLane, + targetLane, + ); + + return ( + <article className={blogStyles.article}> + <Meta {...metas} /> + <Title title={metas.title} /> + <p> + Converts outdoor 200m and 400m times between lanes. Outer lanes are + faster, by an amount per lane that depends on the event and the sex of + the athlete. The constants come from within-athlete comparisons — the + same athlete running the same event out of different lanes — across{" "} + <a + href="/posts/Effect-of-Lane-Draw-In-200m-Sprinters/" + target="_blank" + rel="noreferrer" + > + Diamond League results from 2015-2025 + </a>{" "} + and 26,000+ NCAA results from 2016-2026. Naive lane comparisons that do + not control for who is in each lane overstate the effect several-fold, + and the women's elite estimates are the least precise of the four. + </p> + <label className={styles.formContainer}> + <strong>Event</strong> + <div className={styles.selectWrapper}> + <select + className={styles.select} + value={event} + onChange={e => + handleEvent((e.target as HTMLSelectElement).value as LaneEvent) + } + > + <option value="200m">200m</option> + <option value="400m">400m</option> + </select> + </div> + </label> + <label className={styles.formContainer}> + <strong>Sex</strong> + <div className={styles.selectWrapper}> + <select + className={styles.select} + value={gender} + onChange={e => + handleGender((e.target as HTMLSelectElement).value as Gender) + } + > + <option value="men">Men</option> + <option value="women">Women</option> + </select> + </div> + </label> + <label className={styles.formContainer}> + <strong>{event} Time</strong> + <UnitInput + className={styles.input} + type="number" + step="0.01" + value={time} + onChange={handleTime} + unit="s" + /> + </label> + <label className={styles.formContainer}> + <strong>Current Lane</strong> + <div className={styles.selectWrapper}> + <select + className={styles.select} + value={currentLane} + onChange={e => + setCurrentLane( + parseInt((e.target as HTMLSelectElement).value, 10), + ) + } + > + {LANES.map(lane => ( + <option value={lane} key={lane}> + Lane {lane} + </option> + ))} + </select> + </div> + <small>The lane the time was run in.</small> + </label> + <label className={styles.formContainer}> + <strong>Target Lane</strong> + <div className={styles.selectWrapper}> + <select + className={styles.select} + value={targetLane} + onChange={e => + setTargetLane(parseInt((e.target as HTMLSelectElement).value, 10)) + } + > + {LANES.map(lane => ( + <option value={lane} key={lane}> + Lane {lane} + </option> + ))} + </select> + </div> + <small>The lane to convert the time to.</small> + </label> + <label className={styles.formContainer}> + <strong>Converted Time</strong> + <UnitInput + className={styles.input} + disabled={true} + type="number" + value={conversion?.time.toFixed(2)} + unit="s" + /> + {conversion && conversion.uncertainty > 0 && ( + <small> + range {(conversion.time - conversion.uncertainty).toFixed(2)}– + {(conversion.time + conversion.uncertainty).toFixed(2)} s + </small> + )} + </label> + <button + className={styles.shareButton} + onClick={handleShare} + aria-label="Copy link to clipboard" + disabled={hasShared} + > + {hasShared ? "Copied link!" : "Share"} + </button> + <RelatedPosts + title="Other Utilities" + posts={pages + .filter(({ title }) => title !== metas.title) + .map(({ title, page }) => ({ + fullSlug: `/projects/track/${page}`, + title, + }))} + /> + </article> + ); +} + +export const getStaticProps: GetStaticProps< + LaneDrawConverterProps +> = async () => { + const pages = getAllPages(); + return { + props: { + pages, + }, + }; +}; diff --git a/scripts/generate-search-index.ts b/scripts/generate-search-index.ts index ea69527..22ba2c9 100644 --- a/scripts/generate-search-index.ts +++ b/scripts/generate-search-index.ts @@ -25,8 +25,8 @@ const ALL_PAGES: SearchIndexItem[] = [ group: "Pages", }, { - title: "200m Lane Draw Converter", - route: "/projects/track/200m-lane-draw/", + title: "Lane Draw Converter", + route: "/projects/track/lane-draw/", group: "Pages", }, { From 5642ece17c08d97b50d048f97680998b3e66b770 Mon Sep 17 00:00:00 2001 From: Jeff Chen <hello@jeff.yt> Date: Wed, 12 Aug 2026 10:49:05 -0600 Subject: [PATCH 2/2] Use the shared useInitialQueryParams hook in the lane draw converter Replaces this page's own router.isReady adoption gate with the hook the other calculators use, so all five restore state from a shared link the same way. Enum-ish params are validated before they reach state: event and sex against their unions, lanes against the selectable lanes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- pages/projects/track/lane-draw.tsx | 93 ++++++++++++------------------ 1 file changed, 38 insertions(+), 55 deletions(-) diff --git a/pages/projects/track/lane-draw.tsx b/pages/projects/track/lane-draw.tsx index a9fd7db..7272bd1 100644 --- a/pages/projects/track/lane-draw.tsx +++ b/pages/projects/track/lane-draw.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; +import { useInitialQueryParams } from "lib/hooks"; import { getAllPages } from "lib/track/pages"; import type { TrackPage } from "lib/track/pages"; import { laneSlope } from "lib/track/laneDraw"; @@ -18,14 +19,6 @@ import styles from "styles/pages/track-calculators.module.scss"; import type { GetStaticProps } from "next"; import type { Metas } from "lib/types"; -function useSearchParams() { - const router = useRouter(); - return useMemo( - () => new URLSearchParams(router.query as Record<string, string>), - [router.query], - ); -} - const LANES = [1, 2, 3, 4, 5, 6, 7, 8, 9]; // World-class marks, used as the starting point for each cohort @@ -34,25 +27,12 @@ const defaultTimes: Record<LaneEvent, Record<Gender, string>> = { "400m": { men: "44.15", women: "49.50" }, }; -interface LaneDrawState { - event: LaneEvent; - gender: Gender; - time: string; - currentLane: number; - targetLane: number; +function isLaneEvent(value: string | null): value is LaneEvent { + return value === "200m" || value === "400m"; } -function stateFromParams(params: URLSearchParams): LaneDrawState { - const event: LaneEvent = params.get("event") === "400m" ? "400m" : "200m"; - const gender: Gender = params.get("gender") === "women" ? "women" : "men"; - - return { - event, - gender, - time: params.get("time") || defaultTimes[event][gender], - currentLane: parseInt(params.get("currentLane") ?? "", 10) || 5, - targetLane: parseInt(params.get("targetLane") ?? "", 10) || 5, - }; +function isGender(value: string | null): value is Gender { + return value === "men" || value === "women"; } interface Conversion { @@ -94,43 +74,46 @@ interface LaneDrawConverterProps { export default function LaneDrawConverter({ pages }: LaneDrawConverterProps) { const router = useRouter(); - const searchParams = useSearchParams(); - const initial = stateFromParams(searchParams); - const [event, setEvent] = useState<LaneEvent>(initial.event); - const [gender, setGender] = useState<Gender>(initial.gender); - const [time, setTime] = useState<string | number>(initial.time); + const [event, setEvent] = useState<LaneEvent>("200m"); + const [gender, setGender] = useState<Gender>("men"); + const [time, setTime] = useState<string | number>(defaultTimes["200m"].men); // a time that came from the user or a link is never replaced by a cohort // default - const [timeIsUserSet, setTimeIsUserSet] = useState( - () => !!searchParams.get("time"), - ); - const [currentLane, setCurrentLane] = useState(initial.currentLane); - const [targetLane, setTargetLane] = useState(initial.targetLane); - const [adoptedParams, setAdoptedParams] = useState(false); + const [timeIsUserSet, setTimeIsUserSet] = useState(false); + const [currentLane, setCurrentLane] = useState(5); + const [targetLane, setTargetLane] = useState(5); const [hasShared, setHasShared] = useState(false); - // On a statically generated page router.query is empty until the router is - // ready, so the query a shared link carries is adopted as soon as it arrives. - useEffect(() => { - if (adoptedParams || !router.isReady) { - return; + const loadedFromUrl = useInitialQueryParams(params => { + const urlEvent = params.get("event"); + const urlGender = params.get("gender"); + const cohortEvent = isLaneEvent(urlEvent) ? urlEvent : "200m"; + const cohortGender = isGender(urlGender) ? urlGender : "men"; + setEvent(cohortEvent); + setGender(cohortGender); + + const urlTime = parseFloat(params.get("time") ?? ""); + if (isNaN(urlTime)) { + setTime(defaultTimes[cohortEvent][cohortGender]); + } else { + setTime(urlTime); + setTimeIsUserSet(true); + } + + const urlCurrentLane = parseInt(params.get("currentLane") ?? "", 10); + if (LANES.includes(urlCurrentLane)) { + setCurrentLane(urlCurrentLane); } - const incoming = stateFromParams(searchParams); - setEvent(incoming.event); - setGender(incoming.gender); - setTime(incoming.time); - setTimeIsUserSet(!!searchParams.get("time")); - setCurrentLane(incoming.currentLane); - setTargetLane(incoming.targetLane); - setAdoptedParams(true); - }, [adoptedParams, router.isReady, searchParams]); + const urlTargetLane = parseInt(params.get("targetLane") ?? "", 10); + if (LANES.includes(urlTargetLane)) { + setTargetLane(urlTargetLane); + } + }); - // Writing the URL before the query has been adopted would replace a shared - // link's params with the defaults rendered ahead of them. useEffect(() => { - if (!adoptedParams) { + if (!loadedFromUrl) { return; } @@ -146,7 +129,7 @@ export default function LaneDrawConverter({ pages }: LaneDrawConverterProps) { router.replace(newUrl, undefined, { shallow: true }); setHasShared(false); } - }, [adoptedParams, event, gender, time, currentLane, targetLane, router]); + }, [loadedFromUrl, event, gender, time, currentLane, targetLane, router]); const handleShare = useCallback(() => { navigator.clipboard.writeText(window.location.href);