11import { invoke } from "@tauri-apps/api/core" ;
22import {
33 type DailyReplayProof ,
4- normalizeReplayProof ,
54 type ReplayMove ,
65} from "./replay-proof" ;
76export type { DailyReplayProof , ReplayInputEvent , ReplayMove } from "./replay-proof" ;
@@ -115,10 +114,18 @@ class LocalEntryStore {
115114 return this . sort ( this . load ( ) ) . slice ( 0 , limit ) ;
116115 }
117116
117+ public compactToLimit ( ) : void {
118+ this . save ( this . sort ( this . load ( ) ) . slice ( 0 , this . maxEntries ) ) ;
119+ }
120+
118121 public async add ( entry : ScoreEntry ) : Promise < void > {
119122 const scores = this . load ( ) ;
120123 scores . push ( {
121- ...entry ,
124+ user : entry . user ,
125+ score : entry . score ,
126+ level : entry . level ,
127+ date : entry . date ,
128+ skillUsage : this . normalizeSkillUsage ( entry . skillUsage ) ,
122129 isMe : entry . isMe === true ,
123130 badgePower : normalizeOptionalBadgeMetric ( entry . badgePower ) ,
124131 badgeMaxStreak : normalizeOptionalBadgeMetric ( entry . badgeMaxStreak ) ,
@@ -135,18 +142,25 @@ class LocalEntryStore {
135142 const current = deduped . get ( key ) ;
136143 if ( current ) {
137144 deduped . set ( key , {
138- ...row ,
145+ user : row . user ,
146+ score : row . score ,
147+ level : row . level ,
148+ date : row . date ,
149+ skillUsage : this . normalizeSkillUsage ( row . skillUsage ) ,
139150 isMe : current . isMe === true || row . isMe === true ,
140151 badgePower : normalizeOptionalBadgeMetric ( row . badgePower )
141152 ?? normalizeOptionalBadgeMetric ( current . badgePower ) ,
142153 badgeMaxStreak : normalizeOptionalBadgeMetric ( row . badgeMaxStreak )
143154 ?? normalizeOptionalBadgeMetric ( current . badgeMaxStreak ) ,
144- replayProof : row . replayProof ?? current . replayProof ,
145155 } ) ;
146156 continue ;
147157 }
148158 deduped . set ( key , {
149- ...row ,
159+ user : row . user ,
160+ score : row . score ,
161+ level : row . level ,
162+ date : row . date ,
163+ skillUsage : this . normalizeSkillUsage ( row . skillUsage ) ,
150164 isMe : row . isMe === true ,
151165 badgePower : normalizeOptionalBadgeMetric ( row . badgePower ) ,
152166 badgeMaxStreak : normalizeOptionalBadgeMetric ( row . badgeMaxStreak ) ,
@@ -178,15 +192,38 @@ class LocalEntryStore {
178192 badgeMaxStreak : normalizeOptionalBadgeMetric ( entry . badgeMaxStreak ) ,
179193 skillUsage : this . normalizeSkillUsage ( entry . skillUsage ) ,
180194 isMe : entry . isMe === true ,
181- replayProof : normalizeReplayProof ( entry . replayProof ) ,
182195 } ) ) ;
183196 } catch {
184197 return [ ] ;
185198 }
186199 }
187200
188201 private save ( scores : ScoreEntry [ ] ) : void {
189- this . storage . setItem ( this . storageKey , JSON . stringify ( scores ) ) ;
202+ let candidate = scores ;
203+ for ( let attempt = 0 ; attempt < 6 ; attempt += 1 ) {
204+ try {
205+ safeStorageSetItem ( this . storage , this . storageKey , JSON . stringify ( candidate ) ) ;
206+ return ;
207+ } catch ( error ) {
208+ if ( ! isStorageQuotaExceededError ( error ) ) {
209+ console . warn ( `Failed to persist ${ this . storageKey } .` , error ) ;
210+ return ;
211+ }
212+ if ( candidate . length <= 1 ) {
213+ break ;
214+ }
215+ const shrinkTo = Math . floor ( candidate . length * 0.6 ) ;
216+ const nextSize = Math . max ( 1 , Math . min ( candidate . length - 1 , shrinkTo ) ) ;
217+ candidate = candidate . slice ( 0 , nextSize ) ;
218+ }
219+ }
220+
221+ try {
222+ const fallback = candidate . length > 0 ? JSON . stringify ( [ candidate [ 0 ] ] ) : "[]" ;
223+ safeStorageSetItem ( this . storage , this . storageKey , fallback ) ;
224+ } catch ( error ) {
225+ console . warn ( `Failed to recover storage for ${ this . storageKey } .` , error ) ;
226+ }
190227 }
191228
192229 private sort ( scores : ScoreEntry [ ] ) : ScoreEntry [ ] {
@@ -684,8 +721,11 @@ class TauriScoreboardStore implements ScoreboardStore {
684721}
685722
686723export function createScoreboardStore ( ) : ScoreboardStore {
687- const globalStore = new LocalEntryStore ( "torus-scores-v1" , window . localStorage , 100 ) ;
688- const personalStore = new LocalEntryStore ( "torus-personal-scores-v1" , window . localStorage , 100 ) ;
724+ cleanupStaleDailyStorage ( window . localStorage ) ;
725+ const globalStore = new LocalEntryStore ( "torus-scores-v1" , window . localStorage , 10 ) ;
726+ globalStore . compactToLimit ( ) ;
727+ const personalStore = new LocalEntryStore ( "torus-personal-scores-v1" , window . localStorage , 10 ) ;
728+ personalStore . compactToLimit ( ) ;
689729 const resolveDailyStore = createDailyStoreResolver ( window . localStorage , 100 ) ;
690730 const supabaseUrl = readEnv ( "VITE_SUPABASE_URL" ) ;
691731 const supabaseAnonKey = readEnv ( "VITE_SUPABASE_ANON_KEY" ) ;
@@ -748,9 +788,95 @@ function normalizeSkillCommand(raw: string | null | undefined): string | null {
748788
749789type DailyStoreResolver = ( challengeKey : string ) => LocalEntryStore ;
750790const DAILY_CHALLENGE_MAX_ATTEMPTS = 3 ;
791+ const DAILY_SCORES_STORAGE_PREFIX = "torus-daily-scores-v1:" ;
751792const DAILY_ATTEMPTS_STORAGE_PREFIX = "torus-daily-attempts-v1:" ;
752793const DAILY_ACTIVE_ATTEMPT_TOKEN_STORAGE_PREFIX = "torus-daily-active-attempt-token-v1:" ;
753794const DAILY_BADGE_MAX_POWER = 9 ;
795+ const DAILY_STORAGE_PREFIXES : ReadonlyArray < string > = [
796+ DAILY_SCORES_STORAGE_PREFIX ,
797+ DAILY_ATTEMPTS_STORAGE_PREFIX ,
798+ DAILY_ACTIVE_ATTEMPT_TOKEN_STORAGE_PREFIX ,
799+ ] ;
800+
801+ function safeStorageSetItem ( storage : Storage , key : string , value : string ) : void {
802+ storage . setItem ( key , value ) ;
803+ }
804+
805+ function isStorageQuotaExceededError ( error : unknown ) : boolean {
806+ if ( typeof DOMException !== "undefined" && error instanceof DOMException ) {
807+ if ( error . name === "QuotaExceededError" || error . name === "NS_ERROR_DOM_QUOTA_REACHED" ) {
808+ return true ;
809+ }
810+ if ( error . code === 22 || error . code === 1014 ) {
811+ return true ;
812+ }
813+ }
814+ if ( ! error || typeof error !== "object" ) {
815+ return false ;
816+ }
817+ const record = error as Record < string , unknown > ;
818+ const name = typeof record . name === "string" ? record . name : "" ;
819+ if ( name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED" ) {
820+ return true ;
821+ }
822+ const code = typeof record . code === "number" ? record . code : - 1 ;
823+ if ( code === 22 || code === 1014 ) {
824+ return true ;
825+ }
826+ const message = typeof record . message === "string" ? record . message . toLowerCase ( ) : "" ;
827+ return message . includes ( "quota" ) && message . includes ( "exceed" ) ;
828+ }
829+
830+ function extractDailyChallengeKeyFromStorageKey ( storageKey : string ) : string | null {
831+ for ( const prefix of DAILY_STORAGE_PREFIXES ) {
832+ if ( ! storageKey . startsWith ( prefix ) ) {
833+ continue ;
834+ }
835+ const challengeKey = normalizeChallengeKey ( storageKey . slice ( prefix . length ) ) ;
836+ return isValidChallengeKey ( challengeKey ) ? challengeKey : null ;
837+ }
838+ return null ;
839+ }
840+
841+ function removeDailyStorageForChallenge ( storage : Storage , challengeKey : string ) : void {
842+ for ( const prefix of DAILY_STORAGE_PREFIXES ) {
843+ try {
844+ storage . removeItem ( `${ prefix } ${ challengeKey } ` ) ;
845+ } catch {
846+ // Ignore local storage failures.
847+ }
848+ }
849+ }
850+
851+ function currentDailyChallengeKey ( ) : string {
852+ return new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
853+ }
854+
855+ function cleanupStaleDailyStorage ( storage : Storage ) : void {
856+ try {
857+ const todayChallengeKey = currentDailyChallengeKey ( ) ;
858+ const dailyChallengeKeys = new Set < string > ( ) ;
859+ for ( let index = 0 ; index < storage . length ; index += 1 ) {
860+ const rawKey = storage . key ( index ) ;
861+ if ( ! rawKey ) {
862+ continue ;
863+ }
864+ const challengeKey = extractDailyChallengeKeyFromStorageKey ( rawKey ) ;
865+ if ( challengeKey ) {
866+ dailyChallengeKeys . add ( challengeKey ) ;
867+ }
868+ }
869+
870+ for ( const challengeKey of dailyChallengeKeys ) {
871+ if ( challengeKey === todayChallengeKey ) {
872+ continue ;
873+ }
874+ removeDailyStorageForChallenge ( storage , challengeKey ) ;
875+ }
876+ } catch {
877+ // Ignore local storage failures.
878+ }
879+ }
754880
755881function createDailyStoreResolver (
756882 storage : Storage ,
@@ -759,12 +885,13 @@ function createDailyStoreResolver(
759885 const cache = new Map < string , LocalEntryStore > ( ) ;
760886 return ( challengeKey : string ) => {
761887 const normalized = normalizeChallengeKey ( challengeKey ) ;
888+ cleanupStaleDailyStorage ( storage ) ;
762889 const existing = cache . get ( normalized ) ;
763890 if ( existing ) {
764891 return existing ;
765892 }
766893 const created = new LocalEntryStore (
767- `torus-daily-scores-v1: ${ normalized } ` ,
894+ `${ DAILY_SCORES_STORAGE_PREFIX } ${ normalized } ` ,
768895 storage ,
769896 maxEntries ,
770897 ) ;
@@ -807,7 +934,12 @@ function readDailyAttempts(storage: Storage, challengeKey: string): number {
807934
808935function writeDailyAttempts ( storage : Storage , challengeKey : string , attemptsUsed : number ) : void {
809936 try {
810- storage . setItem ( dailyAttemptsStorageKey ( challengeKey ) , String ( clampAttempts ( attemptsUsed ) ) ) ;
937+ cleanupStaleDailyStorage ( storage ) ;
938+ safeStorageSetItem (
939+ storage ,
940+ dailyAttemptsStorageKey ( challengeKey ) ,
941+ String ( clampAttempts ( attemptsUsed ) ) ,
942+ ) ;
811943 } catch {
812944 // Ignore local storage failures.
813945 }
@@ -837,7 +969,12 @@ function writeDailyActiveAttemptToken(
837969 storage . removeItem ( dailyActiveAttemptTokenStorageKey ( challengeKey ) ) ;
838970 return ;
839971 }
840- storage . setItem ( dailyActiveAttemptTokenStorageKey ( challengeKey ) , token ) ;
972+ cleanupStaleDailyStorage ( storage ) ;
973+ safeStorageSetItem (
974+ storage ,
975+ dailyActiveAttemptTokenStorageKey ( challengeKey ) ,
976+ token ,
977+ ) ;
841978 } catch {
842979 // Ignore local storage failures.
843980 }
0 commit comments