Skip to content

Commit 27d0d3c

Browse files
authored
Merge pull request #1013 from Chris0Jeky/paper/04-home
PAPER-04 · Home / Reset surface
2 parents 720fefe + 666387a commit 27d0d3c

4 files changed

Lines changed: 863 additions & 3 deletions

File tree

frontend/taskdeck-web/src/tests/views/HomeView.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22
import { mount } from '@vue/test-utils'
33
import { reactive } from 'vue'
44
import HomeView from '../../views/HomeView.vue'
5+
import { usePaperThemeStore } from '../../store/paperThemeStore'
56
import type { HomeSummary, WorkspaceOnboarding } from '../../types/workspace'
67

78
const routerMocks = vi.hoisted(() => ({
@@ -71,6 +72,7 @@ describe('HomeView', () => {
7172
beforeEach(() => {
7273
vi.clearAllMocks()
7374
localStorage.clear()
75+
usePaperThemeStore().mode = 'off'
7476
mockWorkspaceStore.onboarding = buildOnboarding()
7577
mockWorkspaceStore.homeLoading = false
7678
mockWorkspaceStore.homeError = null
@@ -127,6 +129,15 @@ describe('HomeView', () => {
127129
expect(mockWorkspaceStore.fetchHomeSummary).toHaveBeenCalledTimes(1)
128130
})
129131

132+
it('lets Paper Home own summary refreshes while the paper variant is active', async () => {
133+
usePaperThemeStore().mode = 'paper'
134+
135+
mount(HomeView)
136+
await waitForUi()
137+
138+
expect(mockWorkspaceStore.fetchHomeSummary).not.toHaveBeenCalled()
139+
})
140+
130141
it('renders setup loop, workload, and recent boards', async () => {
131142
const wrapper = mount(HomeView)
132143
await waitForUi()
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { mount } from '@vue/test-utils'
3+
import { nextTick, reactive } from 'vue'
4+
import PaperHomeView from '../../../views/paper/PaperHomeView.vue'
5+
import type { HomeSummary } from '../../../types/workspace'
6+
7+
/**
8+
* PaperHomeView — vitest coverage for greeting, queue rendering, empty
9+
* state, capture dispatch guard, and ember-accent rules.
10+
*
11+
* The view reads from sessionStore, workspaceStore, captureStore, and
12+
* vue-router; we mock all four with lightweight reactive stand-ins. The
13+
* greeting is time-of-day sensitive, so each test pins the system clock
14+
* with vi.useFakeTimers / setSystemTime.
15+
*/
16+
17+
const routerMocks = vi.hoisted(() => ({
18+
push: vi.fn(),
19+
}))
20+
21+
const mockSessionStore = reactive({
22+
username: 'daniel' as string | null,
23+
})
24+
25+
const mockWorkspaceStore = reactive({
26+
homeSummary: null as HomeSummary | null,
27+
homeLoading: false,
28+
homeError: null as string | null,
29+
hasHomeSummary: false,
30+
fetchHomeSummary: vi.fn<() => Promise<void>>(),
31+
})
32+
33+
const mockCaptureStore = {
34+
createItem: vi.fn(),
35+
}
36+
37+
vi.mock('../../../store/sessionStore', () => ({
38+
useSessionStore: () => mockSessionStore,
39+
}))
40+
41+
vi.mock('../../../store/workspaceStore', () => ({
42+
useWorkspaceStore: () => mockWorkspaceStore,
43+
}))
44+
45+
vi.mock('../../../store/captureStore', () => ({
46+
useCaptureStore: () => mockCaptureStore,
47+
}))
48+
49+
vi.mock('vue-router', () => ({
50+
useRouter: () => ({ push: routerMocks.push }),
51+
}))
52+
53+
function buildSummary(overrides?: Partial<HomeSummary>): HomeSummary {
54+
return {
55+
workspaceMode: 'guided',
56+
isFirstRun: false,
57+
onboarding: {
58+
visibility: 'active',
59+
isComplete: false,
60+
currentStepId: null,
61+
dismissedAt: null,
62+
completedAt: null,
63+
steps: [],
64+
},
65+
workload: {
66+
capturesNeedingTriage: 0,
67+
capturesInProgress: 0,
68+
capturesReadyForFollowUp: 0,
69+
proposalsPendingReview: 0,
70+
},
71+
boards: {
72+
totalBoards: 0,
73+
recentBoardsCount: 0,
74+
recentBoards: [],
75+
},
76+
recommendedActions: [],
77+
...overrides,
78+
}
79+
}
80+
81+
describe('PaperHomeView', () => {
82+
beforeEach(() => {
83+
vi.clearAllMocks()
84+
mockSessionStore.username = 'daniel'
85+
mockWorkspaceStore.homeSummary = buildSummary()
86+
mockWorkspaceStore.homeLoading = false
87+
mockWorkspaceStore.homeError = null
88+
mockWorkspaceStore.hasHomeSummary = true
89+
mockWorkspaceStore.fetchHomeSummary.mockResolvedValue(undefined)
90+
mockCaptureStore.createItem.mockReset()
91+
mockCaptureStore.createItem.mockResolvedValue({ id: 'capture-1' })
92+
})
93+
94+
afterEach(() => {
95+
vi.useRealTimers()
96+
})
97+
98+
describe('greeting period', () => {
99+
it.each([
100+
['08:00 local', new Date(2026, 3, 25, 8, 0, 0), 'Good morning', 'morning'],
101+
['12:30 local', new Date(2026, 3, 25, 12, 30, 0), 'Good afternoon', 'afternoon'],
102+
['19:00 local', new Date(2026, 3, 25, 19, 0, 0), 'Good evening', 'evening'],
103+
])('picks %s', (_label, fixedNow, opener, period) => {
104+
vi.useFakeTimers()
105+
vi.setSystemTime(fixedNow as Date)
106+
107+
const wrapper = mount(PaperHomeView)
108+
const greeting = wrapper.get('[data-testid="paper-home-greeting"]')
109+
const eyebrow = wrapper.get('[data-testid="paper-home-period"]')
110+
111+
expect(greeting.text()).toContain(opener as string)
112+
expect(greeting.text()).toContain('Daniel')
113+
expect(eyebrow.text()).toContain(period as string)
114+
})
115+
116+
it('falls back to "Hello" when no first name is available', () => {
117+
vi.useFakeTimers()
118+
vi.setSystemTime(new Date(2026, 3, 25, 9, 0, 0))
119+
mockSessionStore.username = null
120+
121+
const wrapper = mount(PaperHomeView)
122+
const greeting = wrapper.get('[data-testid="paper-home-greeting"]')
123+
124+
expect(greeting.text()).toBe('Hello.')
125+
expect(greeting.text()).not.toContain('Good morning')
126+
})
127+
128+
it('supports Unicode first-name tokens', () => {
129+
vi.useFakeTimers()
130+
vi.setSystemTime(new Date(2026, 3, 25, 9, 0, 0))
131+
mockSessionStore.username = 'élodie.smith@example.test'
132+
133+
const wrapper = mount(PaperHomeView)
134+
135+
expect(wrapper.get('[data-testid="paper-home-greeting"]').text()).toContain('Élodie')
136+
})
137+
138+
it('refreshes the greeting period while the view stays active', async () => {
139+
vi.useFakeTimers()
140+
vi.setSystemTime(new Date(2026, 3, 25, 11, 59, 30))
141+
142+
const wrapper = mount(PaperHomeView)
143+
expect(wrapper.get('[data-testid="paper-home-period"]').text()).toContain('morning')
144+
145+
await vi.advanceTimersByTimeAsync(60_000)
146+
await nextTick()
147+
148+
expect(wrapper.get('[data-testid="paper-home-period"]').text()).toContain('afternoon')
149+
})
150+
})
151+
152+
describe('queue cards', () => {
153+
it('renders loading instead of an empty queue while summary is missing', () => {
154+
mockWorkspaceStore.homeSummary = null
155+
mockWorkspaceStore.hasHomeSummary = false
156+
mockWorkspaceStore.homeLoading = true
157+
158+
const wrapper = mount(PaperHomeView)
159+
160+
expect(wrapper.find('[data-testid="paper-home-loading"]').exists()).toBe(true)
161+
expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(false)
162+
expect(wrapper.find('.paper-home__queue').exists()).toBe(false)
163+
})
164+
165+
it('renders the store error instead of an empty queue when summary loading fails', () => {
166+
mockWorkspaceStore.homeSummary = null
167+
mockWorkspaceStore.hasHomeSummary = false
168+
mockWorkspaceStore.homeError = 'Failed to load workspace summary'
169+
170+
const wrapper = mount(PaperHomeView)
171+
172+
expect(wrapper.find('[data-testid="paper-home-error"]').text()).toContain('Failed to load workspace summary')
173+
expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(false)
174+
expect(wrapper.find('.paper-home__queue').exists()).toBe(false)
175+
})
176+
177+
it('renders refresh errors even when a cached summary exists', () => {
178+
mockWorkspaceStore.homeSummary = buildSummary({
179+
workload: {
180+
capturesNeedingTriage: 0,
181+
capturesInProgress: 0,
182+
capturesReadyForFollowUp: 0,
183+
proposalsPendingReview: 0,
184+
},
185+
})
186+
mockWorkspaceStore.hasHomeSummary = true
187+
mockWorkspaceStore.homeError = 'Could not refresh workspace summary'
188+
189+
const wrapper = mount(PaperHomeView)
190+
191+
expect(wrapper.find('[data-testid="paper-home-error"]').text()).toContain('Could not refresh workspace summary')
192+
expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(false)
193+
expect(wrapper.get('[data-testid="paper-home-lede"]').text()).toContain('Could not refresh workspace summary')
194+
})
195+
196+
it('renders the empty state when nothing is queued', () => {
197+
mockWorkspaceStore.homeSummary = buildSummary()
198+
const wrapper = mount(PaperHomeView)
199+
200+
expect(wrapper.find('[data-testid="paper-home-empty"]').exists()).toBe(true)
201+
expect(wrapper.find('[data-testid="paper-home-empty"]').text()).toContain('Nothing waiting')
202+
expect(wrapper.find('[data-testid="paper-home-card-proposal"]').exists()).toBe(false)
203+
expect(wrapper.find('[data-testid="paper-home-card-carryover"]').exists()).toBe(false)
204+
})
205+
206+
it('only marks proposal entries with the ember halo, not carry-overs', () => {
207+
mockWorkspaceStore.homeSummary = buildSummary({
208+
workload: {
209+
capturesNeedingTriage: 2,
210+
capturesInProgress: 0,
211+
capturesReadyForFollowUp: 0,
212+
proposalsPendingReview: 1,
213+
},
214+
recommendedActions: [
215+
{
216+
actionId: 'review-proposals',
217+
title: 'Review pending proposals',
218+
description: 'One awaits decision.',
219+
targetSurface: 'review',
220+
attentionCount: 1,
221+
},
222+
],
223+
})
224+
225+
const wrapper = mount(PaperHomeView)
226+
const proposalCards = wrapper.findAll('[data-testid="paper-home-card-proposal"]')
227+
const carryoverCards = wrapper.findAll('[data-testid="paper-home-card-carryover"]')
228+
229+
expect(proposalCards).toHaveLength(1)
230+
expect(carryoverCards.length).toBeGreaterThan(0)
231+
232+
// Proposals carry the ember halo; carry-overs do not.
233+
expect(proposalCards[0].classes()).toContain('halo-ember')
234+
carryoverCards.forEach((card) => {
235+
expect(card.classes()).not.toContain('halo-ember')
236+
})
237+
})
238+
})
239+
240+
describe('quick capture', () => {
241+
it('cleans up the global capture shortcut listener on unmount', () => {
242+
const addSpy = vi.spyOn(window, 'addEventListener')
243+
const removeSpy = vi.spyOn(window, 'removeEventListener')
244+
245+
const wrapper = mount(PaperHomeView)
246+
wrapper.unmount()
247+
248+
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
249+
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
250+
})
251+
252+
it('does not dispatch on empty Enter', async () => {
253+
const wrapper = mount(PaperHomeView)
254+
const input = wrapper.get('[data-testid="paper-home-capture-input"]')
255+
256+
await input.setValue(' ')
257+
await wrapper.get('form').trigger('submit.prevent')
258+
259+
expect(mockCaptureStore.createItem).not.toHaveBeenCalled()
260+
})
261+
262+
it('dispatches a typed capture and refreshes the home summary on submit', async () => {
263+
const wrapper = mount(PaperHomeView)
264+
const input = wrapper.get('[data-testid="paper-home-capture-input"]')
265+
266+
await input.setValue('Refactor the queue store')
267+
await wrapper.get('form').trigger('submit.prevent')
268+
await Promise.resolve()
269+
270+
expect(mockCaptureStore.createItem).toHaveBeenCalledTimes(1)
271+
expect(mockCaptureStore.createItem).toHaveBeenCalledWith({
272+
boardId: null,
273+
text: 'Refactor the queue store',
274+
source: 'Typed',
275+
})
276+
expect(mockWorkspaceStore.fetchHomeSummary).toHaveBeenCalledTimes(1)
277+
})
278+
})
279+
})

frontend/taskdeck-web/src/views/HomeView.vue

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,55 @@
11
<script setup lang="ts">
2-
import { computed, onActivated, onMounted } from 'vue'
2+
import { computed, defineAsyncComponent, defineComponent, h, onActivated, onMounted } from 'vue'
33
import WorkspaceSetupModal from '../components/workspace/WorkspaceSetupModal.vue'
44
import WorkspaceHelpCallout from '../components/workspace/WorkspaceHelpCallout.vue'
55
import { TdSkeleton } from '../components/ui'
66
import { useWorkspaceOnboardingActions } from '../composables/useWorkspaceOnboardingActions'
77
import { useWorkspaceStore } from '../store/workspaceStore'
8+
import { usePaperThemeStore } from '../store/paperThemeStore'
89
import { usePerformanceMark } from '../composables/usePerformanceMark'
910
import type { HomeRecommendedAction, WorkspaceOnboarding } from '../types/workspace'
1011
import { isClientOnboardingDemoBoardName } from '../utils/boardDemo'
1112
13+
// Paper-skin variant is loaded lazily so the Obsidian path stays the
14+
// default code-split entry — only sessions that flip Paper on pay the
15+
// extra chunk.
16+
const PaperHomeAsyncLoading = defineComponent({
17+
name: 'PaperHomeAsyncLoading',
18+
setup: () => () =>
19+
h(
20+
'div',
21+
{
22+
class: 'td-home__skeleton',
23+
role: 'status',
24+
'aria-live': 'polite',
25+
},
26+
[h('span', { class: 'sr-only' }, 'Loading Paper Home...'), h('div', { class: 'td-panel td-home-card' }, 'Loading Paper Home...')],
27+
),
28+
})
29+
30+
const PaperHomeAsyncError = defineComponent({
31+
name: 'PaperHomeAsyncError',
32+
setup: () => () =>
33+
h(
34+
'div',
35+
{
36+
class: 'td-alert td-alert--error',
37+
role: 'alert',
38+
},
39+
'Paper Home could not be loaded. Refresh and try again.',
40+
),
41+
})
42+
43+
const PaperHomeView = defineAsyncComponent({
44+
loader: () => import('./paper/PaperHomeView.vue'),
45+
loadingComponent: PaperHomeAsyncLoading,
46+
errorComponent: PaperHomeAsyncError,
47+
delay: 0,
48+
timeout: 30000,
49+
})
50+
1251
const workspace = useWorkspaceStore()
52+
const paperTheme = usePaperThemeStore()
1353
const homeLoadPerf = usePerformanceMark('home-load')
1454
1555
const summary = computed(() => workspace.homeSummary)
@@ -104,7 +144,7 @@ function openBoard(boardId: string) {
104144
}
105145
106146
function refreshHomeSummary() {
107-
if (workspace.homeLoading) {
147+
if (paperTheme.isOn || workspace.homeLoading) {
108148
return
109149
}
110150
@@ -127,7 +167,8 @@ onActivated(refreshHomeSummary)
127167
</script>
128168

129169
<template>
130-
<div class="td-home" role="region" aria-label="Home workspace">
170+
<PaperHomeView v-if="paperTheme.isOn" />
171+
<div v-else class="td-home" role="region" aria-label="Home workspace">
131172
<header class="td-home__hero td-panel">
132173
<div class="td-home__hero-copy">
133174
<span class="td-home__eyebrow" aria-hidden="true">Workspace</span>

0 commit comments

Comments
 (0)