feat(layout): finalize p01 and p02 changes

Complete UI foundation and app layout implementation, stabilize container health checks, and archive both OpenSpec changes after verification.
This commit is contained in:
2026-02-18 16:12:11 -05:00
parent cdfb15bbfd
commit 493cb78173
47 changed files with 1400 additions and 283 deletions

View File

@@ -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"]

View File

@@ -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",

View File

@@ -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"

View File

@@ -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: [

View File

@@ -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;
}

View File

@@ -4,6 +4,25 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(() => {
try {
const storedTheme = localStorage.getItem('headroom_theme');
const prefersDark =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme =
storedTheme === 'light' || storedTheme === 'dark'
? storedTheme
: prefersDark
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', theme);
} catch {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -0,0 +1,28 @@
<script context="module" lang="ts">
import type { SidebarState } from '$lib/types/layout';
export function getContentOffsetClass(state: SidebarState): string {
if (state === 'expanded') return 'md:ml-60';
if (state === 'collapsed') return 'md:ml-16';
return 'md:ml-0';
}
</script>
<script lang="ts">
import { sidebarState } from '$lib/stores/layout';
import Sidebar from './Sidebar.svelte';
import TopBar from './TopBar.svelte';
$: contentOffsetClass = getContentOffsetClass($sidebarState);
</script>
<div class="min-h-screen bg-base-200" data-testid="app-layout">
<Sidebar />
<div class={`flex min-h-screen flex-col transition-all duration-200 ${contentOffsetClass}`} data-testid="layout-main">
<TopBar />
<main class="flex-1 p-4 md:p-6 overflow-auto" data-testid="layout-content">
<slot />
</main>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<script context="module" lang="ts">
export interface Breadcrumb {
label: string;
href: string;
}
export function generateBreadcrumbs(pathname: string): Breadcrumb[] {
const segments = pathname.split('/').filter(Boolean);
const crumbs: Breadcrumb[] = [{ label: 'Home', href: '/' }];
let current = '';
for (const segment of segments) {
current += `/${segment}`;
crumbs.push({
label: segment
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
href: current
});
}
return crumbs;
}
</script>
<script lang="ts">
import { page } from '$app/stores';
import { Home } from 'lucide-svelte';
$: crumbs = generateBreadcrumbs($page.url.pathname);
</script>
<nav class="breadcrumbs text-sm" aria-label="Breadcrumb" data-testid="breadcrumbs">
<ul>
{#each crumbs as crumb, i (crumb.href)}
<li>
{#if i === 0}
<a href={crumb.href} aria-label="Home">
<Home size={16} />
</a>
{:else if i === crumbs.length - 1}
<span class="font-medium">{crumb.label}</span>
{:else}
<a href={crumb.href}>{crumb.label}</a>
{/if}
</li>
{/each}
</ul>
</nav>

View File

@@ -0,0 +1,61 @@
<script context="module" lang="ts">
export function formatMonth(period: string): string {
const [year, month] = period.split('-').map(Number);
const date = new Date(year, month - 1, 1);
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
}
export function generateMonthOptions(baseDate = new Date()): string[] {
const options: string[] = [];
for (let i = -6; i <= 6; i += 1) {
const date = new Date(baseDate.getFullYear(), baseDate.getMonth() + i, 1);
options.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`);
}
return options;
}
</script>
<script lang="ts">
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
import {
currentMonth,
nextMonth,
previousMonth,
selectedPeriod,
setPeriod
} from '$lib/stores/period';
const monthOptions = generateMonthOptions();
$: displayMonth = formatMonth($selectedPeriod);
</script>
<div class="dropdown dropdown-end" data-testid="month-selector">
<button class="btn btn-sm btn-ghost gap-1" aria-label="Select month">
<span class="font-medium">{displayMonth}</span>
<ChevronDown size={16} />
</button>
<ul class="menu menu-sm dropdown-content mt-2 z-30 w-48 rounded-box bg-base-100 p-2 shadow-lg border border-base-300">
<li>
<button class="justify-between" on:click={previousMonth}>
<span>Previous</span>
<ChevronLeft size={16} />
</button>
</li>
<li><button on:click={currentMonth}>Today</button></li>
<li>
<button class="justify-between" on:click={nextMonth}>
<span>Next</span>
<ChevronRight size={16} />
</button>
</li>
<li><hr class="my-1 border-base-300" /></li>
{#each monthOptions as option}
<li>
<button class:active={option === $selectedPeriod} on:click={() => setPeriod(option)}>
{formatMonth(option)}
</button>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,132 @@
<script context="module" lang="ts">
import type { NavSection } from '$lib/types/layout';
export function isSectionVisible(section: NavSection, role?: string | null): boolean {
if (!section.roles?.length) return true;
if (!role) return false;
return section.roles.includes(role);
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import {
Moon,
PanelLeftClose,
PanelLeftOpen,
PanelRightOpen,
Sun
} from 'lucide-svelte';
import { navigationSections } from '$lib/config/navigation';
import {
setSidebarState,
sidebarState,
theme,
toggleSidebar,
toggleTheme
} from '$lib/stores/layout';
import { user } from '$lib/stores/auth';
import SidebarSection from './SidebarSection.svelte';
$: isExpanded = $sidebarState === 'expanded';
$: isCollapsed = $sidebarState === 'collapsed';
$: isHidden = $sidebarState === 'hidden';
$: isDrawerOpen = $sidebarState !== 'hidden';
$: visibleSections = navigationSections.filter((section) => isSectionVisible(section, $user?.role));
function toggleWithShortcut(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === '\\') {
event.preventDefault();
toggleSidebar();
}
}
$: if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-sidebar', $sidebarState);
}
onMount(() => {
if ($sidebarState === 'expanded') {
if (window.innerWidth < 768) {
setSidebarState('hidden');
} else if (window.innerWidth < 1280) {
setSidebarState('collapsed');
}
}
window.addEventListener('keydown', toggleWithShortcut);
return () => window.removeEventListener('keydown', toggleWithShortcut);
});
</script>
<div
class="mobile-backdrop fixed inset-0 z-20 bg-black/40"
class:hidden={!isDrawerOpen}
aria-hidden={!isDrawerOpen}
on:click={() => setSidebarState('hidden')}
data-testid="sidebar-backdrop"
></div>
<aside
class="fixed left-0 top-0 z-30 flex h-full flex-col border-r border-base-300 bg-base-200 transition-all duration-200 ease-out"
class:w-60={isExpanded}
class:w-16={isCollapsed}
class:w-0={isHidden}
class:overflow-hidden={isHidden}
class:-translate-x-full={isHidden}
class:translate-x-0={!isHidden}
data-testid="sidebar"
>
<div class="flex h-14 items-center justify-between border-b border-base-300 px-3">
{#if isExpanded}
<a href="/dashboard" class="text-lg font-bold">Headroom</a>
{/if}
<button class="btn btn-ghost btn-sm btn-circle" on:click={toggleSidebar} aria-label="Toggle sidebar" data-testid="sidebar-toggle">
{#if isExpanded}
<PanelLeftClose size={18} />
{:else if isCollapsed}
<PanelLeftOpen size={18} />
{:else}
<PanelRightOpen size={18} />
{/if}
</button>
</div>
<nav class="flex-1 overflow-y-auto py-2">
{#each visibleSections as section (section.title)}
<SidebarSection {section} expanded={isExpanded} />
{/each}
</nav>
<div class="border-t border-base-300 p-3">
<button class="btn btn-ghost btn-sm w-full justify-start gap-2" class:justify-center={!isExpanded} on:click={toggleTheme} aria-label="Toggle theme" data-testid="theme-toggle">
{#if $theme === 'light'}
<Moon size={16} />
{#if isExpanded}<span>Dark Mode</span>{/if}
{:else}
<Sun size={16} />
{#if isExpanded}<span>Light Mode</span>{/if}
{/if}
</button>
</div>
</aside>
<style>
.mobile-backdrop {
display: block;
}
@media (max-width: 767px) {
aside {
width: 15rem !important;
}
}
@media (min-width: 768px) {
.mobile-backdrop {
display: none;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { page } from '$app/stores';
import {
AlertTriangle,
BarChart3,
Calendar,
CheckCircle,
Database,
DollarSign,
Folder,
Grid3X3,
LayoutDashboard,
Settings,
TrendingUp,
Users
} from 'lucide-svelte';
const iconMap = {
AlertTriangle,
BarChart3,
Calendar,
CheckCircle,
Database,
DollarSign,
Folder,
Grid3X3,
LayoutDashboard,
Settings,
TrendingUp,
Users
} as const;
export let icon: string;
export let label: string;
export let href: string;
export let expanded = true;
$: active = $page.url.pathname === href || $page.url.pathname.startsWith(`${href}/`);
$: IconComponent = iconMap[icon as keyof typeof iconMap] ?? LayoutDashboard;
</script>
<a
class="btn btn-ghost justify-start w-full h-10 rounded-lg"
class:btn-active={active}
class:justify-center={!expanded}
href={href}
title={!expanded ? label : undefined}
aria-label={label}
data-testid="sidebar-item"
>
<svelte:component this={IconComponent} size={18} />
{#if expanded}
<span class="truncate">{label}</span>
{/if}
</a>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { NavSection } from '$lib/types/layout';
import SidebarItem from './SidebarItem.svelte';
export let section: NavSection;
export let expanded = true;
</script>
<section class="px-2 py-1" data-testid="sidebar-section">
{#if expanded}
<h3 class="text-xs font-semibold uppercase tracking-wide text-base-content/60 px-2 py-2">
{section.title}
</h3>
{/if}
<div class="space-y-1">
{#each section.items as item (item.href)}
<SidebarItem icon={item.icon} label={item.label} href={item.href} {expanded} />
{/each}
</div>
</section>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Menu } from 'lucide-svelte';
import { setSidebarState, sidebarState } from '$lib/stores/layout';
import Breadcrumbs from './Breadcrumbs.svelte';
import MonthSelector from './MonthSelector.svelte';
import UserMenu from './UserMenu.svelte';
function toggleMobileSidebar() {
setSidebarState($sidebarState === 'hidden' ? 'expanded' : 'hidden');
}
</script>
<header class="sticky top-0 z-20 border-b border-base-300 bg-base-100/95 backdrop-blur" data-testid="topbar">
<div class="flex items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 items-center gap-2">
<button
class="btn btn-ghost btn-sm btn-circle md:hidden"
aria-label="Toggle menu"
on:click={toggleMobileSidebar}
data-testid="mobile-hamburger"
>
<Menu size={18} />
</button>
<Breadcrumbs />
</div>
<div class="flex items-center gap-2">
<MonthSelector />
<UserMenu />
</div>
</div>
</header>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout, user } from '$lib/stores/auth';
async function handleLogout() {
await logout();
goto('/login');
}
</script>
<div class="dropdown dropdown-end" data-testid="user-menu">
<button class="btn btn-ghost btn-circle avatar" aria-label="Open user menu">
<div class="w-9 rounded-full bg-primary text-primary-content grid place-items-center">
<span class="text-sm font-semibold">{$user?.email?.charAt(0).toUpperCase() ?? '?'}</span>
</div>
</button>
<ul class="menu menu-sm dropdown-content mt-2 z-30 w-52 rounded-box bg-base-100 p-2 shadow-lg border border-base-300">
<li class="menu-title">
<span>{$user?.email ?? 'Guest'}</span>
</li>
<li><a href="/dashboard">Dashboard</a></li>
{#if $user?.role === 'superuser' || $user?.role === 'manager'}
<li><a href="/team-members">Team Members</a></li>
<li><a href="/projects">Projects</a></li>
{/if}
<li><a href="/reports">Reports</a></li>
<li>
<button class="text-error" on:click={handleLogout}>Logout</button>
</li>
</ul>
</div>

View File

@@ -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' }
]
}
];

View File

@@ -140,13 +140,24 @@ export async function apiRequest<T>(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<T>(response: Response): Promise<T> {
export const api = {
get: <T>(endpoint: string, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
post: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'POST', body }),
put: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'PUT', body }),
patch: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'PATCH', body }),
post: <T>(endpoint: string, body?: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, body === undefined ? { ...options, method: 'POST' } : { ...options, method: 'POST', body }),
put: <T>(endpoint: string, body?: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, body === undefined ? { ...options, method: 'PUT' } : { ...options, method: 'PUT', body }),
patch: <T>(endpoint: string, body?: unknown, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, body === undefined ? { ...options, method: 'PATCH' } : { ...options, method: 'PATCH', body }),
delete: <T>(endpoint: string, options: ApiRequestOptions = {}) =>
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
};
@@ -236,7 +247,7 @@ interface LoginResponse {
user: {
id: string;
email: string;
role: string;
role: 'superuser' | 'manager' | 'developer' | 'top_brass';
};
}

View File

@@ -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 {

View File

@@ -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<SidebarState>(getInitialSidebarState());
const themeWritable = writable<Theme>(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);
}

View File

@@ -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<string>(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());
}

View File

@@ -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';

View File

@@ -1,17 +1,22 @@
<script lang="ts">
import { page } from '$app/stores';
import AppLayout from '$lib/components/layout/AppLayout.svelte';
import { initAuth } from '$lib/stores/auth';
import Navigation from '$lib/components/Navigation.svelte';
import '$lib/stores/layout';
import { onMount } from 'svelte';
const publicPages = ['/login', '/auth'];
$: shouldUseAppLayout = !publicPages.some((path) => $page.url.pathname.startsWith(path));
onMount(() => {
initAuth();
});
</script>
<div class="min-h-screen bg-base-200">
<Navigation />
<main class="container mx-auto px-4 py-6">
{#if shouldUseAppLayout}
<AppLayout>
<slot />
</main>
</div>
</AppLayout>
{:else}
<slot />
{/if}

View File

@@ -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('<slot />');
});
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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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<T>(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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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('<Breadcrumbs />');
expect(source).toContain('<MonthSelector />');
expect(source).toContain('<UserMenu />');
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
function getStoreValue<T>(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');
});
});

View File

@@ -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']);
});
});

View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
function getStoreValue<T>(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');
});
});

View File

@@ -1,4 +1,5 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,