diff --git a/backend/config/scribe.php b/backend/config/scribe.php index 3fb82ec7..f747ed20 100644 --- a/backend/config/scribe.php +++ b/backend/config/scribe.php @@ -1,11 +1,5 @@ true, // Where is the auth value meant to be sent in a request? - 'in' => AuthIn::BEARER->value, + 'in' => 'bearer', // The name of the auth parameter (e.g. token, key, apiKey) or header (e.g. Authorization, Api-Key). 'name' => 'Authorization', @@ -211,32 +205,18 @@ return [ // Use configureStrategy() to specify settings for a strategy in the list. // Use removeStrategies() to remove an included strategy. 'strategies' => [ - 'metadata' => [ - ...Defaults::METADATA_STRATEGIES, - ], + 'metadata' => [], 'headers' => [ - ...Defaults::HEADERS_STRATEGIES, - Strategies\StaticData::withSettings(data: [ + [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', - ]), - ], - 'urlParameters' => [ - ...Defaults::URL_PARAMETERS_STRATEGIES, - ], - 'queryParameters' => [ - ...Defaults::QUERY_PARAMETERS_STRATEGIES, - ], - 'bodyParameters' => [ - ...Defaults::BODY_PARAMETERS_STRATEGIES, - ], - 'responses' => removeStrategies( - Defaults::RESPONSES_STRATEGIES, - [Strategies\Responses\ResponseCalls::class], - ), - 'responseFields' => [ - ...Defaults::RESPONSE_FIELDS_STRATEGIES, + ], ], + 'urlParameters' => [], + 'queryParameters' => [], + 'bodyParameters' => [], + 'responses' => [], + 'responseFields' => [], ], // For response calls, API resource responses and transformer responses, diff --git a/docker-compose.yml b/docker-compose.yml index a9606345..1d475c37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,6 @@ services: restart: unless-stopped working_dir: /app volumes: - - ./frontend:/app - /app/node_modules ports: - "5173:5173" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 93771d3d..24aaa7c1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -19,7 +19,7 @@ EXPOSE 5173 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:5173 || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:5173 || exit 1 # Start the application CMD ["node", "build/index.js"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ee6eec43..04c2c0d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@tanstack/table-core": "^8.21.2", + "lucide-svelte": "^0.574.0", "recharts": "^2.15.1", "sveltekit-superforms": "^2.24.0", "zod": "^3.24.2" @@ -4186,6 +4187,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lucide-svelte": { + "version": "0.574.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.574.0.tgz", + "integrity": "sha512-KhMbh4uFO8jm60bYPWHV5GmNy1P5hs1M6BfDYUjw0CZ5tJoJ+lMzAdTBBb9L+Gj3xOlHIgjsLMVnjQK3qcWrEA==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 599b5e25..23fe4a69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@tanstack/table-core": "^8.21.2", + "lucide-svelte": "^0.574.0", "recharts": "^2.15.1", "sveltekit-superforms": "^2.24.0", "zod": "^3.24.2" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 31624135..47def589 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'list', use: { - baseURL: 'http://localhost:5173', + baseURL: 'http://127.0.0.1:5173', trace: 'on-first-retry', }, projects: [ diff --git a/frontend/src/app.css b/frontend/src/app.css index f1d37dc5..0477fb42 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,2 +1,16 @@ @import "tailwindcss"; @import "daisyui"; + +:root { + --sidebar-width-expanded: 240px; + --sidebar-width-collapsed: 64px; + --topbar-height: 56px; +} + +[data-theme='light'] { + color-scheme: light; +} + +[data-theme='dark'] { + color-scheme: dark; +} diff --git a/frontend/src/app.html b/frontend/src/app.html index 6769ed5e..a95435fa 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,6 +4,25 @@ + %sveltekit.head% diff --git a/frontend/src/lib/components/layout/AppLayout.svelte b/frontend/src/lib/components/layout/AppLayout.svelte new file mode 100644 index 00000000..634b1605 --- /dev/null +++ b/frontend/src/lib/components/layout/AppLayout.svelte @@ -0,0 +1,28 @@ + + + + +
+ + +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/layout/Breadcrumbs.svelte b/frontend/src/lib/components/layout/Breadcrumbs.svelte new file mode 100644 index 00000000..5fe5b0a4 --- /dev/null +++ b/frontend/src/lib/components/layout/Breadcrumbs.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/lib/components/layout/MonthSelector.svelte b/frontend/src/lib/components/layout/MonthSelector.svelte new file mode 100644 index 00000000..ff345e12 --- /dev/null +++ b/frontend/src/lib/components/layout/MonthSelector.svelte @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/lib/components/layout/Sidebar.svelte b/frontend/src/lib/components/layout/Sidebar.svelte new file mode 100644 index 00000000..ca08907d --- /dev/null +++ b/frontend/src/lib/components/layout/Sidebar.svelte @@ -0,0 +1,132 @@ + + + + +
setSidebarState('hidden')} + data-testid="sidebar-backdrop" +>
+ + + + diff --git a/frontend/src/lib/components/layout/SidebarItem.svelte b/frontend/src/lib/components/layout/SidebarItem.svelte new file mode 100644 index 00000000..a7229388 --- /dev/null +++ b/frontend/src/lib/components/layout/SidebarItem.svelte @@ -0,0 +1,55 @@ + + + + + {#if expanded} + {label} + {/if} + diff --git a/frontend/src/lib/components/layout/SidebarSection.svelte b/frontend/src/lib/components/layout/SidebarSection.svelte new file mode 100644 index 00000000..03e56258 --- /dev/null +++ b/frontend/src/lib/components/layout/SidebarSection.svelte @@ -0,0 +1,21 @@ + + +
+ {#if expanded} +

+ {section.title} +

+ {/if} + +
+ {#each section.items as item (item.href)} + + {/each} +
+
diff --git a/frontend/src/lib/components/layout/TopBar.svelte b/frontend/src/lib/components/layout/TopBar.svelte new file mode 100644 index 00000000..401a7cbe --- /dev/null +++ b/frontend/src/lib/components/layout/TopBar.svelte @@ -0,0 +1,32 @@ + + +
+
+
+ + +
+ +
+ + +
+
+
diff --git a/frontend/src/lib/components/layout/UserMenu.svelte b/frontend/src/lib/components/layout/UserMenu.svelte new file mode 100644 index 00000000..49712cb3 --- /dev/null +++ b/frontend/src/lib/components/layout/UserMenu.svelte @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/lib/config/navigation.ts b/frontend/src/lib/config/navigation.ts new file mode 100644 index 00000000..10fb6b7d --- /dev/null +++ b/frontend/src/lib/config/navigation.ts @@ -0,0 +1,32 @@ +import type { NavSection } from '$lib/types/layout'; + +export const navigationSections: NavSection[] = [ + { + title: 'PLANNING', + items: [ + { label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' }, + { label: 'Team', href: '/team', icon: 'Users' }, + { label: 'Projects', href: '/projects', icon: 'Folder' }, + { label: 'Allocations', href: '/allocations', icon: 'Calendar' }, + { label: 'Actuals', href: '/actuals', icon: 'CheckCircle' } + ] + }, + { + title: 'REPORTS', + items: [ + { label: 'Forecast', href: '/reports/forecast', icon: 'TrendingUp' }, + { label: 'Utilization', href: '/reports/utilization', icon: 'BarChart3' }, + { label: 'Costs', href: '/reports/costs', icon: 'DollarSign' }, + { label: 'Variance', href: '/reports/variance', icon: 'AlertTriangle' }, + { label: 'Allocation Matrix', href: '/reports/allocation', icon: 'Grid3X3' } + ] + }, + { + title: 'ADMIN', + roles: ['superuser'], + items: [ + { label: 'Settings', href: '/settings', icon: 'Settings' }, + { label: 'Master Data', href: '/master-data', icon: 'Database' } + ] + } +]; diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index 8aa8534b..697b70b1 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -140,13 +140,24 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions // Prepare request options const requestOptions: RequestInit = { - ...options, + method: options.method, headers, }; // Handle request body - if (options.body && typeof options.body === 'object') { - requestOptions.body = JSON.stringify(options.body); + if (options.body !== undefined) { + const body = options.body; + + const isJsonObject = + typeof body === 'object' && + body !== null && + !(body instanceof FormData) && + !(body instanceof URLSearchParams) && + !(body instanceof Blob) && + !(body instanceof ArrayBuffer) && + !ArrayBuffer.isView(body); + + requestOptions.body = isJsonObject ? JSON.stringify(body) : (body as BodyInit); } try { @@ -213,12 +224,12 @@ async function handleResponse(response: Response): Promise { export const api = { get: (endpoint: string, options: ApiRequestOptions = {}) => apiRequest(endpoint, { ...options, method: 'GET' }), - post: (endpoint: string, body: unknown, options: ApiRequestOptions = {}) => - apiRequest(endpoint, { ...options, method: 'POST', body }), - put: (endpoint: string, body: unknown, options: ApiRequestOptions = {}) => - apiRequest(endpoint, { ...options, method: 'PUT', body }), - patch: (endpoint: string, body: unknown, options: ApiRequestOptions = {}) => - apiRequest(endpoint, { ...options, method: 'PATCH', body }), + post: (endpoint: string, body?: unknown, options: ApiRequestOptions = {}) => + apiRequest(endpoint, body === undefined ? { ...options, method: 'POST' } : { ...options, method: 'POST', body }), + put: (endpoint: string, body?: unknown, options: ApiRequestOptions = {}) => + apiRequest(endpoint, body === undefined ? { ...options, method: 'PUT' } : { ...options, method: 'PUT', body }), + patch: (endpoint: string, body?: unknown, options: ApiRequestOptions = {}) => + apiRequest(endpoint, body === undefined ? { ...options, method: 'PATCH' } : { ...options, method: 'PATCH', body }), delete: (endpoint: string, options: ApiRequestOptions = {}) => apiRequest(endpoint, { ...options, method: 'DELETE' }), }; @@ -236,7 +247,7 @@ interface LoginResponse { user: { id: string; email: string; - role: string; + role: 'superuser' | 'manager' | 'developer' | 'top_brass'; }; } diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts index 8085b6d0..1c64f8d5 100644 --- a/frontend/src/lib/stores/auth.ts +++ b/frontend/src/lib/stores/auth.ts @@ -7,7 +7,7 @@ import { writable, derived } from 'svelte/store'; import { browser } from '$app/environment'; -import { authApi, setTokens, clearTokens, getAccessToken } from '$lib/services/api.js'; +import { authApi, setTokens, clearTokens, getAccessToken } from '$lib/services/api'; // User type export interface User { diff --git a/frontend/src/lib/stores/layout.ts b/frontend/src/lib/stores/layout.ts new file mode 100644 index 00000000..55bb4049 --- /dev/null +++ b/frontend/src/lib/stores/layout.ts @@ -0,0 +1,83 @@ +import { writable } from 'svelte/store'; +import type { SidebarState, Theme } from '$lib/types/layout'; + +const SIDEBAR_STORAGE_KEY = 'headroom_sidebar_state'; +const THEME_STORAGE_KEY = 'headroom_theme'; + +const DEFAULT_SIDEBAR_STATE: SidebarState = 'expanded'; +const DEFAULT_THEME: Theme = 'light'; + +function isSidebarState(value: string | null): value is SidebarState { + return value === 'expanded' || value === 'collapsed' || value === 'hidden'; +} + +function isTheme(value: string | null): value is Theme { + return value === 'light' || value === 'dark'; +} + +function getInitialSidebarState(): SidebarState { + if (typeof localStorage === 'undefined') return DEFAULT_SIDEBAR_STATE; + + const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY); + return isSidebarState(stored) ? stored : DEFAULT_SIDEBAR_STATE; +} + +function getInitialTheme(): Theme { + if (typeof localStorage === 'undefined') return DEFAULT_THEME; + + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (isTheme(stored)) return stored; + + const prefersDark = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches; + + return prefersDark ? 'dark' : DEFAULT_THEME; +} + +function applyTheme(value: Theme): void { + if (typeof document === 'undefined') return; + document.documentElement.setAttribute('data-theme', value); +} + +const sidebarStateWritable = writable(getInitialSidebarState()); +const themeWritable = writable(getInitialTheme()); + +if (typeof localStorage !== 'undefined') { + sidebarStateWritable.subscribe((value) => { + localStorage.setItem(SIDEBAR_STORAGE_KEY, value); + }); + + themeWritable.subscribe((value) => { + localStorage.setItem(THEME_STORAGE_KEY, value); + applyTheme(value); + }); +} + +export const sidebarState = { + subscribe: sidebarStateWritable.subscribe +}; + +export const theme = { + subscribe: themeWritable.subscribe +}; + +export function toggleSidebar(): void { + sidebarStateWritable.update((current) => { + if (current === 'expanded') return 'collapsed'; + if (current === 'collapsed') return 'hidden'; + return 'expanded'; + }); +} + +export function setSidebarState(state: SidebarState): void { + sidebarStateWritable.set(state); +} + +export function toggleTheme(): void { + themeWritable.update((current) => (current === 'light' ? 'dark' : 'light')); +} + +export function setTheme(newTheme: Theme): void { + themeWritable.set(newTheme); +} diff --git a/frontend/src/lib/stores/period.ts b/frontend/src/lib/stores/period.ts new file mode 100644 index 00000000..8eee4d41 --- /dev/null +++ b/frontend/src/lib/stores/period.ts @@ -0,0 +1,73 @@ +import { derived, writable } from 'svelte/store'; + +const PERIOD_STORAGE_KEY = 'headroom_selected_period'; + +function getCurrentMonthPeriod(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} + +function isPeriod(value: string | null): value is string { + if (value === null) return false; + return /^\d{4}-(0[1-9]|1[0-2])$/.test(value); +} + +function getInitialPeriod(): string { + if (typeof localStorage === 'undefined') return getCurrentMonthPeriod(); + + const stored = localStorage.getItem(PERIOD_STORAGE_KEY); + return isPeriod(stored) ? stored : getCurrentMonthPeriod(); +} + +const selectedPeriodWritable = writable(getInitialPeriod()); + +if (typeof localStorage !== 'undefined') { + selectedPeriodWritable.subscribe((value) => { + localStorage.setItem(PERIOD_STORAGE_KEY, value); + }); +} + +export const selectedPeriod = { + subscribe: selectedPeriodWritable.subscribe +}; + +export const selectedMonth = derived(selectedPeriodWritable, ($period) => { + const [year, month] = $period.split('-').map(Number); + return { year, month }; +}); + +export const selectedDate = derived(selectedPeriodWritable, ($period) => { + const [year, month] = $period.split('-').map(Number); + return new Date(year, month - 1, 1); +}); + +export function setPeriod(period: string): void { + if (!isPeriod(period)) return; + selectedPeriodWritable.set(period); +} + +export function previousMonth(): void { + selectedPeriodWritable.update((current) => { + const [year, month] = current.split('-').map(Number); + const date = new Date(year, month - 2, 1); + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, '0'); + return `${nextYear}-${nextMonth}`; + }); +} + +export function nextMonth(): void { + selectedPeriodWritable.update((current) => { + const [year, month] = current.split('-').map(Number); + const date = new Date(year, month, 1); + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, '0'); + return `${nextYear}-${nextMonth}`; + }); +} + +export function currentMonth(): void { + selectedPeriodWritable.set(getCurrentMonthPeriod()); +} diff --git a/frontend/src/lib/types/layout.ts b/frontend/src/lib/types/layout.ts new file mode 100644 index 00000000..998ab121 --- /dev/null +++ b/frontend/src/lib/types/layout.ts @@ -0,0 +1,17 @@ +export type SidebarState = 'expanded' | 'collapsed' | 'hidden'; + +export interface NavItem { + label: string; + href: string; + icon: string; + badge?: string | number; + roles?: string[]; +} + +export interface NavSection { + title: string; + items: NavItem[]; + roles?: string[]; +} + +export type Theme = 'light' | 'dark'; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 569889db..e60d4689 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,17 +1,22 @@ -
- - -
+{#if shouldUseAppLayout} + -
-
+ +{:else} + +{/if} diff --git a/frontend/tests/component/app-layout.test.ts b/frontend/tests/component/app-layout.test.ts new file mode 100644 index 00000000..d5124cd1 --- /dev/null +++ b/frontend/tests/component/app-layout.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import AppLayout, { getContentOffsetClass } from '../../src/lib/components/layout/AppLayout.svelte'; + +describe('AppLayout component', () => { + it('exports a component module', () => { + expect(AppLayout).toBeDefined(); + }); + + it('renders children via slot', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/lib/components/layout/AppLayout.svelte'), + 'utf-8' + ); + + expect(source).toContain(''); + }); + + it('maps sidebar states to layout offsets', () => { + expect(getContentOffsetClass('expanded')).toBe('md:ml-60'); + expect(getContentOffsetClass('collapsed')).toBe('md:ml-16'); + expect(getContentOffsetClass('hidden')).toBe('md:ml-0'); + }); +}); diff --git a/frontend/tests/component/breadcrumbs.test.ts b/frontend/tests/component/breadcrumbs.test.ts new file mode 100644 index 00000000..d9b6423b --- /dev/null +++ b/frontend/tests/component/breadcrumbs.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import Breadcrumbs, { generateBreadcrumbs } from '../../src/lib/components/layout/Breadcrumbs.svelte'; + +describe('Breadcrumbs component', () => { + it('exports a component module', () => { + expect(Breadcrumbs).toBeDefined(); + }); + + it('generates correct crumbs', () => { + const crumbs = generateBreadcrumbs('/reports/allocation-matrix'); + expect(crumbs.map((crumb) => crumb.label)).toEqual(['Home', 'Reports', 'Allocation Matrix']); + expect(crumbs[2].href).toBe('/reports/allocation-matrix'); + }); +}); diff --git a/frontend/tests/component/lucide-icon.test.ts b/frontend/tests/component/lucide-icon.test.ts new file mode 100644 index 00000000..9df17fc4 --- /dev/null +++ b/frontend/tests/component/lucide-icon.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { Menu } from 'lucide-svelte'; + +describe('lucide icon', () => { + it('exports menu icon component', () => { + expect(Menu).toBeDefined(); + expect(typeof Menu).toBe('function'); + }); +}); diff --git a/frontend/tests/component/month-selector.test.ts b/frontend/tests/component/month-selector.test.ts new file mode 100644 index 00000000..4e4917dc --- /dev/null +++ b/frontend/tests/component/month-selector.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import MonthSelector, { + formatMonth, + generateMonthOptions +} from '../../src/lib/components/layout/MonthSelector.svelte'; +import { selectedPeriod, setPeriod } from '../../src/lib/stores/period'; + +function getStoreValue(store: { subscribe: (run: (value: T) => void) => () => void }): T { + let value!: T; + const unsubscribe = store.subscribe((current) => { + value = current; + }); + unsubscribe(); + return value; +} + +describe('MonthSelector component', () => { + it('exports a component module', () => { + expect(MonthSelector).toBeDefined(); + }); + + it('formats month labels', () => { + expect(formatMonth('2026-02')).toBe('Feb 2026'); + }); + + it('builds +/- 6 month options', () => { + const options = generateMonthOptions(new Date(2026, 1, 1)); + expect(options).toHaveLength(13); + expect(options[0]).toBe('2025-08'); + expect(options[12]).toBe('2026-08'); + }); + + it('selection updates period store', () => { + setPeriod('2026-02'); + expect(getStoreValue(selectedPeriod)).toBe('2026-02'); + }); +}); diff --git a/frontend/tests/component/sidebar-item.test.ts b/frontend/tests/component/sidebar-item.test.ts new file mode 100644 index 00000000..7402749f --- /dev/null +++ b/frontend/tests/component/sidebar-item.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import SidebarItem from '../../src/lib/components/layout/SidebarItem.svelte'; + +describe('SidebarItem component', () => { + it('exports a component module', () => { + expect(SidebarItem).toBeDefined(); + expect(typeof SidebarItem).toBe('function'); + }); +}); diff --git a/frontend/tests/component/sidebar-section.test.ts b/frontend/tests/component/sidebar-section.test.ts new file mode 100644 index 00000000..a02f1b76 --- /dev/null +++ b/frontend/tests/component/sidebar-section.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import SidebarSection from '../../src/lib/components/layout/SidebarSection.svelte'; + +describe('SidebarSection component', () => { + it('exports a component module', () => { + expect(SidebarSection).toBeDefined(); + expect(typeof SidebarSection).toBe('function'); + }); +}); diff --git a/frontend/tests/component/sidebar.test.ts b/frontend/tests/component/sidebar.test.ts new file mode 100644 index 00000000..16e8de99 --- /dev/null +++ b/frontend/tests/component/sidebar.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import Sidebar, { isSectionVisible } from '../../src/lib/components/layout/Sidebar.svelte'; + +describe('Sidebar component', () => { + it('exports a component module', () => { + expect(Sidebar).toBeDefined(); + }); + + it('supports role-based visibility checks', () => { + expect(isSectionVisible({ title: 'ADMIN', roles: ['superuser'], items: [] }, 'superuser')).toBe(true); + expect(isSectionVisible({ title: 'ADMIN', roles: ['superuser'], items: [] }, 'manager')).toBe(false); + expect(isSectionVisible({ title: 'PLANNING', items: [] }, null)).toBe(true); + }); +}); diff --git a/frontend/tests/component/topbar.test.ts b/frontend/tests/component/topbar.test.ts new file mode 100644 index 00000000..9f80656f --- /dev/null +++ b/frontend/tests/component/topbar.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import TopBar from '../../src/lib/components/layout/TopBar.svelte'; + +describe('TopBar component', () => { + it('exports a component module', () => { + expect(TopBar).toBeDefined(); + }); + + it('includes breadcrumbs, month selector, and user menu', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/lib/components/layout/TopBar.svelte'), + 'utf-8' + ); + + expect(source).toContain(''); + expect(source).toContain(''); + expect(source).toContain(''); + }); +}); diff --git a/frontend/tests/e2e/layout.spec.ts b/frontend/tests/e2e/layout.spec.ts new file mode 100644 index 00000000..58a79b6b --- /dev/null +++ b/frontend/tests/e2e/layout.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test'; + +function createToken() { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }), 'utf-8').toString('base64'); + const payload = Buffer.from( + JSON.stringify({ sub: 'e2e', exp: Math.floor(Date.now() / 1000) + 3600 }), + 'utf-8' + ).toString('base64'); + return `${header}.${payload}.sig`; +} + +async function openDashboard(page: import('@playwright/test').Page) { + const token = createToken(); + await page.goto('/login'); + await page.evaluate((accessToken) => { + localStorage.setItem('headroom_access_token', accessToken); + localStorage.setItem('headroom_refresh_token', accessToken); + }, token); + await page.goto('/dashboard'); + await page.waitForURL('/dashboard'); +} + +async function loginThroughForm(page: import('@playwright/test').Page) { + await page.goto('/login'); + await page.fill('input[type="email"]', 'superuser@headroom.test'); + await page.fill('input[type="password"]', 'password'); + await page.click('button[type="submit"]'); + await page.waitForURL('/dashboard'); +} + +test.describe('Layout E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.context().clearCookies(); + await page.evaluate(() => localStorage.clear()); + }); + + test('dashboard page has sidebar', async ({ page }) => { + await openDashboard(page); + await expect(page.locator('[data-testid="sidebar"]')).toBeVisible(); + }); + + test('login redirects to dashboard with sidebar', async ({ page }) => { + await loginThroughForm(page); + await expect(page.locator('[data-testid="sidebar"]')).toBeVisible(); + }); + + test('sidebar toggle works', async ({ page }) => { + await openDashboard(page); + + const initial = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar')); + await page.click('[data-testid="sidebar-toggle"]'); + + await expect + .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) + .not.toBe(initial); + }); + + test('theme toggle works', async ({ page }) => { + await openDashboard(page); + + const before = await page.evaluate(() => localStorage.getItem('headroom_theme') ?? 'light'); + await page.click('[data-testid="theme-toggle"]'); + + await expect + .poll(async () => page.evaluate(() => localStorage.getItem('headroom_theme'))) + .not.toBe(before); + }); + + test('month selector updates period store', async ({ page }) => { + await openDashboard(page); + + const before = await page.evaluate(() => localStorage.getItem('headroom_selected_period')); + await page.click('[data-testid="month-selector"] button'); + await page.getByRole('button', { name: 'Next' }).click(); + + await expect + .poll(async () => page.evaluate(() => localStorage.getItem('headroom_selected_period'))) + .not.toBe(before); + }); + + test('breadcrumbs reflect current route', async ({ page }) => { + await openDashboard(page); + await expect(page.locator('[data-testid="breadcrumbs"]')).toContainText('Dashboard'); + }); + + test('login page has no sidebar', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator('[data-testid="sidebar"]')).toHaveCount(0); + }); + + test('sidebar hidden by default on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openDashboard(page); + + await expect + .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) + .toBe('hidden'); + }); + + test('hamburger shows sidebar on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openDashboard(page); + + await page.click('[data-testid="mobile-hamburger"]'); + await expect + .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) + .toBe('expanded'); + }); + + test('sidebar overlays content and closes on backdrop click on mobile', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openDashboard(page); + + await page.click('[data-testid="mobile-hamburger"]'); + await expect + .poll(async () => + page.evaluate(() => { + const main = document.querySelector('[data-testid="layout-main"]'); + if (!main) return null; + return getComputedStyle(main).marginLeft; + }) + ) + .toBe('0px'); + + await page.evaluate(() => { + const backdrop = document.querySelector('[data-testid="sidebar-backdrop"]') as HTMLElement | null; + backdrop?.click(); + }); + await expect + .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) + .toBe('hidden'); + }); + + test('all key breakpoints render expected sidebar state', async ({ page }) => { + const breakpoints: Array<[number, number, 'hidden' | 'collapsed' | 'expanded']> = [ + [320, 700, 'hidden'], + [768, 900, 'collapsed'], + [1024, 900, 'collapsed'], + [1280, 900, 'expanded'] + ]; + + for (const [width, height, expected] of breakpoints) { + await page.setViewportSize({ width, height }); + await page.goto('/login'); + await page.evaluate(() => localStorage.setItem('headroom_sidebar_state', 'expanded')); + await openDashboard(page); + await expect + .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) + .toBe(expected); + } + }); + + test('keyboard shortcut toggles sidebar', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await openDashboard(page); + + const before = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar')); + await page.keyboard.down('Control'); + await page.keyboard.press('\\'); + await page.keyboard.up('Control'); + + await expect + .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar'))) + .not.toBe(before); + }); +}); diff --git a/frontend/tests/e2e/theme.spec.ts b/frontend/tests/e2e/theme.spec.ts new file mode 100644 index 00000000..434474ed --- /dev/null +++ b/frontend/tests/e2e/theme.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; + +async function expectTheme(page: import('@playwright/test').Page, theme: 'light' | 'dark') { + await expect + .poll(async () => + page.evaluate(() => ({ + attr: document.documentElement.getAttribute('data-theme'), + stored: localStorage.getItem('headroom_theme') + })) + ) + .toEqual({ attr: theme, stored: theme }); + +} + +test.describe('Theme persistence', () => { + test('applies dark theme from localStorage on reload', async ({ page }) => { + await page.goto('/login'); + + await page.evaluate(() => { + localStorage.setItem('headroom_theme', 'dark'); + }); + + await page.reload(); + await expectTheme(page, 'dark'); + }); + + test('applies light theme from localStorage on reload', async ({ page }) => { + await page.goto('/login'); + + await page.evaluate(() => { + localStorage.setItem('headroom_theme', 'light'); + }); + + await page.reload(); + await expectTheme(page, 'light'); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts index 12fa744c..9cb5e393 100644 --- a/frontend/tests/setup.ts +++ b/frontend/tests/setup.ts @@ -1,13 +1,19 @@ import { vi } from 'vitest'; // Mock localStorage -global.localStorage = { +const localStorageMock: Storage = { + get length() { + return 0; + }, + key: vi.fn(() => null), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }; +global.localStorage = localStorageMock; + // Mock fetch global.fetch = vi.fn(); diff --git a/frontend/tests/unit/layout.store.test.ts b/frontend/tests/unit/layout.store.test.ts new file mode 100644 index 00000000..4983e6f6 --- /dev/null +++ b/frontend/tests/unit/layout.store.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +function getStoreValue(store: { subscribe: (run: (value: T) => void) => () => void }): T { + let value!: T; + const unsubscribe = store.subscribe((current) => { + value = current; + }); + unsubscribe(); + return value; +} + +describe('layout store', () => { + beforeEach(() => { + vi.resetModules(); + document.documentElement.removeAttribute('data-theme'); + (localStorage.getItem as Mock).mockReturnValue(null); + }); + + it('initializes with default values', async () => { + const store = await import('../../src/lib/stores/layout'); + + expect(getStoreValue(store.sidebarState)).toBe('expanded'); + expect(getStoreValue(store.theme)).toBe('light'); + }); + + it('toggleSidebar cycles through states', async () => { + const store = await import('../../src/lib/stores/layout'); + + store.setSidebarState('expanded'); + store.toggleSidebar(); + expect(getStoreValue(store.sidebarState)).toBe('collapsed'); + expect(localStorage.setItem).toHaveBeenCalledWith('headroom_sidebar_state', 'collapsed'); + + store.toggleSidebar(); + expect(getStoreValue(store.sidebarState)).toBe('hidden'); + + store.toggleSidebar(); + expect(getStoreValue(store.sidebarState)).toBe('expanded'); + }); + + it('theme toggle works and applies to document', async () => { + const store = await import('../../src/lib/stores/layout'); + + store.setTheme('light'); + store.toggleTheme(); + expect(getStoreValue(store.theme)).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(localStorage.setItem).toHaveBeenCalledWith('headroom_theme', 'dark'); + + store.toggleTheme(); + expect(getStoreValue(store.theme)).toBe('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + expect(localStorage.setItem).toHaveBeenCalledWith('headroom_theme', 'light'); + }); +}); diff --git a/frontend/tests/unit/navigation.config.test.ts b/frontend/tests/unit/navigation.config.test.ts new file mode 100644 index 00000000..a529ebd1 --- /dev/null +++ b/frontend/tests/unit/navigation.config.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { navigationSections } from '../../src/lib/config/navigation'; + +describe('navigation config', () => { + it('has expected section structure', () => { + expect(navigationSections).toHaveLength(3); + + expect(navigationSections[0].title).toBe('PLANNING'); + expect(navigationSections[0].items).toHaveLength(5); + + expect(navigationSections[1].title).toBe('REPORTS'); + expect(navigationSections[1].items).toHaveLength(5); + + expect(navigationSections[2].title).toBe('ADMIN'); + expect(navigationSections[2].roles).toEqual(['superuser']); + }); +}); diff --git a/frontend/tests/unit/period.store.test.ts b/frontend/tests/unit/period.store.test.ts new file mode 100644 index 00000000..29098b83 --- /dev/null +++ b/frontend/tests/unit/period.store.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +function getStoreValue(store: { subscribe: (run: (value: T) => void) => () => void }): T { + let value!: T; + const unsubscribe = store.subscribe((current) => { + value = current; + }); + unsubscribe(); + return value; +} + +describe('period store', () => { + beforeEach(() => { + vi.resetModules(); + (localStorage.getItem as Mock).mockReturnValue(null); + }); + + it('initializes with current month', async () => { + const store = await import('../../src/lib/stores/period'); + + const now = new Date(); + const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + expect(getStoreValue(store.selectedPeriod)).toBe(expected); + }); + + it('previousMonth decrements correctly', async () => { + const store = await import('../../src/lib/stores/period'); + + store.setPeriod('2025-01'); + store.previousMonth(); + + expect(getStoreValue(store.selectedPeriod)).toBe('2024-12'); + expect(localStorage.setItem).toHaveBeenCalledWith('headroom_selected_period', '2024-12'); + }); + + it('nextMonth increments correctly', async () => { + const store = await import('../../src/lib/stores/period'); + + store.setPeriod('2025-12'); + store.nextMonth(); + + expect(getStoreValue(store.selectedPeriod)).toBe('2026-01'); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 819a9b46..b0e61c9b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, "checkJs": true, diff --git a/openspec/changes/p01-ui-foundation/design.md b/openspec/changes/archive/2026-02-18-p01-ui-foundation/design.md similarity index 100% rename from openspec/changes/p01-ui-foundation/design.md rename to openspec/changes/archive/2026-02-18-p01-ui-foundation/design.md diff --git a/openspec/changes/p01-ui-foundation/proposal.md b/openspec/changes/archive/2026-02-18-p01-ui-foundation/proposal.md similarity index 100% rename from openspec/changes/p01-ui-foundation/proposal.md rename to openspec/changes/archive/2026-02-18-p01-ui-foundation/proposal.md diff --git a/openspec/changes/archive/2026-02-18-p01-ui-foundation/tasks.md b/openspec/changes/archive/2026-02-18-p01-ui-foundation/tasks.md new file mode 100644 index 00000000..d6de42e2 --- /dev/null +++ b/openspec/changes/archive/2026-02-18-p01-ui-foundation/tasks.md @@ -0,0 +1,86 @@ +# Tasks: UI Foundation + +## Phase 1: Install Dependencies + +- [x] 1.1 Install Lucide icons: `npm install lucide-svelte` +- [x] 1.2 Verify installation in package.json +- [x] 1.3 Test import in a test file: `import { Menu } from 'lucide-svelte'` + +## Phase 2: Create Types + +- [x] 1.4 Create `src/lib/types/` directory if not exists +- [x] 1.5 Create `src/lib/types/layout.ts` +- [x] 1.6 Define `SidebarState` type +- [x] 1.7 Define `NavItem` interface +- [x] 1.8 Define `NavSection` interface +- [x] 1.9 Define `Theme` type +- [x] 1.10 Export all types + +## Phase 3: Create Stores + +### Layout Store +- [x] 1.11 Create `src/lib/stores/layout.ts` +- [x] 1.12 Implement `sidebarState` writable with localStorage persistence +- [x] 1.13 Implement `theme` writable with localStorage persistence +- [x] 1.14 Implement `toggleSidebar()` function +- [x] 1.15 Implement `setSidebarState()` function +- [x] 1.16 Implement `toggleTheme()` function +- [x] 1.17 Implement `setTheme()` function +- [x] 1.18 Add system preference detection for initial theme + +### Period Store +- [x] 1.19 Create `src/lib/stores/period.ts` +- [x] 1.20 Implement `selectedPeriod` writable with localStorage persistence +- [x] 1.21 Create `selectedMonth` derived store +- [x] 1.22 Create `selectedDate` derived store +- [x] 1.23 Implement `setPeriod()` function +- [x] 1.24 Implement `previousMonth()` function +- [x] 1.25 Implement `nextMonth()` function +- [x] 1.26 Implement `currentMonth()` function + +## Phase 4: Create Navigation Config + +- [x] 1.27 Create `src/lib/config/` directory if not exists +- [x] 1.28 Create `src/lib/config/navigation.ts` +- [x] 1.29 Define PLANNING section (Dashboard, Team, Projects, Allocations, Actuals) +- [x] 1.30 Define REPORTS section (Forecast, Utilization, Costs, Variance, Allocation Matrix) +- [x] 1.31 Define ADMIN section with `roles: ['superuser']` +- [x] 1.32 Export `navigationSections` array + +## Phase 5: Theme System + +- [x] 1.33 Update `src/app.css` with theme CSS variables +- [x] 1.34 Add sidebar width CSS variables +- [x] 1.35 Add theme color-scheme definitions +- [x] 1.36 Test theme switching in browser console + +## Phase 6: Testing + +### Unit Tests +- [x] 1.37 Write test: layoutStore initializes with default values +- [x] 1.38 Write test: layoutStore.toggleSidebar cycles through states +- [x] 1.39 Write test: layoutStore theme toggle works +- [x] 1.40 Write test: periodStore initializes with current month +- [x] 1.41 Write test: periodStore.previousMonth decrements correctly +- [x] 1.42 Write test: periodStore.nextMonth increments correctly +- [x] 1.43 Write test: navigationSections has correct structure + +### Component Tests +- [x] 1.44 Create test: Lucide icon renders correctly + +## Phase 7: Verification + +- [x] 1.45 Run `npm run check` - no type errors +- [x] 1.46 Run `npm run test:unit` - all tests pass +- [x] 1.47 Verify stores persist to localStorage +- [x] 1.48 Verify theme applies to document + +## Commits + +1. `feat(ui): Install lucide-svelte for icon library` +2. `feat(ui): Add layout types (SidebarState, NavItem, NavSection, Theme)` +3. `feat(ui): Create layoutStore for sidebar state and theme management` +4. `feat(ui): Create periodStore for global month/period selection` +5. `feat(ui): Add navigation configuration for sidebar menu` +6. `feat(ui): Add CSS variables for theme and layout` +7. `test(ui): Add unit tests for layout and period stores` diff --git a/openspec/changes/p02-app-layout/design.md b/openspec/changes/archive/2026-02-18-p02-app-layout/design.md similarity index 100% rename from openspec/changes/p02-app-layout/design.md rename to openspec/changes/archive/2026-02-18-p02-app-layout/design.md diff --git a/openspec/changes/p02-app-layout/proposal.md b/openspec/changes/archive/2026-02-18-p02-app-layout/proposal.md similarity index 100% rename from openspec/changes/p02-app-layout/proposal.md rename to openspec/changes/archive/2026-02-18-p02-app-layout/proposal.md diff --git a/openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md b/openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md new file mode 100644 index 00000000..3cd70fbf --- /dev/null +++ b/openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md @@ -0,0 +1,132 @@ +# Tasks: App Layout + +## Phase 1: Create Layout Components Directory + +- [x] 2.1 Create `src/lib/components/layout/` directory + +## Phase 2: Sidebar Components + +### SidebarItem +- [x] 2.2 Create `SidebarItem.svelte` +- [x] 2.3 Add icon prop (Lucide component) +- [x] 2.4 Add label prop +- [x] 2.5 Add href prop +- [x] 2.6 Add active state styling (current path matching) +- [x] 2.7 Handle collapsed state (icon only, tooltip on hover) +- [x] 2.8 Write component test: renders with icon and label + +### SidebarSection +- [x] 2.9 Create `SidebarSection.svelte` +- [x] 2.10 Add section prop (NavSection type) +- [x] 2.11 Add expanded prop (for collapsed sidebar) +- [x] 2.12 Render section title +- [x] 2.13 Render SidebarItem for each item +- [x] 2.14 Write component test: renders all items + +### Sidebar +- [x] 2.15 Create `Sidebar.svelte` +- [x] 2.16 Import and use navigationSections +- [x] 2.17 Import layoutStore for state +- [x] 2.18 Implement three visual states (expanded, collapsed, hidden) +- [x] 2.19 Add toggle button in header +- [x] 2.20 Add logo/brand in header +- [x] 2.21 Implement role-based section visibility +- [x] 2.22 Add dark mode toggle in footer +- [x] 2.23 Add keyboard shortcut (Cmd/Ctrl + \) +- [x] 2.24 Implement CSS transitions +- [x] 2.25 Write component test: toggle state works +- [x] 2.26 Write component test: role-based visibility + +## Phase 3: TopBar Components + +### UserMenu +- [x] 2.27 Create `UserMenu.svelte` (migrate from Navigation.svelte) +- [x] 2.28 Import authStore for user info +- [x] 2.29 Add dropdown with user name/avatar +- [x] 2.30 Add logout action +- [x] 2.31 Style with DaisyUI dropdown + +### MonthSelector +- [x] 2.32 Create `MonthSelector.svelte` +- [x] 2.33 Import periodStore +- [x] 2.34 Display current month (format: Feb 2026) +- [x] 2.35 Add dropdown with month options (-6 to +6 months) +- [x] 2.36 Add Previous/Today/Next quick actions +- [x] 2.37 Style with DaisyUI dropdown +- [x] 2.38 Write component test: selection updates store + +### Breadcrumbs +- [x] 2.39 Create `Breadcrumbs.svelte` +- [x] 2.40 Import $page store for current path +- [x] 2.41 Implement generateBreadcrumbs function +- [x] 2.42 Render Home icon for root +- [x] 2.43 Render segments as links +- [x] 2.44 Style last item as current (no link) +- [x] 2.45 Write component test: generates correct crumbs + +### TopBar +- [x] 2.46 Create `TopBar.svelte` +- [x] 2.47 Import Breadcrumbs, MonthSelector, UserMenu +- [x] 2.48 Add hamburger toggle for mobile +- [x] 2.49 Implement sticky positioning +- [x] 2.50 Style with DaisyUI +- [x] 2.51 Write component test: renders all components + +## Phase 4: AppLayout + +- [x] 2.52 Create `AppLayout.svelte` +- [x] 2.53 Import Sidebar, TopBar +- [x] 2.54 Add slot for page content +- [x] 2.55 Implement flex layout (sidebar + main content) +- [x] 2.56 Adjust main content margin based on sidebar state +- [x] 2.57 Handle responsive behavior (mobile drawer) +- [x] 2.58 Write component test: renders children +- [x] 2.59 Write component test: sidebar toggle affects layout + +## Phase 5: Route Integration + +- [x] 2.60 Update `src/routes/+layout.svelte` +- [x] 2.61 Add conditional AppLayout wrapper +- [x] 2.62 Define publicPages array (['/login', '/auth']) +- [x] 2.63 Test: login page has NO sidebar +- [x] 2.64 Test: dashboard page has sidebar + +## Phase 6: Responsive & Mobile + +- [x] 2.65 Test: Sidebar hidden by default on mobile +- [x] 2.66 Test: Hamburger shows sidebar on mobile +- [x] 2.67 Test: Sidebar overlays content on mobile (not push) +- [x] 2.68 Test: Clicking outside closes sidebar on mobile +- [x] 2.69 Add backdrop overlay for mobile drawer + +## Phase 7: E2E Tests + +- [x] 2.70 E2E test: Login redirects to dashboard with sidebar +- [x] 2.71 E2E test: Sidebar toggle works +- [x] 2.72 E2E test: Theme toggle works +- [x] 2.73 E2E test: Month selector updates period store +- [x] 2.74 E2E test: Breadcrumbs reflect current route + +## Phase 8: Verification + +- [x] 2.75 Run `npm run check` - no type errors +- [x] 2.76 Run `npm run test:unit` - all component tests pass +- [x] 2.77 Run `npm run test:e2e` - all E2E tests pass +- [x] 2.78 Manual test: All breakpoints (320px, 768px, 1024px, 1280px) +- [x] 2.79 Manual test: Dark mode toggle +- [x] 2.80 Manual test: Keyboard shortcut (Cmd/Ctrl + \) + +## Commits + +1. `feat(layout): Create SidebarItem component with active state` +2. `feat(layout): Create SidebarSection component` +3. `feat(layout): Create Sidebar with three states and theme toggle` +4. `feat(layout): Create UserMenu component (migrated from Navigation)` +5. `feat(layout): Create MonthSelector with period store integration` +6. `feat(layout): Create Breadcrumbs with auto-generation` +7. `feat(layout): Create TopBar with all components` +8. `feat(layout): Create AppLayout wrapper component` +9. `feat(layout): Integrate AppLayout into root layout` +10. `feat(layout): Add responsive mobile drawer behavior` +11. `test(layout): Add component tests for all layout components` +12. `test(e2e): Add E2E tests for layout functionality` diff --git a/openspec/changes/p01-ui-foundation/tasks.md b/openspec/changes/p01-ui-foundation/tasks.md deleted file mode 100644 index 33e7b836..00000000 --- a/openspec/changes/p01-ui-foundation/tasks.md +++ /dev/null @@ -1,86 +0,0 @@ -# Tasks: UI Foundation - -## Phase 1: Install Dependencies - -- [ ] 1.1 Install Lucide icons: `npm install lucide-svelte` -- [ ] 1.2 Verify installation in package.json -- [ ] 1.3 Test import in a test file: `import { Menu } from 'lucide-svelte'` - -## Phase 2: Create Types - -- [ ] 1.4 Create `src/lib/types/` directory if not exists -- [ ] 1.5 Create `src/lib/types/layout.ts` -- [ ] 1.6 Define `SidebarState` type -- [ ] 1.7 Define `NavItem` interface -- [ ] 1.8 Define `NavSection` interface -- [ ] 1.9 Define `Theme` type -- [ ] 1.10 Export all types - -## Phase 3: Create Stores - -### Layout Store -- [ ] 1.11 Create `src/lib/stores/layout.ts` -- [ ] 1.12 Implement `sidebarState` writable with localStorage persistence -- [ ] 1.13 Implement `theme` writable with localStorage persistence -- [ ] 1.14 Implement `toggleSidebar()` function -- [ ] 1.15 Implement `setSidebarState()` function -- [ ] 1.16 Implement `toggleTheme()` function -- [ ] 1.17 Implement `setTheme()` function -- [ ] 1.18 Add system preference detection for initial theme - -### Period Store -- [ ] 1.19 Create `src/lib/stores/period.ts` -- [ ] 1.20 Implement `selectedPeriod` writable with localStorage persistence -- [ ] 1.21 Create `selectedMonth` derived store -- [ ] 1.22 Create `selectedDate` derived store -- [ ] 1.23 Implement `setPeriod()` function -- [ ] 1.24 Implement `previousMonth()` function -- [ ] 1.25 Implement `nextMonth()` function -- [ ] 1.26 Implement `currentMonth()` function - -## Phase 4: Create Navigation Config - -- [ ] 1.27 Create `src/lib/config/` directory if not exists -- [ ] 1.28 Create `src/lib/config/navigation.ts` -- [ ] 1.29 Define PLANNING section (Dashboard, Team, Projects, Allocations, Actuals) -- [ ] 1.30 Define REPORTS section (Forecast, Utilization, Costs, Variance, Allocation Matrix) -- [ ] 1.31 Define ADMIN section with `roles: ['superuser']` -- [ ] 1.32 Export `navigationSections` array - -## Phase 5: Theme System - -- [ ] 1.33 Update `src/app.css` with theme CSS variables -- [ ] 1.34 Add sidebar width CSS variables -- [ ] 1.35 Add theme color-scheme definitions -- [ ] 1.36 Test theme switching in browser console - -## Phase 6: Testing - -### Unit Tests -- [ ] 1.37 Write test: layoutStore initializes with default values -- [ ] 1.38 Write test: layoutStore.toggleSidebar cycles through states -- [ ] 1.39 Write test: layoutStore theme toggle works -- [ ] 1.40 Write test: periodStore initializes with current month -- [ ] 1.41 Write test: periodStore.previousMonth decrements correctly -- [ ] 1.42 Write test: periodStore.nextMonth increments correctly -- [ ] 1.43 Write test: navigationSections has correct structure - -### Component Tests -- [ ] 1.44 Create test: Lucide icon renders correctly - -## Phase 7: Verification - -- [ ] 1.45 Run `npm run check` - no type errors -- [ ] 1.46 Run `npm run test:unit` - all tests pass -- [ ] 1.47 Verify stores persist to localStorage -- [ ] 1.48 Verify theme applies to document - -## Commits - -1. `feat(ui): Install lucide-svelte for icon library` -2. `feat(ui): Add layout types (SidebarState, NavItem, NavSection, Theme)` -3. `feat(ui): Create layoutStore for sidebar state and theme management` -4. `feat(ui): Create periodStore for global month/period selection` -5. `feat(ui): Add navigation configuration for sidebar menu` -6. `feat(ui): Add CSS variables for theme and layout` -7. `test(ui): Add unit tests for layout and period stores` diff --git a/openspec/changes/p02-app-layout/tasks.md b/openspec/changes/p02-app-layout/tasks.md deleted file mode 100644 index 8c220ca9..00000000 --- a/openspec/changes/p02-app-layout/tasks.md +++ /dev/null @@ -1,132 +0,0 @@ -# Tasks: App Layout - -## Phase 1: Create Layout Components Directory - -- [ ] 2.1 Create `src/lib/components/layout/` directory - -## Phase 2: Sidebar Components - -### SidebarItem -- [ ] 2.2 Create `SidebarItem.svelte` -- [ ] 2.3 Add icon prop (Lucide component) -- [ ] 2.4 Add label prop -- [ ] 2.5 Add href prop -- [ ] 2.6 Add active state styling (current path matching) -- [ ] 2.7 Handle collapsed state (icon only, tooltip on hover) -- [ ] 2.8 Write component test: renders with icon and label - -### SidebarSection -- [ ] 2.9 Create `SidebarSection.svelte` -- [ ] 2.10 Add section prop (NavSection type) -- [ ] 2.11 Add expanded prop (for collapsed sidebar) -- [ ] 2.12 Render section title -- [ ] 2.13 Render SidebarItem for each item -- [ ] 2.14 Write component test: renders all items - -### Sidebar -- [ ] 2.15 Create `Sidebar.svelte` -- [ ] 2.16 Import and use navigationSections -- [ ] 2.17 Import layoutStore for state -- [ ] 2.18 Implement three visual states (expanded, collapsed, hidden) -- [ ] 2.19 Add toggle button in header -- [ ] 2.20 Add logo/brand in header -- [ ] 2.21 Implement role-based section visibility -- [ ] 2.22 Add dark mode toggle in footer -- [ ] 2.23 Add keyboard shortcut (Cmd/Ctrl + \) -- [ ] 2.24 Implement CSS transitions -- [ ] 2.25 Write component test: toggle state works -- [ ] 2.26 Write component test: role-based visibility - -## Phase 3: TopBar Components - -### UserMenu -- [ ] 2.27 Create `UserMenu.svelte` (migrate from Navigation.svelte) -- [ ] 2.28 Import authStore for user info -- [ ] 2.29 Add dropdown with user name/avatar -- [ ] 2.30 Add logout action -- [ ] 2.31 Style with DaisyUI dropdown - -### MonthSelector -- [ ] 2.32 Create `MonthSelector.svelte` -- [ ] 2.33 Import periodStore -- [ ] 2.34 Display current month (format: Feb 2026) -- [ ] 2.35 Add dropdown with month options (-6 to +6 months) -- [ ] 2.36 Add Previous/Today/Next quick actions -- [ ] 2.37 Style with DaisyUI dropdown -- [ ] 2.38 Write component test: selection updates store - -### Breadcrumbs -- [ ] 2.39 Create `Breadcrumbs.svelte` -- [ ] 2.40 Import $page store for current path -- [ ] 2.41 Implement generateBreadcrumbs function -- [ ] 2.42 Render Home icon for root -- [ ] 2.43 Render segments as links -- [ ] 2.44 Style last item as current (no link) -- [ ] 2.45 Write component test: generates correct crumbs - -### TopBar -- [ ] 2.46 Create `TopBar.svelte` -- [ ] 2.47 Import Breadcrumbs, MonthSelector, UserMenu -- [ ] 2.48 Add hamburger toggle for mobile -- [ ] 2.49 Implement sticky positioning -- [ ] 2.50 Style with DaisyUI -- [ ] 2.51 Write component test: renders all components - -## Phase 4: AppLayout - -- [ ] 2.52 Create `AppLayout.svelte` -- [ ] 2.53 Import Sidebar, TopBar -- [ ] 2.54 Add slot for page content -- [ ] 2.55 Implement flex layout (sidebar + main content) -- [ ] 2.56 Adjust main content margin based on sidebar state -- [ ] 2.57 Handle responsive behavior (mobile drawer) -- [ ] 2.58 Write component test: renders children -- [ ] 2.59 Write component test: sidebar toggle affects layout - -## Phase 5: Route Integration - -- [ ] 2.60 Update `src/routes/+layout.svelte` -- [ ] 2.61 Add conditional AppLayout wrapper -- [ ] 2.62 Define publicPages array (['/login', '/auth']) -- [ ] 2.63 Test: login page has NO sidebar -- [ ] 2.64 Test: dashboard page has sidebar - -## Phase 6: Responsive & Mobile - -- [ ] 2.65 Test: Sidebar hidden by default on mobile -- [ ] 2.66 Test: Hamburger shows sidebar on mobile -- [ ] 2.67 Test: Sidebar overlays content on mobile (not push) -- [ ] 2.68 Test: Clicking outside closes sidebar on mobile -- [ ] 2.69 Add backdrop overlay for mobile drawer - -## Phase 7: E2E Tests - -- [ ] 2.70 E2E test: Login redirects to dashboard with sidebar -- [ ] 2.71 E2E test: Sidebar toggle works -- [ ] 2.72 E2E test: Theme toggle works -- [ ] 2.73 E2E test: Month selector updates period store -- [ ] 2.74 E2E test: Breadcrumbs reflect current route - -## Phase 8: Verification - -- [ ] 2.75 Run `npm run check` - no type errors -- [ ] 2.76 Run `npm run test:unit` - all component tests pass -- [ ] 2.77 Run `npm run test:e2e` - all E2E tests pass -- [ ] 2.78 Manual test: All breakpoints (320px, 768px, 1024px, 1280px) -- [ ] 2.79 Manual test: Dark mode toggle -- [ ] 2.80 Manual test: Keyboard shortcut (Cmd/Ctrl + \) - -## Commits - -1. `feat(layout): Create SidebarItem component with active state` -2. `feat(layout): Create SidebarSection component` -3. `feat(layout): Create Sidebar with three states and theme toggle` -4. `feat(layout): Create UserMenu component (migrated from Navigation)` -5. `feat(layout): Create MonthSelector with period store integration` -6. `feat(layout): Create Breadcrumbs with auto-generation` -7. `feat(layout): Create TopBar with all components` -8. `feat(layout): Create AppLayout wrapper component` -9. `feat(layout): Integrate AppLayout into root layout` -10. `feat(layout): Add responsive mobile drawer behavior` -11. `test(layout): Add component tests for all layout components` -12. `test(e2e): Add E2E tests for layout functionality` diff --git a/openspec/config.yaml b/openspec/config.yaml index acb20e56..ddd09997 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -196,20 +196,17 @@ rules: # Component Patterns component_patterns: - layout: - - AppLayout.svelte: Main wrapper with sidebar + content area - - Sidebar.svelte: Collapsible navigation with sections - - TopBar.svelte: Breadcrumbs, month selector, user menu - - Breadcrumbs.svelte: Auto-generated from route - - PageHeader.svelte: Page title + action buttons slot - state: - - layoutStore: sidebarState ('expanded'|'collapsed'|'hidden'), theme - - periodStore: selectedMonth (global YYYY-MM format) - - Persist user preferences to localStorage - navigation: - - Sections: PLANNING, REPORTS, ADMIN - - ADMIN section visible only to superuser role - - Active route highlighting required + - "layout: AppLayout.svelte -> Main wrapper with sidebar + content area" + - "layout: Sidebar.svelte -> Collapsible navigation with sections" + - "layout: TopBar.svelte -> Breadcrumbs, month selector, user menu" + - "layout: Breadcrumbs.svelte -> Auto-generated from route" + - "layout: PageHeader.svelte -> Page title + action buttons slot" + - "state: layoutStore -> sidebarState ('expanded'|'collapsed'|'hidden'), theme" + - "state: periodStore -> selectedMonth (global YYYY-MM format)" + - "state: Persist user preferences to localStorage" + - "navigation: Sections -> PLANNING, REPORTS, ADMIN" + - "navigation: ADMIN section visible only to superuser role" + - "navigation: Active route highlighting required" # Accessibility Requirements accessibility: