Refactor language management to use storedSignal for persistence and update related components
This commit is contained in:
1 parent
c1c82b5311
commit
b66acc21ef
11 files changed
+647
-93
No files matched your search
@@ -0,0 +1,165 @@
|
||||
# Stored Signals
|
||||
|
||||
This document explains how persistent state management works in this application using the `storedSignal` utility.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses a custom `storedSignal` utility to automatically persist signal values to localStorage. This provides a seamless way to maintain user preferences and state across browser sessions.
|
||||
|
||||
## What is storedSignal?
|
||||
|
||||
`storedSignal` is a utility function located in `src/app/shared/utils/stored-signal.ts` that creates Angular signals with automatic localStorage persistence. It combines the reactivity of Angular signals with the durability of browser storage.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Automatic Persistence**: Changes are automatically saved to localStorage with debouncing (default: 300ms)
|
||||
- **SSR-Safe**: Works in server-side rendering environments where localStorage is unavailable
|
||||
- **Type-Safe**: Full TypeScript support with generic types
|
||||
- **Error Handling**: Gracefully handles localStorage errors and corrupted data
|
||||
- **Initialization**: Automatically restores values from localStorage on creation
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { inject, Injector } from '@angular/core';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
export class MyService {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
// Create a stored signal
|
||||
public readonly mySetting = storedSignal<string>(
|
||||
'my.setting.key', // localStorage key
|
||||
'default value', // default/initial value
|
||||
this.injector, // Angular injector
|
||||
);
|
||||
|
||||
// Use it like a normal signal
|
||||
changeValue() {
|
||||
this.mySetting.set('new value'); // Automatically saved to localStorage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Debounce
|
||||
|
||||
```typescript
|
||||
public readonly fastSetting = storedSignal<number>(
|
||||
'fast.setting',
|
||||
0,
|
||||
this.injector,
|
||||
100 // Custom debounce in milliseconds
|
||||
);
|
||||
```
|
||||
|
||||
## Current Usage in the App
|
||||
|
||||
### 1. Randomizer Settings (`RandomizerStateService`)
|
||||
|
||||
**Location**: `src/app/services/randomizer-state.service.ts`
|
||||
|
||||
```typescript
|
||||
// Blacklisted champions (fearless draft)
|
||||
public readonly blacklistedChampions = storedSignal<string[]>(
|
||||
'randomizer.blacklistedChampions',
|
||||
[],
|
||||
this.injector,
|
||||
);
|
||||
|
||||
// Fearless draft enabled/disabled
|
||||
public readonly fearlessDraftEnabled = storedSignal<boolean>(
|
||||
'randomizer.fearlessDraftEnabled',
|
||||
true,
|
||||
this.injector,
|
||||
);
|
||||
```
|
||||
|
||||
**What's Stored**:
|
||||
- List of blacklisted champion names (accumulated when fearless draft is enabled)
|
||||
- Whether fearless draft mode is enabled
|
||||
|
||||
**Behavior**:
|
||||
- When fearless draft is enabled, selected champions are automatically added to the blacklist
|
||||
- Blacklist persists across sessions, so users can continue their fearless draft series
|
||||
- When fearless draft is disabled, the blacklist is cleared
|
||||
|
||||
### 2. Language Preference (`LanguageService`)
|
||||
|
||||
**Location**: `src/app/services/language.service.ts`
|
||||
|
||||
```typescript
|
||||
public readonly currentLang = storedSignal<string>(
|
||||
'app.language',
|
||||
this.getInitialLanguage(),
|
||||
this.injector,
|
||||
);
|
||||
```
|
||||
|
||||
**What's Stored**:
|
||||
- The user's selected language code (e.g., 'en', 'fr', 'zh')
|
||||
|
||||
**Behavior**:
|
||||
- Language preference persists across sessions
|
||||
- Automatically syncs with the TranslateService
|
||||
- Falls back to browser language detection for first-time users
|
||||
- Handles migration from old 'lang' localStorage key
|
||||
|
||||
## localStorage Keys
|
||||
|
||||
All stored values use namespaced keys to avoid conflicts:
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|-----|------|---------|
|
||||
| `randomizer.blacklistedChampions` | `string[]` | List of blacklisted champion names |
|
||||
| `randomizer.fearlessDraftEnabled` | `boolean` | Fearless draft mode toggle |
|
||||
| `app.language` | `string` | User's language preference |
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **User Experience**: Settings and preferences are preserved across sessions
|
||||
2. **Fearless Draft**: Players can continue their draft series even after closing the browser
|
||||
3. **Performance**: Debouncing prevents excessive localStorage writes
|
||||
4. **Type Safety**: TypeScript ensures type correctness at compile time
|
||||
5. **Maintainability**: Centralized storage logic in one utility function
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Descriptive Keys**: Prefix keys with module/feature names (e.g., `randomizer.`, `app.`)
|
||||
2. **Provide Sensible Defaults**: Always include a reasonable initial value
|
||||
3. **Keep Data JSON-Serializable**: Store simple data structures (primitives, arrays, objects)
|
||||
4. **Don't Store Sensitive Data**: localStorage is not secure; never store passwords or tokens
|
||||
|
||||
## Testing
|
||||
|
||||
When testing components or services that use `storedSignal`, ensure:
|
||||
|
||||
1. Clear localStorage before each test
|
||||
2. Use `provideZonelessChangeDetection()` in TestBed setup
|
||||
3. Call `TestBed.flushEffects()` after signal changes to trigger effects
|
||||
4. Wait for debounce timeouts when testing persistence
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
it('should persist value', (done) => {
|
||||
service.mySetting.set('new value');
|
||||
TestBed.flushEffects();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(localStorage.getItem('my.setting.key')).toBe('"new value"');
|
||||
done();
|
||||
}, 400); // Wait for 300ms debounce + buffer
|
||||
});
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements to consider:
|
||||
|
||||
- [ ] Add support for session storage
|
||||
- [ ] Implement storage quota management
|
||||
- [ ] Add encryption option for sensitive data
|
||||
- [ ] Support for storage events (sync across tabs)
|
||||
- [ ] Configurable serialization/deserialization functions
|
||||
- [ ] Storage versioning and migration support
|
||||
+4
-25
@@ -4,7 +4,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { ChampionRandomizerComponent } from './components/champion-randomizer.component';
|
||||
import { ImagePreloadService } from './services/image-preload.service';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { LanguageService } from './services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -20,29 +21,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
styleUrls: ['./app.css'],
|
||||
})
|
||||
export class App {
|
||||
// Initialize the image preload service
|
||||
// Initialize services
|
||||
private readonly imagePreload = inject(ImagePreloadService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
constructor() {
|
||||
// Initialize language: prefer saved value, otherwise try the user's
|
||||
// browser language (navigator.languages / navigator.language), fall back to English.
|
||||
this.translate.setDefaultLang('en');
|
||||
let lang: string | null = null;
|
||||
try {
|
||||
lang = typeof localStorage !== 'undefined' ? localStorage.getItem('lang') : null;
|
||||
} catch {
|
||||
// Ignore localStorage errors (e.g., in private browsing mode)
|
||||
}
|
||||
if (!lang && typeof navigator !== 'undefined') {
|
||||
const candidate =
|
||||
(navigator.languages && navigator.languages[0]) || navigator.language || '';
|
||||
if (candidate.startsWith('fr')) {
|
||||
lang = 'fr';
|
||||
} else {
|
||||
lang = 'en';
|
||||
}
|
||||
}
|
||||
this.translate.use(lang || 'en');
|
||||
}
|
||||
private readonly languageService = inject(LanguageService);
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (state.blacklistedChampions().size === 0) {
|
||||
@if (state.blacklistedChampions().length === 0) {
|
||||
<p class="empty-message">{{ 'blacklist.empty' | translate }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
@@ -6,7 +6,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { SettingsDialogComponent } from '../settings-dialog/settings-dialog.component';
|
||||
import { BlacklistDialogComponent } from '../blacklist-dialog/blacklist-dialog.component';
|
||||
import { RandomizerStateService } from '../../services/randomizer-state.service';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-control-header',
|
||||
@@ -18,21 +19,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
export class ControlHeaderComponent {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
protected readonly state = inject(RandomizerStateService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
// Expose current language for UI or binding
|
||||
get currentLang(): string {
|
||||
return this.translate.currentLang || this.translate.getDefaultLang() || 'en';
|
||||
}
|
||||
|
||||
setLang(lang: string): void {
|
||||
this.translate.use(lang);
|
||||
try {
|
||||
localStorage.setItem('lang', lang);
|
||||
} catch {
|
||||
// Ignore localStorage errors (e.g., in private browsing mode)
|
||||
}
|
||||
}
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
onRoll(): void {
|
||||
this.state.rollAssignments();
|
||||
@@ -64,8 +51,8 @@ export class ControlHeaderComponent {
|
||||
// Update the service state immediately
|
||||
this.state.setFearlessDraftEnabled(value);
|
||||
},
|
||||
currentLang: this.currentLang,
|
||||
setLang: (lang: string) => this.setLang(lang),
|
||||
currentLang: this.languageService.currentLang(),
|
||||
setLang: (lang: string) => this.languageService.setLanguage(lang),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,16 +50,9 @@ export class SettingsDialogComponent {
|
||||
}
|
||||
|
||||
onLangChange(lang: string): void {
|
||||
// If the parent provided a setter, call it; otherwise change directly
|
||||
// Call the parent-provided setter
|
||||
if (this.data.setLang) {
|
||||
this.data.setLang(lang);
|
||||
} else {
|
||||
this.translate.use(lang);
|
||||
try {
|
||||
localStorage.setItem('lang', lang);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// update local copy
|
||||
this.data.currentLang = lang;
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('ChampionDataService', () => {
|
||||
|
||||
const cacheData = {
|
||||
version: 1,
|
||||
timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago
|
||||
timestamp: Date.now() - 31 * 24 * 60 * 60 * 1000, // 31 days ago (expired)
|
||||
champions: mockChampions,
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const POSITION_TO_LANE: Record<RemotePosition, Lane> = {
|
||||
|
||||
const CACHE_KEY = 'lol-champions-cache';
|
||||
const CACHE_VERSION = 1;
|
||||
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const CACHE_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
interface CachedData {
|
||||
version: number;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { LanguageService } from './language.service';
|
||||
|
||||
describe('LanguageService', () => {
|
||||
let service: LanguageService;
|
||||
let translateService: TranslateService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
providers: [LanguageService, provideZonelessChangeDetection()],
|
||||
});
|
||||
|
||||
service = TestBed.inject(LanguageService);
|
||||
translateService = TestBed.inject(TranslateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize with default language', () => {
|
||||
expect(service.currentLang()).toBe('en');
|
||||
});
|
||||
|
||||
it('should change language', () => {
|
||||
service.setLanguage('fr');
|
||||
expect(service.currentLang()).toBe('fr');
|
||||
|
||||
// Flush effects to ensure the effect runs
|
||||
TestBed.flushEffects();
|
||||
|
||||
// In zoneless mode, we need to check if translate was called
|
||||
expect(translateService.currentLang).toBe('fr');
|
||||
});
|
||||
|
||||
it('should persist language to localStorage', (done) => {
|
||||
// Flush effects to ensure initialization is complete
|
||||
TestBed.flushEffects();
|
||||
|
||||
service.setLanguage('zh');
|
||||
expect(service.currentLang()).toBe('zh');
|
||||
|
||||
// Flush effects after setting the language to trigger the storedSignal effect
|
||||
TestBed.flushEffects();
|
||||
|
||||
// Wait for debounce (300ms + buffer)
|
||||
setTimeout(() => {
|
||||
const stored = localStorage.getItem('app.language');
|
||||
expect(stored).toBe('"zh"');
|
||||
done();
|
||||
}, 400);
|
||||
});
|
||||
|
||||
it('should restore language from localStorage', () => {
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
|
||||
// Create a new TestBed instance to test restoration
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
providers: [LanguageService, provideZonelessChangeDetection()],
|
||||
});
|
||||
|
||||
const newService = TestBed.inject(LanguageService);
|
||||
expect(newService.currentLang()).toBe('fr');
|
||||
});
|
||||
|
||||
it('should sync with TranslateService', () => {
|
||||
// The service uses the translate service in constructor and via effect
|
||||
// In tests, just verify the current language matches
|
||||
expect(translateService.currentLang).toBe(service.currentLang());
|
||||
|
||||
service.setLanguage('zh');
|
||||
expect(service.currentLang()).toBe('zh');
|
||||
|
||||
// Flush effects to ensure the effect runs
|
||||
TestBed.flushEffects();
|
||||
|
||||
// The effect should update the translate service in the next change detection
|
||||
expect(translateService.currentLang).toBe('zh');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, inject, Injector, effect } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
/**
|
||||
* Service to manage language preferences with persistence to localStorage.
|
||||
* Uses storedSignal to automatically save and restore the selected language.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LanguageService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
// Store language preference using storedSignal
|
||||
public readonly currentLang = storedSignal<string>(
|
||||
'app.language',
|
||||
this.getInitialLanguage(),
|
||||
this.injector,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Set default language
|
||||
this.translate.setDefaultLang('en');
|
||||
|
||||
// Use the stored language
|
||||
this.translate.use(this.currentLang());
|
||||
|
||||
// Sync language changes to translate service
|
||||
effect(() => {
|
||||
this.translate.use(this.currentLang());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current language
|
||||
*/
|
||||
public setLanguage(lang: string): void {
|
||||
this.currentLang.set(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initial language based on browser preferences or old localStorage key
|
||||
*/
|
||||
private getInitialLanguage(): string {
|
||||
// Check for saved language in old key (for backward compatibility)
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
const savedLang = localStorage.getItem('lang');
|
||||
if (savedLang) {
|
||||
// Clean up old key
|
||||
localStorage.removeItem('lang');
|
||||
return savedLang;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Detect browser language
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const candidate =
|
||||
(navigator.languages && navigator.languages[0]) || navigator.language || '';
|
||||
if (candidate.startsWith('fr')) {
|
||||
return 'fr';
|
||||
} else if (candidate.startsWith('zh')) {
|
||||
return 'zh';
|
||||
}
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed, effect, inject, Injectable, signal } from '@angular/core';
|
||||
import { computed, effect, inject, Injectable, Injector, signal } from '@angular/core';
|
||||
import { Champion, Lane, LANES } from '../data/champions.data';
|
||||
import { ChampionDataService } from './champion-data.service';
|
||||
import { RandomNumber } from './random-number';
|
||||
import { storedMapSignal, storedSetSignal, storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
interface LaneAssignmentView {
|
||||
readonly lane: Lane;
|
||||
@@ -14,6 +15,7 @@ interface LaneAssignmentView {
|
||||
})
|
||||
export class RandomizerStateService {
|
||||
private readonly championData = inject(ChampionDataService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly laneTitleMap: Record<Lane, string> = {
|
||||
top: 'Top Lane',
|
||||
jungle: 'Jungle',
|
||||
@@ -22,17 +24,44 @@ export class RandomizerStateService {
|
||||
support: 'Support',
|
||||
};
|
||||
|
||||
private previousChampionStamp = '';
|
||||
private previousChampionStamp = storedSignal<string>(
|
||||
'randomizer.previousChampionStamp',
|
||||
'',
|
||||
this.injector,
|
||||
);
|
||||
|
||||
// Core state signals
|
||||
public readonly disabledLanes = signal<Set<Lane>>(new Set());
|
||||
public readonly disabledLanes = storedSetSignal<Lane>(
|
||||
'randomizer.disabledLanes',
|
||||
new Set(),
|
||||
this.injector,
|
||||
);
|
||||
public readonly reRollBank = storedSignal<number>('randomizer.reRollBank', 0, this.injector);
|
||||
public readonly reRollBankMax = storedSignal<number>(
|
||||
'randomizer.reRollBankMax',
|
||||
0,
|
||||
this.injector,
|
||||
);
|
||||
|
||||
// Persisted settings using storedSignal
|
||||
public readonly blacklistedChampions = storedSignal<string[]>(
|
||||
'randomizer.blacklistedChampions',
|
||||
[],
|
||||
this.injector,
|
||||
);
|
||||
public readonly fearlessDraftEnabled = storedSignal<boolean>(
|
||||
'randomizer.fearlessDraftEnabled',
|
||||
true,
|
||||
this.injector,
|
||||
);
|
||||
public readonly assignments = storedMapSignal<Lane, Champion | null>(
|
||||
'randomizer.assignments',
|
||||
this.createEmptyAssignments(),
|
||||
this.injector,
|
||||
);
|
||||
|
||||
public readonly isShiftPressed = signal<boolean>(false);
|
||||
public readonly reRollBank = signal<number>(0);
|
||||
public readonly reRollBankMax = signal<number>(0);
|
||||
public readonly showAndy = signal<boolean>(false);
|
||||
public readonly blacklistedChampions = signal<Set<string>>(new Set());
|
||||
public readonly fearlessDraftEnabled = signal<boolean>(true);
|
||||
public readonly assignments = signal<Map<Lane, Champion | null>>(this.createEmptyAssignments());
|
||||
|
||||
// Computed values from champion data service
|
||||
public readonly lanes = LANES;
|
||||
@@ -83,7 +112,7 @@ export class RandomizerStateService {
|
||||
!this.assignmentsArray().some(
|
||||
(assignedChampion) =>
|
||||
assignedChampion && assignedChampion.name === champion.name,
|
||||
) && !blacklisted.has(champion.name),
|
||||
) && !blacklisted.includes(champion.name),
|
||||
) || [];
|
||||
notUsed.set(lane, champion);
|
||||
}
|
||||
@@ -99,7 +128,7 @@ export class RandomizerStateService {
|
||||
const championsForLane =
|
||||
this.championsByLane()
|
||||
.get(lane)
|
||||
?.filter((champion) => blacklisted.has(champion.name)) || [];
|
||||
?.filter((champion) => blacklisted.includes(champion.name)) || [];
|
||||
result.set(lane, championsForLane);
|
||||
}
|
||||
|
||||
@@ -142,13 +171,17 @@ export class RandomizerStateService {
|
||||
const champions = this.champions();
|
||||
if (champions.length === 0) {
|
||||
this.assignments.set(this.createEmptyAssignments());
|
||||
this.previousChampionStamp = '';
|
||||
this.previousChampionStamp.set('');
|
||||
return;
|
||||
}
|
||||
|
||||
const stamp = champions.map((champion) => champion.id).join('|');
|
||||
if (stamp !== this.previousChampionStamp) {
|
||||
this.previousChampionStamp = stamp;
|
||||
if (stamp !== this.previousChampionStamp()) {
|
||||
this.previousChampionStamp.set(stamp);
|
||||
this.rollAssignments();
|
||||
}
|
||||
|
||||
if (this.assignments().size === 0) {
|
||||
this.rollAssignments();
|
||||
}
|
||||
});
|
||||
@@ -177,8 +210,25 @@ export class RandomizerStateService {
|
||||
}
|
||||
}
|
||||
|
||||
private updateBlacklist() {
|
||||
// Add selected champions to blacklist for fearless draft (if enabled)
|
||||
if (this.fearlessDraftEnabled()) {
|
||||
this.blacklistedChampions.update((current) => {
|
||||
const updated = [...current];
|
||||
this.assignmentsArray().forEach((championName) => {
|
||||
if (championName === null) return;
|
||||
if (!updated.includes(championName.name)) {
|
||||
updated.push(championName.name);
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
public rollAssignments(): void {
|
||||
this.updateBlacklist();
|
||||
const championsByLaneMap = this.notUsedChampions();
|
||||
|
||||
this.reRollBank.set(this.activatedLanesArray().length);
|
||||
@@ -208,15 +258,6 @@ export class RandomizerStateService {
|
||||
}
|
||||
|
||||
this.assignments.set(result);
|
||||
|
||||
// Add selected champions to blacklist for fearless draft (if enabled)
|
||||
if (this.fearlessDraftEnabled()) {
|
||||
this.blacklistedChampions.update((current) => {
|
||||
const updated = new Set(current);
|
||||
selected.forEach((championName) => updated.add(championName));
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public retryLoad(): void {
|
||||
@@ -247,26 +288,11 @@ export class RandomizerStateService {
|
||||
|
||||
const champion = notUsedChampionsForLane[number];
|
||||
|
||||
// Get the old champion to remove from blacklist if being replaced
|
||||
const oldChampion = this.assignments().get(lane);
|
||||
|
||||
this.assignments.update((current) => {
|
||||
const newState = new Map(current);
|
||||
newState.set(lane, champion);
|
||||
return newState;
|
||||
});
|
||||
|
||||
// Update blacklist only when fearless draft is enabled
|
||||
if (this.fearlessDraftEnabled()) {
|
||||
this.blacklistedChampions.update((current) => {
|
||||
const updated = new Set(current);
|
||||
if (oldChampion) {
|
||||
updated.delete(oldChampion.name);
|
||||
}
|
||||
updated.add(champion.name);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public toggleLane(lane: Lane): void {
|
||||
@@ -314,7 +340,7 @@ export class RandomizerStateService {
|
||||
}
|
||||
|
||||
public resetBlacklist(): void {
|
||||
this.blacklistedChampions.set(new Set());
|
||||
this.blacklistedChampions.set([]);
|
||||
}
|
||||
|
||||
public setFearlessDraftEnabled(value: boolean): void {
|
||||
|
||||
@@ -12,6 +12,24 @@ function isLocalStorageAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for stored signal values to avoid redundant localStorage reads.
|
||||
* Maps storage keys to their parsed values.
|
||||
*/
|
||||
const signalCache = new Map<string, unknown>();
|
||||
|
||||
/**
|
||||
* Cache for stored Map signal values to avoid redundant localStorage reads.
|
||||
* Maps storage keys to their parsed Map instances.
|
||||
*/
|
||||
const mapSignalCache = new Map<string, Map<unknown, unknown>>();
|
||||
|
||||
/**
|
||||
* Cache for stored Set signal values to avoid redundant localStorage reads.
|
||||
* Maps storage keys to their parsed Set instances.
|
||||
*/
|
||||
const setSignalCache = new Map<string, Set<unknown>>();
|
||||
|
||||
/**
|
||||
* Creates a signal that automatically persists its value to localStorage.
|
||||
*
|
||||
@@ -51,11 +69,16 @@ export function storedSignal<T>(
|
||||
): WritableSignal<T> {
|
||||
let parsedValue = initialValue;
|
||||
|
||||
if (isLocalStorageAvailable()) {
|
||||
// Check cache first
|
||||
if (signalCache.has(key)) {
|
||||
parsedValue = signalCache.get(key) as T;
|
||||
} else if (isLocalStorageAvailable()) {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue) {
|
||||
parsedValue = JSON.parse(storedValue) as T;
|
||||
// Cache the parsed value
|
||||
signalCache.set(key, parsedValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -95,6 +118,8 @@ export function storedSignal<T>(
|
||||
if (isLocalStorageAvailable()) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
// Update cache
|
||||
signalCache.set(key, value);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to save value for key "${key}" to localStorage.`,
|
||||
@@ -109,3 +134,216 @@ export function storedSignal<T>(
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal that automatically persists a Map to localStorage.
|
||||
*
|
||||
* The signal will:
|
||||
* - Initialize with the stored Map from localStorage if available
|
||||
* - Fall back to the provided initial Map if no stored value exists or parsing fails
|
||||
* - Automatically sync changes to localStorage with debouncing (300ms delay)
|
||||
* - Work safely in SSR and environments where localStorage is unavailable
|
||||
*
|
||||
* @template K - The type of the Map keys (must be JSON-serializable)
|
||||
* @template V - The type of the Map values (must be JSON-serializable)
|
||||
* @param key - The localStorage key used to store the Map
|
||||
* @param initialValue - The default Map used when no stored value exists
|
||||
* @param injector - The Angular injector (use inject(Injector) to get current context)
|
||||
* @param debounceMs - Optional debounce delay in milliseconds (default: 300ms)
|
||||
* @returns A writable signal containing a Map that persists to localStorage
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const injector = inject(Injector);
|
||||
* const userPrefs = storedMapSignal<string, boolean>('user.preferences', new Map(), injector);
|
||||
* userPrefs.update(map => new Map(map).set('darkMode', true));
|
||||
* console.log(userPrefs().get('darkMode')); // true
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* - Requires an Injector to create the effect in the proper context
|
||||
* - Keys and values must be JSON-serializable (no functions, circular references, etc.)
|
||||
* - In SSR or when localStorage is unavailable, the signal works normally but won't persist
|
||||
* - Corrupted localStorage data is automatically cleared and reset to initial Map
|
||||
* - localStorage writes are debounced to improve performance with frequent updates
|
||||
* - Maps are stored as arrays of [key, value] pairs in localStorage
|
||||
*/
|
||||
export function storedMapSignal<K, V>(
|
||||
key: string,
|
||||
initialValue: Map<K, V>,
|
||||
injector: Injector,
|
||||
debounceMs = 300,
|
||||
): WritableSignal<Map<K, V>> {
|
||||
let parsedValue = initialValue;
|
||||
|
||||
// Check cache first
|
||||
if (mapSignalCache.has(key)) {
|
||||
parsedValue = mapSignalCache.get(key) as Map<K, V>;
|
||||
} else if (isLocalStorageAvailable()) {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue) {
|
||||
const entries = JSON.parse(storedValue) as Array<[K, V]>;
|
||||
parsedValue = new Map(entries);
|
||||
// Cache the parsed Map
|
||||
mapSignalCache.set(key, parsedValue as Map<unknown, unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse stored Map for key "${key}". Using initial value.`,
|
||||
error,
|
||||
);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore removal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = signal(parsedValue);
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let isFirstRun = true;
|
||||
|
||||
runInInjectionContext(injector, () => {
|
||||
effect(() => {
|
||||
const value = s();
|
||||
|
||||
// Skip the first execution to avoid writing back what we just read
|
||||
if (isFirstRun) {
|
||||
isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending timeout
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Debounce the localStorage write
|
||||
timeoutId = setTimeout(() => {
|
||||
if (isLocalStorageAvailable()) {
|
||||
try {
|
||||
// Convert Map to array of entries for JSON serialization
|
||||
const entries = Array.from(value.entries());
|
||||
localStorage.setItem(key, JSON.stringify(entries));
|
||||
// Update cache
|
||||
mapSignalCache.set(key, value as Map<unknown, unknown>);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save Map for key "${key}" to localStorage.`, error);
|
||||
}
|
||||
}
|
||||
timeoutId = null;
|
||||
}, debounceMs);
|
||||
});
|
||||
});
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal that automatically persists a Set to localStorage.
|
||||
*
|
||||
* The signal will:
|
||||
* - Initialize with the stored Set from localStorage if available
|
||||
* - Fall back to the provided initial Set if no stored value exists or parsing fails
|
||||
* - Automatically sync changes to localStorage with debouncing (300ms delay)
|
||||
* - Work safely in SSR and environments where localStorage is unavailable
|
||||
*
|
||||
* @template T - The type of the Set elements (must be JSON-serializable)
|
||||
* @param key - The localStorage key used to store the Set
|
||||
* @param initialValue - The default Set used when no stored value exists
|
||||
* @param injector - The Angular injector (use inject(Injector) to get current context)
|
||||
* @param debounceMs - Optional debounce delay in milliseconds (default: 300ms)
|
||||
* @returns A writable signal containing a Set that persists to localStorage
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const injector = inject(Injector);
|
||||
* const favorites = storedSetSignal<string>('user.favorites', new Set(), injector);
|
||||
* favorites.update(set => new Set(set).add('item1'));
|
||||
* console.log(favorites().has('item1')); // true
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* - Requires an Injector to create the effect in the proper context
|
||||
* - Elements must be JSON-serializable (no functions, circular references, etc.)
|
||||
* - In SSR or when localStorage is unavailable, the signal works normally but won't persist
|
||||
* - Corrupted localStorage data is automatically cleared and reset to initial Set
|
||||
* - localStorage writes are debounced to improve performance with frequent updates
|
||||
* - Sets are stored as arrays in localStorage
|
||||
*/
|
||||
export function storedSetSignal<T>(
|
||||
key: string,
|
||||
initialValue: Set<T>,
|
||||
injector: Injector,
|
||||
debounceMs = 300,
|
||||
): WritableSignal<Set<T>> {
|
||||
let parsedValue = initialValue;
|
||||
|
||||
// Check cache first
|
||||
if (setSignalCache.has(key)) {
|
||||
parsedValue = setSignalCache.get(key) as Set<T>;
|
||||
} else if (isLocalStorageAvailable()) {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue) {
|
||||
const values = JSON.parse(storedValue) as T[];
|
||||
parsedValue = new Set(values);
|
||||
// Cache the parsed Set
|
||||
setSignalCache.set(key, parsedValue as Set<unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse stored Set for key "${key}". Using initial value.`,
|
||||
error,
|
||||
);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore removal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = signal(parsedValue);
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let isFirstRun = true;
|
||||
|
||||
runInInjectionContext(injector, () => {
|
||||
effect(() => {
|
||||
const value = s();
|
||||
|
||||
// Skip the first execution to avoid writing back what we just read
|
||||
if (isFirstRun) {
|
||||
isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending timeout
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Debounce the localStorage write
|
||||
timeoutId = setTimeout(() => {
|
||||
if (isLocalStorageAvailable()) {
|
||||
try {
|
||||
// Convert Set to array for JSON serialization
|
||||
const values = Array.from(value);
|
||||
localStorage.setItem(key, JSON.stringify(values));
|
||||
// Update cache
|
||||
setSignalCache.set(key, value as Set<unknown>);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save Set for key "${key}" to localStorage.`, error);
|
||||
}
|
||||
}
|
||||
timeoutId = null;
|
||||
}, debounceMs);
|
||||
});
|
||||
});
|
||||
|
||||
return s;
|
||||
}
|
||||
Reference in new issue
Block a user