clone

a daily shikaku puzzle for phones: one seeded puzzle per date, solved by dragging rectangles

main
rect.run / islands / DailyDateController.tsx
DailyDateController.tsx 6.2K
1import { useSignal } from "@preact/signals";2import { useEffect, useMemo } from "preact/hooks";34import { dateToSlug } from "@/lib/date/daily.ts";5import { buildInitialDailyControllerState } from "@/lib/shikaku/daily_controller_state.ts";6import { resolveDayStackState } from "@/lib/shikaku/day_stack_state.ts";7import { getPuzzleByDateIndex } from "@/lib/shikaku/catalog.ts";8import {9 buildProgressKey,10 getPlayableStackIndex,11 getSelectedStackIndex,12 getVisibleStackRange,13} from "@/lib/storage/local_progress.ts";14import DeltaMark from "../components/DeltaMark.tsx";15import ShikakuGame from "./ShikakuGame.tsx";1617interface DailyDateControllerProps {18 date: string;19}2021export default function DailyDateController(22 { date }: DailyDateControllerProps,23) {24 const today = useSignal(date);25 const selectedStack = useSignal(0);26 const playableStack = useSignal(0);27 const visibleMaxStack = useSignal(0);28 const replayStack = useSignal<number | null>(null);29 const refreshKey = useSignal(0);3031 useEffect(() => {32 if (typeof globalThis.localStorage === "undefined") return;33 const nextToday = dateToSlug(new Date());34 const nextSelected = getSelectedStackIndex(globalThis.localStorage, date);35 const nextPlayable = getPlayableStackIndex(globalThis.localStorage, date);36 const nextVisibleMax =37 getVisibleStackRange(globalThis.localStorage, date).max;38 const initialState = buildInitialDailyControllerState({39 date,40 today: nextToday,41 selectedStackIndex: nextSelected,42 playableStackIndex: nextPlayable,43 visibleMaxStack: nextVisibleMax,44 });4546 today.value = nextToday;47 playableStack.value = initialState.playableStack;48 visibleMaxStack.value = initialState.visibleMaxStack;49 selectedStack.value = initialState.selectedStack;50 replayStack.value = null;51 refreshKey.value += 1;52 }, [date]);5354 const highestStack = useMemo(() => {55 if (date > today.value) return 0;56 if (date < today.value) {57 return Math.max(visibleMaxStack.value, selectedStack.value);58 }59 return Math.max(visibleMaxStack.value, playableStack.value);60 }, [61 date,62 today.value,63 visibleMaxStack.value,64 selectedStack.value,65 playableStack.value,66 ]);6768 const puzzle = useMemo(69 () => getPuzzleByDateIndex(date, selectedStack.value),70 [date, selectedStack.value],71 );7273 const dayState = useMemo(74 () =>75 resolveDayStackState({76 date,77 today: today.value,78 stackIndex: selectedStack.value,79 playableStackIndex: playableStack.value,80 mostRecentStackIndex: selectedStack.value,81 isReplay: replayStack.value === selectedStack.value,82 }),83 [84 date,85 today.value,86 selectedStack.value,87 playableStack.value,88 replayStack.value,89 ],90 );9192 if (!puzzle) {93 return (94 <main class="play">95 <div class="play__frame">96 <header class="play__header panel play__notfound">97 <p class="px">Puzzle not found.</p>98 </header>99 </div>100 </main>101 );102 }103104 const currentPuzzle = puzzle;105106 function syncFromStorage() {107 if (typeof globalThis.localStorage === "undefined") return;108 playableStack.value = getPlayableStackIndex(globalThis.localStorage, date);109 visibleMaxStack.value = Math.max(110 getVisibleStackRange(globalThis.localStorage, date).max,111 selectedStack.value,112 );113 }114115 function handleReplayReset() {116 if (typeof globalThis.localStorage !== "undefined") {117 globalThis.localStorage.removeItem(buildProgressKey(currentPuzzle.id));118 }119 replayStack.value = selectedStack.value;120 refreshKey.value += 1;121 syncFromStorage();122 }123124 function handleSolved() {125 replayStack.value = null;126 playableStack.value = Math.max(127 playableStack.value,128 selectedStack.value + 1,129 );130 visibleMaxStack.value = Math.max(131 visibleMaxStack.value,132 selectedStack.value + 1,133 );134 }135136 const canGoDown = selectedStack.value > 0;137 const canGoUp = selectedStack.value < highestStack;138139 return (140 <main class="play">141 <div class="play__frame">142 <header class="play__header panel">143 <a144 class="play__home btn btn--ghost btn--sm btn--icon"145 href="/"146 aria-label="Back home"147 >148 <DeltaMark size={20} ring="black" />149 </a>150 <button151 type="button"152 class={`btn btn--ghost btn--sm btn--icon${153 canGoDown ? "" : " is-disabled"154 }`}155 aria-label="Previous floor"156 disabled={!canGoDown}157 onClick={() => {158 selectedStack.value -= 1;159 replayStack.value = null;160 }}161 >162 ‹163 </button>164 <div class="play__day">165 <span class="label">Floor {selectedStack.value + 1}</span>166 <h1 class="play__date">{formatDate(date)}</h1>167 </div>168 <button169 type="button"170 class={`btn btn--ghost btn--sm btn--icon${171 canGoUp ? "" : " is-disabled"172 }`}173 aria-label="Next floor"174 disabled={!canGoUp}175 onClick={() => {176 selectedStack.value += 1;177 replayStack.value = null;178 }}179 >180 ›181 </button>182 <p class="play__hint">{dayState.message}</p>183 </header>184185 <ShikakuGame186 key={`${currentPuzzle.id}:${refreshKey.value}`}187 puzzle={currentPuzzle}188 readOnly={dayState.mode !== "playable"}189 resetEnabled={dayState.canReset}190 lockedMessage={dayState.message}191 initialMessage={dayState.message}192 solvedMessage={date === today.value &&193 selectedStack.value === playableStack.value194 ? "Floor cleared. Next floor unlocked."195 : "Floor cleared."}196 onReset={dayState.mode !== "playable" ? handleReplayReset : undefined}197 onSolved={handleSolved}198 />199 </div>200 </main>201 );202}203204function formatDate(date: string): string {205 const [year, month, day] = date.split("-").map(Number);206 return new Intl.DateTimeFormat("en-US", {207 month: "short",208 day: "numeric",209 year: "numeric",210 }).format(new Date(year, month - 1, day)).toUpperCase();211}