Skip to content

Commit 60798a7

Browse files
committed
feat: persist preference when new user logs in
1 parent 86128b9 commit 60798a7

File tree

5 files changed

+235
-33
lines changed

5 files changed

+235
-33
lines changed

app/composables/useUserPreferencesProvider.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,15 @@
1313

1414
import type { RemovableRef } from '@vueuse/core'
1515
import { useLocalStorage } from '@vueuse/core'
16-
import { DEFAULT_USER_PREFERENCES, type UserPreferences } from '#shared/schemas/userPreferences'
16+
import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
17+
import {
18+
arePreferencesEqual,
19+
mergePreferences,
20+
type HydratedUserPreferences,
21+
} from '~/utils/preferences-merge'
1722

1823
const STORAGE_KEY = 'npmx-user-preferences'
1924

20-
function arePreferencesEqual(a: UserPreferences, b: UserPreferences): boolean {
21-
const keys = Object.keys(DEFAULT_USER_PREFERENCES) as (keyof typeof DEFAULT_USER_PREFERENCES)[]
22-
return keys.every(key => a[key] === b[key])
23-
}
24-
25-
export type HydratedUserPreferences = Required<Omit<UserPreferences, 'updatedAt'>> &
26-
Pick<UserPreferences, 'updatedAt'>
27-
2825
let dataRef: RemovableRef<HydratedUserPreferences> | null = null
2926
let syncInitialized = false
3027

@@ -65,12 +62,12 @@ export function useUserPreferencesProvider(
6562
setupBeforeUnload(() => preferences.value)
6663

6764
if (isAuthenticated.value) {
68-
const serverPrefs = await loadFromServer()
69-
if (serverPrefs) {
70-
const merged = { ...preferences.value, ...serverPrefs }
71-
if (!arePreferencesEqual(preferences.value, merged)) {
72-
preferences.value = merged
73-
}
65+
const serverResult = await loadFromServer()
66+
const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
67+
if (shouldPushToServer) {
68+
scheduleSync(preferences.value)
69+
} else if (!arePreferencesEqual(preferences.value, merged)) {
70+
preferences.value = merged
7471
}
7572
}
7673

@@ -86,12 +83,12 @@ export function useUserPreferencesProvider(
8683

8784
watch(isAuthenticated, async newIsAuth => {
8885
if (newIsAuth) {
89-
const serverPrefs = await loadFromServer()
90-
if (serverPrefs) {
91-
const merged = { ...defaultValue, ...preferences.value, ...serverPrefs }
92-
if (!arePreferencesEqual(preferences.value, merged)) {
93-
preferences.value = merged
94-
}
86+
const serverResult = await loadFromServer()
87+
const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
88+
if (shouldPushToServer) {
89+
scheduleSync(preferences.value)
90+
} else if (!arePreferencesEqual(preferences.value, merged)) {
91+
preferences.value = merged
9592
}
9693
}
9794
})

app/composables/useUserPreferencesSync.client.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ const SYNCED_DISPLAY_MS = 3000
66

77
type SyncStatus = 'idle' | 'syncing' | 'synced' | 'error'
88

9+
export interface ServerPreferencesResult {
10+
/** The preferences from the server, or defaults if unavailable */
11+
preferences: UserPreferences
12+
/** True when the server has no stored preferences for this user (first login) */
13+
isNewUser: boolean
14+
}
15+
916
interface PreferencesSyncState {
1017
status: Ref<SyncStatus>
1118
lastSyncedAt: Ref<Date | null>
@@ -29,14 +36,25 @@ function getSyncState(): PreferencesSyncState {
2936
return syncStateInstance
3037
}
3138

32-
async function fetchServerPreferences(): Promise<UserPreferences | null> {
39+
type FetchResult =
40+
| { status: 'found'; data: UserPreferences }
41+
| { status: 'new-user' }
42+
| { status: 'error' }
43+
44+
async function fetchServerPreferences(): Promise<FetchResult> {
3345
try {
34-
const response = await $fetch<UserPreferences>('/api/user/preferences', {
46+
const response = await $fetch<UserPreferences | null>('/api/user/preferences', {
3547
method: 'GET',
3648
})
37-
return response
49+
50+
// Server returns null when no stored preferences exist (first-time user)
51+
if (response === null) {
52+
return { status: 'new-user' }
53+
}
54+
55+
return { status: 'found', data: response }
3856
} catch {
39-
return null
57+
return { status: 'error' }
4058
}
4159
}
4260

@@ -105,21 +123,27 @@ export function useUserPreferencesSync() {
105123
}, SYNC_DEBOUNCE_MS)
106124
}
107125

108-
async function loadFromServer(): Promise<UserPreferences> {
126+
async function loadFromServer(): Promise<ServerPreferencesResult> {
109127
if (!isAuthenticated.value) {
110-
return { ...DEFAULT_USER_PREFERENCES }
128+
return { preferences: { ...DEFAULT_USER_PREFERENCES }, isNewUser: false }
111129
}
112130

113131
state.status.value = 'syncing'
114-
const serverPreferences = await fetchServerPreferences()
132+
const result = await fetchServerPreferences()
133+
134+
if (result.status === 'found') {
135+
showSyncedStatus()
136+
return { preferences: result.data, isNewUser: false }
137+
}
115138

116-
if (serverPreferences) {
139+
if (result.status === 'new-user') {
117140
showSyncedStatus()
118-
return serverPreferences
141+
return { preferences: { ...DEFAULT_USER_PREFERENCES }, isNewUser: true }
119142
}
120143

144+
// Network error — fall back to defaults, don't flag as new user
121145
state.status.value = 'idle'
122-
return { ...DEFAULT_USER_PREFERENCES }
146+
return { preferences: { ...DEFAULT_USER_PREFERENCES }, isNewUser: false }
123147
}
124148

125149
async function flushPendingSync(preferences: UserPreferences): Promise<void> {

app/utils/preferences-merge.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { UserPreferences } from '#shared/schemas/userPreferences'
2+
import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
3+
import type { ServerPreferencesResult } from '~/composables/useUserPreferencesSync.client'
4+
5+
export type HydratedUserPreferences = Required<Omit<UserPreferences, 'updatedAt'>> &
6+
Pick<UserPreferences, 'updatedAt'>
7+
8+
export function arePreferencesEqual(a: UserPreferences, b: UserPreferences): boolean {
9+
const keys = Object.keys(DEFAULT_USER_PREFERENCES) as (keyof typeof DEFAULT_USER_PREFERENCES)[]
10+
return keys.every(key => a[key] === b[key])
11+
}
12+
13+
/**
14+
* Merge local preferences with server result.
15+
* - New user (first login): local wins, should be pushed to server.
16+
* - Returning user: server takes precedence, local fills any missing keys.
17+
*/
18+
export function mergePreferences(
19+
localPrefs: HydratedUserPreferences,
20+
serverResult: ServerPreferencesResult,
21+
): { merged: HydratedUserPreferences; shouldPushToServer: boolean } {
22+
if (serverResult.isNewUser) {
23+
return { merged: localPrefs, shouldPushToServer: true }
24+
}
25+
26+
const merged: HydratedUserPreferences = { ...localPrefs, ...serverResult.preferences }
27+
return { merged, shouldPushToServer: false }
28+
}

server/api/user/preferences.get.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { safeParse } from 'valibot'
22
import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
3-
import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
43
import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store'
54

65
export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => {
@@ -11,5 +10,8 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSe
1110

1211
const preferences = await useUserPreferencesStore().get(session.output.did)
1312

14-
return preferences ?? { ...DEFAULT_USER_PREFERENCES, updatedAt: new Date().toISOString() }
13+
// Return null when no stored preferences exist (first-time user).
14+
// This lets the client distinguish "no server prefs" from "user has default prefs"
15+
// so that anonymous localStorage customizations can be preserved on first login.
16+
return preferences
1517
})
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { DEFAULT_USER_PREFERENCES, type UserPreferences } from '#shared/schemas/userPreferences'
3+
import type { ServerPreferencesResult } from '~/composables/useUserPreferencesSync.client'
4+
import {
5+
arePreferencesEqual,
6+
mergePreferences,
7+
type HydratedUserPreferences,
8+
} from '~/utils/preferences-merge'
9+
10+
describe('user preferences merge logic', () => {
11+
const defaults: HydratedUserPreferences = { ...DEFAULT_USER_PREFERENCES }
12+
13+
describe('arePreferencesEqual', () => {
14+
it('returns true when all preference keys match', () => {
15+
const a = { ...defaults, accentColorId: 'rose' }
16+
const b = { ...defaults, accentColorId: 'rose' }
17+
expect(arePreferencesEqual(a, b)).toBe(true)
18+
})
19+
20+
it('returns false when a preference key differs', () => {
21+
const a = { ...defaults, accentColorId: 'rose' }
22+
const b = { ...defaults, accentColorId: 'amber' }
23+
expect(arePreferencesEqual(a, b)).toBe(false)
24+
})
25+
26+
it('ignores updatedAt when comparing', () => {
27+
const a = { ...defaults, updatedAt: '2025-01-01T00:00:00Z' }
28+
const b = { ...defaults, updatedAt: '2026-02-28T12:00:00Z' }
29+
expect(arePreferencesEqual(a, b)).toBe(true)
30+
})
31+
})
32+
33+
describe('first-time user (isNewUser: true)', () => {
34+
it('preserves local preferences when server has no stored prefs', () => {
35+
const localPrefs: HydratedUserPreferences = {
36+
...defaults,
37+
accentColorId: 'rose',
38+
colorModePreference: 'dark',
39+
selectedLocale: 'de',
40+
}
41+
42+
const serverResult: ServerPreferencesResult = {
43+
preferences: { ...DEFAULT_USER_PREFERENCES },
44+
isNewUser: true,
45+
}
46+
47+
const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult)
48+
49+
expect(merged.accentColorId).toBe('rose')
50+
expect(merged.colorModePreference).toBe('dark')
51+
expect(merged.selectedLocale).toBe('de')
52+
expect(shouldPushToServer).toBe(true)
53+
})
54+
55+
it('local prefs are returned unchanged', () => {
56+
const localPrefs: HydratedUserPreferences = {
57+
...defaults,
58+
relativeDates: true,
59+
keyboardShortcuts: false,
60+
}
61+
62+
const serverResult: ServerPreferencesResult = {
63+
preferences: { ...DEFAULT_USER_PREFERENCES },
64+
isNewUser: true,
65+
}
66+
67+
const { merged } = mergePreferences(localPrefs, serverResult)
68+
69+
expect(merged).toEqual(localPrefs)
70+
})
71+
})
72+
73+
describe('returning user (isNewUser: false)', () => {
74+
it('server preferences override local preferences', () => {
75+
const localPrefs: HydratedUserPreferences = {
76+
...defaults,
77+
accentColorId: 'rose',
78+
colorModePreference: 'dark',
79+
}
80+
81+
const serverPrefs: UserPreferences = {
82+
...DEFAULT_USER_PREFERENCES,
83+
accentColorId: 'amber',
84+
colorModePreference: 'light',
85+
updatedAt: '2026-01-15T10:00:00Z',
86+
}
87+
88+
const serverResult: ServerPreferencesResult = {
89+
preferences: serverPrefs,
90+
isNewUser: false,
91+
}
92+
93+
const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult)
94+
95+
expect(merged.accentColorId).toBe('amber')
96+
expect(merged.colorModePreference).toBe('light')
97+
expect(shouldPushToServer).toBe(false)
98+
})
99+
100+
it('local preferences fill new keys not yet stored on server (schema migration)', () => {
101+
const localPrefs: HydratedUserPreferences = {
102+
...defaults,
103+
accentColorId: 'rose',
104+
selectedLocale: 'ja',
105+
}
106+
107+
// Simulates a server response from before a new preference key was added:
108+
// the server has accentColorId but not selectedLocale (added later)
109+
const serverPrefs: UserPreferences = {
110+
accentColorId: 'emerald',
111+
updatedAt: '2026-01-15T10:00:00Z',
112+
}
113+
114+
const serverResult: ServerPreferencesResult = {
115+
preferences: serverPrefs,
116+
isNewUser: false,
117+
}
118+
119+
const { merged } = mergePreferences(localPrefs, serverResult)
120+
121+
// Server wins on accentColorId
122+
expect(merged.accentColorId).toBe('emerald')
123+
// Local fills in selectedLocale (not in server response)
124+
expect(merged.selectedLocale).toBe('ja')
125+
})
126+
127+
it('returning user with default server prefs keeps defaults (not a false first-login)', () => {
128+
const localPrefs: HydratedUserPreferences = {
129+
...defaults,
130+
accentColorId: 'rose',
131+
}
132+
133+
// User explicitly saved defaults on another device
134+
const serverPrefs: UserPreferences = {
135+
...DEFAULT_USER_PREFERENCES,
136+
updatedAt: '2026-02-01T00:00:00Z',
137+
}
138+
139+
const serverResult: ServerPreferencesResult = {
140+
preferences: serverPrefs,
141+
isNewUser: false,
142+
}
143+
144+
const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult)
145+
146+
// Server wins — user intentionally has defaults
147+
expect(merged.accentColorId).toBeNull()
148+
expect(shouldPushToServer).toBe(false)
149+
})
150+
})
151+
})

0 commit comments

Comments
 (0)