diff --git a/client/src/features/dashboard/api/dashboard-widget.api.ts b/client/src/features/dashboard/api/dashboard-widget.api.ts new file mode 100644 index 000000000..92e70cc6d --- /dev/null +++ b/client/src/features/dashboard/api/dashboard-widget.api.ts @@ -0,0 +1,87 @@ +import type { + CurrentlyReadingWidgetData, + DiversityScoreWidgetData, + HighlightOfTheDayWidgetData, + LibraryOverviewWidgetData, + LongWaitWidgetData, + MonthlyChallengeWidgetData, + NeglectedGemsWidgetData, + ReadingDnaWidgetData, + ReadingGoalWidgetData, + ReadingRhythmWidgetData, + ReadingStreakWidgetData, + YearProjectionWidgetData, +} from '@bookorbit/types' +import { api } from '@/lib/api' + +export async function fetchReadingGoal(): Promise { + const res = await api('/api/v1/dashboard/widgets/reading-goal') + if (!res.ok) throw new Error('Failed to fetch reading goal') + return res.json() +} + +export async function fetchCurrentlyReading(): Promise { + const res = await api('/api/v1/dashboard/widgets/currently-reading') + if (!res.ok) throw new Error('Failed to fetch currently reading') + return res.json() +} + +export async function fetchReadingStreak(): Promise { + const res = await api('/api/v1/dashboard/widgets/reading-streak') + if (!res.ok) throw new Error('Failed to fetch reading streak') + return res.json() +} + +export async function fetchLibraryOverview(): Promise { + const res = await api('/api/v1/dashboard/widgets/library-overview') + if (!res.ok) throw new Error('Failed to fetch library overview') + return res.json() +} + +export async function fetchHighlightOfTheDay(): Promise { + const res = await api('/api/v1/dashboard/widgets/highlight-of-the-day') + if (!res.ok) throw new Error('Failed to fetch highlight of the day') + return res.json() +} + +export async function fetchMonthlyChallenge(): Promise { + const res = await api('/api/v1/dashboard/widgets/monthly-challenge') + if (!res.ok) throw new Error('Failed to fetch monthly challenge') + return res.json() +} + +export async function fetchYearProjection(): Promise { + const res = await api('/api/v1/dashboard/widgets/year-projection') + if (!res.ok) throw new Error('Failed to fetch year projection') + return res.json() +} + +export async function fetchNeglectedGems(): Promise { + const res = await api('/api/v1/dashboard/widgets/neglected-gems') + if (!res.ok) throw new Error('Failed to fetch neglected gems') + return res.json() +} + +export async function fetchReadingDna(): Promise { + const res = await api('/api/v1/dashboard/widgets/reading-dna') + if (!res.ok) throw new Error('Failed to fetch reading DNA') + return res.json() +} + +export async function fetchLongWait(): Promise { + const res = await api('/api/v1/dashboard/widgets/long-wait') + if (!res.ok) throw new Error('Failed to fetch long wait') + return res.json() +} + +export async function fetchDiversityScore(): Promise { + const res = await api('/api/v1/dashboard/widgets/diversity-score') + if (!res.ok) throw new Error('Failed to fetch diversity score') + return res.json() +} + +export async function fetchReadingRhythm(): Promise { + const res = await api('/api/v1/dashboard/widgets/reading-rhythm') + if (!res.ok) throw new Error('Failed to fetch reading rhythm') + return res.json() +} diff --git a/client/src/features/dashboard/components/DashboardSettingsSheet.vue b/client/src/features/dashboard/components/DashboardSettingsSheet.vue index 9a671cb2a..7f5197e8b 100644 --- a/client/src/features/dashboard/components/DashboardSettingsSheet.vue +++ b/client/src/features/dashboard/components/DashboardSettingsSheet.vue @@ -2,59 +2,49 @@ import { ref, watch } from 'vue' import { ChevronDown, ChevronUp, GripVertical, Plus, RotateCcw, Trash2 } from 'lucide-vue-next' -import type { ScrollerConfig, ScrollerType } from '@bookorbit/types' +import type { ScrollerConfig, ScrollerType, WidgetConfig } from '@bookorbit/types' import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes' import { DEFAULT_SCROLLERS, SCROLLER_LABELS, useDashboardConfig } from '../composables/useDashboardConfig' +import { useDashboardWidgets } from '../composables/useDashboardWidgets' +import { useDraggableList } from '../composables/useDraggableList' const props = defineProps<{ open: boolean }>() const emit = defineEmits<{ 'update:open': [value: boolean] }>() const { scrollers, saveScrollers, MAX_SCROLLERS } = useDashboardConfig() +const { widgets, saveWidgets, WIDGET_LABELS, DEFAULT_WIDGETS } = useDashboardWidgets() const { smartScopes, fetchSmartScopes } = useSmartScopes() +const activeTab = ref<'widgets' | 'shelves'>('widgets') const draft = ref([]) +const widgetDraft = ref([]) +const widgetSaving = ref(false) watch( () => props.open, (isOpen) => { if (isOpen) { draft.value = (Array.isArray(scrollers.value) ? scrollers.value : DEFAULT_SCROLLERS).map((s) => ({ ...s })) + widgetDraft.value = widgets.value.map((w) => ({ ...w })) fetchSmartScopes() } }, ) // ── Drag to reorder ────────────────────────────────────────── -const draggedIndex = ref(null) -const dragOverIndex = ref(null) - -function onDragStart(index: number) { - draggedIndex.value = index -} - -function onDragOver(e: DragEvent, index: number) { - e.preventDefault() - dragOverIndex.value = index -} - -function onDrop(index: number) { - if (draggedIndex.value === null || draggedIndex.value === index) return - const items = [...draft.value] - const [moved] = items.splice(draggedIndex.value, 1) - if (moved) { - items.splice(index, 0, moved) - draft.value = items - } - draggedIndex.value = null - dragOverIndex.value = null -} - -function onDragEnd() { - draggedIndex.value = null - dragOverIndex.value = null -} +const { draggedIndex, dragOverIndex, onDragStart, onDragOver, onDrop, onDragEnd, moveUp, moveDown } = useDraggableList(draft) +const { + draggedIndex: widgetDraggedIndex, + dragOverIndex: widgetDragOverIndex, + onDragStart: onWidgetDragStart, + onDragOver: onWidgetDragOver, + onDrop: onWidgetDrop, + onDragEnd: onWidgetDragEnd, + moveUp: widgetMoveUp, + moveDown: widgetMoveDown, +} = useDraggableList(widgetDraft) // ── Add / Remove ───────────────────────────────────────────── const ALL_TYPES: ScrollerType[] = ['continue-reading', 'recently-added', 'random', 'smart-scope'] @@ -76,30 +66,6 @@ function removeScroller(index: number) { draft.value.splice(index, 1) } -function moveUp(index: number) { - if (index === 0) return - const items = [...draft.value] - const current = items[index] - const prev = items[index - 1] - if (current && prev) { - items[index - 1] = current - items[index] = prev - } - draft.value = items -} - -function moveDown(index: number) { - if (index === draft.value.length - 1) return - const items = [...draft.value] - const current = items[index] - const next = items[index + 1] - if (current && next) { - items[index + 1] = current - items[index] = next - } - draft.value = items -} - function onTypeChange(scroller: ScrollerConfig) { if (scroller.type === 'smart-scope') { const firstSmartScope = smartScopes.value[0] @@ -117,13 +83,35 @@ function onSmartScopeChange(scroller: ScrollerConfig) { } // ── Save / Reset / Close ───────────────────────────────────── -function save() { +function saveShelves() { saveScrollers(draft.value) emit('update:open', false) } +async function saveWidgetSettings() { + widgetSaving.value = true + try { + await saveWidgets(widgetDraft.value) + emit('update:open', false) + } finally { + widgetSaving.value = false + } +} + +async function handleSave() { + if (activeTab.value === 'widgets') { + await saveWidgetSettings() + } else { + saveShelves() + } +} + function resetToDefault() { - draft.value = DEFAULT_SCROLLERS.map((s) => ({ ...s })) + if (activeTab.value === 'widgets') { + widgetDraft.value = DEFAULT_WIDGETS.map((w) => ({ ...w })) + } else { + draft.value = DEFAULT_SCROLLERS.map((s) => ({ ...s })) + } } @@ -135,107 +123,191 @@ function resetToDefault() { Customize Dashboard + +
+
+ + +
+
+
-

Drag to reorder. Toggle to show or hide a shelf.

+ +
+

Drag to reorder. Toggle to show or hide a widget.

-
-
- -
- -
- -
- +
+
+
+
+ +
+ +
+
+ + + {{ WIDGET_LABELS[widget.type] }}
- - - - - - - - - -
- - -
- - -

No Smart Scopes yet. Create one from the sidebar.

- - + +
+

Drag to reorder. Toggle to show or hide a shelf.

+ +
+
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + +
+ + +
+ + +

No Smart Scopes yet. Create one from the sidebar.

+
+
+
+ + + +
@@ -253,7 +325,8 @@ function resetToDefault() { diff --git a/client/src/features/dashboard/components/DashboardWidgetRow.vue b/client/src/features/dashboard/components/DashboardWidgetRow.vue new file mode 100644 index 000000000..f8935aee4 --- /dev/null +++ b/client/src/features/dashboard/components/DashboardWidgetRow.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/client/src/features/dashboard/components/widgets/CurrentlyReadingWidget.vue b/client/src/features/dashboard/components/widgets/CurrentlyReadingWidget.vue new file mode 100644 index 000000000..fffb75174 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/CurrentlyReadingWidget.vue @@ -0,0 +1,102 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/DiversityScoreWidget.vue b/client/src/features/dashboard/components/widgets/DiversityScoreWidget.vue new file mode 100644 index 000000000..aa5d82940 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/DiversityScoreWidget.vue @@ -0,0 +1,60 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/HighlightOfTheDayWidget.vue b/client/src/features/dashboard/components/widgets/HighlightOfTheDayWidget.vue new file mode 100644 index 000000000..74a6e47f6 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/HighlightOfTheDayWidget.vue @@ -0,0 +1,62 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/LibraryOverviewWidget.vue b/client/src/features/dashboard/components/widgets/LibraryOverviewWidget.vue new file mode 100644 index 000000000..9b999407b --- /dev/null +++ b/client/src/features/dashboard/components/widgets/LibraryOverviewWidget.vue @@ -0,0 +1,103 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/LongWaitWidget.vue b/client/src/features/dashboard/components/widgets/LongWaitWidget.vue new file mode 100644 index 000000000..9741b64e6 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/LongWaitWidget.vue @@ -0,0 +1,88 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/MonthlyChallengeWidget.vue b/client/src/features/dashboard/components/widgets/MonthlyChallengeWidget.vue new file mode 100644 index 000000000..e1c329c42 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/MonthlyChallengeWidget.vue @@ -0,0 +1,49 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/NeglectedGemsWidget.vue b/client/src/features/dashboard/components/widgets/NeglectedGemsWidget.vue new file mode 100644 index 000000000..419cbaed9 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/NeglectedGemsWidget.vue @@ -0,0 +1,105 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/ReadingDnaWidget.vue b/client/src/features/dashboard/components/widgets/ReadingDnaWidget.vue new file mode 100644 index 000000000..e7b302e44 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/ReadingDnaWidget.vue @@ -0,0 +1,66 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/ReadingGoalWidget.vue b/client/src/features/dashboard/components/widgets/ReadingGoalWidget.vue new file mode 100644 index 000000000..45b20d7fa --- /dev/null +++ b/client/src/features/dashboard/components/widgets/ReadingGoalWidget.vue @@ -0,0 +1,160 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue b/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue new file mode 100644 index 000000000..819b812d5 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue @@ -0,0 +1,80 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/ReadingStreakWidget.vue b/client/src/features/dashboard/components/widgets/ReadingStreakWidget.vue new file mode 100644 index 000000000..e86973cdd --- /dev/null +++ b/client/src/features/dashboard/components/widgets/ReadingStreakWidget.vue @@ -0,0 +1,62 @@ + + + diff --git a/client/src/features/dashboard/components/widgets/YearProjectionWidget.vue b/client/src/features/dashboard/components/widgets/YearProjectionWidget.vue new file mode 100644 index 000000000..60e5b5360 --- /dev/null +++ b/client/src/features/dashboard/components/widgets/YearProjectionWidget.vue @@ -0,0 +1,54 @@ + + + diff --git a/client/src/features/dashboard/composables/useCurrentlyReadingWidget.ts b/client/src/features/dashboard/composables/useCurrentlyReadingWidget.ts new file mode 100644 index 000000000..8c7af4392 --- /dev/null +++ b/client/src/features/dashboard/composables/useCurrentlyReadingWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { CurrentlyReadingWidgetData } from '@bookorbit/types' +import { fetchCurrentlyReading } from '../api/dashboard-widget.api' + +export function useCurrentlyReadingWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchCurrentlyReading() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useDashboardConfig.ts b/client/src/features/dashboard/composables/useDashboardConfig.ts index a7bbba538..4a41cbb81 100644 --- a/client/src/features/dashboard/composables/useDashboardConfig.ts +++ b/client/src/features/dashboard/composables/useDashboardConfig.ts @@ -6,7 +6,7 @@ const STORAGE_KEY = 'bookorbit:dashboard:config' const MAX_SCROLLERS = 6 export const DEFAULT_SCROLLERS: ScrollerConfig[] = [ - { id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: true, order: 1, limit: 20 }, + { id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: false, order: 1, limit: 20 }, { id: '2', type: 'recently-added', label: 'Recently Added', enabled: true, order: 2, limit: 20 }, { id: '3', type: 'random', label: 'Discover Something New', enabled: true, order: 3, limit: 20 }, ] diff --git a/client/src/features/dashboard/composables/useDashboardWidgets.test.ts b/client/src/features/dashboard/composables/useDashboardWidgets.test.ts new file mode 100644 index 000000000..a862a4673 --- /dev/null +++ b/client/src/features/dashboard/composables/useDashboardWidgets.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import type { AuthUser, WidgetConfig } from '@bookorbit/types' +import { WIDGET_TYPES } from '@bookorbit/types' + +vi.mock('@/features/auth/composables/useAuth', () => ({ + useAuth: vi.fn<() => unknown>(), +})) + +vi.mock('@/lib/api', () => ({ + api: vi.fn<() => unknown>(), +})) + +import { useAuth } from '@/features/auth/composables/useAuth' +import { api } from '@/lib/api' +import { useDashboardWidgets } from './useDashboardWidgets' + +const mockUseAuth = vi.mocked(useAuth) +const mockApi = vi.mocked(api) + +function makeUser(settings: Record = {}): AuthUser { + return { + id: 1, + username: 'tester', + name: 'Tester', + email: undefined, + active: true, + isSuperuser: false, + isDefaultPassword: false, + settings, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + } +} + +function setupAuth(settings: Record = {}) { + const userRef = ref(makeUser(settings)) + const me = vi.fn<() => Promise>().mockResolvedValue(undefined) + mockUseAuth.mockReturnValue({ user: userRef, me } as unknown as ReturnType) + return { userRef, me } +} + +describe('useDashboardWidgets', () => { + beforeEach(() => { + vi.resetAllMocks() + mockApi.mockResolvedValue(new Response(null, { status: 200 })) + }) + + describe('widgets', () => { + it('returns default widgets when user has no dashboardConfig', () => { + setupAuth() + const { widgets } = useDashboardWidgets() + + expect(widgets.value).toHaveLength(WIDGET_TYPES.length) + expect(widgets.value[0]!.type).toBe('reading-streak') + expect(widgets.value[1]!.type).toBe('currently-reading') + expect(widgets.value[2]!.type).toBe('reading-goal') + expect(widgets.value[3]!.type).toBe('reading-dna') + expect(widgets.value[11]!.type).toBe('long-wait') + expect(widgets.value[9]!.enabled).toBe(false) + expect(widgets.value[10]!.enabled).toBe(false) + expect(widgets.value[11]!.enabled).toBe(false) + }) + + it('returns user-saved widgets first, then appends any missing types', () => { + const widgets: WidgetConfig[] = [ + { id: 'a', type: 'library-overview', enabled: true, order: 1 }, + { id: 'b', type: 'reading-goal', enabled: false, order: 2 }, + ] + setupAuth({ dashboardConfig: { widgets } }) + const { widgets: result } = useDashboardWidgets() + + expect(result.value).toHaveLength(WIDGET_TYPES.length) + expect(result.value[0]!.type).toBe('library-overview') + expect(result.value[1]!.type).toBe('reading-goal') + expect(result.value[1]!.enabled).toBe(false) + // Missing types appended after saved ones + const appendedTypes = result.value.slice(2).map((w) => w.type) + expect(appendedTypes).toContain('currently-reading') + expect(appendedTypes).toContain('reading-streak') + }) + + it('falls back to defaults when widgets array is empty', () => { + setupAuth({ dashboardConfig: { widgets: [] } }) + const { widgets } = useDashboardWidgets() + + expect(widgets.value).toHaveLength(WIDGET_TYPES.length) + }) + + it('filters out widgets with invalid types and appends all valid defaults', () => { + setupAuth({ + dashboardConfig: { + widgets: [ + { id: '1', type: 'invalid-type', enabled: true, order: 1 }, + { id: '2', type: 'reading-goal', enabled: true, order: 2 }, + ], + }, + }) + const { widgets } = useDashboardWidgets() + + // Only reading-goal is valid; the other types are appended + expect(widgets.value).toHaveLength(WIDGET_TYPES.length) + expect(widgets.value[0]!.type).toBe('reading-goal') + }) + + it('normalizes missing enabled field to true', () => { + setupAuth({ + dashboardConfig: { + widgets: [{ id: '1', type: 'reading-goal' }], + }, + }) + const { widgets } = useDashboardWidgets() + + const goal = widgets.value.find((w) => w.type === 'reading-goal')! + expect(goal.enabled).toBe(true) + }) + }) + + describe('enabledWidgets', () => { + it('returns only enabled widgets sorted by order', () => { + const widgetsList: WidgetConfig[] = WIDGET_TYPES.map((type, index) => ({ + id: String(index + 1), + type, + enabled: type === 'currently-reading' || type === 'reading-streak', + order: index + 1, + })) + setupAuth({ dashboardConfig: { widgets: widgetsList } }) + const { enabledWidgets } = useDashboardWidgets() + + expect(enabledWidgets.value).toHaveLength(2) + expect(enabledWidgets.value[0]!.type).toBe('reading-streak') + expect(enabledWidgets.value[1]!.type).toBe('currently-reading') + }) + }) + + describe('readingGoal', () => { + it('returns null when no reading goal is set', () => { + setupAuth() + const { readingGoal } = useDashboardWidgets() + + expect(readingGoal.value).toBeNull() + }) + + it('returns the reading goal when set', () => { + setupAuth({ dashboardConfig: { readingGoal: 24 } }) + const { readingGoal } = useDashboardWidgets() + + expect(readingGoal.value).toBe(24) + }) + }) + + describe('saveWidgets', () => { + it('calls PATCH /api/v1/users/me with read-modify-write pattern, appending missing types', async () => { + const { me } = setupAuth({ dashboardConfig: { readingGoal: 12, widgets: [] } }) + const { saveWidgets } = useDashboardWidgets() + + const newWidgets: WidgetConfig[] = [{ id: '1', type: 'reading-goal', enabled: true, order: 1 }] + await saveWidgets(newWidgets) + + expect(mockApi).toHaveBeenCalledWith('/api/v1/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: expect.any(String), + }) + + const body = JSON.parse((mockApi.mock.calls[0]![1] as { body: string }).body) + expect(body.settings.dashboardConfig.readingGoal).toBe(12) + expect(body.settings.dashboardConfig.widgets).toHaveLength(WIDGET_TYPES.length) + expect(body.settings.dashboardConfig.widgets[0].type).toBe('reading-goal') + expect(me).toHaveBeenCalled() + }) + + it('throws if API returns non-ok response', async () => { + mockApi.mockResolvedValueOnce(new Response(null, { status: 500 })) + setupAuth({}) + const { saveWidgets } = useDashboardWidgets() + + await expect(saveWidgets([])).rejects.toThrow('500') + }) + }) + + describe('saveReadingGoal', () => { + it('preserves existing widgets when saving reading goal', async () => { + const existingWidgets: WidgetConfig[] = [{ id: '1', type: 'reading-goal', enabled: true, order: 1 }] + const { me } = setupAuth({ dashboardConfig: { widgets: existingWidgets } }) + const { saveReadingGoal } = useDashboardWidgets() + + await saveReadingGoal(30) + + const body = JSON.parse((mockApi.mock.calls[0]![1] as { body: string }).body) + expect(body.settings.dashboardConfig.readingGoal).toBe(30) + expect(body.settings.dashboardConfig.widgets).toEqual(existingWidgets) + expect(me).toHaveBeenCalled() + }) + + it('throws if API returns non-ok response', async () => { + mockApi.mockResolvedValueOnce(new Response(null, { status: 403 })) + setupAuth({}) + const { saveReadingGoal } = useDashboardWidgets() + + await expect(saveReadingGoal(12)).rejects.toThrow('403') + }) + }) +}) diff --git a/client/src/features/dashboard/composables/useDashboardWidgets.ts b/client/src/features/dashboard/composables/useDashboardWidgets.ts new file mode 100644 index 000000000..e55075234 --- /dev/null +++ b/client/src/features/dashboard/composables/useDashboardWidgets.ts @@ -0,0 +1,140 @@ +import { computed } from 'vue' + +import type { DashboardConfig, WidgetConfig, WidgetType } from '@bookorbit/types' +import { WIDGET_TYPES } from '@bookorbit/types' +import { api } from '@/lib/api' +import { useAuth } from '@/features/auth/composables/useAuth' + +const WIDGET_LABELS: Record = { + 'reading-goal': 'Reading Goal', + 'currently-reading': 'Currently Reading', + 'reading-streak': 'Reading Streak', + 'library-overview': 'Library Overview', + 'highlight-of-the-day': 'Highlight of the Day', + 'monthly-challenge': 'Monthly Challenge', + 'year-projection': 'Year Projection', + 'neglected-gems': 'Neglected Gems', + 'reading-dna': 'Reading DNA', + 'long-wait': 'The Long Wait', + 'diversity-score': 'Diversity Score', + 'reading-rhythm': 'Reading Rhythm', +} + +const DEFAULT_WIDGETS: WidgetConfig[] = [ + { id: '1', type: 'reading-streak', enabled: true, order: 1 }, + { id: '2', type: 'currently-reading', enabled: true, order: 2 }, + { id: '3', type: 'reading-goal', enabled: true, order: 3 }, + { id: '4', type: 'reading-dna', enabled: true, order: 4 }, + { id: '5', type: 'monthly-challenge', enabled: true, order: 5 }, + { id: '6', type: 'highlight-of-the-day', enabled: true, order: 6 }, + { id: '7', type: 'neglected-gems', enabled: true, order: 7 }, + { id: '8', type: 'reading-rhythm', enabled: true, order: 8 }, + { id: '9', type: 'diversity-score', enabled: true, order: 9 }, + { id: '10', type: 'library-overview', enabled: false, order: 10 }, + { id: '11', type: 'year-projection', enabled: false, order: 11 }, + { id: '12', type: 'long-wait', enabled: false, order: 12 }, +] + +const VALID_TYPES = new Set(WIDGET_TYPES) + +function cloneDefaultWidgets(): WidgetConfig[] { + return DEFAULT_WIDGETS.map((w) => ({ ...w })) +} + +function normalizeWidgets(raw: unknown): WidgetConfig[] { + const seenTypes = new Set() + let normalized: WidgetConfig[] = [] + + if (Array.isArray(raw)) { + normalized = raw + .filter( + (w): w is Record => w != null && typeof w === 'object' && VALID_TYPES.has((w as Record).type as WidgetType), + ) + .map((w, i) => { + const type = w.type as WidgetType + seenTypes.add(type) + return { + id: typeof w.id === 'string' ? w.id : String(i + 1), + type, + enabled: typeof w.enabled === 'boolean' ? w.enabled : true, + order: i + 1, + } + }) + } + + if (normalized.length === 0) { + normalized = cloneDefaultWidgets() + for (const w of normalized) seenTypes.add(w.type) + } + + // Append any widget types not in the saved config so new widgets + // automatically appear for users who already have a saved config. + // Types outside the default set are added disabled so they don't clutter the dashboard. + const defaultTypes = new Set(DEFAULT_WIDGETS.map((d) => d.type)) + const nextOrder = normalized.length + 1 + const missing = WIDGET_TYPES.filter((t) => !seenTypes.has(t)).map((t, i) => ({ + id: String(nextOrder + i), + type: t, + enabled: defaultTypes.has(t), + order: nextOrder + i, + })) + + return [...normalized, ...missing] +} + +export function useDashboardWidgets() { + const { user, me } = useAuth() + + const dashboardConfig = computed(() => { + return (user.value?.settings?.dashboardConfig as DashboardConfig) ?? {} + }) + + const widgets = computed(() => { + return normalizeWidgets(dashboardConfig.value.widgets) + }) + + const enabledWidgets = computed(() => widgets.value.filter((w) => w.enabled).sort((a, b) => a.order - b.order)) + + const readingGoal = computed(() => dashboardConfig.value.readingGoal ?? null) + + async function saveWidgets(newWidgets: WidgetConfig[]): Promise { + const normalized = normalizeWidgets(newWidgets) + const updatedConfig: DashboardConfig = { + ...dashboardConfig.value, + widgets: normalized, + } + + const res = await api('/api/v1/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: { dashboardConfig: updatedConfig } }), + }) + if (!res.ok) throw new Error(`Failed to save widgets: ${res.status}`) + await me() + } + + async function saveReadingGoal(goalBooks: number): Promise { + const updatedConfig: DashboardConfig = { + ...dashboardConfig.value, + readingGoal: goalBooks, + } + + const res = await api('/api/v1/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: { dashboardConfig: updatedConfig } }), + }) + if (!res.ok) throw new Error(`Failed to save reading goal: ${res.status}`) + await me() + } + + return { + widgets, + enabledWidgets, + readingGoal, + saveWidgets, + saveReadingGoal, + WIDGET_LABELS, + DEFAULT_WIDGETS, + } +} diff --git a/client/src/features/dashboard/composables/useDiversityScoreWidget.ts b/client/src/features/dashboard/composables/useDiversityScoreWidget.ts new file mode 100644 index 000000000..1db21a475 --- /dev/null +++ b/client/src/features/dashboard/composables/useDiversityScoreWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { DiversityScoreWidgetData } from '@bookorbit/types' +import { fetchDiversityScore } from '../api/dashboard-widget.api' + +export function useDiversityScoreWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchDiversityScore() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useDraggableList.ts b/client/src/features/dashboard/composables/useDraggableList.ts new file mode 100644 index 000000000..21c0bc4e7 --- /dev/null +++ b/client/src/features/dashboard/composables/useDraggableList.ts @@ -0,0 +1,50 @@ +import { ref, type Ref } from 'vue' + +export function useDraggableList(list: Ref) { + const draggedIndex = ref(null) + const dragOverIndex = ref(null) + + function onDragStart(index: number) { + draggedIndex.value = index + } + + function onDragOver(e: DragEvent, index: number) { + e.preventDefault() + dragOverIndex.value = index + } + + function onDrop(index: number) { + if (draggedIndex.value === null || draggedIndex.value === index) { + draggedIndex.value = null + dragOverIndex.value = null + return + } + const items = [...list.value] + const [moved] = items.splice(draggedIndex.value, 1) + if (moved) items.splice(index, 0, moved) + list.value = items + draggedIndex.value = null + dragOverIndex.value = null + } + + function onDragEnd() { + draggedIndex.value = null + dragOverIndex.value = null + } + + function moveUp(index: number) { + if (index === 0) return + const items = [...list.value] + ;[items[index - 1], items[index]] = [items[index]!, items[index - 1]!] + list.value = items + } + + function moveDown(index: number) { + if (index === list.value.length - 1) return + const items = [...list.value] + ;[items[index], items[index + 1]] = [items[index + 1]!, items[index]!] + list.value = items + } + + return { draggedIndex, dragOverIndex, onDragStart, onDragOver, onDrop, onDragEnd, moveUp, moveDown } +} diff --git a/client/src/features/dashboard/composables/useHighlightOfTheDayWidget.ts b/client/src/features/dashboard/composables/useHighlightOfTheDayWidget.ts new file mode 100644 index 000000000..7959edd9f --- /dev/null +++ b/client/src/features/dashboard/composables/useHighlightOfTheDayWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { HighlightOfTheDayWidgetData } from '@bookorbit/types' +import { fetchHighlightOfTheDay } from '../api/dashboard-widget.api' + +export function useHighlightOfTheDayWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchHighlightOfTheDay() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useLibraryOverviewWidget.ts b/client/src/features/dashboard/composables/useLibraryOverviewWidget.ts new file mode 100644 index 000000000..f10000c27 --- /dev/null +++ b/client/src/features/dashboard/composables/useLibraryOverviewWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { LibraryOverviewWidgetData } from '@bookorbit/types' +import { fetchLibraryOverview } from '../api/dashboard-widget.api' + +export function useLibraryOverviewWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchLibraryOverview() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useLongWaitWidget.ts b/client/src/features/dashboard/composables/useLongWaitWidget.ts new file mode 100644 index 000000000..58df5690d --- /dev/null +++ b/client/src/features/dashboard/composables/useLongWaitWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { LongWaitWidgetData } from '@bookorbit/types' +import { fetchLongWait } from '../api/dashboard-widget.api' + +export function useLongWaitWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchLongWait() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useMonthlyChallengeWidget.ts b/client/src/features/dashboard/composables/useMonthlyChallengeWidget.ts new file mode 100644 index 000000000..1dc8d5bed --- /dev/null +++ b/client/src/features/dashboard/composables/useMonthlyChallengeWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { MonthlyChallengeWidgetData } from '@bookorbit/types' +import { fetchMonthlyChallenge } from '../api/dashboard-widget.api' + +export function useMonthlyChallengeWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchMonthlyChallenge() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useNeglectedGemsWidget.ts b/client/src/features/dashboard/composables/useNeglectedGemsWidget.ts new file mode 100644 index 000000000..deab24b0a --- /dev/null +++ b/client/src/features/dashboard/composables/useNeglectedGemsWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { NeglectedGemsWidgetData } from '@bookorbit/types' +import { fetchNeglectedGems } from '../api/dashboard-widget.api' + +export function useNeglectedGemsWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchNeglectedGems() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useReadingDnaWidget.ts b/client/src/features/dashboard/composables/useReadingDnaWidget.ts new file mode 100644 index 000000000..7d42f19d2 --- /dev/null +++ b/client/src/features/dashboard/composables/useReadingDnaWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { ReadingDnaWidgetData } from '@bookorbit/types' +import { fetchReadingDna } from '../api/dashboard-widget.api' + +export function useReadingDnaWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchReadingDna() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useReadingGoalWidget.ts b/client/src/features/dashboard/composables/useReadingGoalWidget.ts new file mode 100644 index 000000000..0e2afaa7d --- /dev/null +++ b/client/src/features/dashboard/composables/useReadingGoalWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { ReadingGoalWidgetData } from '@bookorbit/types' +import { fetchReadingGoal } from '../api/dashboard-widget.api' + +export function useReadingGoalWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchReadingGoal() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useReadingRhythmWidget.ts b/client/src/features/dashboard/composables/useReadingRhythmWidget.ts new file mode 100644 index 000000000..57d3fe1c2 --- /dev/null +++ b/client/src/features/dashboard/composables/useReadingRhythmWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { ReadingRhythmWidgetData } from '@bookorbit/types' +import { fetchReadingRhythm } from '../api/dashboard-widget.api' + +export function useReadingRhythmWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchReadingRhythm() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useReadingStreakWidget.ts b/client/src/features/dashboard/composables/useReadingStreakWidget.ts new file mode 100644 index 000000000..aae7376e7 --- /dev/null +++ b/client/src/features/dashboard/composables/useReadingStreakWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { ReadingStreakWidgetData } from '@bookorbit/types' +import { fetchReadingStreak } from '../api/dashboard-widget.api' + +export function useReadingStreakWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchReadingStreak() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/features/dashboard/composables/useYearProjectionWidget.ts b/client/src/features/dashboard/composables/useYearProjectionWidget.ts new file mode 100644 index 000000000..ac08952cc --- /dev/null +++ b/client/src/features/dashboard/composables/useYearProjectionWidget.ts @@ -0,0 +1,25 @@ +import { onMounted, ref } from 'vue' + +import type { YearProjectionWidgetData } from '@bookorbit/types' +import { fetchYearProjection } from '../api/dashboard-widget.api' + +export function useYearProjectionWidget() { + const data = ref(null) + const loading = ref(true) + const error = ref(false) + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchYearProjection() + } catch { + error.value = true + } finally { + loading.value = false + } + } + + onMounted(load) + return { data, loading, error, refresh: load } +} diff --git a/client/src/views/DashboardView.vue b/client/src/views/DashboardView.vue index 0b8efcb7a..ded4dfd84 100644 --- a/client/src/views/DashboardView.vue +++ b/client/src/views/DashboardView.vue @@ -8,6 +8,7 @@ import { useLibraries } from '@/features/library/composables/useLibraries' import DashboardScroller from '@/features/dashboard/components/DashboardScroller.vue' import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSettingsSheet.vue' import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue' +import DashboardWidgetRow from '@/features/dashboard/components/DashboardWidgetRow.vue' import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig' import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour' @@ -58,6 +59,7 @@ onMounted(() => {