- commit
- 87cf740b1c7ad38522fc1802a9b049dc0a5e8f77
- parent
- 53c64f2dbdea0e54a3a32a8afcdf34b7b7f785ce
- author
- Dami <dami.etoile@gmail.com>
| A | islands/ | 226 | ++++++++++++++++++++++++++++++ |
| M | islands/ | 24 | +-- |
| M | islands/ | 20 | +-- |
| A | islands/ | 52 | +++++++ |
| M | islands/ | 168 | +++++++++------------- |
| A | lib/ | 35 | +++++ |
| A | lib/ | 69 | +++++++++ |
| A | lib/ | 6 | + |
| M | lib/ | 8 | +- |
| M | lib/ | 38 | +++++ |
| M | routes/ | 15 | ++- |
| M | routes/ | 106 | +-------------- |
| M | routes/ | 2 | +- |
| M | routes/ | 3 | +- |
| A | tests/ | 37 | +++++ |
| A | tests/ | 100 | +++++++++++++ |
| A | tests/ | 14 | ++ |
| M | tests/ | 66 | +-------- |
| M | tests/ | 113 | +++++++++++++++ |
@@ -0,0 +1,226 @@
1+import { useSignal } from "@preact/signals";2+import { useEffect, useMemo } from "preact/hooks";3+4+import { dateToSlug } from "@/lib/date/daily.ts";5+import { buildInitialDailyControllerState } from "@/lib/shikaku/daily_controller_state.ts";6+import { resolveDayStackState } from "@/lib/shikaku/day_stack_state.ts";7+import { getPuzzleByDateIndex } from "@/lib/shikaku/catalog.ts";8+import {9+ buildProgressKey,10+ getPlayableStackIndex,11+ getSelectedStackIndex,12+ getVisibleStackRange,13+} from "@/lib/storage/local_progress.ts";14+import ShikakuGame from "./ShikakuGame.tsx";15+16+interface DailyDateControllerProps {17+ date: string;18+}19+20+export default function DailyDateController(21+ { date }: DailyDateControllerProps,22+) {23+ const today = useSignal(date);24+ const selectedStack = useSignal(0);25+ const playableStack = useSignal(0);26+ const visibleMaxStack = useSignal(0);27+ const replayStack = useSignal<number | null>(null);28+ const refreshKey = useSignal(0);29+30+ useEffect(() => {31+ if (typeof globalThis.localStorage === "undefined") return;32+ const nextToday = dateToSlug(new Date());33+ const nextSelected = getSelectedStackIndex(globalThis.localStorage, date);34+ const nextPlayable = getPlayableStackIndex(globalThis.localStorage, date);35+ const nextVisibleMax =36+ getVisibleStackRange(globalThis.localStorage, date).max;37+ const initialState = buildInitialDailyControllerState({38+ date,39+ today: nextToday,40+ selectedStackIndex: nextSelected,41+ playableStackIndex: nextPlayable,42+ visibleMaxStack: nextVisibleMax,43+ });44+45+ console.debug("[rect.run] daily-init", {46+ date,47+ today: nextToday,48+ selectedStackIndex: nextSelected,49+ playableStackIndex: nextPlayable,50+ visibleMaxStack: nextVisibleMax,51+ initialState,52+ });53+54+ today.value = nextToday;55+ playableStack.value = initialState.playableStack;56+ visibleMaxStack.value = initialState.visibleMaxStack;57+ selectedStack.value = initialState.selectedStack;58+ replayStack.value = null;59+ refreshKey.value += 1;60+ }, [date]);61+62+ const highestStack = useMemo(() => {63+ if (date > today.value) return 0;64+ if (date < today.value) {65+ return Math.max(visibleMaxStack.value, selectedStack.value);66+ }67+ return Math.max(visibleMaxStack.value, playableStack.value);68+ }, [69+ date,70+ today.value,71+ visibleMaxStack.value,72+ selectedStack.value,73+ playableStack.value,74+ ]);75+76+ const puzzle = useMemo(77+ () => getPuzzleByDateIndex(date, selectedStack.value),78+ [date, selectedStack.value],79+ );80+81+ const dayState = useMemo(82+ () =>83+ resolveDayStackState({84+ date,85+ today: today.value,86+ stackIndex: selectedStack.value,87+ playableStackIndex: playableStack.value,88+ mostRecentStackIndex: selectedStack.value,89+ isReplay: replayStack.value === selectedStack.value,90+ }),91+ [92+ date,93+ today.value,94+ selectedStack.value,95+ playableStack.value,96+ replayStack.value,97+ ],98+ );99+100+ if (!puzzle) {101+ return (102+ <main class="shikaku-play-shell">103+ <div class="shikaku-play-frame">104+ <header class="shikaku-play-header">105+ <p class="shikaku-play-hint">Puzzle not found.</p>106+ </header>107+ </div>108+ </main>109+ );110+ }111+112+ const currentPuzzle = puzzle;113+114+ function syncFromStorage() {115+ if (typeof globalThis.localStorage === "undefined") return;116+ playableStack.value = getPlayableStackIndex(globalThis.localStorage, date);117+ visibleMaxStack.value = Math.max(118+ getVisibleStackRange(globalThis.localStorage, date).max,119+ selectedStack.value,120+ );121+ }122+123+ function handleReplayReset() {124+ if (typeof globalThis.localStorage !== "undefined") {125+ globalThis.localStorage.removeItem(buildProgressKey(currentPuzzle.id));126+ }127+ replayStack.value = selectedStack.value;128+ refreshKey.value += 1;129+ syncFromStorage();130+ }131+132+ function handleSolved() {133+ replayStack.value = null;134+ playableStack.value = Math.max(135+ playableStack.value,136+ selectedStack.value + 1,137+ );138+ visibleMaxStack.value = Math.max(139+ visibleMaxStack.value,140+ selectedStack.value + 1,141+ );142+ }143+144+ const canGoDown = selectedStack.value > 0;145+ const canGoUp = selectedStack.value < highestStack;146+147+ return (148+ <main class="shikaku-play-shell">149+ <div class="shikaku-play-frame">150+ <header class="shikaku-play-header">151+ <div class="shikaku-play-header__row">152+ <div class="shikaku-play-day-nav shikaku-play-day-nav--with-home">153+ <a154+ class="shikaku-play-nav shikaku-play-nav--icon"155+ href="/"156+ aria-label="Back home"157+ >158+ ⌂159+ </a>160+ {canGoDown161+ ? (162+ <button163+ type="button"164+ class="shikaku-play-nav shikaku-play-nav--icon"165+ aria-label="Previous stack"166+ onClick={() => {167+ selectedStack.value -= 1;168+ replayStack.value = null;169+ }}170+ >171+ ‹172+ </button>173+ )174+ : (175+ <span class="shikaku-play-nav shikaku-play-nav--icon is-disabled">176+ ‹177+ </span>178+ )}179+ <div>180+ <p class="shikaku-play-kicker">181+ Daily stack · {selectedStack.value}182+ </p>183+ <h1 class="shikaku-play-title">{date}</h1>184+ </div>185+ {canGoUp186+ ? (187+ <button188+ type="button"189+ class="shikaku-play-nav shikaku-play-nav--icon"190+ aria-label="Next stack"191+ onClick={() => {192+ selectedStack.value += 1;193+ replayStack.value = null;194+ }}195+ >196+ ›197+ </button>198+ )199+ : (200+ <span class="shikaku-play-nav shikaku-play-nav--icon is-disabled">201+ ›202+ </span>203+ )}204+ </div>205+ </div>206+ <p class="shikaku-play-hint">{dayState.message}</p>207+ </header>208+209+ <ShikakuGame210+ key={`${currentPuzzle.id}:${refreshKey.value}`}211+ puzzle={currentPuzzle}212+ readOnly={dayState.mode !== "playable"}213+ resetEnabled={dayState.canReset}214+ lockedMessage={dayState.message}215+ initialMessage={dayState.message}216+ solvedMessage={date === today.value &&217+ selectedStack.value === playableStack.value218+ ? "Solved. Stack above unlocked."219+ : "Solved!"}220+ onReset={dayState.mode !== "playable" ? handleReplayReset : undefined}221+ onSolved={handleSolved}222+ />223+ </div>224+ </main>225+ );226+}@@ -1,21 +1,25 @@
11 import { useEffect, useState } from "preact/hooks";22 33 import { dateToSlug } from "@/lib/date/daily.ts";4-import { resolvePlayableDailyPath } from "@/lib/site/paths.ts";4+import { buildDailyPath } from "@/lib/site/paths.ts";55 66 interface DailyStackLinkProps {77 fallbackDate: string;88 }99 1010 export default function DailyStackLink({ fallbackDate }: DailyStackLinkProps) {11- const [href, setHref] = useState(12- resolvePlayableDailyPath(localStorageFallback(), fallbackDate),13- );11+ const [href, setHref] = useState(buildDailyPath(fallbackDate));1412 1513 useEffect(() => {1614 const localDate = dateToSlug(new Date());17- setHref(resolvePlayableDailyPath(globalThis.localStorage, localDate));18- }, []);15+ const nextHref = buildDailyPath(localDate);16+ console.debug("[rect.run] home-play-link", {17+ fallbackDate,18+ localDate,19+ nextHref,20+ });21+ setHref(nextHref);22+ }, [fallbackDate]);1923 2024 return (2125 <a class="shikaku-home__action shikaku-home__action--gold" href={href}>@@ -24,11 +28,3 @@ export default function DailyStackLink({ fallbackDate }: DailyStackLinkProps) {
2428 </a>2529 );2630 }27-28-function localStorageFallback(): Pick<Storage, "length" | "key" | "getItem"> {29- return {30- length: 0,31- key: () => null,32- getItem: () => null,33- };34-}@@ -1,7 +1,7 @@
11 import { useEffect, useState } from "preact/hooks";22 33 import { dateToSlug } from "@/lib/date/daily.ts";4-import { resolvePlayableDailyPath } from "@/lib/site/paths.ts";4+import { buildDailyPath } from "@/lib/site/paths.ts";55 66 interface DailyStackRedirectProps {77 fallbackDate: string;@@ -10,16 +10,10 @@ interface DailyStackRedirectProps {
1010 export default function DailyStackRedirect(1111 { fallbackDate }: DailyStackRedirectProps,1212 ) {13- const [href, setHref] = useState(14- resolvePlayableDailyPath(localStorageFallback(), fallbackDate),15- );13+ const [href, setHref] = useState(buildDailyPath(fallbackDate));1614 1715 useEffect(() => {18- const localDate = dateToSlug(new Date());19- const nextHref = resolvePlayableDailyPath(20- globalThis.localStorage,21- localDate,22- );16+ const nextHref = buildDailyPath(dateToSlug(new Date()));2317 setHref(nextHref);2418 globalThis.location.replace(nextHref);2519 }, []);@@ -47,11 +41,3 @@ export default function DailyStackRedirect(
4741 </main>4842 );4943 }50-51-function localStorageFallback(): Pick<Storage, "length" | "key" | "getItem"> {52- return {53- length: 0,54- key: () => null,55- getItem: () => null,56- };57-}@@ -0,0 +1,52 @@
1+import { useEffect, useState } from "preact/hooks";2+3+import { dateToSlug } from "@/lib/date/daily.ts";4+import { getInitialStackIndex } from "@/lib/shikaku/day_stack_state.ts";5+import {6+ getPlayableStackIndex,7+ getSelectedStackIndex,8+} from "@/lib/storage/local_progress.ts";9+10+interface HomeDateStackEyebrowProps {11+ fallbackDate: string;12+}13+14+export default function HomeDateStackEyebrow(15+ { fallbackDate }: HomeDateStackEyebrowProps,16+) {17+ const [label, setLabel] = useState(buildLabel(fallbackDate, 0));18+19+ useEffect(() => {20+ const localDate = dateToSlug(new Date());21+ const selectedStackIndex = getSelectedStackIndex(22+ globalThis.localStorage,23+ localDate,24+ );25+ const playableStackIndex = getPlayableStackIndex(26+ globalThis.localStorage,27+ localDate,28+ );29+ const stackIndex = getInitialStackIndex({30+ date: localDate,31+ today: localDate,32+ selectedStackIndex,33+ playableStackIndex,34+ });35+36+ setLabel(buildLabel(localDate, stackIndex));37+ }, []);38+39+ return <div class="shikaku-home__eyebrow">{label}</div>;40+}41+42+function buildLabel(date: string, stackIndex: number): string {43+ const [year, month, day] = date.split("-").map(Number);44+ const value = new Date(year, month - 1, day);45+ const formatted = new Intl.DateTimeFormat("en-US", {46+ month: "short",47+ day: "numeric",48+ year: "numeric",49+ }).format(value);50+51+ return `${formatted} - Stack ${stackIndex + 1}`;52+}@@ -1,8 +1,7 @@
11 import { useSignal } from "@preact/signals";22 import { useEffect, useMemo, useRef } from "preact/hooks";33 4-import { dateToSlug } from "@/lib/date/daily.ts";5-4+import { shouldReloadSavedProgress } from "@/lib/shikaku/game_session.ts";65 import {76 createRectangleFromCorners,87 getRectangleCells,@@ -17,11 +16,9 @@ import {
1716 getStatusTone,1817 } from "@/lib/shikaku/play_state.ts";1918 import {20- getPlayableStackIndex,2119 loadPuzzleProgress,2220 savePuzzleProgress,2321 } from "@/lib/storage/local_progress.ts";24-import { buildDailyPath } from "@/lib/site/paths.ts";2522 import type {2623 GridPoint,2724 PlacedRectangle,@@ -32,6 +29,12 @@ import type {
3229 interface ShikakuGameProps {3330 puzzle: ShikakuPuzzle;3431 readOnly?: boolean;32+ resetEnabled?: boolean;33+ lockedMessage?: string;34+ initialMessage?: string;35+ solvedMessage?: string;36+ onReset?: () => void;37+ onSolved?: () => void;3538 }3639 3740 const COLORS = [@@ -45,48 +48,48 @@ const COLORS = [
4548 "#a7f3d0",4649 ];4750 48-export default function ShikakuGame(49- { puzzle, readOnly = false }: ShikakuGameProps,50-) {51+export default function ShikakuGame(props: ShikakuGameProps) {52+ const {53+ puzzle,54+ readOnly = false,55+ resetEnabled,56+ lockedMessage = "View only.",57+ initialMessage,58+ solvedMessage = "Solved!",59+ onReset,60+ onSolved,61+ } = props;62+ const resolvedInitialMessage = initialMessage ??63+ (readOnly64+ ? lockedMessage65+ : "Drag to size a rectangle. Release when it encloses one clue.");66+ const resolvedResetEnabled = resetEnabled ?? !readOnly;67+5168 const boardRef = useRef<HTMLDivElement>(null);69+ const previousPuzzleIdRef = useRef("");5270 const rectangles = useSignal<PlacedRectangle[]>([]);5371 const activeClueId = useSignal<number | null>(null);5472 const dragAnchor = useSignal<GridPoint | null>(null);5573 const dragCurrent = useSignal<GridPoint | null>(null);5674 const dragPointerId = useSignal<number | null>(null);5775 const status = useSignal<"in_progress" | "solved">("in_progress");58- const message = useSignal(59- "Drag to size a rectangle. Release when it encloses one clue.",60- );76+ const message = useSignal(resolvedInitialMessage);6177 const hydrated = useSignal(false);62- const effectiveReadOnly = useSignal(readOnly);63- const playableStackIndex = useSignal(puzzle.streakIndex);64- const localToday = useSignal(puzzle.date);6578 66- useEffect(() => {67- const saved = loadPuzzleProgress(globalThis.localStorage, puzzle.id);68- if (saved) {69- rectangles.value = selectionsToRectangles(saved, puzzle);70- status.value = saved.status === "solved" ? "solved" : "in_progress";71- message.value = saved.status === "solved"72- ? "Reset or play another."73- : "Resumed from local storage.";74- }75- hydrated.value = true;76- }, [puzzle.date, puzzle.id]);77-78- useEffect(() => {79- if (!hydrated.value) return;79+ function persistProgress(80+ nextRectangles: PlacedRectangle[],81+ nextStatus: "in_progress" | "solved",82+ ) {8083 const now = new Date().toISOString();8184 const progress: PuzzleProgress = {8285 date: puzzle.date,8386 puzzleId: puzzle.id,8487 startedAt: now,8588 updatedAt: now,86- status: status.value === "solved" ? "solved" : "in_progress",89+ status: nextStatus,8790 elapsedMs: 0,8891 mistakes: 0,89- selections: rectangles.value.map((rectangle) => ({92+ selections: nextRectangles.map((rectangle) => ({9093 clueId: rectangle.clueId,9194 cells: getRectangleCells(rectangle),9295 value: rectangle.value,@@ -96,44 +99,27 @@ export default function ShikakuGame(
9699 clientVersion: 1,97100 };98101 savePuzzleProgress(globalThis.localStorage, progress);99- }, [puzzle.date, rectangles.value, status.value, hydrated.value]);102+ }100103 101104 useEffect(() => {102- if (!hydrated.value) return;103- localToday.value = dateToSlug(new Date());104- playableStackIndex.value = getPlayableStackIndex(105- globalThis.localStorage,106- puzzle.date,107- );108-109- effectiveReadOnly.value = readOnly || puzzle.date !== localToday.value ||110- puzzle.streakIndex !== playableStackIndex.value;111-112- if (status.value === "solved") {113- message.value = playableStackIndex.value === puzzle.streakIndex + 1114- ? "Solved. Next stack unlocked."115- : "Reset or play another.";105+ if (!shouldReloadSavedProgress(previousPuzzleIdRef.current, puzzle.id)) {116106 return;117107 }118108 119- if (!effectiveReadOnly.value) return;120- if (puzzle.date < localToday.value) {121- message.value = "Past day. View only.";122- } else if (puzzle.date > localToday.value) {123- message.value = "Locked until this day.";124- } else if (puzzle.streakIndex < playableStackIndex.value) {125- message.value = "Earlier stack. View only.";126- } else {127- message.value = `Locked. Finish stack ${playableStackIndex.value} first.`;128- }129- }, [130- puzzle.date,131- puzzle.streakIndex,132- status.value,133- rectangles.value,134- hydrated.value,135- readOnly,136- ]);109+ previousPuzzleIdRef.current = puzzle.id;110+ const saved = loadPuzzleProgress(globalThis.localStorage, puzzle.id);111+ rectangles.value = saved ? selectionsToRectangles(saved, puzzle) : [];112+ status.value = saved?.status === "solved" ? "solved" : "in_progress";113+ message.value = saved?.status === "solved"114+ ? solvedMessage115+ : resolvedInitialMessage;116+ hydrated.value = true;117+ }, [puzzle.id, resolvedInitialMessage, solvedMessage]);118+119+ useEffect(() => {120+ if (!hydrated.value || readOnly) return;121+ persistProgress(rectangles.value, status.value);122+ }, [puzzle.id, rectangles.value, status.value, hydrated.value, readOnly]);137123 138124 const clueEntries = useMemo(() => {139125 return puzzle.givens.map((given, index) => {@@ -247,25 +233,18 @@ export default function ShikakuGame(
247233 248234 if (isPuzzleSolved(puzzle, nextRectangles)) {249235 status.value = "solved";250- message.value = "Solved!";236+ persistProgress(nextRectangles, "solved");237+ message.value = solvedMessage;238+ onSolved?.();251239 return;252240 }253241 254242 message.value = "Rectangle placed.";255243 }256244 257- function getLockedMessage(): string {258- if (puzzle.date < localToday.value) return "Past day. View only.";259- if (puzzle.date > localToday.value) return "Locked until this day.";260- if (puzzle.streakIndex < playableStackIndex.value) {261- return "Earlier stack. View only.";262- }263- return `Locked. Finish stack ${playableStackIndex.value} first.`;264- }265-266245 function handleCellPointerDown(point: GridPoint, pointerId: number) {267- if (effectiveReadOnly.value) {268- message.value = getLockedMessage();246+ if (readOnly) {247+ message.value = lockedMessage;269248 clearDrag();270249 return;271250 }@@ -291,7 +270,7 @@ export default function ShikakuGame(
291270 }292271 293272 function handleBoardPointerDown(event: PointerEvent) {294- if (effectiveReadOnly.value) return;273+ if (readOnly) return;295274 event.preventDefault();296275 const point = getBoardPointFromPointer(event);297276 if (!point) return;@@ -301,14 +280,14 @@ export default function ShikakuGame(
301280 }302281 303282 function handleBoardPointerMove(event: PointerEvent) {304- if (effectiveReadOnly.value) return;283+ if (readOnly) return;305284 const point = getBoardPointFromPointer(event);306285 if (!point) return;307286 updateDrag(point, event.pointerId);308287 }309288 310289 function handleBoardPointerUp(event: PointerEvent) {311- if (effectiveReadOnly.value) return;290+ if (readOnly) return;312291 event.preventDefault();313292 const point = getBoardPointFromPointer(event);314293 if (!point) {@@ -321,14 +300,18 @@ export default function ShikakuGame(
321300 }322301 323302 function handleBoardPointerCancel(event: PointerEvent) {324- if (effectiveReadOnly.value) return;303+ if (readOnly) return;325304 clearDrag();326305 boardRef.current?.releasePointerCapture?.(event.pointerId);327306 }328307 329308 function clearBoard() {330- if (effectiveReadOnly.value) {331- message.value = getLockedMessage();309+ if (readOnly) {310+ if (!resolvedResetEnabled) {311+ message.value = lockedMessage;312+ return;313+ }314+ onReset?.();332315 return;333316 }334317 @@ -336,14 +319,9 @@ export default function ShikakuGame(
336319 clearDrag();337320 status.value = "in_progress";338321 message.value = "Board cleared.";322+ onReset?.();339323 }340324 341- const nextStackHref = status.value === "solved" &&342- puzzle.date === localToday.value &&343- playableStackIndex.value === puzzle.streakIndex + 1344- ? buildDailyPath(puzzle.date, playableStackIndex.value)345- : null;346-347325 return (348326 <section class="shikaku-game-shell">349327 <div@@ -358,22 +336,12 @@ export default function ShikakuGame(
358336 <span>{message}</span>359337 </div>360338 <div class="shikaku-game-actions">361- {nextStackHref362- ? (363- <a364- class="shikaku-game-clear shikaku-game-clear--link"365- href={nextStackHref}366- >367- Next368- </a>369- )370- : null}371339 <button372340 class="shikaku-game-clear"373341 type="button"374342 onClick={clearBoard}375- disabled={effectiveReadOnly.value}376- aria-disabled={effectiveReadOnly.value}343+ disabled={readOnly && !resolvedResetEnabled}344+ aria-disabled={readOnly && !resolvedResetEnabled}377345 >378346 Reset379347 </button>@@ -445,8 +413,8 @@ export default function ShikakuGame(
445413 }`}446414 style={{ ["--token-color" as string]: entry.color }}447415 onClick={() => {448- if (effectiveReadOnly.value) {449- message.value = getLockedMessage();416+ if (readOnly) {417+ message.value = lockedMessage;450418 return;451419 }452420 activeClueId.value = entry.given.id;@@ -0,0 +1,35 @@
1+export interface DailyControllerStateInput {2+ date: string;3+ today: string;4+ selectedStackIndex: number;5+ playableStackIndex: number;6+ visibleMaxStack: number;7+}8+9+export interface DailyControllerState {10+ selectedStack: number;11+ playableStack: number;12+ visibleMaxStack: number;13+}14+15+export function buildInitialDailyControllerState(16+ input: DailyControllerStateInput,17+): DailyControllerState {18+ if (input.date === input.today) {19+ const topStack = Math.max(20+ input.selectedStackIndex,21+ input.playableStackIndex,22+ );23+ return {24+ selectedStack: topStack,25+ playableStack: input.playableStackIndex,26+ visibleMaxStack: Math.max(input.visibleMaxStack, topStack),27+ };28+ }29+30+ return {31+ selectedStack: input.selectedStackIndex,32+ playableStack: input.playableStackIndex,33+ visibleMaxStack: input.visibleMaxStack,34+ };35+}@@ -0,0 +1,69 @@
1+export interface DayStackStateInput {2+ date: string;3+ today: string;4+ stackIndex: number;5+ playableStackIndex: number;6+ mostRecentStackIndex: number;7+ isReplay: boolean;8+}9+10+export interface DayStackState {11+ mode: "playable" | "readonly" | "locked";12+ message: string;13+ canReset: boolean;14+}15+16+export interface InitialStackIndexInput {17+ date: string;18+ today: string;19+ selectedStackIndex: number;20+ playableStackIndex: number;21+}22+23+export function getInitialStackIndex(input: InitialStackIndexInput): number {24+ return input.date === input.today25+ ? Math.max(input.selectedStackIndex, input.playableStackIndex)26+ : input.selectedStackIndex;27+}28+29+export function resolveDayStackState(30+ input: DayStackStateInput,31+): DayStackState {32+ if (input.date > input.today) {33+ return {34+ mode: "locked",35+ message: "Locked until this day.",36+ canReset: false,37+ };38+ }39+40+ if (input.date < input.today) {41+ if (input.isReplay) {42+ return { mode: "playable", message: "Replay mode.", canReset: true };43+ }44+45+ return {46+ mode: "readonly",47+ message: "Past day. View only.",48+ canReset: true,49+ };50+ }51+52+ if (input.stackIndex > input.playableStackIndex) {53+ return {54+ mode: "locked",55+ message: `Locked. Finish stack ${input.playableStackIndex} first.`,56+ canReset: false,57+ };58+ }59+60+ if (input.stackIndex < input.playableStackIndex) {61+ return {62+ mode: "readonly",63+ message: "Earlier stack. View only.",64+ canReset: false,65+ };66+ }67+68+ return { mode: "playable", message: "Drag. One clue.", canReset: true };69+}@@ -1,8 +1,7 @@
11 import { getPlayableStackIndex } from "@/lib/storage/local_progress.ts";22 3-export function buildDailyPath(date?: string, streakIndex?: number): string {3+export function buildDailyPath(date?: string, _stackIndex?: number): string {44 if (!date) return "/d";5- if (typeof streakIndex === "number") return `/d/${date}/${streakIndex}`;65 return `/d/${date}`;76 }87 @@ -15,14 +14,15 @@ export function buildShareDailyStackText(
1514 date: string,1615 stackIndex: number,1716 ): string {18- return `Daily stack · ${origin}${buildDailyPath(date, stackIndex)}`;17+ return `Stack ${stackIndex + 1}\n${origin}${buildDailyPath(date)}`;1918 }2019 2120 export function resolvePlayableDailyPath(2221 storage: Pick<Storage, "length" | "key" | "getItem">,2322 date: string,2423 ): string {25- return buildDailyPath(date, getPlayableStackIndex(storage, date));24+ getPlayableStackIndex(storage, date);25+ return buildDailyPath(date);2626 }2727 2828 export function isDailyPathname(pathname: string): boolean {@@ -80,6 +80,44 @@ export function getPlayableStackIndex(
8080 return playableStackIndex;8181 }8282 83+export function getMostRecentStackIndex(84+ storage: Pick<Storage, "length" | "key" | "getItem">,85+ date: string,86+): number {87+ let latest = { stackIndex: 0, updatedAt: "" };88+89+ for (const progress of listDailyProgress(storage, date)) {90+ if (progress.updatedAt >= latest.updatedAt) {91+ latest = {92+ stackIndex: progress.stackIndex,93+ updatedAt: progress.updatedAt,94+ };95+ }96+ }97+98+ return latest.stackIndex;99+}100+101+export function getVisibleStackRange(102+ storage: Pick<Storage, "length" | "key" | "getItem">,103+ date: string,104+): { min: number; max: number } {105+ let max = 0;106+107+ for (const progress of listDailyProgress(storage, date)) {108+ max = Math.max(max, progress.stackIndex);109+ }110+111+ return { min: 0, max };112+}113+114+export function getSelectedStackIndex(115+ storage: Pick<Storage, "length" | "key" | "getItem">,116+ date: string,117+): number {118+ return getVisibleStackRange(storage, date).max;119+}120+83121 function listDailyProgress(84122 storage: Pick<Storage, "length" | "key" | "getItem">,85123 date: string,@@ -1,6 +1,17 @@
1-import { buildDailyPath } from "@/lib/site/paths.ts";1+import { DAILY_STREAK_START_DATE } from "@/lib/shikaku/catalog.ts";22 import { define } from "../../utils.ts";3+import DailyDateController from "../../islands/DailyDateController.tsx";34 45 export const handler = define.handlers((ctx) => {5- return ctx.redirect(buildDailyPath(ctx.params.date, 0));6+ const date = ctx.params.date;7+8+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || date < DAILY_STREAK_START_DATE) {9+ return new Response("Puzzle not found", { status: 404 });10+ }11+12+ return { data: { date } };13+});14+15+export default define.page<typeof handler>(({ data }) => {16+ return <DailyDateController date={data.date} />;617 });@@ -1,110 +1,6 @@
1-import { dateToSlug, isPastDate } from "@/lib/date/daily.ts";2-import { getPuzzleByDateIndex } from "@/lib/shikaku/catalog.ts";3-import { DAILY_STREAK_START_DATE } from "@/lib/shikaku/catalog.ts";41 import { buildDailyPath } from "@/lib/site/paths.ts";52 import { define } from "../../../utils.ts";6-import ShikakuGame from "../../../islands/ShikakuGame.tsx";73 84 export const handler = define.handlers((ctx) => {9- const date = ctx.params.date;10- const streakIndex = Number(ctx.params.index);11- const today = dateToSlug(new Date());12-13- if (14- !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isInteger(streakIndex) ||15- streakIndex < 016- ) {17- return new Response("Puzzle not found", { status: 404 });18- }19-20- if (date < DAILY_STREAK_START_DATE) {21- return new Response("Puzzle not found", { status: 404 });22- }23-24- const puzzle = getPuzzleByDateIndex(date, streakIndex);25- if (!puzzle) {26- return new Response("Puzzle not found", { status: 404 });27- }28-29- const previousDate = isPastDate(DAILY_STREAK_START_DATE, date)30- ? shiftDate(date, -1)31- : null;32- const nextDate = date < today ? shiftDate(date, 1) : null;33-34- return {35- data: {36- puzzle,37- previousDate,38- nextDate,39- today,40- },41- };5+ return ctx.redirect(buildDailyPath(ctx.params.date));426 });43-44-export default define.page<typeof handler>(({ data }) => {45- return (46- <main class="shikaku-play-shell">47- <div class="shikaku-play-frame">48- <header class="shikaku-play-header">49- <div class="shikaku-play-header__row">50- <div class="shikaku-play-day-nav shikaku-play-day-nav--with-home">51- <a52- class="shikaku-play-nav shikaku-play-nav--icon"53- href="/"54- aria-label="Back home"55- >56- ⌂57- </a>58- {data.previousDate59- ? (60- <a61- class="shikaku-play-nav shikaku-play-nav--icon"62- href={buildDailyPath(data.previousDate, 0)}63- aria-label="Previous day"64- >65- ‹66- </a>67- )68- : (69- <span class="shikaku-play-nav shikaku-play-nav--icon is-disabled">70- ‹71- </span>72- )}73- <div>74- <p class="shikaku-play-kicker">75- Daily stack · {data.puzzle.streakIndex}76- </p>77- <h1 class="shikaku-play-title">{data.puzzle.date}</h1>78- </div>79- {data.nextDate80- ? (81- <a82- class="shikaku-play-nav shikaku-play-nav--icon"83- href={buildDailyPath(data.nextDate, 0)}84- aria-label="Next day"85- >86- ›87- </a>88- )89- : (90- <span class="shikaku-play-nav shikaku-play-nav--icon is-disabled">91- ›92- </span>93- )}94- </div>95- </div>96- <p class="shikaku-play-hint">Drag. One clue.</p>97- </header>98-99- <ShikakuGame puzzle={data.puzzle} />100- </div>101- </main>102- );103-});104-105-function shiftDate(date: string, deltaDays: number): string {106- const [year, month, day] = date.split("-").map(Number);107- const value = new Date(year, month - 1, day);108- value.setDate(value.getDate() + deltaDays);109- return dateToSlug(value);110-}@@ -2,5 +2,5 @@ import { buildDailyPath } from "@/lib/site/paths.ts";
22 import { define } from "../../utils.ts";33 44 export const handler = define.handlers((ctx) => {5- return ctx.redirect(buildDailyPath(ctx.params.date, 0));5+ return ctx.redirect(buildDailyPath(ctx.params.date));66 });@@ -1,6 +1,7 @@
11 import { listAvailablePuzzleDates } from "@/lib/shikaku/catalog.ts";22 import { define } from "../utils.ts";33 import DailyStackLink from "../islands/DailyStackLink.tsx";4+import HomeDateStackEyebrow from "../islands/HomeDateStackEyebrow.tsx";45 import OfflineBadge from "../islands/OfflineBadge.tsx";56 import ShareDailyStackButton from "../islands/ShareDailyStreakButton.tsx";67 @@ -18,7 +19,7 @@ export default define.page(function Home() {
1819 1920 <section class="shikaku-home__hero">2021 <div class="shikaku-home__hero-copy">21- <div class="shikaku-home__eyebrow">SEED {latest}</div>22+ <HomeDateStackEyebrow fallbackDate={latest} />2223 <h1 class="shikaku-home__title">RECT.RUN</h1>2324 <div class="shikaku-home__speech-stack" aria-label="Puzzle pitch">2425 <p class="shikaku-home__speech">Daily grid.</p>@@ -0,0 +1,37 @@
1+import { assertEquals } from "@std/assert";2+3+import { buildInitialDailyControllerState } from "@/lib/shikaku/daily_controller_state.ts";4+5+Deno.test("buildInitialDailyControllerState opens today's top unlocked stack", () => {6+ assertEquals(7+ buildInitialDailyControllerState({8+ date: "2026-04-17",9+ today: "2026-04-17",10+ selectedStackIndex: 0,11+ playableStackIndex: 2,12+ visibleMaxStack: 1,13+ }),14+ {15+ selectedStack: 2,16+ playableStack: 2,17+ visibleMaxStack: 2,18+ },19+ );20+});21+22+Deno.test("buildInitialDailyControllerState keeps past dates on their saved top stack", () => {23+ assertEquals(24+ buildInitialDailyControllerState({25+ date: "2026-04-16",26+ today: "2026-04-17",27+ selectedStackIndex: 2,28+ playableStackIndex: 0,29+ visibleMaxStack: 2,30+ }),31+ {32+ selectedStack: 2,33+ playableStack: 0,34+ visibleMaxStack: 2,35+ },36+ );37+});@@ -0,0 +1,100 @@
1+import { assertEquals } from "@std/assert";2+3+import {4+ getInitialStackIndex,5+ resolveDayStackState,6+} from "@/lib/shikaku/day_stack_state.ts";7+8+Deno.test("resolveDayStackState allows play on today's unlocked stack", () => {9+ assertEquals(10+ resolveDayStackState({11+ date: "2026-04-16",12+ today: "2026-04-16",13+ stackIndex: 2,14+ playableStackIndex: 2,15+ mostRecentStackIndex: 2,16+ isReplay: false,17+ }),18+ { mode: "playable", message: "Drag. One clue.", canReset: true },19+ );20+});21+22+Deno.test("resolveDayStackState keeps earlier stacks read-only on today", () => {23+ assertEquals(24+ resolveDayStackState({25+ date: "2026-04-16",26+ today: "2026-04-16",27+ stackIndex: 1,28+ playableStackIndex: 3,29+ mostRecentStackIndex: 3,30+ isReplay: false,31+ }),32+ { mode: "readonly", message: "Earlier stack. View only.", canReset: false },33+ );34+});35+36+Deno.test("resolveDayStackState locks future days", () => {37+ assertEquals(38+ resolveDayStackState({39+ date: "2026-04-17",40+ today: "2026-04-16",41+ stackIndex: 0,42+ playableStackIndex: 0,43+ mostRecentStackIndex: 0,44+ isReplay: false,45+ }),46+ { mode: "locked", message: "Locked until this day.", canReset: false },47+ );48+});49+50+Deno.test("resolveDayStackState keeps past days readonly until replay is requested", () => {51+ assertEquals(52+ resolveDayStackState({53+ date: "2026-04-15",54+ today: "2026-04-16",55+ stackIndex: 2,56+ playableStackIndex: 0,57+ mostRecentStackIndex: 2,58+ isReplay: false,59+ }),60+ { mode: "readonly", message: "Past day. View only.", canReset: true },61+ );62+});63+64+Deno.test("resolveDayStackState allows replay after reset on a past day", () => {65+ assertEquals(66+ resolveDayStackState({67+ date: "2026-04-15",68+ today: "2026-04-16",69+ stackIndex: 2,70+ playableStackIndex: 0,71+ mostRecentStackIndex: 2,72+ isReplay: true,73+ }),74+ { mode: "playable", message: "Replay mode.", canReset: true },75+ );76+});77+78+Deno.test("getInitialStackIndex loads the unlocked top stack for today", () => {79+ assertEquals(80+ getInitialStackIndex({81+ date: "2026-04-17",82+ today: "2026-04-17",83+ selectedStackIndex: 0,84+ playableStackIndex: 2,85+ }),86+ 2,87+ );88+});89+90+Deno.test("getInitialStackIndex keeps the saved top stack for past days", () => {91+ assertEquals(92+ getInitialStackIndex({93+ date: "2026-04-16",94+ today: "2026-04-17",95+ selectedStackIndex: 2,96+ playableStackIndex: 0,97+ }),98+ 2,99+ );100+});@@ -0,0 +1,14 @@
1+import { assertEquals } from "@std/assert";2+3+import { shouldReloadSavedProgress } from "@/lib/shikaku/game_session.ts";4+5+Deno.test("shouldReloadSavedProgress only reloads when the puzzle changes", () => {6+ assertEquals(7+ shouldReloadSavedProgress("daily-2026-04-16-0", "daily-2026-04-16-0"),8+ false,9+ );10+ assertEquals(11+ shouldReloadSavedProgress("daily-2026-04-16-0", "daily-2026-04-16-1"),12+ true,13+ );14+});@@ -5,33 +5,11 @@ import {
55 buildLegacyDailyPath,66 buildShareDailyStackText,77 isDailyPathname,8- resolvePlayableDailyPath,98 } from "@/lib/site/paths.ts";109 11-class MemoryStorage implements Pick<Storage, "length" | "key" | "getItem"> {12- #data = new Map<string, string>();13-14- get length(): number {15- return this.#data.size;16- }17-18- getItem(key: string): string | null {19- return this.#data.get(key) ?? null;20- }21-22- key(index: number): string | null {23- return [...this.#data.keys()][index] ?? null;24- }25-26- set(key: string, value: string): void {27- this.#data.set(key, value);28- }29-}30-31-Deno.test("buildDailyPath uses the /d/date/index format", () => {10+Deno.test("buildDailyPath uses the /d/date format without stack indexes", () => {3211 assertEquals(buildDailyPath(), "/d");33- assertEquals(buildDailyPath("2026-04-17", 0), "/d/2026-04-17/0");34- assertEquals(buildDailyPath("2026-04-17", 4), "/d/2026-04-17/4");12+ assertEquals(buildDailyPath("2026-04-17"), "/d/2026-04-17");3513 });3614 3715 Deno.test("buildLegacyDailyPath keeps /daily redirects available", () => {@@ -39,45 +17,17 @@ Deno.test("buildLegacyDailyPath keeps /daily redirects available", () => {
3917 assertEquals(buildLegacyDailyPath("2026-04-17"), "/daily/2026-04-17");4018 });4119 42-Deno.test("buildShareDailyStackText returns a plain share string", () => {43- assertEquals(44- buildShareDailyStackText("https://rect.run", "2026-04-17", 0),45- "Daily stack · https://rect.run/d/2026-04-17/0",46- );47-});48-49-Deno.test("resolvePlayableDailyPath uses the unlocked stack for the day", () => {50- const storage = new MemoryStorage();51- storage.set(52- "shikaku:v1:progress:daily-2026-04-17-0",53- JSON.stringify({54- date: "2026-04-17",55- puzzleId: "daily-2026-04-17-0",56- startedAt: "2026-04-17T10:00:00.000Z",57- updatedAt: "2026-04-17T10:03:00.000Z",58- status: "solved",59- elapsedMs: 180000,60- mistakes: 0,61- selections: [],62- notes: {},63- lastViewedAt: "2026-04-17T10:03:00.000Z",64- clientVersion: 1,65- }),66- );67-68- assertEquals(69- resolvePlayableDailyPath(storage, "2026-04-17"),70- "/d/2026-04-17/1",71- );20+Deno.test("buildShareDailyStackText returns compact text plus a link on a new line", () => {7221 assertEquals(73- resolvePlayableDailyPath(storage, "2026-04-18"),74- "/d/2026-04-18/0",22+ buildShareDailyStackText("https://rect.run", "2026-04-17", 3),23+ "Stack 4\nhttps://rect.run/d/2026-04-17",7524 );7625 });7726 78-Deno.test("isDailyPathname recognizes both current and legacy daily routes", () => {27+Deno.test("isDailyPathname recognizes date-only daily routes", () => {7928 assertEquals(isDailyPathname("/d"), true);80- assertEquals(isDailyPathname("/d/2026-04-17/0"), true);29+ assertEquals(isDailyPathname("/d/2026-04-17"), true);30+ assertEquals(isDailyPathname("/d/2026-04-17/3"), true);8131 assertEquals(isDailyPathname("/daily"), true);8232 assertEquals(isDailyPathname("/daily/2026-04-17"), true);8333 assertEquals(isDailyPathname("/"), false);@@ -5,7 +5,10 @@ import {
55 buildHistoryKey,66 buildProgressKey,77 getAchievedStackIndex,8+ getMostRecentStackIndex,89 getPlayableStackIndex,10+ getSelectedStackIndex,11+ getVisibleStackRange,912 loadPuzzleProgress,1013 savePuzzleProgress,1114 } from "@/lib/storage/local_progress.ts";@@ -61,6 +64,21 @@ function createProgress(): PuzzleProgress {
6164 };6265 }6366 67+function createProgressFor(68+ puzzleId: string,69+ status: PuzzleProgress["status"],70+ updatedAt: string,71+): PuzzleProgress {72+ return {73+ ...createProgress(),74+ date: puzzleId.split("-").slice(1, 4).join("-"),75+ puzzleId,76+ status,77+ updatedAt,78+ lastViewedAt: updatedAt,79+ };80+}81+6482 Deno.test("buildProgressKey and buildHistoryKey use versioned storage keys", () => {6583 assertEquals(6684 buildProgressKey("daily-2026-04-16-0"),@@ -172,3 +190,98 @@ Deno.test("getPlayableStackIndex ignores non-contiguous solved entries", () => {
172190 173191 assertEquals(getPlayableStackIndex(storage, "2026-04-16"), 0);174192 });193+194+Deno.test("getMostRecentStackIndex returns the last touched stack for a date", () => {195+ const storage = new MemoryStorage();196+197+ savePuzzleProgress(198+ storage,199+ createProgressFor(200+ "daily-2026-04-16-0",201+ "solved",202+ "2026-04-16T10:03:00.000Z",203+ ),204+ );205+ savePuzzleProgress(206+ storage,207+ createProgressFor(208+ "daily-2026-04-16-2",209+ "in_progress",210+ "2026-04-16T10:07:00.000Z",211+ ),212+ );213+214+ assertEquals(getMostRecentStackIndex(storage, "2026-04-16"), 2);215+ assertEquals(getMostRecentStackIndex(storage, "2026-04-17"), 0);216+});217+218+Deno.test("getVisibleStackRange exposes the highest touched stack for date navigation", () => {219+ const storage = new MemoryStorage();220+221+ savePuzzleProgress(222+ storage,223+ createProgressFor(224+ "daily-2026-04-16-0",225+ "solved",226+ "2026-04-16T10:03:00.000Z",227+ ),228+ );229+ savePuzzleProgress(230+ storage,231+ createProgressFor(232+ "daily-2026-04-16-3",233+ "in_progress",234+ "2026-04-16T10:07:00.000Z",235+ ),236+ );237+238+ assertEquals(getVisibleStackRange(storage, "2026-04-16"), { min: 0, max: 3 });239+ assertEquals(getVisibleStackRange(storage, "2026-04-17"), { min: 0, max: 0 });240+});241+242+Deno.test("getSelectedStackIndex returns the top stack for a date", () => {243+ const storage = new MemoryStorage();244+245+ savePuzzleProgress(246+ storage,247+ createProgressFor(248+ "daily-2026-04-16-0",249+ "solved",250+ "2026-04-16T10:03:00.000Z",251+ ),252+ );253+ savePuzzleProgress(254+ storage,255+ createProgressFor(256+ "daily-2026-04-16-2",257+ "in_progress",258+ "2026-04-16T10:07:00.000Z",259+ ),260+ );261+262+ assertEquals(getSelectedStackIndex(storage, "2026-04-16"), 2);263+ assertEquals(getSelectedStackIndex(storage, "2026-04-17"), 0);264+});265+266+Deno.test("getSelectedStackIndex prefers the highest visible stack over a lower recent one", () => {267+ const storage = new MemoryStorage();268+269+ savePuzzleProgress(270+ storage,271+ createProgressFor(272+ "daily-2026-04-16-1",273+ "in_progress",274+ "2026-04-16T10:09:00.000Z",275+ ),276+ );277+ savePuzzleProgress(278+ storage,279+ createProgressFor(280+ "daily-2026-04-16-2",281+ "solved",282+ "2026-04-16T10:05:00.000Z",283+ ),284+ );285+286+ assertEquals(getSelectedStackIndex(storage, "2026-04-16"), 2);287+});