clone

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

Add daily stack progression and fix mobile play

Dami committed 53c64f2
commit
53c64f2dbdea0e54a3a32a8afcdf34b7b7f785ce
parent
0da5b0c12fecd85e9d51198db15fc8d9e94bddd6
author
Dami <dami.etoile@gmail.com>
20 files changed, +692 -130
Massets/styles.css20+++++
Aislands/DailyStackLink.tsx34+++++++++
Aislands/DailyStackRedirect.tsx57+++++++++++++++
Aislands/ShareDailyStreakButton.tsx55+++++++++++++++
Mislands/ShikakuGame.tsx111++++++++++++++++++++++++++++--
Mlib/shikaku/catalog.ts38+++++++++-
Mlib/shikaku/generator.ts9++-
Mlib/shikaku/types.ts1+
Mlib/site/paths.ts23++++++-
Mlib/storage/local_progress.ts76+++++++++++++++++++--
Mroutes/d/[date].tsx66+------------------
Aroutes/d/[date]/[index].tsx110++++++++++++++++++++++++++++++
Mroutes/d/index.tsx15+---
Mroutes/daily/[date].tsx2+-
Mroutes/index.tsx12+--
Mtests/lib/shikaku/catalog_test.ts36+++++++---
Mtests/lib/shikaku/engine_test.ts5+-
Mtests/lib/shikaku/generator_test.ts9+-
Mtests/lib/site/paths_test.ts65+++++++++++++++++-
Mtests/lib/storage/local_progress_test.ts78+++++++++++++++++++--
M assets/styles.css +20 -0
A islands/DailyStackLink.tsx +34 -0
A islands/DailyStackRedirect.tsx +57 -0
A islands/ShareDailyStreakButton.tsx +55 -0
M islands/ShikakuGame.tsx +105 -6
@@ -1,6 +1,8 @@
11 import { useSignal } from "@preact/signals";22 import { useEffect, useMemo, useRef } from "preact/hooks";33 4+import { dateToSlug } from "@/lib/date/daily.ts";5+46 import {57 createRectangleFromCorners,68 getRectangleCells,
@@ -15,9 +17,11 @@ import {
1517 getStatusTone,1618 } from "@/lib/shikaku/play_state.ts";1719 import {20+ getPlayableStackIndex,1821 loadPuzzleProgress,1922 savePuzzleProgress,2023 } from "@/lib/storage/local_progress.ts";24+import { buildDailyPath } from "@/lib/site/paths.ts";2125 import type {2226 GridPoint,2327 PlacedRectangle,
@@ -27,6 +31,7 @@ import type {
2731 2832 interface ShikakuGameProps {2933 puzzle: ShikakuPuzzle;34+ readOnly?: boolean;3035 }3136 3237 const COLORS = [
@@ -40,7 +45,9 @@ const COLORS = [
4045 "#a7f3d0",4146 ];4247 43-export default function ShikakuGame({ puzzle }: ShikakuGameProps) {48+export default function ShikakuGame(49+ { puzzle, readOnly = false }: ShikakuGameProps,50+) {4451 const boardRef = useRef<HTMLDivElement>(null);4552 const rectangles = useSignal<PlacedRectangle[]>([]);4653 const activeClueId = useSignal<number | null>(null);
@@ -52,9 +59,12 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
5259 "Drag to size a rectangle. Release when it encloses one clue.",5360 );5461 const hydrated = useSignal(false);62+ const effectiveReadOnly = useSignal(readOnly);63+ const playableStackIndex = useSignal(puzzle.streakIndex);64+ const localToday = useSignal(puzzle.date);5565 5666 useEffect(() => {57- const saved = loadPuzzleProgress(globalThis.localStorage, puzzle.date);67+ const saved = loadPuzzleProgress(globalThis.localStorage, puzzle.id);5868 if (saved) {5969 rectangles.value = selectionsToRectangles(saved, puzzle);6070 status.value = saved.status === "solved" ? "solved" : "in_progress";
@@ -63,7 +73,7 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
6373 : "Resumed from local storage.";6474 }6575 hydrated.value = true;66- }, [puzzle.date]);76+ }, [puzzle.date, puzzle.id]);6777 6878 useEffect(() => {6979 if (!hydrated.value) return;
@@ -88,6 +98,43 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
8898 savePuzzleProgress(globalThis.localStorage, progress);8999 }, [puzzle.date, rectangles.value, status.value, hydrated.value]);90100 101+ 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.";116+ return;117+ }118+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+ ]);137+91138 const clueEntries = useMemo(() => {92139 return puzzle.givens.map((given, index) => {93140 const rectangle = rectangles.value.find((item) =>
@@ -207,7 +254,22 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
207254 message.value = "Rectangle placed.";208255 }209256 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+210266 function handleCellPointerDown(point: GridPoint, pointerId: number) {267+ if (effectiveReadOnly.value) {268+ message.value = getLockedMessage();269+ clearDrag();270+ return;271+ }272+211273 const containing = getContainingRectangle(point);212274 if (containing) {213275 if (!canRemovePlacedRectangle(status.value)) {
@@ -229,6 +291,7 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
229291 }230292 231293 function handleBoardPointerDown(event: PointerEvent) {294+ if (effectiveReadOnly.value) return;232295 event.preventDefault();233296 const point = getBoardPointFromPointer(event);234297 if (!point) return;
@@ -238,12 +301,14 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
238301 }239302 240303 function handleBoardPointerMove(event: PointerEvent) {304+ if (effectiveReadOnly.value) return;241305 const point = getBoardPointFromPointer(event);242306 if (!point) return;243307 updateDrag(point, event.pointerId);244308 }245309 246310 function handleBoardPointerUp(event: PointerEvent) {311+ if (effectiveReadOnly.value) return;247312 event.preventDefault();248313 const point = getBoardPointFromPointer(event);249314 if (!point) {
@@ -256,17 +321,29 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
256321 }257322 258323 function handleBoardPointerCancel(event: PointerEvent) {324+ if (effectiveReadOnly.value) return;259325 clearDrag();260326 boardRef.current?.releasePointerCapture?.(event.pointerId);261327 }262328 263329 function clearBoard() {330+ if (effectiveReadOnly.value) {331+ message.value = getLockedMessage();332+ return;333+ }334+264335 rectangles.value = [];265336 clearDrag();266337 status.value = "in_progress";267338 message.value = "Board cleared.";268339 }269340 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+270347 return (271348 <section class="shikaku-game-shell">272349 <div
@@ -280,9 +357,27 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
280357 </strong>281358 <span>{message}</span>282359 </div>283- <button class="shikaku-game-clear" type="button" onClick={clearBoard}>284- Reset285- </button>360+ <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}371+ <button372+ class="shikaku-game-clear"373+ type="button"374+ onClick={clearBoard}375+ disabled={effectiveReadOnly.value}376+ aria-disabled={effectiveReadOnly.value}377+ >378+ Reset379+ </button>380+ </div>286381 </div>287382 288383 <div class="shikaku-game-board-wrap">
@@ -350,6 +445,10 @@ export default function ShikakuGame({ puzzle }: ShikakuGameProps) {
350445 }`}351446 style={{ ["--token-color" as string]: entry.color }}352447 onClick={() => {448+ if (effectiveReadOnly.value) {449+ message.value = getLockedMessage();450+ return;451+ }353452 activeClueId.value = entry.given.id;354453 message.value = `Clue ${entry.given.value} highlighted on board.`;355454 }}
M lib/shikaku/catalog.ts +34 -4
M lib/shikaku/generator.ts +7 -2
M lib/shikaku/types.ts +1 -0
M lib/site/paths.ts +21 -2
M lib/storage/local_progress.ts +70 -6
@@ -6,8 +6,8 @@ import {
66 77 const STORAGE_NAMESPACE = "shikaku:v1";88 9-export function buildProgressKey(date: string): string {10- return `${STORAGE_NAMESPACE}:progress:${date}`;9+export function buildProgressKey(puzzleId: string): string {10+ return `${STORAGE_NAMESPACE}:progress:${puzzleId}`;1111 }1212 1313 export function buildHistoryKey(): string {
@@ -16,9 +16,9 @@ export function buildHistoryKey(): string {
1616 1717 export function loadPuzzleProgress(1818 storage: Pick<Storage, "getItem">,19- date: string,19+ puzzleId: string,2020 ): PuzzleProgress | null {21- const raw = storage.getItem(buildProgressKey(date));21+ const raw = storage.getItem(buildProgressKey(puzzleId));2222 if (raw === null) return null;2323 2424 try {
@@ -32,7 +32,10 @@ export function savePuzzleProgress(
3232 storage: Pick<Storage, "setItem">,3333 progress: PuzzleProgress,3434 ): void {35- storage.setItem(buildProgressKey(progress.date), JSON.stringify(progress));35+ storage.setItem(36+ buildProgressKey(progress.puzzleId),37+ JSON.stringify(progress),38+ );3639 }3740 3841 export function archiveProgress(
@@ -42,7 +45,68 @@ export function archiveProgress(
4245 const existing = loadHistory(storage);4346 existing.push(entry);4447 storage.setItem(buildHistoryKey(), JSON.stringify(existing));45- storage.removeItem(buildProgressKey(entry.date));48+ storage.removeItem(buildProgressKey(entry.puzzleId));49+}50+51+export function getAchievedStackIndex(52+ storage: Pick<Storage, "length" | "key" | "getItem">,53+ date: string,54+): number {55+ let highestSolvedIndex = -1;56+57+ for (const progress of listDailyProgress(storage, date)) {58+ if (progress.status !== "solved") continue;59+ highestSolvedIndex = Math.max(highestSolvedIndex, progress.stackIndex);60+ }61+62+ return Math.max(highestSolvedIndex, 0);63+}64+65+export function getPlayableStackIndex(66+ storage: Pick<Storage, "length" | "key" | "getItem">,67+ date: string,68+): number {69+ const progressByIndex = new Map<number, PuzzleProgress["status"]>();70+71+ for (const progress of listDailyProgress(storage, date)) {72+ progressByIndex.set(progress.stackIndex, progress.status);73+ }74+75+ let playableStackIndex = 0;76+ while (progressByIndex.get(playableStackIndex) === "solved") {77+ playableStackIndex += 1;78+ }79+80+ return playableStackIndex;81+}82+83+function listDailyProgress(84+ storage: Pick<Storage, "length" | "key" | "getItem">,85+ date: string,86+): Array<PuzzleProgress & { stackIndex: number }> {87+ const entries: Array<PuzzleProgress & { stackIndex: number }> = [];88+89+ for (let index = 0; index < storage.length; index++) {90+ const key = storage.key(index);91+ if (!key?.startsWith(`${STORAGE_NAMESPACE}:progress:daily-${date}-`)) {92+ continue;93+ }94+95+ const raw = storage.getItem(key);96+ if (!raw) continue;97+98+ try {99+ const progress = parsePuzzleProgress(JSON.parse(raw));100+ if (!progress) continue;101+ const match = progress.puzzleId.match(/-(\d+)$/);102+ if (!match) continue;103+ entries.push({ ...progress, stackIndex: Number(match[1]) });104+ } catch {105+ continue;106+ }107+ }108+109+ return entries;46110 }47111 48112 function loadHistory(storage: Pick<Storage, "getItem">): PuzzleHistoryEntry[] {
M routes/d/[date].tsx +1 -65
@@ -1,70 +1,6 @@
1-import { getPuzzleByDate } from "@/lib/shikaku/catalog.ts";21 import { buildDailyPath } from "@/lib/site/paths.ts";32 import { define } from "../../utils.ts";4-import ShikakuGame from "../../islands/ShikakuGame.tsx";53 64 export const handler = define.handlers((ctx) => {7- const puzzle = getPuzzleByDate(ctx.params.date);8-9- if (!puzzle) {10- return new Response("Puzzle not found", { status: 404 });11- }12-13- const [year, month, day] = puzzle.date.split("-").map(Number);14- const currentDate = new Date(Date.UTC(year, month - 1, day));15- const previousDate = new Date(currentDate);16- previousDate.setUTCDate(previousDate.getUTCDate() - 1);17- const nextDate = new Date(currentDate);18- nextDate.setUTCDate(nextDate.getUTCDate() + 1);19-20- return {21- data: {22- puzzle,23- previousDate: previousDate.toISOString().slice(0, 10),24- nextDate: nextDate.toISOString().slice(0, 10),25- },26- };27-});28-29-export default define.page<typeof handler>(({ data }) => {30- return (31- <main class="shikaku-play-shell">32- <div class="shikaku-play-frame">33- <header class="shikaku-play-header">34- <div class="shikaku-play-header__row">35- <div class="shikaku-play-day-nav shikaku-play-day-nav--with-home">36- <a37- class="shikaku-play-nav shikaku-play-nav--icon"38- href="/"39- aria-label="Back home"40- >41- โŒ‚42- </a>43- <a44- class="shikaku-play-nav shikaku-play-nav--icon"45- href={buildDailyPath(data.previousDate)}46- aria-label="Previous day"47- >48- โ€น49- </a>50- <div>51- <p class="shikaku-play-kicker">Daily puzzle</p>52- <h1 class="shikaku-play-title">{data.puzzle.date}</h1>53- </div>54- <a55- class="shikaku-play-nav shikaku-play-nav--icon"56- href={buildDailyPath(data.nextDate)}57- aria-label="Next day"58- >59- โ€บ60- </a>61- </div>62- </div>63- <p class="shikaku-play-hint">Drag. One clue.</p>64- </header>65-66- <ShikakuGame puzzle={data.puzzle} />67- </div>68- </main>69- );5+ return ctx.redirect(buildDailyPath(ctx.params.date, 0));706 });
A routes/d/[date]/[index].tsx +110 -0
@@ -0,0 +1,110 @@
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";4+import { buildDailyPath } from "@/lib/site/paths.ts";5+import { define } from "../../../utils.ts";6+import ShikakuGame from "../../../islands/ShikakuGame.tsx";7+8+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+ };42+});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+}
M routes/d/index.tsx +5 -10
M routes/daily/[date].tsx +1 -1
M routes/index.tsx +4 -8
M tests/lib/shikaku/catalog_test.ts +25 -11
@@ -1,26 +1,40 @@
1-import { assertEquals, assertExists } from "@std/assert";1+import { assertEquals, assertExists, assertNotEquals } from "@std/assert";22 33 import {4- getPuzzleByDate,4+ getPuzzleByDateIndex,55 listAvailablePuzzleDates,66 } from "@/lib/shikaku/catalog.ts";77 8-Deno.test("getPuzzleByDate returns a deterministic generated puzzle for a valid date", () => {9- const puzzle = getPuzzleByDate("2026-04-16");8+Deno.test("getPuzzleByDateIndex returns a deterministic generated puzzle for a valid date and streak index", () => {9+ const puzzle = getPuzzleByDateIndex("2026-04-16", 0);1010 1111 assertExists(puzzle);12- assertEquals(puzzle.id, "daily-2026-04-16");12+ assertEquals(puzzle.id, "daily-2026-04-16-0");1313 assertEquals(puzzle.width, 8);1414 assertEquals(puzzle.height, 8);15- assertEquals(puzzle.seed, "2026-04-16");15+ assertEquals(puzzle.seed, "2026-04-16:0");16+ assertEquals(puzzle.streakIndex, 0);1617 });1718 18-Deno.test("getPuzzleByDate rejects invalid date slugs", () => {19- assertEquals(getPuzzleByDate("not-a-date"), null);19+Deno.test("getPuzzleByDateIndex changes the puzzle when the streak index changes", () => {20+ const first = getPuzzleByDateIndex("2026-04-16", 0);21+ const second = getPuzzleByDateIndex("2026-04-16", 1);22+23+ assertExists(first);24+ assertExists(second);25+ assertNotEquals(first.seed, second.seed);26+ assertNotEquals(first.id, second.id);27+});28+29+Deno.test("getPuzzleByDateIndex rejects invalid date slugs or negative indexes", () => {30+ assertEquals(getPuzzleByDateIndex("not-a-date", 0), null);31+ assertEquals(getPuzzleByDateIndex("2026-04-16", -1), null);2032 });2133 22-Deno.test("listAvailablePuzzleDates returns today by default", () => {23- assertEquals(listAvailablePuzzleDates(), [24- new Date().toISOString().slice(0, 10),34+Deno.test("listAvailablePuzzleDates returns the streak start date through a provided day", () => {35+ assertEquals(listAvailablePuzzleDates("2026-04-18"), [36+ "2026-04-16",37+ "2026-04-17",38+ "2026-04-18",2539 ]);2640 });
M tests/lib/shikaku/engine_test.ts +4 -1
M tests/lib/shikaku/generator_test.ts +5 -4
M tests/lib/site/paths_test.ts +62 -3
@@ -3,12 +3,35 @@ import { assertEquals } from "@std/assert";
33 import {44 buildDailyPath,55 buildLegacyDailyPath,6+ buildShareDailyStackText,67 isDailyPathname,8+ resolvePlayableDailyPath,79 } from "@/lib/site/paths.ts";810 9-Deno.test("buildDailyPath uses the /d prefix", () => {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", () => {1032 assertEquals(buildDailyPath(), "/d");11- assertEquals(buildDailyPath("2026-04-17"), "/d/2026-04-17");33+ assertEquals(buildDailyPath("2026-04-17", 0), "/d/2026-04-17/0");34+ assertEquals(buildDailyPath("2026-04-17", 4), "/d/2026-04-17/4");1235 });1336 1437 Deno.test("buildLegacyDailyPath keeps /daily redirects available", () => {
@@ -16,9 +39,45 @@ Deno.test("buildLegacyDailyPath keeps /daily redirects available", () => {
1639 assertEquals(buildLegacyDailyPath("2026-04-17"), "/daily/2026-04-17");1740 });1841 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+ );72+ assertEquals(73+ resolvePlayableDailyPath(storage, "2026-04-18"),74+ "/d/2026-04-18/0",75+ );76+});77+1978 Deno.test("isDailyPathname recognizes both current and legacy daily routes", () => {2079 assertEquals(isDailyPathname("/d"), true);21- assertEquals(isDailyPathname("/d/2026-04-17"), true);80+ assertEquals(isDailyPathname("/d/2026-04-17/0"), true);2281 assertEquals(isDailyPathname("/daily"), true);2382 assertEquals(isDailyPathname("/daily/2026-04-17"), true);2483 assertEquals(isDailyPathname("/"), false);
M tests/lib/storage/local_progress_test.ts +71 -7
@@ -4,6 +4,8 @@ import {
44 archiveProgress,55 buildHistoryKey,66 buildProgressKey,7+ getAchievedStackIndex,8+ getPlayableStackIndex,79 loadPuzzleProgress,810 savePuzzleProgress,911 } from "@/lib/storage/local_progress.ts";
@@ -40,7 +42,7 @@ class MemoryStorage implements Storage {
4042 function createProgress(): PuzzleProgress {4143 return {4244 date: "2026-04-16",43- puzzleId: "daily-2026-04-16",45+ puzzleId: "daily-2026-04-16-0",4446 startedAt: "2026-04-16T10:00:00.000Z",4547 updatedAt: "2026-04-16T10:03:00.000Z",4648 status: "in_progress",
@@ -61,8 +63,8 @@ function createProgress(): PuzzleProgress {
6163 6264 Deno.test("buildProgressKey and buildHistoryKey use versioned storage keys", () => {6365 assertEquals(64- buildProgressKey("2026-04-16"),65- "shikaku:v1:progress:2026-04-16",66+ buildProgressKey("daily-2026-04-16-0"),67+ "shikaku:v1:progress:daily-2026-04-16-0",6668 );6769 assertEquals(buildHistoryKey(), "shikaku:v1:history");6870 });
@@ -73,14 +75,14 @@ Deno.test("savePuzzleProgress and loadPuzzleProgress roundtrip progress", () =>
7375 7476 savePuzzleProgress(storage, progress);7577 76- assertEquals(loadPuzzleProgress(storage, progress.date), progress);78+ assertEquals(loadPuzzleProgress(storage, progress.puzzleId), progress);7779 });7880 7981 Deno.test("loadPuzzleProgress ignores malformed json", () => {8082 const storage = new MemoryStorage();81- storage.setItem(buildProgressKey("2026-04-16"), "{not-json");83+ storage.setItem(buildProgressKey("daily-2026-04-16-0"), "{not-json");8284 83- assertEquals(loadPuzzleProgress(storage, "2026-04-16"), null);85+ assertEquals(loadPuzzleProgress(storage, "daily-2026-04-16-0"), null);8486 });8587 8688 Deno.test("archiveProgress appends history entry and removes saved progress", () => {
@@ -95,7 +97,7 @@ Deno.test("archiveProgress appends history entry and removes saved progress", ()
9597 mistakes: progress.mistakes,9698 });9799 98- assertEquals(loadPuzzleProgress(storage, progress.date), null);100+ assertEquals(loadPuzzleProgress(storage, progress.puzzleId), null);99101 assertEquals(100102 storage.getItem(buildHistoryKey()),101103 JSON.stringify([
@@ -108,3 +110,65 @@ Deno.test("archiveProgress appends history entry and removes saved progress", ()
108110 ]),109111 );110112 });113+114+Deno.test("getAchievedStackIndex returns the highest solved index for a day", () => {115+ const storage = new MemoryStorage();116+117+ savePuzzleProgress(storage, {118+ ...createProgress(),119+ puzzleId: "daily-2026-04-16-0",120+ status: "solved",121+ });122+ savePuzzleProgress(storage, {123+ ...createProgress(),124+ puzzleId: "daily-2026-04-16-1",125+ status: "solved",126+ });127+ savePuzzleProgress(storage, {128+ ...createProgress(),129+ puzzleId: "daily-2026-04-16-2",130+ status: "in_progress",131+ });132+133+ assertEquals(getAchievedStackIndex(storage, "2026-04-16"), 1);134+ assertEquals(getAchievedStackIndex(storage, "2026-04-17"), 0);135+});136+137+Deno.test("getPlayableStackIndex starts at zero and unlocks the next stack only after a solve", () => {138+ const storage = new MemoryStorage();139+140+ assertEquals(getPlayableStackIndex(storage, "2026-04-16"), 0);141+142+ savePuzzleProgress(storage, {143+ ...createProgress(),144+ puzzleId: "daily-2026-04-16-0",145+ status: "in_progress",146+ });147+ assertEquals(getPlayableStackIndex(storage, "2026-04-16"), 0);148+149+ savePuzzleProgress(storage, {150+ ...createProgress(),151+ puzzleId: "daily-2026-04-16-0",152+ status: "solved",153+ });154+ assertEquals(getPlayableStackIndex(storage, "2026-04-16"), 1);155+156+ savePuzzleProgress(storage, {157+ ...createProgress(),158+ puzzleId: "daily-2026-04-16-1",159+ status: "in_progress",160+ });161+ assertEquals(getPlayableStackIndex(storage, "2026-04-16"), 1);162+});163+164+Deno.test("getPlayableStackIndex ignores non-contiguous solved entries", () => {165+ const storage = new MemoryStorage();166+167+ savePuzzleProgress(storage, {168+ ...createProgress(),169+ puzzleId: "daily-2026-04-16-1",170+ status: "solved",171+ });172+173+ assertEquals(getPlayableStackIndex(storage, "2026-04-16"), 0);174+});