// Expert in Lighthouse Journey Timeline frontend architecture, React patterns, TypeScript, TanStack Query, Zustand, and component development with strong type safety. Use when building UI components, implementing data fetching, managing state, writing forms, styling with Tailwind, testing React components, or integrating with backend APIs.
| name | frontend-engineer |
| description | Expert in Lighthouse Journey Timeline frontend architecture, React patterns, TypeScript, TanStack Query, Zustand, and component development with strong type safety. Use when building UI components, implementing data fetching, managing state, writing forms, styling with Tailwind, testing React components, or integrating with backend APIs. |
Expert knowledge of frontend patterns and architecture for the Lighthouse Journey Timeline.
Component (React)
โ
State Decision:
โโโ Server Data โ TanStack Query Hook โ API Service โ fetch (credentials)
โ โ
โ Type-Safe Response (@journey/schema/api)
โ
โโโ UI State โ Zustand Store (devtools)
โ
Type-Safe Types (from @journey/schema)
Key Flow:
credentials: "include"@journey/schema/src/api/| Pattern | Primary Reference | Secondary Reference |
|---|---|---|
| Reusable Components | packages/components/src/ | ALWAYS CHECK FIRST |
| API Types (Request/Response) | packages/schema/src/api/ | CHECK HERE FIRST |
| API Service Layer | packages/ui/src/services/ | Type-safe API calls |
| TanStack Query Hook | packages/ui/src/hooks/search/useSearchPageQuery.ts | Query patterns, URL state |
| Zustand Store | packages/ui/src/stores/search-store.ts | UI state management |
| Query Client Setup | packages/ui/src/lib/queryClient.ts | Global config, fetch wrapper |
| Token Manager | packages/ui/src/services/token-manager.ts | JWT storage, singleton pattern |
| Component Test | packages/ui/src/components/nodes/shared/InsightCard.test.tsx | Testing patterns with MSW |
| Form Patterns | packages/ui/src/components/nodes/job/JobModal.tsx | react-hook-form + Zod |
| Domain Types | packages/schema/src/types.ts | Shared type definitions |
| MSW Handlers | packages/ui/src/mocks/profile-handlers.ts | API mocking patterns |
| Side Panel Component | packages/ui/src/components/timeline/NetworkInsightsSidePanel.tsx | Figma-based component design |
Never use magic strings. Always use enums, constants, or Zod enums for:
z.nativeEnum(TimelineNodeType) from shared schemaExample - Route Constants:
// โ
GOOD: Constants file
export const ROUTES = {
TIMELINE: '/timeline',
SEARCH: '/search',
NODE_DETAIL: (id: string) => `/timeline/${id}`,
} as const;
// Usage
navigate(ROUTES.NODE_DETAIL(nodeId));
// โ BAD: Magic strings
navigate(`/timeline/${nodeId}`);
Example - Zod Enums:
// โ
GOOD: Shared enum from schema
import { TimelineNodeType } from '@journey/schema';
const NodeTypeSchema = z.nativeEnum(TimelineNodeType);
// โ
GOOD: String literal union
const StatusSchema = z.enum(['draft', 'published', 'archived']);
type Status = z.infer<typeof StatusSchema>;
// โ BAD: Plain strings
type Status = string;
# From packages/server directory
pnpm generate:swagger # Updates openapi-schema.yaml
# Manual type creation in packages/schema based on OpenAPI
# TODO: Add automated OpenAPI โ TypeScript generator when available
// packages/schema/src/types.ts
// Generate from OpenAPI schema
export interface CreateNodeRequest {
type: TimelineNodeType;
parentId?: string;
meta: {
title: string;
company?: string;
startDate: string;
endDate?: string;
};
}
// Create Zod schema for validation
export const CreateNodeRequestSchema = z.object({
type: z.nativeEnum(TimelineNodeType),
parentId: z.string().uuid().optional(),
meta: z.object({
title: z.string().min(1).max(200),
company: z.string().optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime().optional(),
}),
});
// Infer type from schema
export type CreateNodeRequestDTO = z.infer<typeof CreateNodeRequestSchema>;
// packages/ui/src/services/hierarchy-api.ts
import { CreateNodeRequestDTO, CreateNodeRequestSchema } from '@journey/schema';
export async function createNode(data: CreateNodeRequestDTO) {
// Validate at runtime
const validated = CreateNodeRequestSchema.parse(data);
return httpClient.post<NodeResponse>('/api/v2/timeline/nodes', validated);
}
// Component using the API
import { CreateNodeRequestDTO } from '@journey/schema';
const onSubmit = async (data: CreateNodeRequestDTO) => {
await createNode(data); // Type-safe all the way
};
packages/server/openapi-schema.yamlNeed Component?
โ
1. Check packages/components/ first โ **ALWAYS START HERE**
โโโ Exists & works? โ Use it
โโโ Doesn't exist or needs changes
โ
2. Is it reusable across features?
โโโ Yes โ Create/extend in packages/components/
โโโ No โ Create in packages/ui/src/components/
packages/components/ # Reusable component library
โโโ src/
โ โโโ ui/ # shadcn/ui base components
โ โโโ custom/ # Custom reusable components
โ โโโ index.ts # Public exports
packages/ui/ # Application-specific
โโโ src/components/
โ โโโ timeline/ # Timeline domain
โ โโโ search/ # Search domain
โ โโโ nodes/ # Node modals & forms
โ โโโ user/ # User components
โโโ pages/ # Route pages
// packages/ui/src/components/timeline/NetworkInsightsSidePanel.tsx
import { ChevronDown, X } from 'lucide-react';
import { useState } from 'react';
// Import from @packages/components if reusable parts exist
import { Card, CardHeader } from '@packages/components';
interface NetworkInsightsSidePanelProps {
data: GraphRAGSearchResponse | undefined;
isLoading: boolean;
matchCount: number;
isOpen: boolean;
onClose: () => void;
onOpenModal: () => void;
}
export function NetworkInsightsSidePanel({
data,
isLoading,
matchCount,
isOpen,
onClose,
onOpenModal,
}: NetworkInsightsSidePanelProps) {
const [isInsightsExpanded, setIsInsightsExpanded] = useState(true);
if (!isOpen) return null;
return (
<div className="fixed bottom-0 right-0 top-[64px] z-50 w-[380px]">
{/* Content matching Figma design specs */}
<div className="flex items-start gap-[9px]">
<div className="h-[92px] w-[4px] bg-[#5c9eeb] rounded-[2px]" />
{/* ... */}
</div>
</div>
);
}
Key Patterns:
packages/components/ first before creatinggraph TD
A[State Needed] --> B{From Server?}
B -->|Yes| C{Needs Caching?}
B -->|No| D{Shared Across Components?}
C -->|Yes| E[TanStack Query]
C -->|No| F{One-time Fetch?}
F -->|Yes| G[Direct API Call in useEffect]
F -->|No| E
D -->|Yes| H{Complex Logic?}
D -->|No| I[Local useState]
H -->|Yes| J[Zustand Store]
H -->|No| K[React Context]
// hooks/useNodeData.ts
export function useNodeData(nodeId: string) {
return useQuery({
queryKey: ['nodes', nodeId],
queryFn: () => nodeApi.getNode(nodeId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!nodeId, // Conditional fetching
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000)
});
}
// Usage in component
function NodeDetail({ nodeId }) {
const { data, isLoading, error } = useNodeData(nodeId);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage />;
return <NodeContent node={data} />;
}
// stores/search-store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface SearchState {
// UI state only (NOT server data)
selectedProfileId: string | undefined;
currentQuery: string;
preloadedMatchData: GraphRAGSearchResponse | undefined;
// Actions
setSelectedProfile: (profileId: string | undefined) => void;
setCurrentQuery: (query: string) => void;
clearSelection: () => void;
setPreloadedMatchData: (data: GraphRAGSearchResponse | undefined) => void;
clearPreloadedData: () => void;
}
export const useSearchStore = create<SearchState>()(
devtools(
(set, get) => ({
// Initial state
selectedProfileId: undefined,
currentQuery: '',
preloadedMatchData: undefined,
// Actions
setSelectedProfile: (profileId) => {
set({ selectedProfileId: profileId }, false, 'setSelectedProfile');
},
setCurrentQuery: (query) => {
const currentQuery = get().currentQuery;
// Clear selection when query changes
if (query !== currentQuery) {
set(
{
currentQuery: query,
selectedProfileId: undefined,
},
false,
'setCurrentQuery'
);
}
},
clearSelection: () => {
set({ selectedProfileId: undefined }, false, 'clearSelection');
},
setPreloadedMatchData: (data) => {
set({ preloadedMatchData: data }, false, 'setPreloadedMatchData');
},
clearPreloadedData: () => {
set({ preloadedMatchData: undefined }, false, 'clearPreloadedData');
},
}),
{
name: 'search-store',
}
)
);
Key Patterns:
// services/node-api.ts
import { httpClient } from './http-client';
import {
NodeResponse,
CreateNodeRequestDTO,
UpdateNodeRequestDTO,
NodeResponseSchema, // Zod schema for validation
} from '@journey/schema';
class NodeAPI {
async getNode(id: string): Promise<NodeResponse> {
const response = await httpClient.get<NodeResponse>(`/api/v2/nodes/${id}`);
// Validate response matches schema
return NodeResponseSchema.parse(response);
}
async createNode(data: CreateNodeRequestDTO): Promise<NodeResponse> {
const response = await httpClient.post<NodeResponse>('/api/v2/nodes', data);
return NodeResponseSchema.parse(response);
}
async updateNode(
id: string,
data: UpdateNodeRequestDTO
): Promise<NodeResponse> {
const response = await httpClient.patch<NodeResponse>(
`/api/v2/nodes/${id}`,
data
);
return NodeResponseSchema.parse(response);
}
}
export const nodeApi = new NodeAPI();
// lib/queryClient.ts pattern
export async function apiRequest(
method: string,
url: string,
data?: unknown
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { 'Content-Type': 'application/json' } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: 'include', // Always send cookies
});
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
return res;
}
// Default query function with 401 handling
export const getQueryFn: <T>(options: {
on401: 'returnNull' | 'throw';
}) => QueryFunction<T> =
({ on401 }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey.join('/'), {
credentials: 'include',
});
if (on401 === 'returnNull' && res.status === 401) {
return null;
}
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
return await res.json();
};
Key Patterns:
credentials: "include" on all requestsALWAYS write or update unit tests BEFORE MSW or integration tests.
// components/NodeCard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi } from 'vitest';
// MSW is set up globally in test/setup.ts
// Server starts before all tests, resets handlers between tests
// Test wrapper with providers
function TestWrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
});
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('NodeCard', () => {
it('should handle user interaction', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(
<TestWrapper>
<NodeCard node={mockNode} onEdit={onEdit} />
</TestWrapper>
);
await user.click(screen.getByRole('button', { name: /edit/i }));
await waitFor(() => {
expect(onEdit).toHaveBeenCalledWith(mockNode);
});
});
});
// test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// mocks/profile-handlers.ts
import { http, HttpResponse } from 'msw';
export const profileHandlers = [
http.get('/api/v2/profiles/:id', ({ params }) => {
const profile = mockProfiles.find((p) => p.id === params.id);
if (!profile) {
return HttpResponse.json({ error: 'Profile not found' }, { status: 404 });
}
return HttpResponse.json(profile);
}),
http.post('/api/v2/profiles', async ({ request }) => {
const body = await request.json();
const newProfile = { id: uuid(), ...body };
return HttpResponse.json(newProfile, { status: 201 });
}),
];
// Mock the API
vi.mock('../services/node-api', () => ({
nodeApi: {
getNode: vi.fn(),
createNode: vi.fn(),
},
}));
it('should fetch node data', async () => {
const mockNode = { id: '1', title: 'Test' };
vi.mocked(nodeApi.getNode).mockResolvedValue(mockNode);
const { result } = renderHook(() => useNodeData('1'), {
wrapper: TestWrapper,
});
await waitFor(() => {
expect(result.current.data).toEqual(mockNode);
});
});
// stores/__tests__/app-store.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAppStore } from '../app-store';
describe('AppStore', () => {
beforeEach(() => {
// Reset store state
useAppStore.setState({
selectedNodeId: null,
expandedNodes: new Set(),
});
});
it('should select node', () => {
const { result } = renderHook(() => useAppStore());
act(() => {
result.current.selectNode('node-1');
});
expect(result.current.selectedNodeId).toBe('node-1');
});
});
// components/forms/NodeForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateNodeRequestSchema, CreateNodeRequestDTO } from '@journey/schema';
export function NodeForm({ onSubmit }: { onSubmit: (data: CreateNodeRequestDTO) => void }) {
const form = useForm<CreateNodeRequestDTO>({
resolver: zodResolver(CreateNodeRequestSchema),
defaultValues: {
type: 'job',
meta: {
title: '',
startDate: new Date().toISOString()
}
}
});
const handleSubmit = form.handleSubmit(async (data) => {
try {
await onSubmit(data);
form.reset();
} catch (error) {
// Handle errors - could set form errors
form.setError('root', {
message: 'Failed to create node'
});
}
});
return (
<form onSubmit={handleSubmit}>
<input
{...form.register('meta.title')}
placeholder="Title"
/>
{form.formState.errors.meta?.title && (
<span>{form.formState.errors.meta.title.message}</span>
)}
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save'}
</button>
</form>
);
}
// components/forms/FormField.tsx
import { Control, FieldPath, FieldValues } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@journey/components';
interface FormFieldProps<T extends FieldValues> {
control: Control<T>;
name: FieldPath<T>;
label: string;
placeholder?: string;
}
export function TextField<T extends FieldValues>({
control,
name,
label,
placeholder
}: FormFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<input
{...field}
placeholder={placeholder}
className="w-full rounded-md border px-3 py-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// components/auth/ProtectedRoute.tsx
import { Redirect } from 'wouter';
import { useAuthStore } from '@/stores/auth-store';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuthStore();
if (isLoading) {
return <LoadingSpinner />;
}
if (!user) {
return <Redirect to="/signin" />;
}
return <>{children}</>;
}
// App.tsx route setup with wouter
import { Route, Switch } from 'wouter';
<Switch>
<Route path="/signin" component={SignIn} />
<Route path="/dashboard">
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
</Route>
<Route path="/profile">
<ProtectedRoute>
<Profile />
</ProtectedRoute>
</Route>
</Switch>
// hooks/useNavigationState.ts
import { useLocation, useRoute } from 'wouter';
export function useNavigationState() {
const [location, setLocation] = useLocation();
const [previousPath, setPreviousPath] = useState<string | null>(null);
useEffect(() => {
setPreviousPath(location);
}, [location]);
const goBack = useCallback(() => {
if (previousPath) {
setLocation(previousPath);
} else {
setLocation('/');
}
}, [previousPath, setLocation]);
return {
currentPath: location,
previousPath,
goBack,
navigate: setLocation,
};
}
// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: (error: Error) => ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Error caught by boundary:', error, errorInfo);
// Send to error tracking service
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback(this.state.error!);
}
return <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
// Usage in App.tsx
<ErrorBoundary fallback={(error) => <AppErrorPage error={error} />}>
<App />
</ErrorBoundary>
// All components use CSS variables from Tailwind config
// packages/components/tailwind.config.js defines:
// --primary, --secondary, --accent, --destructive, etc.
// Using in custom components
<div className="bg-primary text-primary-foreground">
Uses CSS variable colors
</div>
// Extend existing component with additional props
import { Button, ButtonProps } from '@journey/components';
interface LoadingButtonProps extends ButtonProps {
isLoading?: boolean;
}
export function LoadingButton({
isLoading,
children,
disabled,
...props
}: LoadingButtonProps) {
return (
<Button disabled={disabled || isLoading} {...props}>
{isLoading && <Spinner className="mr-2" />}
{children}
</Button>
);
}
# Navigate to package first
cd packages/ui
# Development
pnpm dev # Start dev server
pnpm build # Build for production
pnpm type-check # Type checking
# Testing (IMPORTANT: Use smart testing)
pnpm test:changed # โก๏ธ FAST - Only test changed (unit only) - RECOMMENDED
pnpm test:unit # Unit tests only (excludes e2e)
pnpm test # ๐ข SLOW - All tests including e2e
# Specific test file (FASTEST for focused work)
pnpm vitest run --no-coverage src/services/updates-api.test.ts
pnpm vitest --no-coverage src/services/updates-api.test.ts # Watch mode
# From project root
pnpm test:changed # Smart Nx testing - only affected packages
pnpm test:changed:base # Compare to main branch
Testing Strategy:
pnpm test:changed (only tests what you changed)pnpm vitest run --no-coverage [file]pnpm vitest --no-coverage [file] (auto-rerun)pnpm test:unit (fast, no e2e)pnpm test (full suite).claude/skills/frontend-engineer/SKILL.md// Auth
useAuthStore(); // User, login, logout
useAuth(); // Auth utilities wrapper
// Data Fetching
useQuery(); // TanStack Query
useMutation(); // TanStack mutations
useInfiniteQuery(); // Pagination
// State
useHierarchyStore(); // Timeline state
useProfileReviewStore(); // Profile state
// Forms
useForm(); // react-hook-form
useFieldArray(); // Dynamic form fields
Before Any Development:
packages/components/ for reusable componentspackages/schema/src/api/ for request/response typespnpm test:changed during developmentcredentials: "include" for all API callsArchitecture Flow:
Component โ TanStack Hook โ API Service โ fetch (credentials) โ @schema/api types
โ
Zustand (UI state only)
Remember: Maintain type safety across all layers. Always validate with Zod at boundaries. Check component library before creating new components.