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

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