feat(dashboard): Enhance dashboard with PageHeader, StatCard, and auth fixes

- Create PageHeader component with title, description, and action slots
- Create StatCard component with trend indicators and icons
- Update dashboard with KPI cards, Quick Actions, and Allocation Preview
- Polish login page with branding and centered layout
- Fix auth redirect: authenticated users accessing /login go to dashboard
- Fix page refresh: auth state persists, no blank page
- Fix sidebar: visible after login, toggle works, state persists
- Fix CSS import: add app.css to layout, fix DaisyUI import path
- Fix breadcrumbs: home icon links to /dashboard
- Add comprehensive E2E and unit tests

Refs: openspec/changes/p03-dashboard-enhancement
Closes: p03-dashboard-enhancement
This commit is contained in:
2026-02-18 18:14:57 -05:00
parent 493cb78173
commit 96f1d0a6e5
22 changed files with 770 additions and 138 deletions

View File

@@ -1,5 +1,5 @@
@import "tailwindcss";
@import "daisyui";
@import "daisyui/daisyui.css";
:root {
--sidebar-width-expanded: 240px;

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-sidebar="expanded">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import StatCard from './StatCard.svelte';
import { Folder } from 'lucide-svelte';
describe('StatCard', () => {
it('renders value', () => {
render(StatCard, {
props: {
title: 'Active Projects',
value: 14
}
});
expect(screen.getByText('14')).toBeInTheDocument();
expect(screen.getByText('Active Projects')).toBeInTheDocument();
});
it('renders string value', () => {
render(StatCard, {
props: {
title: 'Utilization',
value: '87%'
}
});
expect(screen.getByText('87%')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(StatCard, {
props: {
title: 'Team Members',
value: 8,
description: 'active'
}
});
expect(screen.getByText('active')).toBeInTheDocument();
});
it('applies correct trend colors for up trend', () => {
const { container } = render(StatCard, {
props: {
title: 'Projects',
value: 14,
trend: 'up',
trendValue: '+2'
}
});
const trendElement = container.querySelector('.text-success');
expect(trendElement).toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('applies correct trend colors for down trend', () => {
const { container } = render(StatCard, {
props: {
title: 'Allocations',
value: 186,
trend: 'down',
trendValue: '-12'
}
});
const trendElement = container.querySelector('.text-error');
expect(trendElement).toBeInTheDocument();
expect(screen.getByText('-12')).toBeInTheDocument();
});
it('applies neutral trend color when trend is neutral', () => {
const { container } = render(StatCard, {
props: {
title: 'Team',
value: 8,
trend: 'neutral',
trendValue: '0'
}
});
const trendElement = container.querySelector('.text-base-content\\/50');
expect(trendElement).toBeInTheDocument();
});
it('renders icon when provided', () => {
const { container } = render(StatCard, {
props: {
title: 'Projects',
value: 14,
icon: Folder
}
});
// Icon should render an SVG
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('uses DaisyUI card classes', () => {
const { container } = render(StatCard, {
props: {
title: 'Test',
value: 100
}
});
const card = container.querySelector('.card');
expect(card).toBeInTheDocument();
expect(card).toHaveClass('bg-base-100');
expect(card).toHaveClass('shadow-sm');
});
});

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { TrendingUp, TrendingDown, Minus } from 'lucide-svelte';
import type { ComponentType, SvelteComponent } from 'svelte';
interface Props {
title: string;
value: string | number;
description?: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
icon?: ComponentType<SvelteComponent>;
}
let {
title,
value,
description,
trend = 'neutral',
trendValue,
icon: Icon
}: Props = $props();
const trendColorMap: Record<'up' | 'down' | 'neutral', string> = {
up: 'text-success',
down: 'text-error',
neutral: 'text-base-content/50'
};
const trendIconMap: Record<'up' | 'down' | 'neutral', ComponentType<SvelteComponent>> = {
up: TrendingUp,
down: TrendingDown,
neutral: Minus
};
</script>
<div class="stat-card card bg-base-100 shadow-sm border border-base-300">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="stat-title text-sm text-base-content/70">{title}</div>
{#if Icon}
<div class="text-base-content/50">
<svelte:component this={Icon} size={20} />
</div>
{/if}
</div>
<div class="stat-value text-3xl font-bold mt-1">{value}</div>
<div class="stat-desc flex items-center gap-1 mt-1">
{#if trendValue}
<span class={trendColorMap[trend]}>
<svelte:component this={trendIconMap[trend]} size={14} />
</span>
<span class={trendColorMap[trend]}>{trendValue}</span>
{/if}
{#if description}
<span class="text-base-content/50">{description}</span>
{/if}
</div>
</div>
</div>

View File

@@ -6,7 +6,7 @@
export function generateBreadcrumbs(pathname: string): Breadcrumb[] {
const segments = pathname.split('/').filter(Boolean);
const crumbs: Breadcrumb[] = [{ label: 'Home', href: '/' }];
const crumbs: Breadcrumb[] = [{ label: 'Home', href: '/dashboard' }];
let current = '';
for (const segment of segments) {
@@ -33,7 +33,7 @@
<nav class="breadcrumbs text-sm" aria-label="Breadcrumb" data-testid="breadcrumbs">
<ul>
{#each crumbs as crumb, i (crumb.href)}
{#each crumbs as crumb, i (crumb.href + '-' + i)}
<li>
{#if i === 0}
<a href={crumb.href} aria-label="Home">

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import PageHeader from './PageHeader.svelte';
describe('PageHeader', () => {
it('renders title', () => {
render(PageHeader, { props: { title: 'Test Page' } });
expect(screen.getByText('Test Page')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(PageHeader, {
props: {
title: 'Test Page',
description: 'This is a test description'
}
});
expect(screen.getByText('This is a test description')).toBeInTheDocument();
});
it('does not render description when not provided', () => {
const { container } = render(PageHeader, { props: { title: 'Test Page' } });
expect(container.querySelector('p')).not.toBeInTheDocument();
});
it('renders action buttons via children snippet', () => {
const { container } = render(PageHeader, {
props: {
title: 'Test Page'
}
});
// The component should have a slot for children
expect(container.querySelector('.page-header')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
description?: string;
children?: Snippet;
}
let { title, description, children }: Props = $props();
</script>
<div class="page-header mb-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-base-content">{title}</h1>
{#if description}
<p class="text-base-content/70 mt-1">{description}</p>
{/if}
</div>
{#if children}
<div class="flex items-center gap-2">
{@render children()}
</div>
{/if}
</div>
</div>

View File

@@ -10,6 +10,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import {
Moon,
PanelLeftClose,
@@ -26,6 +27,7 @@
toggleTheme
} from '$lib/stores/layout';
import { user } from '$lib/stores/auth';
import type { SidebarState } from '$lib/types/layout';
import SidebarSection from './SidebarSection.svelte';
$: isExpanded = $sidebarState === 'expanded';
@@ -46,13 +48,22 @@
document.documentElement.setAttribute('data-sidebar', $sidebarState);
}
function getSidebarStateForWidth(width: number): SidebarState {
if (width < 768) return 'hidden';
if (width < 1280) return 'collapsed';
return 'expanded';
}
onMount(() => {
if ($sidebarState === 'expanded') {
if (window.innerWidth < 768) {
setSidebarState('hidden');
} else if (window.innerWidth < 1280) {
setSidebarState('collapsed');
}
if (typeof window === 'undefined') return;
const currentState = get(sidebarState);
const desiredState = getSidebarStateForWidth(window.innerWidth);
if (window.innerWidth < 768 && currentState !== 'hidden') {
setSidebarState('hidden');
} else if (currentState === 'hidden' && window.innerWidth >= 768) {
setSidebarState(desiredState);
}
window.addEventListener('keydown', toggleWithShortcut);

View File

@@ -15,24 +15,46 @@ function isTheme(value: string | null): value is Theme {
return value === 'light' || value === 'dark';
}
function getDefaultSidebarForViewport(): SidebarState {
if (typeof window === 'undefined') {
return DEFAULT_SIDEBAR_STATE;
}
if (window.innerWidth < 768) {
return 'hidden';
}
if (window.innerWidth < 1280) {
return 'collapsed';
}
return 'expanded';
}
function getInitialSidebarState(): SidebarState {
if (typeof localStorage === 'undefined') return DEFAULT_SIDEBAR_STATE;
if (typeof localStorage === 'undefined') return getDefaultSidebarForViewport();
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY);
return isSidebarState(stored) ? stored : DEFAULT_SIDEBAR_STATE;
if (isSidebarState(stored) && stored !== 'hidden') {
return stored;
}
return getDefaultSidebarForViewport();
}
function getInitialTheme(): Theme {
if (typeof localStorage === 'undefined') return DEFAULT_THEME;
if (typeof localStorage === 'undefined') return DEFAULT_THEME;
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (isTheme(stored)) return stored;
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;
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return DEFAULT_THEME;
}
return prefersDark ? 'dark' : DEFAULT_THEME;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : DEFAULT_THEME;
}
function applyTheme(value: Theme): void {
@@ -40,12 +62,21 @@ function applyTheme(value: Theme): void {
document.documentElement.setAttribute('data-theme', value);
}
const sidebarStateWritable = writable<SidebarState>(getInitialSidebarState());
const initialSidebarState = getInitialSidebarState();
const sidebarStateWritable = writable<SidebarState>(initialSidebarState);
const themeWritable = writable<Theme>(getInitialTheme());
function syncSidebarAttribute(value: SidebarState): void {
if (typeof document === 'undefined') return;
document.documentElement.setAttribute('data-sidebar', value);
}
syncSidebarAttribute(initialSidebarState);
if (typeof localStorage !== 'undefined') {
sidebarStateWritable.subscribe((value) => {
localStorage.setItem(SIDEBAR_STORAGE_KEY, value);
syncSidebarAttribute(value);
});
themeWritable.subscribe((value) => {
@@ -65,7 +96,7 @@ export const theme = {
export function toggleSidebar(): void {
sidebarStateWritable.update((current) => {
if (current === 'expanded') return 'collapsed';
if (current === 'collapsed') return 'hidden';
if (current === 'collapsed') return 'expanded';
return 'expanded';
});
}

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import { page } from '$app/stores';
import AppLayout from '$lib/components/layout/AppLayout.svelte';
import { initAuth } from '$lib/stores/auth';
import '$lib/stores/layout';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import AppLayout from '$lib/components/layout/AppLayout.svelte';
import { browser } from '$app/environment';
import { initAuth } from '$lib/stores/auth';
import '$lib/stores/layout';
import '../app.css';
const publicPages = ['/login', '/auth'];
$: shouldUseAppLayout = !publicPages.some((path) => $page.url.pathname.startsWith(path));
const publicPages = ['/login', '/auth'];
$: shouldUseAppLayout = !publicPages.some((path) => $page.url.pathname.startsWith(path));
onMount(() => {
if (browser) {
initAuth();
});
}
</script>
{#if shouldUseAppLayout}

View File

@@ -1,51 +1,112 @@
<script lang="ts">
import { user, logout } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import StatCard from '$lib/components/common/StatCard.svelte';
import { selectedPeriod } from '$lib/stores/period';
import {
Folder,
Users,
Calendar,
BarChart3,
Plus,
ArrowRight
} from 'lucide-svelte';
async function handleLogout() {
await logout();
goto('/login');
}
// TODO: Fetch from API in future
const stats = {
activeProjects: 14,
teamMembers: 8,
allocationsThisMonth: 186,
avgUtilization: 87
};
</script>
<svelte:head>
<title>Dashboard - Headroom</title>
<title>Dashboard | Headroom</title>
</svelte:head>
<div class="max-w-4xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title text-3xl mb-4">Welcome to Headroom! 👋</h1>
{#if $user}
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>Logged in as {$user.email} ({$user.role})</span>
</div>
{/if}
<PageHeader
title="Dashboard"
description="Overview of your resource allocation"
>
{#snippet children()}
<button class="btn btn-primary btn-sm gap-2">
<Plus size={16} />
New Allocation
</button>
{/snippet}
</PageHeader>
<p class="text-lg mb-4">
You have successfully authenticated. This is a protected dashboard page
that requires a valid JWT token to access.
</p>
<!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard
title="Active Projects"
value={stats.activeProjects}
trend="up"
trendValue="+2"
description="from last month"
icon={Folder}
/>
<StatCard
title="Team Members"
value={stats.teamMembers}
trend="neutral"
description="active"
icon={Users}
/>
<StatCard
title="Allocations (hrs)"
value={stats.allocationsThisMonth}
trend="down"
trendValue="-12"
description="vs capacity"
icon={Calendar}
/>
<StatCard
title="Avg Utilization"
value={`${stats.avgUtilization}%`}
trend="up"
trendValue="+5%"
description="from last month"
icon={BarChart3}
/>
</div>
<div class="divider"></div>
<h2 class="text-xl font-bold mb-2">Authentication Features Implemented:</h2>
<ul class="list-disc list-inside space-y-2 mb-6">
<li>✅ JWT Token Authentication</li>
<li>✅ Token Auto-refresh on 401</li>
<li>✅ Protected Route Guards</li>
<li>✅ Form Validation with Zod</li>
<li>✅ Role-based Access Control</li>
<li>✅ Redis Token Storage</li>
</ul>
<div class="card-actions">
<button class="btn btn-error" on:click={handleLogout}>
Logout
</button>
</div>
<!-- Quick Links -->
<div class="card bg-base-100 shadow-sm border border-base-300 mb-6">
<div class="card-body">
<h2 class="card-title text-lg">Quick Actions</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-2">
<a href="/team-members" class="btn btn-ghost justify-start gap-2">
<Users size={18} />
Team
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
<a href="/projects" class="btn btn-ghost justify-start gap-2">
<Folder size={18} />
Projects
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
<a href="/allocations" class="btn btn-ghost justify-start gap-2">
<Calendar size={18} />
Allocate
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
<a href="/reports/forecast" class="btn btn-ghost justify-start gap-2">
<BarChart3 size={18} />
Forecast
<ArrowRight size={14} class="ml-auto opacity-50" />
</a>
</div>
</div>
</div>
<!-- Placeholder for Allocation Preview -->
<div class="card bg-base-100 shadow-sm border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">Allocation Preview</h2>
<p class="text-base-content/50 text-sm">
Allocation matrix for {$selectedPeriod} will appear here.
</p>
<div class="skeleton h-48 w-full mt-4"></div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import LoginForm from '$lib/components/Auth/LoginForm.svelte';
import { login, auth } from '$lib/stores/auth';
import { LayoutDashboard } from 'lucide-svelte';
async function handleLogin(event: CustomEvent<{ email: string; password: string }>) {
const { email, password } = event.detail;
@@ -17,14 +18,23 @@
<title>Login - Headroom</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title text-2xl text-center justify-center mb-6">
Welcome to Headroom
</h1>
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4">
<!-- App Branding/Logo -->
<div class="mb-8 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl shadow-lg mb-4">
<LayoutDashboard size={32} class="text-primary-content" />
</div>
<h1 class="text-3xl font-bold text-base-content">Headroom</h1>
<p class="text-base-content/60 mt-2">Resource Planning & Capacity Management</p>
</div>
<div class="card w-full max-w-md bg-base-100 shadow-xl border border-base-300">
<div class="card-body p-8">
<h2 class="card-title text-xl text-center justify-center mb-2">
Welcome Back
</h2>
<p class="text-center text-base-content/70 mb-6">
<p class="text-center text-base-content/60 mb-6">
Sign in to access your dashboard
</p>
@@ -34,12 +44,21 @@
errorMessage={$auth.error}
/>
<div class="divider"></div>
<div class="divider text-base-content/40 text-sm">Demo Access</div>
<div class="text-center text-sm text-base-content/60">
<p>Demo credentials:</p>
<p class="font-mono mt-1">admin@example.com / password</p>
<div class="text-center text-sm">
<p class="text-base-content/60">Use these credentials to explore:</p>
<div class="mt-2 p-3 bg-base-200 rounded-lg">
<code class="text-sm font-mono text-base-content">admin@example.com</code>
<span class="text-base-content/40 mx-2">/</span>
<code class="text-sm font-mono text-base-content">password</code>
</div>
</div>
</div>
</div>
<!-- Footer -->
<p class="mt-8 text-sm text-base-content/40 text-center">
Headroom - Engineering Resource Planning
</p>
</div>

View File

@@ -1 +1,16 @@
import { browser } from '$app/environment';
import { redirect } from '@sveltejs/kit';
import { getAccessToken, isJwtExpired, isValidJwtFormat } from '$lib/services/api';
export const ssr = false;
export const load = () => {
if (!browser) return;
const token = getAccessToken();
const isAuthenticated = Boolean(token && isValidJwtFormat(token) && !isJwtExpired(token));
if (isAuthenticated) {
throw redirect(307, '/dashboard');
}
};

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
describe('PageHeader component', () => {
it('exports a component module', () => {
expect(PageHeader).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import StatCard from '$lib/components/common/StatCard.svelte';
describe('StatCard component', () => {
it('exports a component module', () => {
expect(StatCard).toBeDefined();
});
});

View File

@@ -232,6 +232,26 @@ test.describe('Authentication E2E', () => {
});
});
test.describe('Already Authenticated Redirects', () => {
test('authenticated user accessing login redirects to dashboard @auth', async ({ page }) => {
// Login first
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');
// Now try to access login page while authenticated
await page.goto('/login');
// Should redirect to dashboard, not show login page
await page.waitForURL('/dashboard');
// Verify we're on dashboard
await expect(page).toHaveURL('/dashboard');
});
});
test.describe('Token Auto-refresh', () => {
test('token auto-refresh on 401 response @auth', async ({ page }) => {
// Login first

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
test.describe('Dashboard Page', () => {
test.beforeEach(async ({ page }) => {
// Login first
await page.goto('/login');
await page.fill('input[type="email"]', 'admin@example.com');
await page.fill('input[type="password"]', 'password');
await page.click('button[type="submit"]');
// Wait for navigation to dashboard
await page.waitForURL('/dashboard');
});
test('dashboard renders correctly', async ({ page }) => {
// Check page title
await expect(page).toHaveTitle(/Dashboard/);
// Check PageHeader renders
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Overview of your resource allocation')).toBeVisible();
// Check New Allocation button
await expect(page.getByRole('button', { name: /New Allocation/i })).toBeVisible();
// Check all 4 StatCards render
await expect(page.getByText('Active Projects')).toBeVisible();
await expect(page.getByText('Team Members')).toBeVisible();
await expect(page.getByText('Allocations (hrs)')).toBeVisible();
await expect(page.getByText('Avg Utilization')).toBeVisible();
// Check stat values
await expect(page.getByText('14')).toBeVisible(); // Active Projects
await expect(page.getByText('8')).toBeVisible(); // Team Members
await expect(page.getByText('186')).toBeVisible(); // Allocations
await expect(page.getByText('87%')).toBeVisible(); // Avg Utilization
// Check Quick Actions section
await expect(page.getByRole('heading', { name: 'Quick Actions' })).toBeVisible();
await expect(page.getByRole('link', { name: /Team/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Projects/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Allocate/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Forecast/i })).toBeVisible();
// Check Allocation Preview section
await expect(page.getByRole('heading', { name: 'Allocation Preview' })).toBeVisible();
await expect(page.getByText(/Allocation matrix for/)).toBeVisible();
});
});

View File

@@ -164,4 +164,119 @@ test.describe('Layout E2E', () => {
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
.not.toBe(before);
});
test('sidebar is visible after login', async ({ page }) => {
await loginThroughForm(page);
// Sidebar should be visible
const sidebar = page.locator('[data-testid="sidebar"]');
await expect(sidebar).toBeVisible();
// Verify sidebar has content
await expect(sidebar.locator('text=Dashboard')).toBeVisible();
});
test('sidebar toggle button works when collapsed', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openDashboard(page);
// Get initial state
const sidebar = page.locator('[data-testid="sidebar"]');
await expect(sidebar).toBeVisible();
// Find and click collapse button (if exists)
const collapseButton = page.locator('[data-testid="sidebar-toggle"], [aria-label*="toggle"], button').filter({ has: page.locator('svg') }).first();
if (await collapseButton.isVisible().catch(() => false)) {
// Collapse the sidebar
await collapseButton.click();
// Wait for collapsed state
await page.waitForTimeout(300);
// Sidebar should still exist but be in collapsed state
await expect(sidebar).toBeAttached();
// Click toggle again to expand
await collapseButton.click();
await page.waitForTimeout(300);
// Sidebar should be visible again
await expect(sidebar).toBeVisible();
}
});
test('page refresh after login does not show blank page', async ({ page }) => {
await loginThroughForm(page);
// Verify we're on dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Get the token before refresh
const tokenBefore = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
expect(tokenBefore).toBeTruthy();
// Refresh the page
await page.reload();
// Wait for page to fully load after refresh
await page.waitForLoadState('networkidle');
// Check what URL we're on (for debugging)
const currentUrl = page.url();
// We should either be on dashboard (success) or redirected to login (bug)
// If we're on login, that's the bug being reported
if (currentUrl.includes('/login')) {
// Bug: We were redirected to login after refresh
// Check if token is still there
const tokenAfter = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
expect(tokenAfter, 'Token should persist after refresh').toBeTruthy();
// Fail the test to document the bug
throw new Error('Bug: User was redirected to login after page refresh despite having valid token');
}
// If we're still on dashboard, wait for client-side hydration to complete
await expect(page).toHaveURL('/dashboard');
// Wait for SvelteKit to hydrate the page
// The page might be SSR'd but needs client-side hydration to show content
await page.waitForFunction(() => {
// Check if the page has hydrated by looking for SvelteKit's hydration marker
return document.querySelector('[data-sveltekit-hydrated]') !== null ||
document.body.innerHTML.includes('Dashboard');
}, { timeout: 10000 });
// Now verify content is visible
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 5000 });
// Verify token is still there
const tokenAfter = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
expect(tokenAfter).toBe(tokenBefore);
});
test('sidebar state persists after page refresh', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await openDashboard(page);
// Get initial sidebar state
const initialState = await page.evaluate(() =>
document.documentElement.getAttribute('data-sidebar')
);
// Refresh page
await page.reload();
await page.waitForLoadState('networkidle');
// Verify sidebar is still visible
const sidebar = page.locator('[data-testid="sidebar"]');
await expect(sidebar).toBeVisible();
// Verify state persisted
const afterRefresh = await page.evaluate(() =>
document.documentElement.getAttribute('data-sidebar')
);
expect(afterRefresh).toBe(initialState);
});
});

View File

@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';
test.describe('Login Page', () => {
test('login page is centered and displays branding', async ({ page }) => {
await page.goto('/login');
// Check page title
await expect(page).toHaveTitle(/Login/);
// Check branding/logo is visible
await expect(page.getByRole('heading', { name: 'Headroom' })).toBeVisible();
await expect(page.getByText('Resource Planning & Capacity Management')).toBeVisible();
// Check logo icon is present (using the LayoutDashboard icon container)
const logoContainer = page.locator('.bg-primary.rounded-2xl');
await expect(logoContainer).toBeVisible();
// Check login form is centered (card is visible and centered via flexbox)
const card = page.locator('.card');
await expect(card).toBeVisible();
// Check welcome message
await expect(page.getByRole('heading', { name: 'Welcome Back' })).toBeVisible();
await expect(page.getByText('Sign in to access your dashboard')).toBeVisible();
// Check form elements are present
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: /Login/i })).toBeVisible();
// Check demo credentials section
await expect(page.getByText('Demo Access')).toBeVisible();
await expect(page.getByText('Use these credentials to explore:')).toBeVisible();
await expect(page.locator('code').filter({ hasText: 'admin@example.com' })).toBeVisible();
await expect(page.locator('code').filter({ hasText: 'password' })).toBeVisible();
// Check footer
await expect(page.getByText('Headroom - Engineering Resource Planning')).toBeVisible();
});
test('login form is vertically centered in viewport', async ({ page }) => {
await page.goto('/login');
// Get the container and check it uses flexbox centering
const container = page.locator('.min-h-screen');
await expect(container).toHaveClass(/flex-col/);
await expect(container).toHaveClass(/items-center/);
await expect(container).toHaveClass(/justify-center/);
// The card should be centered both horizontally and vertically
const card = page.locator('.card');
const box = await card.boundingBox();
const viewport = page.viewportSize();
if (box && viewport) {
const cardCenterY = box.y + box.height / 2;
const viewportCenterY = viewport.height / 2;
// Allow for some margin of error (within 100px of center)
expect(Math.abs(cardCenterY - viewportCenterY)).toBeLessThan(100);
}
});
});

View File

@@ -23,7 +23,7 @@ describe('layout store', () => {
expect(getStoreValue(store.theme)).toBe('light');
});
it('toggleSidebar cycles through states', async () => {
it('toggleSidebar toggles expanded/collapsed and recovers from hidden', async () => {
const store = await import('../../src/lib/stores/layout');
store.setSidebarState('expanded');
@@ -32,8 +32,9 @@ describe('layout store', () => {
expect(localStorage.setItem).toHaveBeenCalledWith('headroom_sidebar_state', 'collapsed');
store.toggleSidebar();
expect(getStoreValue(store.sidebarState)).toBe('hidden');
expect(getStoreValue(store.sidebarState)).toBe('expanded');
store.setSidebarState('hidden');
store.toggleSidebar();
expect(getStoreValue(store.sidebarState)).toBe('expanded');
});