feat(settings): allow users to disable achievements and gamification

Introduce user-specific settings to toggle achievements. Avoid evaluation
and awarding of achievements on the backend when disabled. Render a toggle
in the Account Settings interface on the frontend.

Closes #614
This commit is contained in:
neon
2026-07-10 04:24:26 +00:00
parent 3ba405729d
commit 56e0a554f3
12 changed files with 233 additions and 7 deletions
+3 -2
View File
@@ -76,6 +76,7 @@ const isBookDockActive = computed(() => route.name === 'book-dock')
const isAchievementsActive = computed(() => route.name === 'achievements')
const isStatisticsActive = computed(() => route.name === 'statistics')
const isAnnotationsActive = computed(() => route.name === 'annotations')
const achievementsEnabled = computed(() => user.value?.settings?.achievementPreferences?.enabled !== false)
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
const canChangePassword = computed(
@@ -624,7 +625,7 @@ function formatBadgeStyle(fmt: string) {
<BarChart3 :size="15" class="mr-2 text-muted-foreground" />
Statistics
</DropdownMenuItem>
<DropdownMenuItem @click="navigateToAchievements">
<DropdownMenuItem v-if="achievementsEnabled" @click="navigateToAchievements">
<Trophy :size="15" class="mr-2 text-muted-foreground" />
Achievements
</DropdownMenuItem>
@@ -761,7 +762,7 @@ function formatBadgeStyle(fmt: string) {
</Tooltip>
<!-- Achievements button -->
<Tooltip>
<Tooltip v-if="achievementsEnabled">
<TooltipTrigger as-child>
<Button
variant="ghost"
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
loadingMore: null as unknown as Ref<boolean>,
settled: null as unknown as Ref<boolean>,
hasMore: null as unknown as Ref<boolean>,
user: null as unknown as Ref<{ provisioningMethod: string; settings?: { achievementPreferences?: { enabled?: boolean } } }>,
loadMore: vi.fn<() => Promise<void>>(),
clearGlobalSearch: vi.fn<() => void>(),
}))
@@ -39,10 +40,9 @@ vi.mock('@/features/book/composables/useGlobalSearch', () => ({
}))
vi.mock('@/features/auth/composables/useAuth', async () => {
const { ref: vueRef } = await import('vue')
return {
useAuth: () => ({
user: vueRef({ provisioningMethod: 'password' }),
user: mocks.user,
logout: vi.fn<() => void>(),
}),
}
@@ -195,6 +195,7 @@ describe('AppHeader global search', () => {
mocks.loadingMore = ref(false)
mocks.settled = ref(true)
mocks.hasMore = ref(true)
mocks.user = ref({ provisioningMethod: 'password', settings: {} })
})
it('does not leave the dropdown for a separate search route on Enter when no result is selected', async () => {
@@ -276,4 +277,12 @@ describe('AppHeader global search', () => {
expect(wrapper.text()).not.toContain('Prey 1')
expect(wrapper.text()).toContain('Prey 60')
})
it('hides achievement entry points when achievements are disabled', () => {
mocks.user = ref({ provisioningMethod: 'password', settings: { achievementPreferences: { enabled: false } } })
const wrapper = mountHeader()
expect(wrapper.text()).not.toContain('Achievements')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { OidcProviderPublic, UserSettings } from '@bookorbit/types'
import { ChevronDown, ChevronUp, Clock, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Upload } from '@lucide/vue'
import { ChevronDown, ChevronUp, Clock, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Trophy, Upload } from '@lucide/vue'
import { toast } from 'vue-sonner'
import UserAvatar from '@/components/UserAvatar.vue'
import { api } from '@/lib/api'
@@ -36,6 +36,8 @@ const formName = ref('')
const formTimezone = ref('UTC')
const savingTimezone = ref(false)
const timezoneChanged = computed(() => formTimezone.value !== ((user.value?.settings as UserSettings | undefined)?.timezone ?? 'UTC'))
const savingAchievements = ref(false)
const achievementsEnabled = computed(() => (user.value?.settings as UserSettings | undefined)?.achievementPreferences?.enabled !== false)
const timezones = (() => {
try {
@@ -178,6 +180,33 @@ async function saveTimezone() {
}
}
async function handleAchievementsToggle() {
if (shouldBlockAccountEdit() || !user.value || savingAchievements.value) return
const enabled = !achievementsEnabled.value
savingAchievements.value = true
try {
const res = await api('/api/v1/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { achievementPreferences: { enabled } } }),
})
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { message?: string | string[] } | null
const message = Array.isArray(payload?.message)
? (payload.message[0] ?? 'Failed to update achievement preference')
: (payload?.message ?? 'Failed to update achievement preference')
toast.error(message)
return
}
await me()
toast.success(`Achievements ${enabled ? 'enabled' : 'disabled'}`)
} finally {
savingAchievements.value = false
}
}
watch([formName], () => {
profileState.value = 'idle'
if (profileError.value) profileError.value = null
@@ -430,6 +459,34 @@ function closeUnlinkDialog() {
</button>
</section>
<section class="rounded-lg border border-border bg-card p-4 md:p-5 shadow-xs">
<div class="flex items-center justify-between gap-4 py-1.5">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Trophy :size="16" class="text-muted-foreground shrink-0" />
<p class="text-sm font-semibold text-foreground">Enable achievements</p>
</div>
<p class="mt-1 text-xs text-muted-foreground">
Track achievement progress and show achievement-related interface. Manage achievement notifications separately in Notifications.
</p>
</div>
<button
type="button"
role="switch"
:aria-checked="achievementsEnabled"
:disabled="savingAchievements || accountEditBlocked"
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
:class="achievementsEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
@click="handleAchievementsToggle"
>
<span
class="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-xs ring-0 transition-transform"
:class="achievementsEnabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
</section>
<section class="rounded-lg border border-border bg-card p-4 md:p-5 space-y-4 shadow-xs">
<button class="md:hidden w-full flex items-center justify-between gap-2 text-left" @click="avatarCardOpen = !avatarCardOpen">
<div>
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref, type Ref } from 'vue'
import { registerAuthGuard } from './auth.guard'
const mocks = vi.hoisted(() => ({
fetchSetupStatus: vi.fn<() => Promise<boolean>>(),
openChangePassword: vi.fn<(required: boolean) => void>(),
user: null as unknown as Ref<{
isDefaultPassword: boolean
provisioningMethod: string
settings: { achievementPreferences: { enabled: boolean } }
}>,
}))
vi.mock('@/features/auth/composables/useAuth', () => ({
useAuth: () => ({ user: mocks.user }),
}))
vi.mock('@/composables/useChangePasswordDialog', () => ({
useChangePasswordDialog: () => ({ open: mocks.openChangePassword }),
}))
vi.mock('@/features/auth/composables/useSetupStatus', () => ({
useSetupStatus: () => ({ fetchSetupStatus: mocks.fetchSetupStatus }),
}))
describe('registerAuthGuard', () => {
type TestRoute = {
name: string
path: string
fullPath: string
meta: Record<string, unknown>
}
let guard: ((to: TestRoute) => Promise<unknown>) | null = null
beforeEach(() => {
mocks.fetchSetupStatus.mockReset()
mocks.fetchSetupStatus.mockResolvedValue(false)
mocks.openChangePassword.mockReset()
mocks.user = ref({
isDefaultPassword: false,
provisioningMethod: 'local',
settings: { achievementPreferences: { enabled: false } },
})
registerAuthGuard({ beforeEach: vi.fn((callback: (to: TestRoute) => Promise<unknown>) => (guard = callback)) } as never)
})
it('redirects disabled achievement routes to Account profile settings', async () => {
if (!guard) throw new Error('Expected guard to be registered')
await expect(guard({ name: 'achievements', path: '/achievements', fullPath: '/achievements', meta: {} })).resolves.toEqual({
name: 'settings-account',
query: { tab: 'profile' },
})
})
it('allows achievement routes when achievements are enabled', async () => {
mocks.user.value.settings.achievementPreferences.enabled = true
if (!guard) throw new Error('Expected guard to be registered')
await expect(guard({ name: 'achievements', path: '/achievements', fullPath: '/achievements', meta: {} })).resolves.toBe(true)
})
})
+4
View File
@@ -35,6 +35,10 @@ export function registerAuthGuard(router: Router): void {
if (to.path !== '/') return { path: '/' }
}
if (to.name === 'achievements' && user.value.settings.achievementPreferences?.enabled === false) {
return { name: 'settings-account', query: { tab: 'profile' } }
}
return true
})
}