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:
@@ -1,11 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Knuckles\Scribe\Config\AuthIn;
|
||||
use Knuckles\Scribe\Config\Defaults;
|
||||
use Knuckles\Scribe\Extracting\Strategies;
|
||||
|
||||
use function Knuckles\Scribe\Config\removeStrategies;
|
||||
|
||||
// Only the most common configs are shown. See the https://scribe.knuckles.wtf/laravel/reference/config for all.
|
||||
|
||||
return [
|
||||
@@ -110,7 +104,7 @@ return [
|
||||
'default' => 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,
|
||||
|
||||
@@ -81,7 +81,6 @@ services:
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "5173:5173"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
28
frontend/src/lib/components/layout/AppLayout.svelte
Normal file
28
frontend/src/lib/components/layout/AppLayout.svelte
Normal 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>
|
||||
50
frontend/src/lib/components/layout/Breadcrumbs.svelte
Normal file
50
frontend/src/lib/components/layout/Breadcrumbs.svelte
Normal 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>
|
||||
61
frontend/src/lib/components/layout/MonthSelector.svelte
Normal file
61
frontend/src/lib/components/layout/MonthSelector.svelte
Normal 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>
|
||||
132
frontend/src/lib/components/layout/Sidebar.svelte
Normal file
132
frontend/src/lib/components/layout/Sidebar.svelte
Normal 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>
|
||||
55
frontend/src/lib/components/layout/SidebarItem.svelte
Normal file
55
frontend/src/lib/components/layout/SidebarItem.svelte
Normal 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>
|
||||
21
frontend/src/lib/components/layout/SidebarSection.svelte
Normal file
21
frontend/src/lib/components/layout/SidebarSection.svelte
Normal 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>
|
||||
32
frontend/src/lib/components/layout/TopBar.svelte
Normal file
32
frontend/src/lib/components/layout/TopBar.svelte
Normal 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>
|
||||
32
frontend/src/lib/components/layout/UserMenu.svelte
Normal file
32
frontend/src/lib/components/layout/UserMenu.svelte
Normal 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>
|
||||
32
frontend/src/lib/config/navigation.ts
Normal file
32
frontend/src/lib/config/navigation.ts
Normal 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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
83
frontend/src/lib/stores/layout.ts
Normal file
83
frontend/src/lib/stores/layout.ts
Normal 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);
|
||||
}
|
||||
73
frontend/src/lib/stores/period.ts
Normal file
73
frontend/src/lib/stores/period.ts
Normal 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());
|
||||
}
|
||||
17
frontend/src/lib/types/layout.ts
Normal file
17
frontend/src/lib/types/layout.ts
Normal 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';
|
||||
@@ -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}
|
||||
|
||||
25
frontend/tests/component/app-layout.test.ts
Normal file
25
frontend/tests/component/app-layout.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
14
frontend/tests/component/breadcrumbs.test.ts
Normal file
14
frontend/tests/component/breadcrumbs.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
9
frontend/tests/component/lucide-icon.test.ts
Normal file
9
frontend/tests/component/lucide-icon.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
37
frontend/tests/component/month-selector.test.ts
Normal file
37
frontend/tests/component/month-selector.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
9
frontend/tests/component/sidebar-item.test.ts
Normal file
9
frontend/tests/component/sidebar-item.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
9
frontend/tests/component/sidebar-section.test.ts
Normal file
9
frontend/tests/component/sidebar-section.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
14
frontend/tests/component/sidebar.test.ts
Normal file
14
frontend/tests/component/sidebar.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
frontend/tests/component/topbar.test.ts
Normal file
21
frontend/tests/component/topbar.test.ts
Normal 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 />');
|
||||
});
|
||||
});
|
||||
167
frontend/tests/e2e/layout.spec.ts
Normal file
167
frontend/tests/e2e/layout.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
frontend/tests/e2e/theme.spec.ts
Normal file
37
frontend/tests/e2e/theme.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
55
frontend/tests/unit/layout.store.test.ts
Normal file
55
frontend/tests/unit/layout.store.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
17
frontend/tests/unit/navigation.config.test.ts
Normal file
17
frontend/tests/unit/navigation.config.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
44
frontend/tests/unit/period.store.test.ts
Normal file
44
frontend/tests/unit/period.store.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
|
||||
@@ -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`
|
||||
132
openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md
Normal file
132
openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md
Normal file
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user