diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc6afeb794..cb3bea5669 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,6 +166,7 @@ The `.cache/` directory is a separate storage mount used for fetch-cache and atp app/ # Nuxt 4 app directory ├── components/ # Vue components (PascalCase.vue) ├── composables/ # Vue composables (useFeature.ts) +│ └── userPreferences/ # User preference composables (synced to server) ├── pages/ # File-based routing ├── plugins/ # Nuxt plugins ├── app.vue # Root component @@ -189,6 +190,9 @@ test/ # Vitest tests > [!TIP] > For more about the meaning of these directories, check out the docs on the [Nuxt directory structure](https://nuxt.com/docs/4.x/directory-structure). +> [!TIP] +> For guidance on working with user preferences and local settings, see the [User Preferences README](./app/composables/userPreferences/README.md). + ### Local connector CLI The `cli/` workspace contains a local connector that enables authenticated npm operations from the web UI. It runs on your machine and uses your existing npm credentials. diff --git a/app/app.vue b/app/app.vue index 5916e3e328..e2c93665f3 100644 --- a/app/app.vue +++ b/app/app.vue @@ -47,15 +47,15 @@ if (import.meta.server) { setJsonLd(createWebSiteSchema()) } -const keyboardShortcuts = useKeyboardShortcuts() -const { settings } = useSettings() +const keyboardShortcuts = useKeyboardShortcutsPreference() +const instantSearch = useInstantSearchPreference() onKeyDown( '/', e => { if (e.ctrlKey) { e.preventDefault() - settings.value.instantSearch = !settings.value.instantSearch + instantSearch.value = !instantSearch.value return } diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue index 5febbd6fa7..0a4e5d7c77 100644 --- a/app/components/AppHeader.vue +++ b/app/components/AppHeader.vue @@ -4,7 +4,7 @@ import type { NavigationConfig, NavigationConfigWithGroups } from '~/types' import { isEditableElement } from '~/utils/input' import { NPMX_DOCS_SITE } from '#shared/utils/constants' -const keyboardShortcuts = useKeyboardShortcuts() +const keyboardShortcuts = useKeyboardShortcutsPreference() const discord = useDiscordLink() withDefaults( diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue index f37d57be06..b816af7bf0 100644 --- a/app/components/Button/Base.vue +++ b/app/components/Button/Base.vue @@ -26,7 +26,7 @@ const props = withDefaults( const el = useTemplateRef('el') -const keyboardShortcutsEnabled = useKeyboardShortcuts() +const keyboardShortcutsEnabled = useKeyboardShortcutsPreference() defineExpose({ focus: () => el.value?.focus(), diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue index 961e3502e5..8509a4c353 100644 --- a/app/components/CollapsibleSection.vue +++ b/app/components/CollapsibleSection.vue @@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), { headingLevel: 'h2', }) -const appSettings = useSettings() +const { localSettings } = useUserLocalSettings() const buttonId = `${props.id}-collapsible-button` const contentId = `${props.id}-collapsible-content` @@ -48,17 +48,16 @@ onMounted(() => { function toggle() { isOpen.value = !isOpen.value - const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id) + const removed = localSettings.value.sidebar.collapsed.filter(c => c !== props.id) if (isOpen.value) { - appSettings.settings.value.sidebar.collapsed = removed + localSettings.value.sidebar.collapsed = removed } else { removed.push(props.id) - appSettings.settings.value.sidebar.collapsed = removed + localSettings.value.sidebar.collapsed = removed } - document.documentElement.dataset.collapsed = - appSettings.settings.value.sidebar.collapsed.join(' ') + document.documentElement.dataset.collapsed = localSettings.value.sidebar.collapsed.join(' ') } const ariaLabel = computed(() => { diff --git a/app/components/Compare/PackageSelector.vue b/app/components/Compare/PackageSelector.vue index 43e7ec81c7..80a88cdbca 100644 --- a/app/components/Compare/PackageSelector.vue +++ b/app/components/Compare/PackageSelector.vue @@ -75,7 +75,7 @@ const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0)) const numberFormatter = useNumberFormatter() -const keyboardShortcuts = useKeyboardShortcuts() +const keyboardShortcuts = useKeyboardShortcutsPreference() function addPackage(name: string) { if (packages.value.length >= maxPackages.value) return diff --git a/app/components/DateTime.vue b/app/components/DateTime.vue index e2fc331bb3..6b19f445ac 100644 --- a/app/components/DateTime.vue +++ b/app/components/DateTime.vue @@ -31,7 +31,7 @@ const props = withDefaults( const { locale } = useI18n() -const relativeDates = useRelativeDates() +const relativeDates = useRelativeDatesPreference() const dateFormatter = new Intl.DateTimeFormat(locale.value, { month: 'short', diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue index 3ef85ff2fc..8fc4dd09ef 100644 --- a/app/components/Header/ConnectorModal.vue +++ b/app/components/Header/ConnectorModal.vue @@ -2,7 +2,7 @@ const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } = useConnector() -const { settings } = useSettings() +const { localSettings } = useUserLocalSettings() const tokenInput = shallowRef('') const portInput = shallowRef('31415') @@ -67,7 +67,7 @@ const executeNpmxConnectorCommand = computed(() => {
@@ -158,7 +158,7 @@ const executeNpmxConnectorCommand = computed(() => {
diff --git a/app/components/Input/Base.vue b/app/components/Input/Base.vue index 2154332617..d8d5321cb0 100644 --- a/app/components/Input/Base.vue +++ b/app/components/Input/Base.vue @@ -30,7 +30,7 @@ const emit = defineEmits<{ const el = useTemplateRef('el') -const keyboardShortcutsEnabled = useKeyboardShortcuts() +const keyboardShortcutsEnabled = useKeyboardShortcutsPreference() defineExpose({ focus: () => el.value?.focus(), diff --git a/app/components/InstantSearch.vue b/app/components/InstantSearch.vue index ae721f8376..1e8929fb1d 100644 --- a/app/components/InstantSearch.vue +++ b/app/components/InstantSearch.vue @@ -1,11 +1,14 @@ + + diff --git a/app/plugins/i18n-loader.client.ts b/app/plugins/i18n-loader.client.ts index b34894396f..9479bfa60b 100644 --- a/app/plugins/i18n-loader.client.ts +++ b/app/plugins/i18n-loader.client.ts @@ -1,20 +1,18 @@ export default defineNuxtPlugin({ + name: 'i18n-loader', + dependsOn: ['preferences-sync'], enforce: 'post', env: { islands: false }, setup() { const { $i18n } = useNuxtApp() const { locale, locales, setLocale } = $i18n - const { settings } = useSettings() - const settingsLocale = settings.value.selectedLocale + const { preferences } = useUserPreferencesState() + const settingsLocale = preferences.value.selectedLocale - if ( - settingsLocale && - // Check if the value is a supported locale - locales.value.map(l => l.code).includes(settingsLocale) && - // Check if the value is not a current locale - settingsLocale !== locale.value - ) { - setLocale(settingsLocale) + const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) + + if (matchedLocale && matchedLocale !== locale.value) { + setLocale(matchedLocale) } }, }) diff --git a/app/plugins/preferences-sync.client.ts b/app/plugins/preferences-sync.client.ts new file mode 100644 index 0000000000..cf58122d6a --- /dev/null +++ b/app/plugins/preferences-sync.client.ts @@ -0,0 +1,13 @@ +export default defineNuxtPlugin({ + name: 'preferences-sync', + setup() { + const { initSync } = useInitUserPreferencesSync() + const { applyStoredColorMode } = useColorModePreference() + + // Apply stored color mode preference early (before components mount) + applyStoredColorMode() + + // Initialize server sync for authenticated users + initSync() + }, +}) diff --git a/app/utils/preferences-merge.ts b/app/utils/preferences-merge.ts new file mode 100644 index 0000000000..50901fe1df --- /dev/null +++ b/app/utils/preferences-merge.ts @@ -0,0 +1,28 @@ +import type { UserPreferences } from '#shared/schemas/userPreferences' +import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences' +import type { ServerPreferencesResult } from '~/composables/useUserPreferencesSync.client' + +export type HydratedUserPreferences = Required> & + Pick + +export function arePreferencesEqual(a: UserPreferences, b: UserPreferences): boolean { + const keys = Object.keys(DEFAULT_USER_PREFERENCES) as (keyof typeof DEFAULT_USER_PREFERENCES)[] + return keys.every(key => a[key] === b[key]) +} + +/** + * Merge local preferences with server result. + * - New user (first login): local wins, should be pushed to server. + * - Returning user: server takes precedence, local fills any missing keys. + */ +export function mergePreferences( + localPrefs: HydratedUserPreferences, + serverResult: ServerPreferencesResult, +): { merged: HydratedUserPreferences; shouldPushToServer: boolean } { + if (serverResult.isNewUser) { + return { merged: localPrefs, shouldPushToServer: true } + } + + const merged: HydratedUserPreferences = { ...localPrefs, ...serverResult.preferences } + return { merged, shouldPushToServer: false } +} diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts index 223771b720..23a22053c0 100644 --- a/app/utils/prehydrate.ts +++ b/app/utils/prehydrate.ts @@ -1,3 +1,6 @@ +import type { UserPreferences } from '#shared/schemas/userPreferences' +import type { UserLocalSettings } from '~/composables/useUserLocalSettings' + /** * Initialize user preferences before hydration to prevent flash/layout shift. * This sets CSS custom properties and data attributes that CSS can use @@ -9,6 +12,61 @@ export function initPreferencesOnPrehydrate() { // Callback is stringified by Nuxt - external variables won't be available. // All constants must be hardcoded inside the callback. onPrehydrate(() => { + // See comment above for oxlint-disable reason + // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping) + function getValueFromLs(lsKey: string): T | undefined { + try { + const value = localStorage.getItem(lsKey) + if (value) { + const parsed = JSON.parse(value) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed + } + } + } catch { + return undefined + } + } + // oxlint-disable-next-line eslint-plugin-unicorn(consistent-function-scoping) + function migrateLegacySettings() { + const migrationFlag = 'npmx-prefs-migrated' + if (localStorage.getItem(migrationFlag)) return + + const legacySettings = getValueFromLs('npmx-settings') || {} + let userPreferences = getValueFromLs('npmx-user-preferences') || {} + + const migratableKeys = [ + 'accentColorId', + 'preferredBackgroundTheme', + 'selectedLocale', + 'relativeDates', + ] as const + + const keysToMigrate = migratableKeys.filter( + key => key in legacySettings && !(key in userPreferences), + ) + + if (keysToMigrate.length > 0) { + const migrated = Object.fromEntries(keysToMigrate.map(key => [key, legacySettings[key]])) + userPreferences = { ...userPreferences, ...migrated } + localStorage.setItem('npmx-user-preferences', JSON.stringify(userPreferences)) + } + + // Clean migrated fields from legacy storage + const keysToRemove = migratableKeys.filter(key => key in legacySettings) + if (keysToRemove.length > 0) { + const cleaned = { ...legacySettings } + for (const key of keysToRemove) { + delete cleaned[key] + } + localStorage.setItem('npmx-settings', JSON.stringify(cleaned)) + } + + localStorage.setItem(migrationFlag, '1') + } + + migrateLegacySettings() + // Valid accent color IDs (must match --swatch-* variables defined in main.css) const accentColorIds = new Set([ 'sky', @@ -23,18 +81,16 @@ export function initPreferencesOnPrehydrate() { // Valid package manager IDs const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt']) - // Read settings from localStorage - const settings = JSON.parse( - localStorage.getItem('npmx-settings') || '{}', - ) as Partial + // Read user preferences from localStorage + const preferences = getValueFromLs('npmx-user-preferences') || {} - const accentColorId = settings.accentColorId + const accentColorId = preferences.accentColorId if (accentColorId && accentColorIds.has(accentColorId)) { document.documentElement.style.setProperty('--accent-color', `var(--swatch-${accentColorId})`) } // Apply background accent - const preferredBackgroundTheme = settings.preferredBackgroundTheme + const preferredBackgroundTheme = preferences.preferredBackgroundTheme if (preferredBackgroundTheme) { document.documentElement.dataset.bgTheme = preferredBackgroundTheme } @@ -60,11 +116,7 @@ export function initPreferencesOnPrehydrate() { // Set data attribute for CSS-based visibility document.documentElement.dataset.pm = pm - document.documentElement.dataset.collapsed = settings.sidebar?.collapsed?.join(' ') ?? '' - - // Keyboard shortcuts (default: true) - if (settings.keyboardShortcuts === false) { - document.documentElement.dataset.kbdShortcuts = 'false' - } + const localSettings = getValueFromLs>('npmx-settings') || {} + document.documentElement.dataset.collapsed = localSettings.sidebar?.collapsed?.join(' ') ?? '' }) } diff --git a/app/utils/storage.ts b/app/utils/storage.ts new file mode 100644 index 0000000000..9706226a3f --- /dev/null +++ b/app/utils/storage.ts @@ -0,0 +1,34 @@ +export interface StorageProvider { + get: () => T | null + set: (value: T) => void + remove: () => void +} + +export function createLocalStorageProvider(key: string): StorageProvider { + return { + get: () => { + if (import.meta.server) return null + try { + const stored = localStorage.getItem(key) + if (stored) { + return JSON.parse(stored) as T + } + } catch { + localStorage.removeItem(key) + } + return null + }, + set: (value: T) => { + if (import.meta.server) return + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Storage full or other error, fail silently + } + }, + remove: () => { + if (import.meta.server) return + localStorage.removeItem(key) + }, + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 00ba413a5b..6ccdb6566d 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -147,7 +147,11 @@ "translation_progress": "Translation progress", "background_themes": "Background shade", "keyboard_shortcuts_enabled": "Enable keyboard shortcuts", - "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts" + "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts", + "syncing": "Syncing...", + "synced": "Settings synced", + "sync_error": "Sync failed", + "sync_enabled": "Cloud sync enabled" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/i18n/schema.json b/i18n/schema.json index e3c3c0a4b4..e4536a67c0 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -447,6 +447,18 @@ }, "keyboard_shortcuts_enabled_description": { "type": "string" + }, + "syncing": { + "type": "string" + }, + "synced": { + "type": "string" + }, + "sync_error": { + "type": "string" + }, + "sync_enabled": { + "type": "string" } }, "additionalProperties": false diff --git a/modules/cache.ts b/modules/cache.ts index ab317475af..9a89eb1f6e 100644 --- a/modules/cache.ts +++ b/modules/cache.ts @@ -51,6 +51,9 @@ export default defineNuxtModule({ const env = process.env.VERCEL_ENV nitroConfig.storage.atproto = env === 'production' ? upstash : { driver: 'vercel-runtime-cache' } + + nitroConfig.storage['user-preferences'] = + env === 'production' ? upstash : { driver: 'vercel-runtime-cache' } }) }, }) diff --git a/nuxt.config.ts b/nuxt.config.ts index 3a4c167ece..50382e216c 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -137,6 +137,7 @@ export default defineNuxtConfig({ // never cache '/api/auth/**': { isr: false, cache: false }, '/api/social/**': { isr: false, cache: false }, + '/api/user/**': { isr: false, cache: false }, '/api/atproto/bluesky-comments': { isr: { expiration: 60 * 60 /* one hour */, @@ -243,6 +244,10 @@ export default defineNuxtConfig({ driver: 'fsLite', base: './.cache/atproto', }, + 'user-preferences': { + driver: 'fsLite', + base: './.cache/user-preferences', + }, }, typescript: { tsConfig: { diff --git a/server/api/user/preferences.get.ts b/server/api/user/preferences.get.ts new file mode 100644 index 0000000000..50cfb9d268 --- /dev/null +++ b/server/api/user/preferences.get.ts @@ -0,0 +1,17 @@ +import { safeParse } from 'valibot' +import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' +import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const session = safeParse(PublicUserSessionSchema, serverSession.data.public) + if (!session.success) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const preferences = await useUserPreferencesStore().get(session.output.did) + + // Return null when no stored preferences exist (first-time user). + // This lets the client distinguish "no server prefs" from "user has default prefs" + // so that anonymous localStorage customizations can be preserved on first login. + return preferences +}) diff --git a/server/api/user/preferences.post.ts b/server/api/user/preferences.post.ts new file mode 100644 index 0000000000..9fd1a91b56 --- /dev/null +++ b/server/api/user/preferences.post.ts @@ -0,0 +1 @@ +export { default } from './preferences.put' diff --git a/server/api/user/preferences.put.ts b/server/api/user/preferences.put.ts new file mode 100644 index 0000000000..9ab6e85582 --- /dev/null +++ b/server/api/user/preferences.put.ts @@ -0,0 +1,20 @@ +import { safeParse } from 'valibot' +import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' +import { UserPreferencesSchema } from '#shared/schemas/userPreferences' +import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const session = safeParse(PublicUserSessionSchema, serverSession.data.public) + if (!session.success) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const settings = safeParse(UserPreferencesSchema, await readBody(event)) + if (!settings.success) { + throw createError({ statusCode: 400, message: 'Invalid settings format' }) + } + + await useUserPreferencesStore().set(session.output.did, settings.output) + + return { success: true } +}) diff --git a/server/utils/preferences/user-preferences-store.ts b/server/utils/preferences/user-preferences-store.ts new file mode 100644 index 0000000000..bde1d7cfad --- /dev/null +++ b/server/utils/preferences/user-preferences-store.ts @@ -0,0 +1,43 @@ +import type { UserPreferences } from '#shared/schemas/userPreferences' +import { + USER_PREFERENCES_STORAGE_BASE, + DEFAULT_USER_PREFERENCES, +} from '#shared/schemas/userPreferences' + +export class UserPreferencesStore { + private readonly storage = useStorage(USER_PREFERENCES_STORAGE_BASE) + + async get(did: string): Promise { + const result = await this.storage.getItem(did) + return result ?? null + } + + async set(did: string, preferences: UserPreferences): Promise { + const withTimestamp: UserPreferences = { + ...preferences, + updatedAt: new Date().toISOString(), + } + await this.storage.setItem(did, withTimestamp) + return withTimestamp + } + + async merge(did: string, partial: Partial): Promise { + const existing = await this.get(did) + const base = existing ?? { ...DEFAULT_USER_PREFERENCES } + + return this.set(did, { ...base, ...partial }) + } + + async delete(did: string): Promise { + await this.storage.removeItem(did) + } +} + +let storeInstance: UserPreferencesStore | null = null + +export function useUserPreferencesStore(): UserPreferencesStore { + if (!storeInstance) { + storeInstance = new UserPreferencesStore() + } + return storeInstance +} diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts new file mode 100644 index 0000000000..6ebc8af231 --- /dev/null +++ b/shared/schemas/userPreferences.ts @@ -0,0 +1,61 @@ +import { object, string, boolean, nullable, optional, picklist, type InferOutput } from 'valibot' +import { ACCENT_COLORS, BACKGROUND_THEMES } from '#shared/utils/constants' + +const AccentColorIdSchema = picklist(Object.keys(ACCENT_COLORS.light) as [string, ...string[]]) + +const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [string, ...string[]]) + +const ColorModePreferenceSchema = picklist(['light', 'dark', 'system']) + +const SearchProviderSchema = picklist(['npm', 'algolia']) + +export const UserPreferencesSchema = object({ + /** Display dates as relative (e.g., "3 days ago") instead of absolute */ + relativeDates: optional(boolean()), + /** Include @types/* package in install command for packages without built-in types */ + includeTypesInInstall: optional(boolean()), + /** Accent color theme ID */ + accentColorId: optional(nullable(AccentColorIdSchema)), + /** Preferred background shade */ + preferredBackgroundTheme: optional(nullable(BackgroundThemeIdSchema)), + /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ + hidePlatformPackages: optional(boolean()), + /** User-selected locale code */ + selectedLocale: optional(nullable(string())), + /** Color mode preference: 'light', 'dark', or 'system' */ + colorModePreference: optional(nullable(ColorModePreferenceSchema)), + /** Search provider for package search: 'npm' or 'algolia' */ + searchProvider: optional(SearchProviderSchema), + /** Whether keyboard shortcuts are enabled globally */ + keyboardShortcuts: optional(boolean()), + /** Whether search runs as user types (vs requiring explicit submit) */ + instantSearch: optional(boolean()), + /** Timestamp of last update (ISO 8601) - managed by server */ + updatedAt: optional(string()), +}) + +export type UserPreferences = InferOutput + +export type AccentColorId = keyof typeof ACCENT_COLORS.light +export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES +export type ColorModePreference = 'light' | 'dark' | 'system' +export type SearchProvider = 'npm' | 'algolia' + +/** + * Default user preferences. + * Used when creating new user preferences or merging with partial updates. + */ +export const DEFAULT_USER_PREFERENCES: Required> = { + relativeDates: false, + includeTypesInInstall: true, + accentColorId: null, + preferredBackgroundTheme: null, + hidePlatformPackages: true, + selectedLocale: null, + colorModePreference: null, + searchProvider: import.meta.test ? 'npm' : 'algolia', + keyboardShortcuts: true, + instantSearch: true, +} + +export const USER_PREFERENCES_STORAGE_BASE = 'npmx-kv-user-preferences' diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts index 0f1c6ae9e4..bcd3c3fbab 100644 --- a/test/e2e/hydration.spec.ts +++ b/test/e2e/hydration.spec.ts @@ -49,7 +49,7 @@ test.describe('Hydration', () => { for (const page of PAGES) { test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => { await injectLocalStorage(pw, { - 'npmx-settings': JSON.stringify({ accentColorId: 'violet' }), + 'npmx-user-preferences': JSON.stringify({ accentColorId: 'violet' }), }) await goto(page, { waitUntil: 'hydration' }) @@ -63,7 +63,7 @@ test.describe('Hydration', () => { for (const page of PAGES) { test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => { await injectLocalStorage(pw, { - 'npmx-settings': JSON.stringify({ preferredBackgroundTheme: 'slate' }), + 'npmx-user-preferences': JSON.stringify({ preferredBackgroundTheme: 'slate' }), }) await goto(page, { waitUntil: 'hydration' }) @@ -91,7 +91,7 @@ test.describe('Hydration', () => { for (const page of PAGES) { test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => { await injectLocalStorage(pw, { - 'npmx-settings': JSON.stringify({ selectedLocale: 'ar-EG' }), + 'npmx-user-preferences': JSON.stringify({ selectedLocale: 'ar-EG' }), }) await goto(page, { waitUntil: 'hydration' }) @@ -105,7 +105,7 @@ test.describe('Hydration', () => { for (const page of PAGES) { test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => { await injectLocalStorage(pw, { - 'npmx-settings': JSON.stringify({ relativeDates: true }), + 'npmx-user-preferences': JSON.stringify({ relativeDates: true }), }) await goto(page, { waitUntil: 'hydration' }) diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts index 2737778ce0..ae742441ea 100644 --- a/test/e2e/interactions.spec.ts +++ b/test/e2e/interactions.spec.ts @@ -271,7 +271,7 @@ test.describe('Keyboard Shortcuts', () => { test.describe('Keyboard Shortcuts disabled', () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { - localStorage.setItem('npmx-settings', JSON.stringify({ keyboardShortcuts: false })) + localStorage.setItem('npmx-user-preferences', JSON.stringify({ keyboardShortcuts: false })) }) }) diff --git a/test/e2e/legacy-settings-migration.spec.ts b/test/e2e/legacy-settings-migration.spec.ts new file mode 100644 index 0000000000..e739d99730 --- /dev/null +++ b/test/e2e/legacy-settings-migration.spec.ts @@ -0,0 +1,205 @@ +import type { Page } from '@playwright/test' +import { expect, test } from './test-utils' + +const LS_USER_PREFERENCES = 'npmx-user-preferences' +const LS_LOCAL_SETTINGS = 'npmx-settings' +const LS_MIGRATION_FLAG = 'npmx-prefs-migrated' + +const MIGRATABLE_KEYS = [ + 'accentColorId', + 'preferredBackgroundTheme', + 'selectedLocale', + 'relativeDates', +] as const + +async function injectLocalStorage(page: Page, entries: Record) { + await page.addInitScript((e: Record) => { + for (const [key, value] of Object.entries(e)) { + localStorage.setItem(key, value) + } + }, entries) +} + +function readLs(page: Page, key: string) { + return page.evaluate((k: string) => localStorage.getItem(k), key) +} + +function readLsJson(page: Page, key: string) { + return page.evaluate((k: string) => { + const raw = localStorage.getItem(k) + return raw ? JSON.parse(raw) : null + }, key) +} + +async function verifyDefaults(page: Page) { + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + expect(prefs.accentColorId).toBeNull() + expect(prefs.preferredBackgroundTheme).toBeNull() + expect(prefs.selectedLocale).toBeNull() + expect(prefs.relativeDates).toBe(false) +} + +async function verifyLegacyCleaned(page: Page) { + const remaining = await readLsJson(page, LS_LOCAL_SETTINGS) + for (const key of MIGRATABLE_KEYS) { + expect(remaining).not.toHaveProperty(key) + } +} + +test.describe('Legacy settings migration', () => { + test('migrates all legacy keys to user preferences', async ({ page, goto }) => { + const legacy = { + accentColorId: 'violet', + preferredBackgroundTheme: 'slate', + selectedLocale: 'de', + relativeDates: true, + // non-migratable key should remain untouched + sidebar: { collapsed: ['deps'] }, + } + + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify(legacy), + }) + + await goto('/', { waitUntil: 'hydration' }) + + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + expect(prefs).toMatchObject({ + accentColorId: 'violet', + preferredBackgroundTheme: 'slate', + selectedLocale: 'de', + relativeDates: true, + }) + + await verifyLegacyCleaned(page) + const localSettings = await readLsJson(page, LS_LOCAL_SETTINGS) + expect(localSettings).toMatchObject({ + sidebar: { collapsed: ['deps'] }, + }) + }) + + test('does not overwrite existing user preferences', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'coral', relativeDates: false }), + [LS_USER_PREFERENCES]: JSON.stringify({ accentColorId: 'violet' }), + }) + + await goto('/', { waitUntil: 'hydration' }) + + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + // accentColorId should remain 'violet' (not overwritten by legacy 'coral') + expect(prefs.accentColorId).toBe('violet') + // relativeDates was not in user prefs, so it should be migrated from legacy + expect(prefs.relativeDates).toBe(false) + + await verifyLegacyCleaned(page) + }) + + test('cleans migrated keys from legacy storage', async ({ page, goto }) => { + const legacy = { + accentColorId: 'violet', + preferredBackgroundTheme: 'slate', + sidebar: { collapsed: ['deps'] }, + } + + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify(legacy), + }) + + await goto('/', { waitUntil: 'hydration' }) + + await verifyLegacyCleaned(page) + const remaining = await readLsJson(page, LS_LOCAL_SETTINGS) + expect(remaining).toHaveProperty('sidebar') + }) + + test('sets migration flag after completion', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'violet' }), + }) + + await goto('/', { waitUntil: 'hydration' }) + + const flag = await readLs(page, LS_MIGRATION_FLAG) + expect(flag).toBe('1') + }) + + test('skips migration if flag is already set', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'coral' }), + [LS_MIGRATION_FLAG]: '1', + }) + + await goto('/', { waitUntil: 'hydration' }) + + // Legacy accentColorId should NOT have been migrated since flag was already set + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + expect(prefs?.accentColorId).not.toBe('coral') + }) + + test('applies migrated accent color to DOM', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify({ accentColorId: 'violet' }), + }) + + await goto('/', { waitUntil: 'hydration' }) + + const accentColor = await page.evaluate(() => + document.documentElement.style.getPropertyValue('--accent-color'), + ) + expect(accentColor).toBe('var(--swatch-violet)') + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + expect(prefs?.accentColorId).toBe('violet') + + await verifyLegacyCleaned(page) + }) + + test('applies migrated background theme to DOM', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify({ preferredBackgroundTheme: 'slate' }), + }) + + await goto('/', { waitUntil: 'hydration' }) + + const bgTheme = await page.evaluate(() => document.documentElement.dataset.bgTheme) + expect(bgTheme).toBe('slate') + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + expect(prefs?.preferredBackgroundTheme).toBe('slate') + + await verifyLegacyCleaned(page) + }) + + test('handles empty legacy storage gracefully', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_LOCAL_SETTINGS]: JSON.stringify({}), + }) + + await goto('/', { waitUntil: 'hydration' }) + + const flag = await readLs(page, LS_MIGRATION_FLAG) + expect(flag).toBe('1') + + await verifyDefaults(page) + }) + + test('handles missing legacy storage gracefully', async ({ page, goto }) => { + // No npmx-settings at all — migration should still set the flag + await goto('/', { waitUntil: 'hydration' }) + + const flag = await readLs(page, LS_MIGRATION_FLAG) + expect(flag).toBe('1') + await verifyDefaults(page) + }) + + test('handles missing legacy storage and applies current', async ({ page, goto }) => { + await injectLocalStorage(page, { + [LS_USER_PREFERENCES]: JSON.stringify({ accentColorId: 'violet' }), + }) + await goto('/', { waitUntil: 'hydration' }) + + const flag = await readLs(page, LS_MIGRATION_FLAG) + expect(flag).toBe('1') + const prefs = await readLsJson(page, LS_USER_PREFERENCES) + expect(prefs?.accentColorId).toBe('violet') + }) +}) diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts index 1ae7c16443..e65af1f935 100644 --- a/test/nuxt/components/DateTime.spec.ts +++ b/test/nuxt/components/DateTime.spec.ts @@ -2,14 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import DateTime from '~/components/DateTime.vue' -// Mock the useRelativeDates composable +// Mock the useRelativeDatesPreference composable const mockRelativeDates = shallowRef(false) -vi.mock('~/composables/useSettings', () => ({ - useRelativeDates: () => mockRelativeDates, - useSettings: () => ({ - settings: ref({ relativeDates: mockRelativeDates.value }), - }), - useAccentColor: () => ({}), +vi.mock('~/composables/userPreferences/useRelativeDatesPreference', () => ({ + useRelativeDatesPreference: () => mockRelativeDates, })) describe('DateTime', () => { diff --git a/test/nuxt/components/HeaderConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts index b2d0ce5971..4dfc656dfc 100644 --- a/test/nuxt/components/HeaderConnectorModal.spec.ts +++ b/test/nuxt/components/HeaderConnectorModal.spec.ts @@ -101,7 +101,7 @@ function resetMockState() { error: null, lastExecutionTime: null, } - mockSettings.value.connector = { + mockUserLocalSettings.value.connector = { autoOpenURL: false, } } @@ -112,28 +112,21 @@ function simulateConnect() { mockState.value.avatar = 'https://example.com/avatar.png' } -const mockSettings = ref({ - relativeDates: false, - includeTypesInInstall: true, - accentColorId: null, - hidePlatformPackages: true, - selectedLocale: null, - preferredBackgroundTheme: null, - searchProvider: 'npm', - connector: { - autoOpenURL: false, - }, +const mockUserLocalSettings = ref({ sidebar: { collapsed: [], }, + connector: { + autoOpenURL: false, + }, }) mockNuxtImport('useConnector', () => { return createMockUseConnector }) -mockNuxtImport('useSettings', () => { - return () => ({ settings: mockSettings }) +mockNuxtImport('useUserLocalSettings', () => { + return () => ({ localSettings: mockUserLocalSettings }) }) mockNuxtImport('useSelectedPackageManager', () => { diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts index cfa30adc74..1f7d73abb5 100644 --- a/test/nuxt/components/compare/FacetRow.spec.ts +++ b/test/nuxt/components/compare/FacetRow.spec.ts @@ -2,14 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { mountSuspended } from '@nuxt/test-utils/runtime' import FacetRow from '~/components/Compare/FacetRow.vue' -// Mock useRelativeDates for DateTime component -vi.mock('~/composables/useSettings', () => ({ - useRelativeDates: () => ref(false), - useSettings: () => ({ - settings: ref({ relativeDates: false }), - }), - useAccentColor: () => ({}), - initAccentOnPrehydrate: () => {}, +// Mock useRelativeDatesPreference for DateTime component +vi.mock('~/composables/userPreferences/useRelativeDatesPreference', () => ({ + useRelativeDatesPreference: () => ref(false), })) describe('FacetRow', () => { diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts index 54eece3168..f182962b12 100644 --- a/test/nuxt/components/compare/PackageSelector.spec.ts +++ b/test/nuxt/components/compare/PackageSelector.spec.ts @@ -1,22 +1,66 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' -import { mountSuspended } from '@nuxt/test-utils/runtime' +import { ref, shallowRef } from 'vue' +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' +import type { NpmSearchResponse } from '#shared/types' import PackageSelector from '~/components/Compare/PackageSelector.vue' -const mockFetch = vi.fn() -vi.stubGlobal('$fetch', mockFetch) +const mockSearchData = shallowRef(null) + +type MinimalSearchObject = { + package: Pick +} + +function createSearchResponse(objects: readonly MinimalSearchObject[]): NpmSearchResponse { + return { + isStale: false, + objects: objects.map(({ package: pkg }) => ({ + package: { + name: pkg.name, + description: pkg.description, + version: '0.0.0', + date: '', + links: {}, + publisher: { username: '' }, + maintainers: [], + }, + score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, + searchScore: 0, + })), + total: objects.length, + time: new Date().toISOString(), + } +} + +mockNuxtImport('useSearchProvider', () => { + return () => ({ + searchProvider: ref('npm'), + isAlgolia: ref(false), + toggle: vi.fn(), + }) +}) + +mockNuxtImport('useSearch', () => { + return () => ({ + data: mockSearchData, + status: ref('idle'), + isLoadingMore: ref(false), + hasMore: ref(false), + fetchMore: vi.fn(), + isRateLimited: ref(false), + suggestions: ref([]), + suggestionsLoading: ref(false), + packageAvailability: ref(null), + }) +}) describe('PackageSelector', () => { beforeEach(() => { - mockFetch.mockReset() - mockFetch.mockResolvedValue({ - objects: [ - { package: { name: 'lodash', description: 'Lodash modular utilities' } }, - { package: { name: 'underscore', description: 'JavaScript utility library' } }, - ], - total: 2, - time: new Date().toISOString(), - }) + const objects = [ + { package: { name: 'lodash', description: 'Lodash modular utilities' } }, + { package: { name: 'underscore', description: 'JavaScript utility library' } }, + ] + + mockSearchData.value = createSearchResponse(objects) }) describe('selected packages display', () => { diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index 5799427945..1350fec6a1 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { JsrPackageInfo } from '#shared/types/jsr' +import { __resetPreferencesForTest } from '../../../app/composables/useUserPreferencesProvider' describe('useInstallCommand', () => { beforeEach(() => { @@ -12,6 +13,7 @@ describe('useInstallCommand', () => { afterEach(() => { vi.unstubAllGlobals() + __resetPreferencesForTest() }) describe('basic install commands', () => { @@ -217,9 +219,9 @@ describe('useInstallCommand', () => { }) it('should only include main command when @types disabled via settings', () => { - // Get settings and disable includeTypesInInstall directly - const { settings } = useSettings() - settings.value.includeTypesInInstall = false + // Get preferences and disable includeTypesInInstall directly + const { preferences } = useUserPreferencesState() + preferences.value.includeTypesInInstall = false const { fullInstallCommand, showTypesInInstall } = useInstallCommand( 'express', diff --git a/test/nuxt/composables/use-keyboard-shortcuts.spec.ts b/test/nuxt/composables/use-keyboard-shortcuts.spec.ts new file mode 100644 index 0000000000..eab8632022 --- /dev/null +++ b/test/nuxt/composables/use-keyboard-shortcuts.spec.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' +import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences' + +describe('useKeyboardShortcutsPreference', () => { + beforeEach(() => { + localStorage.clear() + // Reset preferences to defaults + const { preferences } = useUserPreferencesState() + preferences.value = { ...DEFAULT_USER_PREFERENCES } + }) + + afterEach(() => { + delete document.documentElement.dataset.kbdShortcuts + }) + + it('should return true by default', () => { + const enabled = useKeyboardShortcutsPreference() + expect(enabled.value).toBe(true) + }) + + it('should return false when preference is disabled', () => { + const { preferences } = useUserPreferencesState() + preferences.value = { ...preferences.value, keyboardShortcuts: false } + + const enabled = useKeyboardShortcutsPreference() + expect(enabled.value).toBe(false) + }) + + it('should reactively update when preferences change', () => { + const enabled = useKeyboardShortcutsPreference() + const { preferences } = useUserPreferencesState() + + expect(enabled.value).toBe(true) + + preferences.value = { ...preferences.value, keyboardShortcuts: false } + expect(enabled.value).toBe(false) + + preferences.value = { ...preferences.value, keyboardShortcuts: true } + expect(enabled.value).toBe(true) + }) + + it('should set data-kbd-shortcuts attribute when disabled', async () => { + const { preferences } = useUserPreferencesState() + + useKeyboardShortcutsPreference() + + preferences.value = { ...preferences.value, keyboardShortcuts: false } + await nextTick() + expect(document.documentElement.dataset.kbdShortcuts).toBe('false') + + preferences.value = { ...preferences.value, keyboardShortcuts: true } + await nextTick() + expect(document.documentElement.dataset.kbdShortcuts).toBeUndefined() + }) +}) diff --git a/test/nuxt/composables/use-package-list-preferences.spec.ts b/test/nuxt/composables/use-package-list-preferences.spec.ts new file mode 100644 index 0000000000..3235724d54 --- /dev/null +++ b/test/nuxt/composables/use-package-list-preferences.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { defineComponent, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { usePackageListPreferences } from '../../../app/composables/usePackageListPreferences' +import { DEFAULT_PREFERENCES } from '../../../shared/types/preferences' + +const STORAGE_KEY = 'npmx-list-prefs' + +async function mountWithSetup(setupFn: () => T) { + let result: T + const wrapper = mount( + defineComponent({ + name: 'TestHarness', + setup() { + result = setupFn() + return () => null + }, + }), + { attachTo: document.body }, + ) + await nextTick() + return { wrapper, result: result! } +} + +function setLocalStorage(stored: Record) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) +} + +describe('usePackageListPreferences', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('initializes with default values when storage is empty', async () => { + const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences()) + expect(result.preferences.value).toEqual(DEFAULT_PREFERENCES) + wrapper.unmount() + }) + + it('loads and merges values from localStorage', async () => { + const stored = { viewMode: 'table' } + setLocalStorage(stored) + const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences()) + expect(result.preferences.value.viewMode).toBe('table') + expect(result.preferences.value.paginationMode).toBe(DEFAULT_PREFERENCES.paginationMode) + expect(result.preferences.value.pageSize).toBe(DEFAULT_PREFERENCES.pageSize) + expect(result.preferences.value.columns).toEqual(DEFAULT_PREFERENCES.columns) + wrapper.unmount() + }) + + it('handles array merging by replacement', async () => { + const stored = { columns: [] } + setLocalStorage(stored) + const { result, wrapper } = await mountWithSetup(() => usePackageListPreferences()) + expect(result.preferences.value.columns).toEqual([]) + wrapper.unmount() + }) +}) diff --git a/test/nuxt/composables/use-preferences-provider.spec.ts b/test/nuxt/composables/use-preferences-provider.spec.ts deleted file mode 100644 index 21d06e9af5..0000000000 --- a/test/nuxt/composables/use-preferences-provider.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { defineComponent, onMounted } from 'vue' -import { mount } from '@vue/test-utils' -import { usePreferencesProvider } from '../../../app/composables/usePreferencesProvider' - -const STORAGE_KEY = 'npmx-list-prefs' - -function mountWithSetup(run: () => void) { - return mount( - defineComponent({ - name: 'TestHarness', - setup() { - run() - return () => null - }, - }), - { attachTo: document.body }, - ) -} - -function setLocalStorage(stored: Record) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) -} - -describe('usePreferencesProvider', () => { - beforeEach(() => { - localStorage.clear() - }) - - it('initializes with default values when storage is empty', () => { - mountWithSetup(() => { - const defaults = { theme: 'light', cols: ['name', 'version'] } - const { data } = usePreferencesProvider(defaults) - onMounted(() => { - expect(data.value).toEqual(defaults) - }) - }) - }) - - it('loads values from localStorage', () => { - mountWithSetup(() => { - const defaults = { theme: 'light' } - const stored = { theme: 'dark' } - setLocalStorage(stored) - const { data } = usePreferencesProvider(defaults) - onMounted(() => { - expect(data.value).toEqual(stored) - }) - }) - }) - - it('handles array merging by replacement', () => { - mountWithSetup(() => { - const defaults = { cols: ['name', 'version', 'date'] } - const stored = { cols: ['name', 'version'] } - setLocalStorage(stored) - const { data } = usePreferencesProvider(defaults) - onMounted(() => { - expect(data.value.cols).toEqual(['name', 'version']) - }) - }) - }) -}) diff --git a/test/nuxt/composables/use-settings.spec.ts b/test/nuxt/composables/use-settings.spec.ts deleted file mode 100644 index 9234d59495..0000000000 --- a/test/nuxt/composables/use-settings.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -describe('useSettings - keyboardShortcuts', () => { - beforeEach(() => { - vi.resetModules() - }) - - describe('default value', () => { - it('should default keyboardShortcuts to true', async () => { - const { useSettings } = await import('../../../app/composables/useSettings') - const { settings } = useSettings() - expect(settings.value.keyboardShortcuts).toBe(true) - }) - }) - - describe('useKeyboardShortcuts composable', () => { - it('should return true by default', async () => { - const { useKeyboardShortcuts } = await import('../../../app/composables/useSettings') - const enabled = useKeyboardShortcuts() - expect(enabled.value).toBe(true) - }) - - it('should reflect changes made via settings', async () => { - const { useSettings } = await import('../../../app/composables/useSettings') - const { useKeyboardShortcuts } = await import('../../../app/composables/useSettings') - const { settings } = useSettings() - const enabled = useKeyboardShortcuts() - - settings.value.keyboardShortcuts = false - expect(enabled.value).toBe(false) - - settings.value.keyboardShortcuts = true - expect(enabled.value).toBe(true) - }) - - it('should be reactive', async () => { - const { useSettings } = await import('../../../app/composables/useSettings') - const { useKeyboardShortcuts } = await import('../../../app/composables/useSettings') - const { settings } = useSettings() - const enabled = useKeyboardShortcuts() - - expect(enabled.value).toBe(true) - - settings.value.keyboardShortcuts = false - expect(enabled.value).toBe(false) - }) - }) -}) diff --git a/test/unit/app/composables/user-preferences-merge.spec.ts b/test/unit/app/composables/user-preferences-merge.spec.ts new file mode 100644 index 0000000000..9ad5f630f0 --- /dev/null +++ b/test/unit/app/composables/user-preferences-merge.spec.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_USER_PREFERENCES, type UserPreferences } from '#shared/schemas/userPreferences' +import type { ServerPreferencesResult } from '~/composables/useUserPreferencesSync.client' +import { + arePreferencesEqual, + mergePreferences, + type HydratedUserPreferences, +} from '~/utils/preferences-merge' + +describe('user preferences merge logic', () => { + const defaults: HydratedUserPreferences = { ...DEFAULT_USER_PREFERENCES } + + describe('arePreferencesEqual', () => { + it('returns true when all preference keys match', () => { + const a = { ...defaults, accentColorId: 'rose' } + const b = { ...defaults, accentColorId: 'rose' } + expect(arePreferencesEqual(a, b)).toBe(true) + }) + + it('returns false when a preference key differs', () => { + const a = { ...defaults, accentColorId: 'rose' } + const b = { ...defaults, accentColorId: 'amber' } + expect(arePreferencesEqual(a, b)).toBe(false) + }) + + it('ignores updatedAt when comparing', () => { + const a = { ...defaults, updatedAt: '2025-01-01T00:00:00Z' } + const b = { ...defaults, updatedAt: '2026-02-28T12:00:00Z' } + expect(arePreferencesEqual(a, b)).toBe(true) + }) + }) + + describe('first-time user (isNewUser: true)', () => { + it('preserves local preferences when server has no stored prefs', () => { + const localPrefs: HydratedUserPreferences = { + ...defaults, + accentColorId: 'rose', + colorModePreference: 'dark', + selectedLocale: 'de', + } + + const serverResult: ServerPreferencesResult = { + preferences: { ...DEFAULT_USER_PREFERENCES }, + isNewUser: true, + } + + const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult) + + expect(merged.accentColorId).toBe('rose') + expect(merged.colorModePreference).toBe('dark') + expect(merged.selectedLocale).toBe('de') + expect(shouldPushToServer).toBe(true) + }) + + it('local prefs are returned unchanged', () => { + const localPrefs: HydratedUserPreferences = { + ...defaults, + relativeDates: true, + keyboardShortcuts: false, + } + + const serverResult: ServerPreferencesResult = { + preferences: { ...DEFAULT_USER_PREFERENCES }, + isNewUser: true, + } + + const { merged } = mergePreferences(localPrefs, serverResult) + + expect(merged).toEqual(localPrefs) + }) + }) + + describe('returning user (isNewUser: false)', () => { + it('server preferences override local preferences', () => { + const localPrefs: HydratedUserPreferences = { + ...defaults, + accentColorId: 'rose', + colorModePreference: 'dark', + } + + const serverPrefs: UserPreferences = { + ...DEFAULT_USER_PREFERENCES, + accentColorId: 'amber', + colorModePreference: 'light', + updatedAt: '2026-01-15T10:00:00Z', + } + + const serverResult: ServerPreferencesResult = { + preferences: serverPrefs, + isNewUser: false, + } + + const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult) + + expect(merged.accentColorId).toBe('amber') + expect(merged.colorModePreference).toBe('light') + expect(shouldPushToServer).toBe(false) + }) + + it('local preferences fill new keys not yet stored on server (schema migration)', () => { + const localPrefs: HydratedUserPreferences = { + ...defaults, + accentColorId: 'rose', + selectedLocale: 'ja', + } + + // Simulates a server response from before a new preference key was added: + // the server has accentColorId but not selectedLocale (added later) + const serverPrefs: UserPreferences = { + accentColorId: 'emerald', + updatedAt: '2026-01-15T10:00:00Z', + } + + const serverResult: ServerPreferencesResult = { + preferences: serverPrefs, + isNewUser: false, + } + + const { merged } = mergePreferences(localPrefs, serverResult) + + // Server wins on accentColorId + expect(merged.accentColorId).toBe('emerald') + // Local fills in selectedLocale (not in server response) + expect(merged.selectedLocale).toBe('ja') + }) + + it('returning user with default server prefs keeps defaults (not a false first-login)', () => { + const localPrefs: HydratedUserPreferences = { + ...defaults, + accentColorId: 'rose', + } + + // User explicitly saved defaults on another device + const serverPrefs: UserPreferences = { + ...DEFAULT_USER_PREFERENCES, + updatedAt: '2026-02-01T00:00:00Z', + } + + const serverResult: ServerPreferencesResult = { + preferences: serverPrefs, + isNewUser: false, + } + + const { merged, shouldPushToServer } = mergePreferences(localPrefs, serverResult) + + // Server wins — user intentionally has defaults + expect(merged.accentColorId).toBeNull() + expect(shouldPushToServer).toBe(false) + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index ba3986903b..fb7fd3ee4b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ alias: { '#shared': `${rootDir}/shared`, '#server': `${rootDir}/server`, + '~': `${rootDir}/app`, }, }, test: {