1import {2 parsePuzzleProgress,3 type PuzzleHistoryEntry,4 type PuzzleProgress,5} from "@/lib/shikaku/types.ts";67const STORAGE_NAMESPACE = "shikaku:v1";89export function buildProgressKey(puzzleId: string): string {10 return `${STORAGE_NAMESPACE}:progress:${puzzleId}`;11}1213export function buildHistoryKey(): string {14 return `${STORAGE_NAMESPACE}:history`;15}1617export function loadPuzzleProgress(18 storage: Pick<Storage, "getItem">,19 puzzleId: string,20): PuzzleProgress | null {21 const raw = storage.getItem(buildProgressKey(puzzleId));22 if (raw === null) return null;2324 try {25 return parsePuzzleProgress(JSON.parse(raw));26 } catch {27 return null;28 }29}3031export function savePuzzleProgress(32 storage: Pick<Storage, "setItem">,33 progress: PuzzleProgress,34): void {35 storage.setItem(36 buildProgressKey(progress.puzzleId),37 JSON.stringify(progress),38 );39}4041export function archiveProgress(42 storage: Pick<Storage, "getItem" | "setItem" | "removeItem">,43 entry: PuzzleHistoryEntry,44): void {45 const existing = loadHistory(storage);46 existing.push(entry);47 storage.setItem(buildHistoryKey(), JSON.stringify(existing));48 storage.removeItem(buildProgressKey(entry.puzzleId));49}5051export function getAchievedStackIndex(52 storage: Pick<Storage, "length" | "key" | "getItem">,53 date: string,54): number {55 let highestSolvedIndex = -1;5657 for (const progress of listDailyProgress(storage, date)) {58 if (progress.status !== "solved") continue;59 highestSolvedIndex = Math.max(highestSolvedIndex, progress.stackIndex);60 }6162 return Math.max(highestSolvedIndex, 0);63}6465export function getPlayableStackIndex(66 storage: Pick<Storage, "length" | "key" | "getItem">,67 date: string,68): number {69 const progressByIndex = new Map<number, PuzzleProgress["status"]>();7071 for (const progress of listDailyProgress(storage, date)) {72 progressByIndex.set(progress.stackIndex, progress.status);73 }7475 let playableStackIndex = 0;76 while (progressByIndex.get(playableStackIndex) === "solved") {77 playableStackIndex += 1;78 }7980 return playableStackIndex;81}8283export function getMostRecentStackIndex(84 storage: Pick<Storage, "length" | "key" | "getItem">,85 date: string,86): number {87 let latest = { stackIndex: 0, updatedAt: "" };8889 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 }9798 return latest.stackIndex;99}100101export function getVisibleStackRange(102 storage: Pick<Storage, "length" | "key" | "getItem">,103 date: string,104): { min: number; max: number } {105 let max = 0;106107 for (const progress of listDailyProgress(storage, date)) {108 max = Math.max(max, progress.stackIndex);109 }110111 return { min: 0, max };112}113114export function getSelectedStackIndex(115 storage: Pick<Storage, "length" | "key" | "getItem">,116 date: string,117): number {118 return getVisibleStackRange(storage, date).max;119}120121function listDailyProgress(122 storage: Pick<Storage, "length" | "key" | "getItem">,123 date: string,124): Array<PuzzleProgress & { stackIndex: number }> {125 const entries: Array<PuzzleProgress & { stackIndex: number }> = [];126127 for (let index = 0; index < storage.length; index++) {128 const key = storage.key(index);129 if (!key?.startsWith(`${STORAGE_NAMESPACE}:progress:daily-${date}-`)) {130 continue;131 }132133 const raw = storage.getItem(key);134 if (!raw) continue;135136 try {137 const progress = parsePuzzleProgress(JSON.parse(raw));138 if (!progress) continue;139 const match = progress.puzzleId.match(/-(\d+)$/);140 if (!match) continue;141 entries.push({ ...progress, stackIndex: Number(match[1]) });142 } catch {143 continue;144 }145 }146147 return entries;148}149150function loadHistory(storage: Pick<Storage, "getItem">): PuzzleHistoryEntry[] {151 const raw = storage.getItem(buildHistoryKey());152 if (raw === null) return [];153154 try {155 const parsed = JSON.parse(raw);156 return Array.isArray(parsed) ? parsed as PuzzleHistoryEntry[] : [];157 } catch {158 return [];159 }160}161162/**163 * Consecutive days (ending today, or yesterday if today is not yet solved)164 * with at least one solved stack.165 */166export function getDailyStreak(167 storage: Pick<Storage, "length" | "key" | "getItem">,168 today: string,169): number {170 const solvedDates = new Set<string>();171 const prefix = `${STORAGE_NAMESPACE}:progress:daily-`;172173 for (let index = 0; index < storage.length; index++) {174 const key = storage.key(index);175 if (!key?.startsWith(prefix)) continue;176 const raw = storage.getItem(key);177 if (!raw) continue;178 try {179 const progress = parsePuzzleProgress(JSON.parse(raw));180 if (progress?.status === "solved") solvedDates.add(progress.date);181 } catch {182 continue;183 }184 }185186 const cursor = parseSlug(today);187 if (!solvedDates.has(today)) cursor.setDate(cursor.getDate() - 1);188189 let streak = 0;190 while (solvedDates.has(slugOf(cursor))) {191 streak += 1;192 cursor.setDate(cursor.getDate() - 1);193 }194 return streak;195}196197function parseSlug(date: string): Date {198 const [year, month, day] = date.split("-").map(Number);199 return new Date(year, month - 1, day);200}201202function slugOf(value: Date): string {203 const y = value.getFullYear();204 const m = String(value.getMonth() + 1).padStart(2, "0");205 const d = String(value.getDate()).padStart(2, "0");206 return `${y}-${m}-${d}`;207}