{ + return request('/v1/article/') + } + + return { listArticles } +} diff --git a/web/src/composables/useColorMode.ts b/web/src/composables/useColorMode.ts new file mode 100644 index 0000000..47705ec --- /dev/null +++ b/web/src/composables/useColorMode.ts @@ -0,0 +1,19 @@ +import { useColorMode as useVueUseColorMode } from '@vueuse/core' + +export type ColorModePreference = 'system' | 'light' | 'dark' + +export function useColorMode() { + const { store, system } = useVueUseColorMode() + + return { + get preference(): ColorModePreference { + return store.value === 'auto' ? 'system' : (store.value as ColorModePreference) + }, + set preference(value: ColorModePreference) { + store.value = value === 'system' ? 'auto' : value + }, + get value() { + return store.value === 'auto' ? system.value : store.value + }, + } +} diff --git a/web/src/lib/__tests__/relativeDate.spec.ts b/web/src/lib/__tests__/relativeDate.spec.ts new file mode 100644 index 0000000..aef2528 --- /dev/null +++ b/web/src/lib/__tests__/relativeDate.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { formatRelativeDate } from '../relativeDate' + +const now = new Date('2026-08-25T12:00:00Z') + +describe('formatRelativeDate', () => { + it('formats under an hour in minutes', () => { + expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago') + }) + + it('clamps a sub-minute difference to "1 minute ago"', () => { + expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago') + }) + + it('uses the singular for exactly one minute ago', () => { + expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago') + }) + + it('formats under a day in hours', () => { + expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago') + }) + + it('uses the singular for exactly one hour ago', () => { + expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago') + }) + + it('formats exactly one day back as Yesterday', () => { + expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday') + }) + + it('formats within the week in days', () => { + expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago') + }) + + it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => { + expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026') + }) + + it('formats a week or more ago as an absolute date', () => { + expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026') + }) +}) diff --git a/web/src/lib/relativeDate.ts b/web/src/lib/relativeDate.ts new file mode 100644 index 0000000..6f3e883 --- /dev/null +++ b/web/src/lib/relativeDate.ts @@ -0,0 +1,45 @@ +const MINUTE_MS = 60 * 1000 +const HOUR_MS = 60 * MINUTE_MS +const DAY_MS = 24 * HOUR_MS +const RELATIVE_CUTOFF_DAYS = 7 + +const MONTH_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + +function formatAbsoluteDate(date: Date) { + return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}` +} + +export function formatRelativeDate(iso: string, now: Date = new Date()) { + const date = new Date(iso) + const diffMs = now.getTime() - date.getTime() + + if (diffMs < HOUR_MS) { + const minutes = Math.max(1, Math.floor(diffMs / MINUTE_MS)) + return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago` + } + if (diffMs < DAY_MS) { + const hours = Math.floor(diffMs / HOUR_MS) + return hours === 1 ? '1 hour ago' : `${hours} hours ago` + } + const days = Math.floor(diffMs / DAY_MS) + if (days === 1) { + return 'Yesterday' + } + if (days < RELATIVE_CUTOFF_DAYS) { + return `${days} days ago` + } + return formatAbsoluteDate(date) +} diff --git a/web/src/router/__tests__/routerGuard.spec.ts b/web/src/router/__tests__/routerGuard.spec.ts index 964f1b2..31de35c 100644 --- a/web/src/router/__tests__/routerGuard.spec.ts +++ b/web/src/router/__tests__/routerGuard.spec.ts @@ -75,13 +75,13 @@ describe('Nuxt UI shell foundation and router guards', () => { await flushPromises() expect(app.find('[aria-label="Loading"]').exists()).toBe(true) - expect(app.text()).not.toContain('Home placeholder') + expect(app.text()).not.toContain('Connect your website') resolveMe({ verified: true }) await flushPromises() expect(app.find('[aria-label="Loading"]').exists()).toBe(false) - expect(app.text()).toContain('Home placeholder') + expect(app.text()).toContain('Connect your website') expect(router.currentRoute.value.path).toBe('/home') }) diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 9074e92..3b85106 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -6,6 +6,7 @@ export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error' export interface Session { verified: boolean + username?: string } export const useSessionStore = defineStore('session', () => { diff --git a/web/src/theme/ui.config.ts b/web/src/theme/ui.config.ts index 8e386cc..d8560fc 100644 --- a/web/src/theme/ui.config.ts +++ b/web/src/theme/ui.config.ts @@ -39,6 +39,14 @@ export const renewUi = { slots: { base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)', }, + variants: { + size: { + md: { + leadingIcon: 'size-4', + trailingIcon: 'size-4', + }, + }, + }, defaultVariants: { size: 'md', }, @@ -98,6 +106,14 @@ export const renewUi = { slots: { base: 'rounded-none font-medium', }, + variants: { + size: { + md: { + base: 'h-5.5 gap-1 px-1.75 py-0 text-xs', + leadingIcon: 'size-3', + }, + }, + }, }, kbd: { base: 'rounded-none font-mono', @@ -181,7 +197,16 @@ export const renewUi = { orientation: 'vertical', active: true, class: { - link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) before:hidden', + link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) font-medium before:hidden', + linkLeadingIcon: 'text-(--ui-color-primary-900)', + }, + }, + { + orientation: 'vertical', + active: false, + class: { + link: 'text-toned font-normal', + linkLeadingIcon: 'text-toned', }, }, ], diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 98a7f57..609fdb2 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -1,9 +1,275 @@ - Home placeholder. + + + + + + + + + {{ greeting }} + + {{ subtitle }} + + + New article + + + + + + + + + + + + {{ + tile.value + }} + {{ tile.label }} + + + + + + + Recently updated + + All articles + + + + + + + + + + + + + {{ article.title }} + + + + {{ formatRelativeDate(article.updated_at) }} + + + + + + + + + + + New article + + + + + + + + Try again + + + + + + + + + + Connect your website + + Your site reads published articles from RenewCMS. Share this guide with + whoever builds it. + + + Open the guide + + + + + + diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts new file mode 100644 index 0000000..23fcf1b --- /dev/null +++ b/web/src/views/__tests__/HomeView.spec.ts @@ -0,0 +1,375 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import router from '@/router' +import { useSessionStore } from '@/stores/session' +import type { Article } from '@/composables/useArticles' +import HomeView from '../HomeView.vue' + +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => body, + } as unknown as Response +} + +function routeFor(url: string, init?: RequestInit) { + const method = init?.method ?? 'GET' + return `${method} ${url}` +} + +function fetchRouter(handlers: Record Response | Promise>) { + return vi.fn((url: string, init?: RequestInit) => { + const key = routeFor(url, init) + const handler = handlers[key] + if (!handler) { + throw new Error(`Unexpected fetch call: ${key}`) + } + return handler() + }) +} + +const sixArticles: Article[] = [ + { + id: 1, + title: 'How we price a bathroom renovation', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-06T00:00:00Z', + }, + { + id: 2, + title: 'Winter checklist for timber windows', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-05T00:00:00Z', + }, + { + id: 3, + title: 'Choosing tiles for a small bathroom', + body: '', + images: [], + is_online: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-04T00:00:00Z', + }, + { + id: 4, + title: 'A guide to underfloor heating', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-03T00:00:00Z', + }, + { + id: 5, + title: 'When to reseal a stone worktop', + body: '', + images: [], + is_online: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + }, + { + id: 6, + title: 'Small bathroom lighting ideas', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, +] + +let currentWrapper: Awaited> | null = null + +async function mountHome(handlers: Record Response | Promise>) { + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true, username: 'marta' } + session.status = 'ready' + global.fetch = fetchRouter(handlers) as unknown as typeof fetch + await router.push('/home') + const wrapper = mount(HomeView, { + attachTo: document.body, + global: { + plugins: [router, uiPlugin], + }, + }) + currentWrapper = wrapper + await flushPromises() + return wrapper +} + +function findButton(wrapper: Awaited>, label: string) { + return wrapper.findAll('button').find((button) => button.text() === label) +} + +afterEach(async () => { + currentWrapper?.unmount() + currentWrapper = null + vi.useRealTimers() + await router.push('/login') +}) + +describe('Skeletons appear only after 150 ms, never for a fast response', () => { + it('shows no skeleton before 150 ms, a skeleton after, then Default once resolved', async () => { + vi.useFakeTimers() + let resolveList: (() => void) | null = null + const wrapper = await mountHome({ + 'GET /v1/article/': () => + new Promise((resolve) => { + resolveList = () => resolve(mockResponse(200, sixArticles)) + }), + }) + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + + await vi.advanceTimersByTimeAsync(150) + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) + + resolveList?.() + await vi.advanceTimersByTimeAsync(0) + await flushPromises() + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + expect(wrapper.text()).toContain('6') + expect(wrapper.text()).toContain('articles') + }) + + it('never shows a skeleton when the response resolves in under 150 ms', async () => { + vi.useFakeTimers() + const wrapper = await mountHome({ + 'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)), + }) + + await vi.advanceTimersByTimeAsync(0) + await flushPromises() + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + expect(wrapper.text()).toContain('6') + }) +}) + +describe('Default — the list loaded with a mix of online and draft articles', () => { + it('shows the subtitle, stat tiles and the first 5 articles in order', async () => { + const wrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + + expect(wrapper.text()).toContain("Here's where things stand on your site.") + + const rows = wrapper.find('[data-testid="recent-list"]').findAll('li') + expect(rows).toHaveLength(5) + expect(rows[0].text()).toContain('How we price a bathroom renovation') + expect(rows[4].text()).toContain('When to reseal a stone worktop') + expect(wrapper.text()).not.toContain('Small bathroom lighting ideas') + + expect(rows[0].find('a').attributes('href')).toBe('/article/1/edit') + expect(rows[0].text()).toContain('Online') + expect(rows[2].text()).toContain('Draft') + + expect(wrapper.text()).toContain('6') + expect(wrapper.text()).toContain('articles') + expect(wrapper.text()).toContain('4') + expect(wrapper.text()).toContain('online') + expect(wrapper.text()).toContain('2') + expect(wrapper.text()).toContain('drafts') + + const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide') + expect(guideLink?.attributes('href')).toBe('/integration') + + await findButton(wrapper, 'New article')?.trigger('click') + await flushPromises() + expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() + }) +}) + +describe('Empty, first run — zero articles', () => { + it('shows the empty state and opens NewArticleModal', async () => { + const wrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, []), + }) + + expect(wrapper.text()).toContain('Welcome to RenewCMS.') + expect(wrapper.text()).toContain('Write your first article') + expect(wrapper.text()).toContain( + 'It starts as a draft, so nothing appears on your site until you publish it.', + ) + expect(wrapper.find('[data-testid="stat-tile"]').exists()).toBe(false) + + await findButton(wrapper, 'New article')?.trigger('click') + await flushPromises() + + expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() + }) +}) + +describe('Error — a 500 or a network failure shows the same inline alert', () => { + it.each([ + { name: 'a 500 response', handler: () => mockResponse(500) }, + { + name: 'a network failure', + handler: () => Promise.reject(new TypeError('Failed to fetch')), + }, + ])('shows the alert with identical copy for $name', async ({ handler }) => { + const wrapper = await mountHome({ + 'GET /v1/article/': handler, + }) + + expect(wrapper.text()).toContain("Here's where things stand on your site.") + expect(wrapper.text()).toContain('Good') + const alert = wrapper.find('[role="alert"]') + expect(alert.exists()).toBe(true) + expect(alert.text()).toContain("Couldn't load your articles") + expect(alert.text()).toContain('Check your connection and try again.') + expect(findButton(wrapper, 'Try again')).toBeTruthy() + }) +}) + +describe('Retry re-issues the request and recovers into the Default state', () => { + it('replaces the alert with the stat tiles and recent list once the retry succeeds', async () => { + const listCall = vi + .fn() + .mockResolvedValueOnce(mockResponse(500)) + .mockResolvedValueOnce(mockResponse(200, sixArticles)) + const wrapper = await mountHome({ + 'GET /v1/article/': listCall, + }) + + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + + await findButton(wrapper, 'Try again')?.trigger('click') + await flushPromises() + + expect(listCall).toHaveBeenCalledTimes(2) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(wrapper.text()).toContain('6') + expect(wrapper.text()).toContain('articles') + }) +}) + +describe('The list is fetched exactly once per navigation', () => { + it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => { + const listCall = vi.fn(() => mockResponse(200, sixArticles)) + const wrapper = await mountHome({ + 'GET /v1/article/': listCall, + }) + + expect(listCall).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('6') + expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5) + }) +}) + +describe('The greeting matches the hour of day', () => { + it.each([ + { hour: 0, greeting: 'Good morning' }, + { hour: 11, greeting: 'Good morning' }, + { hour: 12, greeting: 'Good afternoon' }, + { hour: 17, greeting: 'Good afternoon' }, + { hour: 18, greeting: 'Good evening' }, + { hour: 23, greeting: 'Good evening' }, + ])('reads "$greeting, marta" at hour $hour', async ({ hour, greeting }) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0)) + const wrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + + expect(wrapper.text()).toContain(`${greeting}, marta`) + }) +}) + +describe('The greeting is identical across every load state at a fixed hour', () => { + it('reads "Good morning, marta" while loading, in Default, Empty and Error', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) + + const loadingWrapper = await mountHome({ + 'GET /v1/article/': () => new Promise(() => {}), + }) + await vi.advanceTimersByTimeAsync(150) + expect(loadingWrapper.text()).toContain('Good morning, marta') + + const defaultWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + expect(defaultWrapper.text()).toContain('Good morning, marta') + + const emptyWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, []), + }) + expect(emptyWrapper.text()).toContain('Good morning, marta') + + const errorWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(500), + }) + expect(errorWrapper.text()).toContain('Good morning, marta') + }) +}) + +describe('The greeting handles a missing username', () => { + it('renders the plain greeting without a stray comma when the session has no username', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true } + session.status = 'ready' + global.fetch = fetchRouter({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) as unknown as typeof fetch + await router.push('/home') + const wrapper = mount(HomeView, { + attachTo: document.body, + global: { plugins: [router, uiPlugin] }, + }) + currentWrapper = wrapper + await flushPromises() + + const heading = wrapper.find('[data-testid="greeting"]') + expect(heading.text()).toBe('Good morning') + expect(heading.text()).not.toContain('undefined') + expect(heading.text()).not.toMatch(/,\s*$/) + }) +}) + +describe('The "Connect your website" card renders in every state', () => { + it('renders the same card while loading, in Default, Empty and Error', async () => { + vi.useFakeTimers() + const wrapper = await mountHome({ + 'GET /v1/article/': () => new Promise(() => {}), + }) + await vi.advanceTimersByTimeAsync(150) + expect(wrapper.text()).toContain('Connect your website') + expect(wrapper.text()).toContain( + 'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.', + ) + vi.useRealTimers() + + const defaultWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + expect(defaultWrapper.text()).toContain('Connect your website') + + const emptyWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, []), + }) + expect(emptyWrapper.text()).toContain('Connect your website') + + const errorWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(500), + }) + expect(errorWrapper.text()).toContain('Connect your website') + }) +}) diff --git a/web/visual/check.ts b/web/visual/check.ts index 74ba12c..3c960d8 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -283,6 +283,7 @@ async function captureApp( viewport: { width, height }, colorScheme: theme, reducedMotion: 'reduce', + ...(setup.now ? { timezoneId: 'UTC' } : {}), }) await serveIconsLocally(context) const unmocked = new Set() @@ -301,6 +302,9 @@ async function captureApp( await route.fulfill(fulfilment(mock)) }) const page = await context.newPage() + if (setup.now) { + await page.clock.setFixedTime(new Date(setup.now)) + } await page.goto(`${origin}${setup.path}`) await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)]) await setup.steps?.(page) diff --git a/web/visual/states.ts b/web/visual/states.ts index b8520f2..323a796 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -9,6 +9,7 @@ export interface ApiMock { export interface VisualState { path: string + now?: string api?: Record steps?: (page: Page) => Promise } @@ -80,7 +81,105 @@ async function confirmCancelDialog(page: Page) { .click() } +const boardNow = '2026-09-10T09:00:00Z' + +const sixArticlesFixture = [ + { + id: 1, + title: 'How we price a bathroom renovation', + body: '', + images: [], + is_online: true, + created_at: '2026-03-12T09:00:00Z', + updated_at: '2026-09-10T07:00:00Z', + }, + { + id: 2, + title: 'Choosing lime plaster for old stone walls', + body: '', + images: [], + is_online: false, + created_at: '2026-09-09T08:00:00Z', + updated_at: '2026-09-09T09:00:00Z', + }, + { + id: 3, + title: 'Before and after: the Rue Verte kitchen', + body: '', + images: [], + is_online: true, + created_at: '2026-08-28T09:00:00Z', + updated_at: '2026-09-07T09:00:00Z', + }, + { + id: 4, + title: 'Winter checklist for timber windows', + body: '', + images: [], + is_online: false, + created_at: '2026-09-02T09:00:00Z', + updated_at: '2026-09-05T09:00:00Z', + }, + { + id: 5, + title: 'Why we reuse original floorboards', + body: '', + images: [], + is_online: true, + created_at: '2026-06-14T09:00:00Z', + updated_at: '2026-08-18T09:00:00Z', + }, + { + id: 6, + title: 'Our first passive-house retrofit', + body: '', + images: [], + is_online: true, + created_at: '2026-04-03T09:00:00Z', + updated_at: '2026-07-02T09:00:00Z', + }, +] + +const verifiedSession: ApiMock = { + status: 200, + body: { is_verified: true, username: 'marta' }, +} + export const visualStates: Record> = { + home: { + Default: { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: sixArticlesFixture }, + }, + }, + Loading: { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true }, + }, + }, + 'Empty, first run': { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: [] }, + }, + }, + Error: { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 500 }, + }, + }, + }, 'log-in': { Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } }, 'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },
('/v1/article/') + } + + return { listArticles } +} diff --git a/web/src/composables/useColorMode.ts b/web/src/composables/useColorMode.ts new file mode 100644 index 0000000..47705ec --- /dev/null +++ b/web/src/composables/useColorMode.ts @@ -0,0 +1,19 @@ +import { useColorMode as useVueUseColorMode } from '@vueuse/core' + +export type ColorModePreference = 'system' | 'light' | 'dark' + +export function useColorMode() { + const { store, system } = useVueUseColorMode() + + return { + get preference(): ColorModePreference { + return store.value === 'auto' ? 'system' : (store.value as ColorModePreference) + }, + set preference(value: ColorModePreference) { + store.value = value === 'system' ? 'auto' : value + }, + get value() { + return store.value === 'auto' ? system.value : store.value + }, + } +} diff --git a/web/src/lib/__tests__/relativeDate.spec.ts b/web/src/lib/__tests__/relativeDate.spec.ts new file mode 100644 index 0000000..aef2528 --- /dev/null +++ b/web/src/lib/__tests__/relativeDate.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { formatRelativeDate } from '../relativeDate' + +const now = new Date('2026-08-25T12:00:00Z') + +describe('formatRelativeDate', () => { + it('formats under an hour in minutes', () => { + expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago') + }) + + it('clamps a sub-minute difference to "1 minute ago"', () => { + expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago') + }) + + it('uses the singular for exactly one minute ago', () => { + expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago') + }) + + it('formats under a day in hours', () => { + expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago') + }) + + it('uses the singular for exactly one hour ago', () => { + expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago') + }) + + it('formats exactly one day back as Yesterday', () => { + expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday') + }) + + it('formats within the week in days', () => { + expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago') + }) + + it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => { + expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026') + }) + + it('formats a week or more ago as an absolute date', () => { + expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026') + }) +}) diff --git a/web/src/lib/relativeDate.ts b/web/src/lib/relativeDate.ts new file mode 100644 index 0000000..6f3e883 --- /dev/null +++ b/web/src/lib/relativeDate.ts @@ -0,0 +1,45 @@ +const MINUTE_MS = 60 * 1000 +const HOUR_MS = 60 * MINUTE_MS +const DAY_MS = 24 * HOUR_MS +const RELATIVE_CUTOFF_DAYS = 7 + +const MONTH_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] + +function formatAbsoluteDate(date: Date) { + return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}` +} + +export function formatRelativeDate(iso: string, now: Date = new Date()) { + const date = new Date(iso) + const diffMs = now.getTime() - date.getTime() + + if (diffMs < HOUR_MS) { + const minutes = Math.max(1, Math.floor(diffMs / MINUTE_MS)) + return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago` + } + if (diffMs < DAY_MS) { + const hours = Math.floor(diffMs / HOUR_MS) + return hours === 1 ? '1 hour ago' : `${hours} hours ago` + } + const days = Math.floor(diffMs / DAY_MS) + if (days === 1) { + return 'Yesterday' + } + if (days < RELATIVE_CUTOFF_DAYS) { + return `${days} days ago` + } + return formatAbsoluteDate(date) +} diff --git a/web/src/router/__tests__/routerGuard.spec.ts b/web/src/router/__tests__/routerGuard.spec.ts index 964f1b2..31de35c 100644 --- a/web/src/router/__tests__/routerGuard.spec.ts +++ b/web/src/router/__tests__/routerGuard.spec.ts @@ -75,13 +75,13 @@ describe('Nuxt UI shell foundation and router guards', () => { await flushPromises() expect(app.find('[aria-label="Loading"]').exists()).toBe(true) - expect(app.text()).not.toContain('Home placeholder') + expect(app.text()).not.toContain('Connect your website') resolveMe({ verified: true }) await flushPromises() expect(app.find('[aria-label="Loading"]').exists()).toBe(false) - expect(app.text()).toContain('Home placeholder') + expect(app.text()).toContain('Connect your website') expect(router.currentRoute.value.path).toBe('/home') }) diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 9074e92..3b85106 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -6,6 +6,7 @@ export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error' export interface Session { verified: boolean + username?: string } export const useSessionStore = defineStore('session', () => { diff --git a/web/src/theme/ui.config.ts b/web/src/theme/ui.config.ts index 8e386cc..d8560fc 100644 --- a/web/src/theme/ui.config.ts +++ b/web/src/theme/ui.config.ts @@ -39,6 +39,14 @@ export const renewUi = { slots: { base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)', }, + variants: { + size: { + md: { + leadingIcon: 'size-4', + trailingIcon: 'size-4', + }, + }, + }, defaultVariants: { size: 'md', }, @@ -98,6 +106,14 @@ export const renewUi = { slots: { base: 'rounded-none font-medium', }, + variants: { + size: { + md: { + base: 'h-5.5 gap-1 px-1.75 py-0 text-xs', + leadingIcon: 'size-3', + }, + }, + }, }, kbd: { base: 'rounded-none font-mono', @@ -181,7 +197,16 @@ export const renewUi = { orientation: 'vertical', active: true, class: { - link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) before:hidden', + link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) font-medium before:hidden', + linkLeadingIcon: 'text-(--ui-color-primary-900)', + }, + }, + { + orientation: 'vertical', + active: false, + class: { + link: 'text-toned font-normal', + linkLeadingIcon: 'text-toned', }, }, ], diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 98a7f57..609fdb2 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -1,9 +1,275 @@ - Home placeholder. + + + + + + + + + {{ greeting }} + + {{ subtitle }} + + + New article + + + + + + + + + + + + {{ + tile.value + }} + {{ tile.label }} + + + + + + + Recently updated + + All articles + + + + + + + + + + + + + {{ article.title }} + + + + {{ formatRelativeDate(article.updated_at) }} + + + + + + + + + + + New article + + + + + + + + Try again + + + + + + + + + + Connect your website + + Your site reads published articles from RenewCMS. Share this guide with + whoever builds it. + + + Open the guide + + + + + + diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts new file mode 100644 index 0000000..23fcf1b --- /dev/null +++ b/web/src/views/__tests__/HomeView.spec.ts @@ -0,0 +1,375 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import uiPlugin from '@nuxt/ui/vue-plugin' +import router from '@/router' +import { useSessionStore } from '@/stores/session' +import type { Article } from '@/composables/useArticles' +import HomeView from '../HomeView.vue' + +function mockResponse(status: number, body: unknown = null, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => body, + } as unknown as Response +} + +function routeFor(url: string, init?: RequestInit) { + const method = init?.method ?? 'GET' + return `${method} ${url}` +} + +function fetchRouter(handlers: Record Response | Promise>) { + return vi.fn((url: string, init?: RequestInit) => { + const key = routeFor(url, init) + const handler = handlers[key] + if (!handler) { + throw new Error(`Unexpected fetch call: ${key}`) + } + return handler() + }) +} + +const sixArticles: Article[] = [ + { + id: 1, + title: 'How we price a bathroom renovation', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-06T00:00:00Z', + }, + { + id: 2, + title: 'Winter checklist for timber windows', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-05T00:00:00Z', + }, + { + id: 3, + title: 'Choosing tiles for a small bathroom', + body: '', + images: [], + is_online: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-04T00:00:00Z', + }, + { + id: 4, + title: 'A guide to underfloor heating', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-03T00:00:00Z', + }, + { + id: 5, + title: 'When to reseal a stone worktop', + body: '', + images: [], + is_online: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + }, + { + id: 6, + title: 'Small bathroom lighting ideas', + body: '', + images: [], + is_online: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, +] + +let currentWrapper: Awaited> | null = null + +async function mountHome(handlers: Record Response | Promise>) { + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true, username: 'marta' } + session.status = 'ready' + global.fetch = fetchRouter(handlers) as unknown as typeof fetch + await router.push('/home') + const wrapper = mount(HomeView, { + attachTo: document.body, + global: { + plugins: [router, uiPlugin], + }, + }) + currentWrapper = wrapper + await flushPromises() + return wrapper +} + +function findButton(wrapper: Awaited>, label: string) { + return wrapper.findAll('button').find((button) => button.text() === label) +} + +afterEach(async () => { + currentWrapper?.unmount() + currentWrapper = null + vi.useRealTimers() + await router.push('/login') +}) + +describe('Skeletons appear only after 150 ms, never for a fast response', () => { + it('shows no skeleton before 150 ms, a skeleton after, then Default once resolved', async () => { + vi.useFakeTimers() + let resolveList: (() => void) | null = null + const wrapper = await mountHome({ + 'GET /v1/article/': () => + new Promise((resolve) => { + resolveList = () => resolve(mockResponse(200, sixArticles)) + }), + }) + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + + await vi.advanceTimersByTimeAsync(150) + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true) + + resolveList?.() + await vi.advanceTimersByTimeAsync(0) + await flushPromises() + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + expect(wrapper.text()).toContain('6') + expect(wrapper.text()).toContain('articles') + }) + + it('never shows a skeleton when the response resolves in under 150 ms', async () => { + vi.useFakeTimers() + const wrapper = await mountHome({ + 'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)), + }) + + await vi.advanceTimersByTimeAsync(0) + await flushPromises() + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false) + expect(wrapper.text()).toContain('6') + }) +}) + +describe('Default — the list loaded with a mix of online and draft articles', () => { + it('shows the subtitle, stat tiles and the first 5 articles in order', async () => { + const wrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + + expect(wrapper.text()).toContain("Here's where things stand on your site.") + + const rows = wrapper.find('[data-testid="recent-list"]').findAll('li') + expect(rows).toHaveLength(5) + expect(rows[0].text()).toContain('How we price a bathroom renovation') + expect(rows[4].text()).toContain('When to reseal a stone worktop') + expect(wrapper.text()).not.toContain('Small bathroom lighting ideas') + + expect(rows[0].find('a').attributes('href')).toBe('/article/1/edit') + expect(rows[0].text()).toContain('Online') + expect(rows[2].text()).toContain('Draft') + + expect(wrapper.text()).toContain('6') + expect(wrapper.text()).toContain('articles') + expect(wrapper.text()).toContain('4') + expect(wrapper.text()).toContain('online') + expect(wrapper.text()).toContain('2') + expect(wrapper.text()).toContain('drafts') + + const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide') + expect(guideLink?.attributes('href')).toBe('/integration') + + await findButton(wrapper, 'New article')?.trigger('click') + await flushPromises() + expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() + }) +}) + +describe('Empty, first run — zero articles', () => { + it('shows the empty state and opens NewArticleModal', async () => { + const wrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, []), + }) + + expect(wrapper.text()).toContain('Welcome to RenewCMS.') + expect(wrapper.text()).toContain('Write your first article') + expect(wrapper.text()).toContain( + 'It starts as a draft, so nothing appears on your site until you publish it.', + ) + expect(wrapper.find('[data-testid="stat-tile"]').exists()).toBe(false) + + await findButton(wrapper, 'New article')?.trigger('click') + await flushPromises() + + expect(document.body.querySelector('[role="dialog"]')).toBeTruthy() + }) +}) + +describe('Error — a 500 or a network failure shows the same inline alert', () => { + it.each([ + { name: 'a 500 response', handler: () => mockResponse(500) }, + { + name: 'a network failure', + handler: () => Promise.reject(new TypeError('Failed to fetch')), + }, + ])('shows the alert with identical copy for $name', async ({ handler }) => { + const wrapper = await mountHome({ + 'GET /v1/article/': handler, + }) + + expect(wrapper.text()).toContain("Here's where things stand on your site.") + expect(wrapper.text()).toContain('Good') + const alert = wrapper.find('[role="alert"]') + expect(alert.exists()).toBe(true) + expect(alert.text()).toContain("Couldn't load your articles") + expect(alert.text()).toContain('Check your connection and try again.') + expect(findButton(wrapper, 'Try again')).toBeTruthy() + }) +}) + +describe('Retry re-issues the request and recovers into the Default state', () => { + it('replaces the alert with the stat tiles and recent list once the retry succeeds', async () => { + const listCall = vi + .fn() + .mockResolvedValueOnce(mockResponse(500)) + .mockResolvedValueOnce(mockResponse(200, sixArticles)) + const wrapper = await mountHome({ + 'GET /v1/article/': listCall, + }) + + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + + await findButton(wrapper, 'Try again')?.trigger('click') + await flushPromises() + + expect(listCall).toHaveBeenCalledTimes(2) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(wrapper.text()).toContain('6') + expect(wrapper.text()).toContain('articles') + }) +}) + +describe('The list is fetched exactly once per navigation', () => { + it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => { + const listCall = vi.fn(() => mockResponse(200, sixArticles)) + const wrapper = await mountHome({ + 'GET /v1/article/': listCall, + }) + + expect(listCall).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('6') + expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5) + }) +}) + +describe('The greeting matches the hour of day', () => { + it.each([ + { hour: 0, greeting: 'Good morning' }, + { hour: 11, greeting: 'Good morning' }, + { hour: 12, greeting: 'Good afternoon' }, + { hour: 17, greeting: 'Good afternoon' }, + { hour: 18, greeting: 'Good evening' }, + { hour: 23, greeting: 'Good evening' }, + ])('reads "$greeting, marta" at hour $hour', async ({ hour, greeting }) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0)) + const wrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + + expect(wrapper.text()).toContain(`${greeting}, marta`) + }) +}) + +describe('The greeting is identical across every load state at a fixed hour', () => { + it('reads "Good morning, marta" while loading, in Default, Empty and Error', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) + + const loadingWrapper = await mountHome({ + 'GET /v1/article/': () => new Promise(() => {}), + }) + await vi.advanceTimersByTimeAsync(150) + expect(loadingWrapper.text()).toContain('Good morning, marta') + + const defaultWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + expect(defaultWrapper.text()).toContain('Good morning, marta') + + const emptyWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, []), + }) + expect(emptyWrapper.text()).toContain('Good morning, marta') + + const errorWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(500), + }) + expect(errorWrapper.text()).toContain('Good morning, marta') + }) +}) + +describe('The greeting handles a missing username', () => { + it('renders the plain greeting without a stray comma when the session has no username', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) + setActivePinia(createPinia()) + const session = useSessionStore() + session.session = { verified: true } + session.status = 'ready' + global.fetch = fetchRouter({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) as unknown as typeof fetch + await router.push('/home') + const wrapper = mount(HomeView, { + attachTo: document.body, + global: { plugins: [router, uiPlugin] }, + }) + currentWrapper = wrapper + await flushPromises() + + const heading = wrapper.find('[data-testid="greeting"]') + expect(heading.text()).toBe('Good morning') + expect(heading.text()).not.toContain('undefined') + expect(heading.text()).not.toMatch(/,\s*$/) + }) +}) + +describe('The "Connect your website" card renders in every state', () => { + it('renders the same card while loading, in Default, Empty and Error', async () => { + vi.useFakeTimers() + const wrapper = await mountHome({ + 'GET /v1/article/': () => new Promise(() => {}), + }) + await vi.advanceTimersByTimeAsync(150) + expect(wrapper.text()).toContain('Connect your website') + expect(wrapper.text()).toContain( + 'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.', + ) + vi.useRealTimers() + + const defaultWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, sixArticles), + }) + expect(defaultWrapper.text()).toContain('Connect your website') + + const emptyWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(200, []), + }) + expect(emptyWrapper.text()).toContain('Connect your website') + + const errorWrapper = await mountHome({ + 'GET /v1/article/': () => mockResponse(500), + }) + expect(errorWrapper.text()).toContain('Connect your website') + }) +}) diff --git a/web/visual/check.ts b/web/visual/check.ts index 74ba12c..3c960d8 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -283,6 +283,7 @@ async function captureApp( viewport: { width, height }, colorScheme: theme, reducedMotion: 'reduce', + ...(setup.now ? { timezoneId: 'UTC' } : {}), }) await serveIconsLocally(context) const unmocked = new Set() @@ -301,6 +302,9 @@ async function captureApp( await route.fulfill(fulfilment(mock)) }) const page = await context.newPage() + if (setup.now) { + await page.clock.setFixedTime(new Date(setup.now)) + } await page.goto(`${origin}${setup.path}`) await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)]) await setup.steps?.(page) diff --git a/web/visual/states.ts b/web/visual/states.ts index b8520f2..323a796 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -9,6 +9,7 @@ export interface ApiMock { export interface VisualState { path: string + now?: string api?: Record steps?: (page: Page) => Promise } @@ -80,7 +81,105 @@ async function confirmCancelDialog(page: Page) { .click() } +const boardNow = '2026-09-10T09:00:00Z' + +const sixArticlesFixture = [ + { + id: 1, + title: 'How we price a bathroom renovation', + body: '', + images: [], + is_online: true, + created_at: '2026-03-12T09:00:00Z', + updated_at: '2026-09-10T07:00:00Z', + }, + { + id: 2, + title: 'Choosing lime plaster for old stone walls', + body: '', + images: [], + is_online: false, + created_at: '2026-09-09T08:00:00Z', + updated_at: '2026-09-09T09:00:00Z', + }, + { + id: 3, + title: 'Before and after: the Rue Verte kitchen', + body: '', + images: [], + is_online: true, + created_at: '2026-08-28T09:00:00Z', + updated_at: '2026-09-07T09:00:00Z', + }, + { + id: 4, + title: 'Winter checklist for timber windows', + body: '', + images: [], + is_online: false, + created_at: '2026-09-02T09:00:00Z', + updated_at: '2026-09-05T09:00:00Z', + }, + { + id: 5, + title: 'Why we reuse original floorboards', + body: '', + images: [], + is_online: true, + created_at: '2026-06-14T09:00:00Z', + updated_at: '2026-08-18T09:00:00Z', + }, + { + id: 6, + title: 'Our first passive-house retrofit', + body: '', + images: [], + is_online: true, + created_at: '2026-04-03T09:00:00Z', + updated_at: '2026-07-02T09:00:00Z', + }, +] + +const verifiedSession: ApiMock = { + status: 200, + body: { is_verified: true, username: 'marta' }, +} + export const visualStates: Record> = { + home: { + Default: { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: sixArticlesFixture }, + }, + }, + Loading: { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true }, + }, + }, + 'Empty, first run': { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: [] }, + }, + }, + Error: { + path: '/home', + now: boardNow, + api: { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 500 }, + }, + }, + }, 'log-in': { Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } }, 'Fresh install': { path: '/login', api: { 'GET /v1/auth/registration': registrationOpen } },