- commit
- 53c64f2dbdea0e54a3a32a8afcdf34b7b7f785ce
- parent
- 0da5b0c12fecd85e9d51198db15fc8d9e94bddd6
- author
- Dami <dami.etoile@gmail.com>
| M | assets/ | 20 | +++++ |
| A | islands/ | 34 | +++++++++ |
| A | islands/ | 57 | +++++++++++++++ |
| A | islands/ | 55 | +++++++++++++++ |
| M | islands/ | 111 | ++++++++++++++++++++++++++++-- |
| M | lib/ | 38 | +++++++++- |
| M | lib/ | 9 | ++- |
| M | lib/ | 1 | + |
| M | lib/ | 23 | ++++++- |
| M | lib/ | 76 | +++++++++++++++++++-- |
| M | routes/ | 66 | +------------------ |
| A | routes/ | 110 | ++++++++++++++++++++++++++++++ |
| M | routes/ | 15 | +--- |
| M | routes/ | 2 | +- |
| M | routes/ | 12 | +-- |
| M | tests/ | 36 | +++++++--- |
| M | tests/ | 5 | +- |
| M | tests/ | 9 | +- |
| M | tests/ | 65 | +++++++++++++++++- |
| M | tests/ | 78 | +++++++++++++++++++-- |
@@ -520,6 +520,12 @@ select {
520520 line-height: 1;521521 }522522 523+.shikaku-play-nav.is-disabled,524+.shikaku-game-clear[disabled] {525+ opacity: 0.45;526+ pointer-events: none;527+}528+523529 .shikaku-play-kicker {524530 font-size: 0.65rem;525531 color: #415338;@@ -589,6 +595,13 @@ select {
589595 text-transform: uppercase;590596 }591597 598+.shikaku-game-actions {599+ display: flex;600+ align-items: center;601+ gap: 0.45rem;602+ flex-shrink: 0;603+}604+592605 .shikaku-game-clear {593606 border: 2px solid #0b1209;594607 background: #d4deb0;@@ -600,6 +613,13 @@ select {
600613 box-shadow: 3px 3px 0 #0b1209;601614 }602615 616+.shikaku-game-clear--link {617+ display: inline-flex;618+ align-items: center;619+ color: inherit;620+ text-decoration: none;621+}622+603623 .shikaku-game-board-wrap {604624 min-height: 0;605625 display: grid;@@ -0,0 +1,34 @@
1+import { useEffect, useState } from "preact/hooks";2+3+import { dateToSlug } from "@/lib/date/daily.ts";4+import { resolvePlayableDailyPath } from "@/lib/site/paths.ts";5+6+interface DailyStackLinkProps {7+ fallbackDate: string;8+}9+10+export default function DailyStackLink({ fallbackDate }: DailyStackLinkProps) {11+ const [href, setHref] = useState(12+ resolvePlayableDailyPath(localStorageFallback(), fallbackDate),13+ );14+15+ useEffect(() => {16+ const localDate = dateToSlug(new Date());17+ setHref(resolvePlayableDailyPath(globalThis.localStorage, localDate));18+ }, []);19+20+ return (21+ <a class="shikaku-home__action shikaku-home__action--gold" href={href}>22+ <span class="shikaku-home__action-kicker">/ START</span>23+ <strong>Play</strong>24+ </a>25+ );26+}27+28+function localStorageFallback(): Pick<Storage, "length" | "key" | "getItem"> {29+ return {30+ length: 0,31+ key: () => null,32+ getItem: () => null,33+ };34+}@@ -0,0 +1,57 @@
1+import { useEffect, useState } from "preact/hooks";2+3+import { dateToSlug } from "@/lib/date/daily.ts";4+import { resolvePlayableDailyPath } from "@/lib/site/paths.ts";5+6+interface DailyStackRedirectProps {7+ fallbackDate: string;8+}9+10+export default function DailyStackRedirect(11+ { fallbackDate }: DailyStackRedirectProps,12+) {13+ const [href, setHref] = useState(14+ resolvePlayableDailyPath(localStorageFallback(), fallbackDate),15+ );16+17+ useEffect(() => {18+ const localDate = dateToSlug(new Date());19+ const nextHref = resolvePlayableDailyPath(20+ globalThis.localStorage,21+ localDate,22+ );23+ setHref(nextHref);24+ globalThis.location.replace(nextHref);25+ }, []);26+27+ return (28+ <main class="shikaku-home shikaku-app-shell">29+ <div class="shikaku-home__frame">30+ <section class="shikaku-home__hero">31+ <div class="shikaku-home__hero-copy">32+ <div class="shikaku-home__speech-stack">33+ <p class="shikaku-home__speech">Opening today.</p>34+ </div>35+ <div class="shikaku-home__actions">36+ <a37+ class="shikaku-home__action shikaku-home__action--gold"38+ href={href}39+ >40+ <span class="shikaku-home__action-kicker">/ OPEN</span>41+ <strong>Continue</strong>42+ </a>43+ </div>44+ </div>45+ </section>46+ </div>47+ </main>48+ );49+}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,55 @@
1+import { useEffect, useState } from "preact/hooks";2+3+import { buildDailyPath, buildShareDailyStackText } from "@/lib/site/paths.ts";4+import { getAchievedStackIndex } from "@/lib/storage/local_progress.ts";5+6+interface ShareDailyStackButtonProps {7+ date: string;8+}9+10+export default function ShareDailyStackButton(11+ { date }: ShareDailyStackButtonProps,12+) {13+ const [stackIndex, setStackIndex] = useState(0);14+ const [origin, setOrigin] = useState("");15+16+ useEffect(() => {17+ setOrigin(globalThis.location.origin);18+ setStackIndex(getAchievedStackIndex(globalThis.localStorage, date));19+ }, [date]);20+21+ const shareUrl = origin ? `${origin}${buildDailyPath(date, stackIndex)}` : "";22+ const shareText = origin23+ ? buildShareDailyStackText(origin, date, stackIndex)24+ : "Daily stack";25+26+ async function handleShare() {27+ if (!shareUrl) return;28+29+ try {30+ if (navigator.share) {31+ await navigator.share({32+ title: "Daily stack",33+ text: shareText,34+ url: shareUrl,35+ });36+ return;37+ }38+39+ await navigator.clipboard.writeText(shareUrl);40+ } catch {41+ // ignore user-cancelled share errors and clipboard failures42+ }43+ }44+45+ return (46+ <button47+ class="shikaku-home__action shikaku-home__action--ghost"48+ type="button"49+ onClick={handleShare}50+ >51+ <span class="shikaku-home__action-kicker">/ SHARE</span>52+ <strong>Share daily stack</strong>53+ </button>54+ );55+}@@ -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 }}@@ -1,14 +1,44 @@
11 import { createPuzzleFromDailySeed } from "@/lib/shikaku/generator.ts";2+import { dateToSlug } from "@/lib/date/daily.ts";23 import type { ShikakuPuzzle } from "@/lib/shikaku/types.ts";34 4-export function getPuzzleByDate(date: string): ShikakuPuzzle | null {5+export const DAILY_STREAK_START_DATE = "2026-04-16";6+7+export function getPuzzleByDateIndex(8+ date: string,9+ streakIndex: number,10+): ShikakuPuzzle | null {511 if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {612 return null;713 }814 9- return createPuzzleFromDailySeed(date);15+ if (!Number.isInteger(streakIndex) || streakIndex < 0) {16+ return null;17+ }18+19+ return createPuzzleFromDailySeed(date, streakIndex);20+}21+22+export function getPuzzleByDate(date: string): ShikakuPuzzle | null {23+ return getPuzzleByDateIndex(date, 0);24+}25+26+export function listAvailablePuzzleDates(27+ today = dateToSlug(new Date()),28+): string[] {29+ const dates: string[] = [];30+ const cursor = parseLocalDateSlug(DAILY_STREAK_START_DATE);31+ const end = parseLocalDateSlug(today);32+33+ while (cursor <= end) {34+ dates.push(dateToSlug(cursor));35+ cursor.setDate(cursor.getDate() + 1);36+ }37+38+ return dates;1039 }1140 12-export function listAvailablePuzzleDates(): string[] {13- return [new Date().toISOString().slice(0, 10)];41+function parseLocalDateSlug(date: string): Date {42+ const [year, month, day] = date.split("-").map(Number);43+ return new Date(year, month - 1, day);1444 }@@ -48,13 +48,17 @@ export function createSeededRng(seed: string): () => number {
4848 4949 export function createPuzzleFromDailySeed(5050 date: string,51+ streakIndex = 0,5152 options: Partial<GeneratorOptions> = {},5253 ): ShikakuPuzzle {53- const puzzle = generatePuzzleFromSeed(date, options);54+ const seed = `${date}:${streakIndex}`;55+ const puzzle = generatePuzzleFromSeed(seed, options);5456 return {5557 ...puzzle,56- id: `daily-${date}`,58+ id: `daily-${date}-${streakIndex}`,5759 date,60+ streakIndex,61+ seed,5862 };5963 }6064 @@ -101,6 +105,7 @@ export function generatePuzzleFromSeed(
101105 return {102106 id: `seed-${seed}`,103107 date: seed,108+ streakIndex: 0,104109 width: config.width,105110 height: config.height,106111 givens,@@ -40,6 +40,7 @@ export interface PlacedRectangle extends RectanglePlacement {
4040 export interface ShikakuPuzzle {4141 id: string;4242 date: string;43+ streakIndex: number;4344 width: number;4445 height: number;4546 givens: PuzzleGiven[];@@ -1,11 +1,30 @@
1-export function buildDailyPath(date?: string): string {2- return date ? `/d/${date}` : "/d";1+import { getPlayableStackIndex } from "@/lib/storage/local_progress.ts";2+3+export function buildDailyPath(date?: string, streakIndex?: number): string {4+ if (!date) return "/d";5+ if (typeof streakIndex === "number") return `/d/${date}/${streakIndex}`;6+ return `/d/${date}`;37 }48 59 export function buildLegacyDailyPath(date?: string): string {610 return date ? `/daily/${date}` : "/daily";711 }812 13+export function buildShareDailyStackText(14+ origin: string,15+ date: string,16+ stackIndex: number,17+): string {18+ return `Daily stack ยท ${origin}${buildDailyPath(date, stackIndex)}`;19+}20+21+export function resolvePlayableDailyPath(22+ storage: Pick<Storage, "length" | "key" | "getItem">,23+ date: string,24+): string {25+ return buildDailyPath(date, getPlayableStackIndex(storage, date));26+}27+928 export function isDailyPathname(pathname: string): boolean {1029 return pathname === "/d" || pathname.startsWith("/d/") ||1130 pathname === "/daily" ||@@ -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[] {@@ -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 });@@ -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+}@@ -1,14 +1,9 @@
1-import { listAvailablePuzzleDates } from "@/lib/shikaku/catalog.ts";2-import { buildDailyPath } from "@/lib/site/paths.ts";1+import { dateToSlug } from "@/lib/date/daily.ts";32 import { define } from "../../utils.ts";3+import DailyStackRedirect from "../../islands/DailyStackRedirect.tsx";44 5-export const handler = define.handlers((ctx) => {6- const dates = listAvailablePuzzleDates();7- const latest = dates.at(-1);5+export default define.page(function DailyEntry() {6+ const fallbackDate = dateToSlug(new Date());87 9- if (!latest) {10- return new Response("No puzzles available", { status: 404 });11- }12-13- return ctx.redirect(buildDailyPath(latest));8+ return <DailyStackRedirect fallbackDate={fallbackDate} />;149 });@@ -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));5+ return ctx.redirect(buildDailyPath(ctx.params.date, 0));66 });@@ -1,7 +1,8 @@
11 import { listAvailablePuzzleDates } from "@/lib/shikaku/catalog.ts";2-import { buildDailyPath } from "@/lib/site/paths.ts";32 import { define } from "../utils.ts";3+import DailyStackLink from "../islands/DailyStackLink.tsx";44 import OfflineBadge from "../islands/OfflineBadge.tsx";5+import ShareDailyStackButton from "../islands/ShareDailyStreakButton.tsx";56 67 export default define.page(function Home() {78 const latest = listAvailablePuzzleDates().at(-1) ??@@ -26,13 +27,8 @@ export default define.page(function Home() {
2627 </p>2728 </div>2829 <div class="shikaku-home__actions">29- <a30- class="shikaku-home__action shikaku-home__action--gold"31- href={buildDailyPath(latest)}32- >33- <span class="shikaku-home__action-kicker">/ START</span>34- <strong>Play</strong>35- </a>30+ <DailyStackLink fallbackDate={latest} />31+ <ShareDailyStackButton date={latest} />3632 </div>3733 </div>3834 @@ -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 });@@ -10,8 +10,9 @@ import {
1010 import type { PlacedRectangle, ShikakuPuzzle } from "@/lib/shikaku/types.ts";1111 1212 const samplePuzzle: ShikakuPuzzle = {13- id: "daily-2026-04-16",13+ id: "daily-2026-04-16-0",1414 date: "2026-04-16",15+ streakIndex: 0,1516 width: 4,1617 height: 4,1718 difficulty: "easy",@@ -64,6 +65,7 @@ Deno.test("validateRectanglePlacement accepts rectangles whose clue is not at th
6465 const offCenterPuzzle: ShikakuPuzzle = {6566 id: "off-center",6667 date: "2026-04-16",68+ streakIndex: 0,6769 width: 3,6870 height: 2,6971 difficulty: "easy",@@ -142,6 +144,7 @@ Deno.test("isPuzzleSolved accepts any valid full tiling, not only the planned on
142144 const multiSolutionPuzzle: ShikakuPuzzle = {143145 id: "multi-solution",144146 date: "2026-04-16",147+ streakIndex: 0,145148 width: 2,146149 height: 2,147150 difficulty: "easy",@@ -75,10 +75,11 @@ Deno.test("generated puzzle covers the full board and respects clue area invaria
7575 assertEquals(covered.size, puzzle.width * puzzle.height);7676 });7777 78-Deno.test("createPuzzleFromDailySeed exposes date-based ids", () => {79- const puzzle = createPuzzleFromDailySeed("2026-04-16");78+Deno.test("createPuzzleFromDailySeed exposes date and streak-based ids", () => {79+ const puzzle = createPuzzleFromDailySeed("2026-04-16", 3);8080 8181 assertEquals(puzzle.date, "2026-04-16");82- assertEquals(puzzle.id, "daily-2026-04-16");83- assertEquals(typeof puzzle.seed, "string");82+ assertEquals(puzzle.streakIndex, 3);83+ assertEquals(puzzle.id, "daily-2026-04-16-3");84+ assertEquals(puzzle.seed, "2026-04-16:3");8485 });@@ -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);@@ -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+});