Skip to content

Commit 1d566ca

Browse files
authored
Merge pull request #1927 from AtCoder-NoviSteps/#1924
🎨 Save tab and button choices to local storage (#1924)
2 parents 8be4d3a + fcc2ee9 commit 1d566ca

6 files changed

+215
-21
lines changed

src/lib/components/TaskTables/TaskTable.svelte

+7-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
// Prepare contest table provider based on the active contest type.
3535
let activeContestType = $derived(activeContestTypeStore.get());
3636
37+
// Note: This is necessary to ensure that the active contest type is updated correctly.
38+
function updateActiveContestType(type: ContestTableProviders): void {
39+
activeContestType = type;
40+
activeContestTypeStore.set(type);
41+
}
42+
3743
let provider: ContestTableProvider = $derived(
3844
contestTableProviders[activeContestType as ContestTableProviders],
3945
);
@@ -113,7 +119,7 @@
113119
<ButtonGroup class="m-4 contents-center">
114120
{#each Object.entries(contestTableProviders) as [type, config]}
115121
<Button
116-
onclick={() => activeContestTypeStore.set(type as ContestTableProviders)}
122+
onclick={() => updateActiveContestType(type as ContestTableProviders)}
117123
class={activeContestTypeStore.isSame(type as ContestTableProviders)
118124
? 'active-button-class'
119125
: ''}

src/lib/stores/active_contest_type.svelte.ts

+24-7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useLocalStorage } from '$lib/stores/local_storage_helper.svelte';
12
import { type ContestTableProviders } from '$lib/utils/contest_table_provider';
23

34
/**
@@ -12,7 +13,10 @@ import { type ContestTableProviders } from '$lib/utils/contest_table_provider';
1213
* with a default value of 'abcLatest20Rounds'.
1314
*/
1415
export class ActiveContestTypeStore {
15-
value = $state<ContestTableProviders>('abcLatest20Rounds');
16+
private storage = useLocalStorage<ContestTableProviders>(
17+
'contest_table_providers',
18+
'abcLatest20Rounds',
19+
);
1620

1721
/**
1822
* Creates an instance with the specified contest type.
@@ -21,7 +25,9 @@ export class ActiveContestTypeStore {
2125
* Defaults to 'abcLatest20Rounds'.
2226
*/
2327
constructor(defaultContestType: ContestTableProviders = 'abcLatest20Rounds') {
24-
this.value = defaultContestType;
28+
if (defaultContestType !== 'abcLatest20Rounds' || !this.storage.value) {
29+
this.storage.value = defaultContestType;
30+
}
2531
}
2632

2733
/**
@@ -30,7 +36,7 @@ export class ActiveContestTypeStore {
3036
* @returns The current value of contest table providers.
3137
*/
3238
get(): ContestTableProviders {
33-
return this.value;
39+
return this.storage.value;
3440
}
3541

3642
/**
@@ -39,7 +45,7 @@ export class ActiveContestTypeStore {
3945
* @param newContestType - The contest type to set as the current value
4046
*/
4147
set(newContestType: ContestTableProviders): void {
42-
this.value = newContestType;
48+
this.storage.value = newContestType;
4349
}
4450

4551
/**
@@ -48,16 +54,27 @@ export class ActiveContestTypeStore {
4854
* @returns `true` if the current contest type matches the provided contest type, `false` otherwise
4955
*/
5056
isSame(contestType: ContestTableProviders): boolean {
51-
return this.value === contestType;
57+
return this.storage.value === contestType;
5258
}
5359

5460
/**
5561
* Resets the active contest type to the default value.
5662
* Sets the internal value to 'abcLatest20Rounds'.
5763
*/
5864
reset(): void {
59-
this.value = 'abcLatest20Rounds';
65+
this.storage.value = 'abcLatest20Rounds';
6066
}
6167
}
6268

63-
export const activeContestTypeStore = new ActiveContestTypeStore();
69+
let instance: ActiveContestTypeStore | null = null;
70+
71+
export function getActiveContestTypeStore(): ActiveContestTypeStore {
72+
if (!instance) {
73+
instance = new ActiveContestTypeStore();
74+
}
75+
76+
return instance;
77+
}
78+
79+
// Export the singleton instance of the store.
80+
export const activeContestTypeStore = getActiveContestTypeStore();
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { useLocalStorage } from '$lib/stores/local_storage_helper.svelte';
2+
13
export type ActiveProblemListTab = 'contestTable' | 'listByGrade' | 'gradeGuidelineTable';
24

35
export class ActiveProblemListTabStore {
4-
value = $state<ActiveProblemListTab>('contestTable');
6+
private storage = useLocalStorage<ActiveProblemListTab>(
7+
'active_problem_list_tab',
8+
'contestTable',
9+
);
510

611
/**
712
* Creates an instance with the specified problem list tab.
@@ -10,7 +15,9 @@ export class ActiveProblemListTabStore {
1015
* Defaults to 'contestTable'.
1116
*/
1217
constructor(activeTab: ActiveProblemListTab = 'contestTable') {
13-
this.value = activeTab;
18+
if (activeTab !== 'contestTable' || !this.storage.value) {
19+
this.storage.value = activeTab;
20+
}
1421
}
1522

1623
/**
@@ -19,7 +26,7 @@ export class ActiveProblemListTabStore {
1926
* @returns The current active tab.
2027
*/
2128
get(): ActiveProblemListTab {
22-
return this.value;
29+
return this.storage.value;
2330
}
2431

2532
/**
@@ -28,7 +35,7 @@ export class ActiveProblemListTabStore {
2835
* @param activeTab - The active tab to set as the current value
2936
*/
3037
set(activeTab: ActiveProblemListTab): void {
31-
this.value = activeTab;
38+
this.storage.value = activeTab;
3239
}
3340

3441
/**
@@ -37,16 +44,27 @@ export class ActiveProblemListTabStore {
3744
* @returns `true` if the active tab matches the task list, `false` otherwise
3845
*/
3946
isSame(activeTab: ActiveProblemListTab): boolean {
40-
return this.value === activeTab;
47+
return this.storage.value === activeTab;
4148
}
4249

4350
/**
4451
* Resets the active tab to the default value.
4552
* Sets the internal value to 'contestTable'.
4653
*/
4754
reset(): void {
48-
this.value = 'contestTable';
55+
this.storage.value = 'contestTable';
56+
}
57+
}
58+
59+
let instance: ActiveProblemListTabStore | null = null;
60+
61+
export function getActiveProblemListTabStore(): ActiveProblemListTabStore {
62+
if (!instance) {
63+
instance = new ActiveProblemListTabStore();
4964
}
65+
66+
return instance;
5067
}
5168

52-
export const activeProblemListTabStore = new ActiveProblemListTabStore();
69+
// Export the singleton instance of the store.
70+
export const activeProblemListTabStore = getActiveProblemListTabStore();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// See:
2+
// https://svelte.dev/docs/kit/$app-environment#browser
3+
import { browser } from '$app/environment';
4+
5+
export function useLocalStorage<T>(key: string, initialValue: T) {
6+
return new LocalStorageWrapper<T>(key, initialValue);
7+
}
8+
9+
/**
10+
* A type-safe wrapper for interacting with localStorage.
11+
*
12+
* This class provides a convenient way to store and retrieve typed data from localStorage
13+
* with automatic JSON serialization/deserialization. It gracefully handles server-side
14+
* rendering environments where localStorage is not available.
15+
*
16+
* @template T The type of value being stored
17+
*
18+
* @example
19+
* ```typescript
20+
* // Create a wrapper for a user settings object
21+
* const userSettings = new LocalStorageWrapper<UserSettings>('user_settings', defaultSettings);
22+
*
23+
* // Read the current value
24+
* const currentSettings = userSettings.value;
25+
*
26+
* // Update the value (automatically persists to localStorage)
27+
* userSettings.value = { ...currentSettings, theme: 'dark' };
28+
* ```
29+
*/
30+
class LocalStorageWrapper<T> {
31+
private _value: T;
32+
private key: string;
33+
34+
constructor(key: string, initialValue: T) {
35+
this.key = key;
36+
this._value = this.getInitialValue(initialValue);
37+
}
38+
39+
private getInitialValue(defaultValue: T): T {
40+
// WHY: Cannot access localStorage during SSR (server-side rendering).
41+
if (!browser) {
42+
return defaultValue;
43+
}
44+
45+
try {
46+
const item = localStorage.getItem(this.key);
47+
return item ? JSON.parse(item) : defaultValue;
48+
} catch (error) {
49+
console.error(`Failed to parse ${this.key} from local storage:`, error);
50+
return defaultValue;
51+
}
52+
}
53+
54+
get value(): T {
55+
return this._value;
56+
}
57+
58+
set value(newValue: T) {
59+
this._value = newValue;
60+
61+
if (browser) {
62+
localStorage.setItem(this.key, JSON.stringify(newValue));
63+
}
64+
}
65+
}

src/test/lib/stores/active_contest_type.svelte.test.ts

+47-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
1-
import { describe, test, expect, beforeEach } from 'vitest';
1+
import { describe, test, expect, vi, beforeEach } from 'vitest';
22

33
import type { ContestTableProviders } from '$lib/utils/contest_table_provider';
4-
import { ActiveContestTypeStore } from '$lib/stores/active_contest_type.svelte';
4+
import {
5+
activeContestTypeStore,
6+
ActiveContestTypeStore,
7+
} from '$lib/stores/active_contest_type.svelte';
8+
9+
vi.mock('$app/environment', () => ({
10+
browser: true,
11+
}));
512

613
describe('ActiveContestTypeStore', () => {
714
let store: ActiveContestTypeStore;
815

16+
const mockLocalStorage: Storage = {
17+
getItem: vi.fn((key) => mockStorage[key] || null),
18+
setItem: vi.fn((key, value) => {
19+
mockStorage[key] = value;
20+
}),
21+
removeItem: vi.fn(),
22+
clear: vi.fn(),
23+
length: 0,
24+
key: vi.fn(),
25+
};
26+
const mockStorage: Record<string, string> = {};
27+
928
beforeEach(() => {
29+
vi.clearAllMocks();
30+
// Setup mock for localStorage
31+
vi.stubGlobal('localStorage', mockLocalStorage);
32+
1033
store = new ActiveContestTypeStore();
34+
store.reset();
35+
});
36+
37+
afterEach(() => {
38+
vi.unstubAllGlobals();
1139
});
1240

1341
test('expects to initialize with default value', () => {
@@ -29,11 +57,9 @@ describe('ActiveContestTypeStore', () => {
2957

3058
test('expects to update the value when calling set()', () => {
3159
store.set('fromAbc212ToAbc318' as ContestTableProviders);
32-
expect(store.value).toBe('fromAbc212ToAbc318');
3360
expect(store.get()).toBe('fromAbc212ToAbc318');
3461

3562
store.set('abc319Onwards' as ContestTableProviders);
36-
expect(store.value).toBe('abc319Onwards');
3763
expect(store.get()).toBe('abc319Onwards');
3864
});
3965

@@ -70,3 +96,20 @@ describe('ActiveContestTypeStore', () => {
7096
expect(store.get()).toBe('abcLatest20Rounds');
7197
});
7298
});
99+
100+
describe('Active contest type store in SSR', () => {
101+
beforeEach(() => {
102+
vi.restoreAllMocks();
103+
vi.mock('$app/environment', () => ({
104+
browser: false,
105+
}));
106+
});
107+
108+
afterEach(() => {
109+
vi.restoreAllMocks();
110+
});
111+
112+
test('handles SSR gracefully', () => {
113+
expect(activeContestTypeStore.get()).toBe('abcLatest20Rounds');
114+
});
115+
});

src/test/lib/stores/active_problem_list_tab.svelte.test.ts

+47-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,40 @@
1-
import { describe, test, expect, beforeEach } from 'vitest';
1+
import { describe, test, expect, vi, beforeEach } from 'vitest';
22

3-
import { ActiveProblemListTabStore } from '$lib/stores/active_problem_list_tab.svelte';
3+
import {
4+
activeProblemListTabStore,
5+
ActiveProblemListTabStore,
6+
} from '$lib/stores/active_problem_list_tab.svelte';
7+
8+
vi.mock('$app/environment', () => ({
9+
browser: true,
10+
}));
411

512
describe('ActiveProblemListTabStore', () => {
613
let store: ActiveProblemListTabStore;
714

15+
const mockLocalStorage: Storage = {
16+
getItem: vi.fn((key) => mockStorage[key] || null),
17+
setItem: vi.fn((key, value) => {
18+
mockStorage[key] = value;
19+
}),
20+
removeItem: vi.fn(),
21+
clear: vi.fn(),
22+
length: 0,
23+
key: vi.fn(),
24+
};
25+
const mockStorage: Record<string, string> = {};
26+
827
beforeEach(() => {
28+
vi.clearAllMocks();
29+
// Setup mock for localStorage
30+
vi.stubGlobal('localStorage', mockLocalStorage);
31+
932
store = new ActiveProblemListTabStore();
33+
store.reset();
34+
});
35+
36+
afterEach(() => {
37+
vi.unstubAllGlobals();
1038
});
1139

1240
describe('constructor', () => {
@@ -59,3 +87,20 @@ describe('ActiveProblemListTabStore', () => {
5987
});
6088
});
6189
});
90+
91+
describe('Active problem list tab store in SSR', () => {
92+
beforeEach(() => {
93+
vi.restoreAllMocks();
94+
vi.mock('$app/environment', () => ({
95+
browser: false,
96+
}));
97+
});
98+
99+
afterEach(() => {
100+
vi.restoreAllMocks();
101+
});
102+
103+
test('handles SSR gracefully', () => {
104+
expect(activeProblemListTabStore.get()).toBe('contestTable');
105+
});
106+
});

0 commit comments

Comments
 (0)