| name | arcana-angular-developer-skill |
| description | Angular development guide based on Arcana Angular enterprise architecture. Provides comprehensive support for Clean Architecture, Offline-First design with 4-layer caching, Angular Signals, MVVM Input/Output/Effect pattern, and enterprise security. Suitable for Angular project development, architecture design, code review, and debugging. |
| allowed-tools | ["Read","Grep","Glob","Bash","Write","Edit"] |
Angular Developer Skill
Professional Angular development skill based on Arcana Angular enterprise architecture.
⚡ Workflow — Always Start From the Reference Project
Every task starts by cloning the complete reference project — NEVER scaffold from scratch (no ng new):
git clone https://github.com/jrjohn/arcana-angular.git [new-project-directory]
- Clone the reference project (command above).
- Build + test the UNTOUCHED clone first —
npm install && npm run build && npm run test -- --watch=false --browsers=ChromeHeadless must be green before any modification (known-good baseline).
- Follow 0. Project Setup to rename the project and strip demo code — KEEP the infrastructure: auth (guards/interceptors), 4-layer caching, offline/sync, security layers, DI/core, router skeleton.
- Add features by copying the nearest working feature, then adapting (see 🔁 Adding a feature) — the File-by-File Feature Recipe below is the completeness checklist.
🔁 Adding a feature = copy the nearest working feature (NOT from scratch)
When you add a new feature, do NOT re-create it from memory by walking the File-by-File Recipe on a blank slate. Copy the nearest already-working feature in the cloned reference, then adapt it.
- Find the closest conformant feature already in the reference — e.g. the example
users feature (user-list / user-detail / user-form) — it already has the full Model → Repository → Service → ViewModel (Input/Output/Effect) → Component → specs chain wired through DI.
- Duplicate its ENTIRE file set (model, validator, service interface + impl, repository interface + impl, DTO, mapper, mock repository, provider binding, ViewModel, component, NavGraph methods, route, specs) 1:1, keeping every layer.
- Rename + adapt to the new domain (types, routes, endpoints, DI bindings/providers).
- Diff against the original to confirm nothing was dropped — same layer split, same ViewModel/service/repository boundary, same error model, same tests.
Why this is mandatory: the File-by-File Recipe is a checklist of what must exist, not a from-scratch build order. Re-deriving the pattern each time makes every step a chance to skip a layer (the ViewModel, the repository), wire a shortcut (component → data/API directly), or drop the tests — the "vibe-coding" deviations that pass local build + coverage but fail architecture review (e.g. arch-qube). Copying a known-good feature carries conformance in by construction; the recipe is then only your verification that nothing is missing.
Supporting files — load on demand
| File | When to read |
|---|
patterns.md | Detailed design patterns beyond the core examples in this file |
patterns/mvvm-input-output.md | Deep-dive on the MVVM Input/Output/Effect ViewModel pattern |
examples.md | Full working code examples for complete features |
checklists/production-ready.md | Pre-release production & code review checklists |
verification/commands.md | Complete catalog of verification bash commands |
reference.md | Technical reference (versions, APIs, configuration details) |
Quick Reference Card
New Screen Checklist:
1. Add route → app.routes.ts (path + component)
2. Create Component with ChangeDetectionStrategy.OnPush
3. Create ViewModel (Input/Output/Effect with Signals)
4. Create template with Loading/Error/Empty states
5. Wire @Output events in parent template
6. Verify mock data returns non-empty values
New Repository Checklist:
1. Interface → domain/repositories/xxx.repository.ts
2. Implementation → data/repositories/xxx.repository.impl.ts
3. Mock → data/repositories/mock/mock-xxx.repository.ts
4. Provider binding → core/providers/repository.providers.ts
5. Mock data (NEVER return [] or null!)
6. Verify ID consistency across repositories
Quick Diagnosis:
| Symptom | Check Command |
|---|
| Blank screen | `grep "\[\]\ |
| Navigation crash | Compare app.routes.ts paths vs component imports |
| Button does nothing | grep "(click)=\"\"" src/app/**/*.html |
| Data not loading | `grep "throw.*NotImplemented\ |
File-by-File Feature Recipe
Create files in this order when adding a new feature (example: project):
1. Model -> src/app/domain/models/project.model.ts
2. Validator -> src/app/domain/validators/project.validator.ts
3. Service Interface -> src/app/domain/services/project.service.ts
4. Service Impl -> src/app/domain/services/project.service.impl.ts
5. Repository Interface -> src/app/domain/repositories/project.repository.ts
6. Repository Impl -> src/app/data/repositories/project.repository.impl.ts
7. DTO -> src/app/data/remote/dtos/project.dto.ts
8. Mapper -> src/app/data/remote/mappers/project.mapper.ts
9. Mock Repository -> src/app/data/repositories/mock/mock-project.repository.ts
10. Provider Binding -> src/app/core/providers/repository.providers.ts
11. ViewModel -> src/app/presentation/projects/project.viewmodel.ts
12. Component -> src/app/presentation/projects/project.component.ts
13. NavGraph Methods -> src/app/core/services/nav-graph.service.ts (toProjectList, toProjectDetail)
14. Route -> src/app/app.routes.ts (path + component)
15. Tests -> project.viewmodel.spec.ts + service/repository specs
Step notes:
- 1-2: Model is a plain TypeScript interface; validator is pure logic (no Angular imports beyond signals if needed).
- 3-6: Service depends on the repository INTERFACE (abstract class, step 5), never the impl. Repository impl wraps the 4-layer cache + offline sync (Dexie).
- 7-8: DTO mirrors the API payload exactly; mapper converts DTO <-> domain model. Never leak DTOs above the data layer.
- 9: Mock data must be realistic and non-empty (see Mock Data Rules — NEVER return
[] / of([])).
- 10: Bind impl vs mock in
repository.providers.ts via environment.production switch.
- 11:
@Injectable() ViewModel with Input/Output/Effect pattern — Signals for output, Subject for effects, single onInput() entry point.
- 12: Standalone component with
ChangeDetectionStrategy.OnPush, providers: [ProjectViewModel], Loading/Error/Empty/Content states, subscribe to effect$.
- 13-14: Every route added in
app.routes.ts MUST get a matching NavGraphService method, and every @Output() navigation event must be bound in the parent template.
- 15: ViewModel specs first (90%+ target), then service (85%+) and repository (80%+) specs.
Verify after wiring: npm run build && npm run test -- --watch=false --browsers=ChromeHeadless.
Rules Priority
🔴 CRITICAL (Must Fix Immediately)
| Rule | Description | Verification |
|---|
| Zero-Empty Policy | Repository stubs NEVER return empty arrays | `grep "\[\]\ |
| Navigation Wiring | ALL routes MUST have component imports | Count paths vs components |
| @Output Binding | ALL @Output events MUST be bound in parent | Check template bindings |
| ID Consistency | Cross-repository IDs must match | Check mock data IDs |
🟡 IMPORTANT (Should Fix Before PR)
| Rule | Description | Verification |
|---|
| UI States | Loading/Error/Empty for all screens | grep -L "isLoading" *.component.ts |
| Mock Data Quality | Realistic, varied values (not all same) | Review mock data arrays |
| Error Messages | User-friendly, not technical errors | Check error handling |
| OnPush Detection | All components use OnPush | Check changeDetection |
🟢 RECOMMENDED (Nice to Have)
| Rule | Description |
|---|
| Animations | Smooth route transitions |
| Accessibility | ARIA labels for interactive elements |
| Dark Mode | Support system theme preference |
| PWA | Service worker for offline |
Error Handling Pattern
AppError - Unified Error Model
export type AppError =
| { type: 'NETWORK_UNAVAILABLE' }
| { type: 'TIMEOUT' }
| { type: 'SERVER_ERROR'; statusCode: number }
| { type: 'UNAUTHORIZED' }
| { type: 'TOKEN_EXPIRED' }
| { type: 'INVALID_CREDENTIALS' }
| { type: 'NOT_FOUND' }
| { type: 'VALIDATION_FAILED'; message: string }
| { type: 'DATA_CORRUPTED' }
| { type: 'UNKNOWN'; underlying: Error };
export function getErrorMessage(error: AppError): string {
switch (error.type) {
case 'NETWORK_UNAVAILABLE':
return 'No internet connection. Please check your network.';
case 'TIMEOUT':
return 'Request timed out. Please try again.';
case 'SERVER_ERROR':
return `Server error (${error.statusCode}). Please try again later.`;
case 'UNAUTHORIZED':
case 'TOKEN_EXPIRED':
return 'Session expired. Please login again.';
case 'INVALID_CREDENTIALS':
return 'Invalid email or password.';
case 'NOT_FOUND':
return 'The requested item was not found.';
case 'VALIDATION_FAILED':
return error.message;
case 'DATA_CORRUPTED':
return 'Data error. Please contact support.';
case 'UNKNOWN':
return 'An unexpected error occurred.';
}
}
export function requiresReauth(error: AppError): boolean {
return error.type === 'UNAUTHORIZED' || error.type === 'TOKEN_EXPIRED';
}
Error Handling Flow
┌─────────────────────────────────────────────────────────────────┐
│ Error Flow │
├─────────────────────────────────────────────────────────────────┤
│ Repository Layer: │
│ - Catch HTTP errors │
│ - Map to AppError │
│ - Throw AppError │
├─────────────────────────────────────────────────────────────────┤
│ Service Layer: │
│ - Catch repository errors │
│ - Add business context if needed │
│ - Re-throw as AppError │
├─────────────────────────────────────────────────────────────────┤
│ ViewModel Layer: │
│ - Catch all errors │
│ - Update _error signal with getErrorMessage() │
│ - Check requiresReauth() for auth redirect │
├─────────────────────────────────────────────────────────────────┤
│ Component Layer: │
│ - Display error from output().error │
│ - Show retry button │
│ - Handle auth redirect via effect │
└─────────────────────────────────────────────────────────────────┘
Error Handling by Layer
HTTP Interceptor (functional HttpInterceptorFn — the modern primary pattern; see 9. HTTP Interceptors for the legacy class-based alternative):
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
catchError((error: HttpErrorResponse) => {
let appError: AppError;
if (error.status === 0) {
appError = { type: 'NETWORK_UNAVAILABLE' };
} else if (error.status === 401) {
appError = { type: 'UNAUTHORIZED' };
} else if (error.status === 404) {
appError = { type: 'NOT_FOUND' };
} else if (error.status >= 500) {
appError = { type: 'SERVER_ERROR', statusCode: error.status };
} else {
appError = { type: 'UNKNOWN', underlying: error };
}
return throwError(() => appError);
})
);
ViewModel Layer:
private async loadData(): Promise<void> {
this._isLoading.set(true);
this._error.set(null);
try {
const items = await this.repository.getItems();
this._items.set(items);
} catch (error) {
const appError = error as AppError;
this._error.set(getErrorMessage(appError));
if (requiresReauth(appError)) {
this._effect.next({ type: 'NAVIGATE_TO_LOGIN' });
}
} finally {
this._isLoading.set(false);
}
}
Test Coverage Targets
Coverage by Layer
| Layer | Target | Focus Areas |
|---|
| ViewModel | 90%+ | All Input handlers, state transitions, effects |
| Service | 85%+ | Business logic, edge cases |
| Repository | 80%+ | Data mapping, error handling |
| Component | 60%+ | Template binding, user interactions |
What to Test
ViewModel Tests (Highest Priority):
describe('FeatureViewModel', () => {
it('should load items on LOAD input', async () => { });
it('should set error on LOAD failure', async () => { });
it('should emit toast on REFRESH success', async () => { });
it('should navigate on ITEM_CLICKED', () => { });
it('should set isLoading true then false', async () => { });
it('should show empty state when no items', async () => { });
});
Service Tests:
describe('UserService', () => {
it('should validate email format', () => { });
it('should calculate score correctly', () => { });
});
Repository Tests:
describe('UserRepository', () => {
it('should map DTO to domain model', async () => { });
it('should return cached data when offline', async () => { });
});
Test Commands
npm run test -- --code-coverage --watch=false --browsers=ChromeHeadless
open coverage/[app-name]/index.html
Core Architecture Principles
Clean Architecture - Three Layers
┌─────────────────────────────────────────────────────┐
│ Presentation Layer │
│ Components + MVVM + Input/Output/Effect │
├─────────────────────────────────────────────────────┤
│ Domain Layer │
│ Business Logic + Services + Models │
├─────────────────────────────────────────────────────┤
│ Data Layer │
│ Offline-First Repository + IndexedDB + 4L Cache │
└─────────────────────────────────────────────────────┘
Dependency Rules
- Unidirectional Dependencies: Presentation → Domain → Data
- Interface Segregation: Decouple layers through interfaces
- Dependency Inversion: Data layer implements Domain layer interfaces
Instructions
When handling Angular development tasks, follow these principles:
Quick Verification Commands
Use these commands to quickly check for common issues:
grep -rn "throw.*NotImplemented\|TODO.*implement" src/app/
grep -rn "(click)=\"\"\|(click)=\"undefined\"" src/app/
echo "Routes defined:" && grep -c "path:" src/app/app.routes.ts 2>/dev/null || echo 0
echo "Components imported:" && grep -c "component:" src/app/app.routes.ts 2>/dev/null || echo 0
grep -c "to\|navigate" src/app/core/services/nav-graph.service.ts 2>/dev/null || echo 0
npm run build
echo "=== Component @Output Events ===" && \
grep -rh "@Output()" src/app/presentation/ | grep -oE "[a-zA-Z]+\s*=" | sed 's/\s*=//' | sort -u
echo "=== Bound Events in Templates ===" && \
grep -rh "([a-zA-Z]*Navigate[a-zA-Z]*)=" src/app/**/*.html 2>/dev/null | grep -oE "\([a-zA-Z]+\)" | tr -d '()' | sort -u
echo "=== Routes Defined ===" && \
grep -rh "path:" src/app/app.routes.ts | grep -oE "'[^']+'" | sort -u
echo "=== NavGraphService Methods ===" && \
grep -rh "to[A-Z][a-zA-Z]*\(" src/app/core/services/nav-graph.service.ts | grep -oE "to[A-Z][a-zA-Z]*" | sort -u
grep -rn "onNavigate.*:\s*EventEmitter" src/app/presentation/
echo "=== Repository Methods Called in Services ===" && \
grep -roh "this\.[a-zA-Z]*Repository\.[a-zA-Z]*(" src/app/domain/services/*.ts | sort -u
echo "=== Repository Interface Methods ===" && \
grep -rh "[a-zA-Z]*\(" src/app/domain/repositories/*.repository.ts | grep -oE "[a-zA-Z]+\(" | sort -u
echo "=== Repository Interface Methods ===" && \
grep -rh "abstract\|[a-zA-Z]*\(" src/app/domain/repositories/*.repository.ts | grep -oE "[a-zA-Z]+\(" | sort -u
echo "=== Repository Implementation Methods ===" && \
grep -rh "[a-zA-Z]*\(" src/app/data/repositories/*.repository.impl.ts | grep -oE "[a-zA-Z]+\(" | sort -u
⚠️ CRITICAL: All routes in app.routes.ts MUST have corresponding component imports. Missing components cause runtime errors.
⚠️ NAVIGATION WIRING CRITICAL: Commands #6-#8 detect navigation @Output events that exist in Components but aren't bound in parent templates. A Component can declare @Output() onNavigateToSettings = new EventEmitter() but if the parent template doesn't bind (onNavigateToSettings)="handler()", the event does nothing!
If any of these return results or counts don't match, FIX THEM before completing the task.
📊 Mock Data Requirements for Repository Stubs
The Chart Data Problem
When implementing Repository stubs, NEVER return empty arrays for data that powers UI charts or visualizations. This causes:
- Charts that render but show nothing (blank ng2-charts/D3 canvas)
- Line charts that skip rendering (e.g.,
if (data.length < 2) return;)
- Empty state components even when data structure exists
Mock Data Rules
Rule 1: Array data for charts MUST have at least 7 items
getCurrentWeekSummary(): Observable<WeeklySummary> {
return of({
dailyReports: []
});
}
getCurrentWeekSummary(): Observable<WeeklySummary> {
const mockDailyReports = Array.from({ length: 7 }, (_, i) =>
this.createMockDailyReport(
[72, 78, 85, 80, 76, 88, 82][i],
[390, 420, 450, 410, 380, 460, 435][i]
)
);
return of({ dailyReports: mockDailyReports });
}
Rule 2: Use realistic, varied sample values
const scores = Array(7).fill(80);
const scores = [72, 78, 85, 80, 76, 88, 82];
Rule 3: Data must match interface exactly
grep -A 20 "interface TherapyData" src/app/domain/models/*.ts
Rule 4: Create helper functions for complex mock data
private createMockDailyReport(score: number, duration: number): DailySleepReport {
return {
id: `mock_${Date.now()}`,
sleepScore: score,
sleepDuration: { totalMinutes: duration, ... },
};
}
Quick Verification Commands for Mock Data
grep -rn "\[\]" src/app/data/repositories/*.repository.impl.ts
grep -rn "dailyReports\|weeklyData\|chartData" src/app/data/repositories/ | grep -E "= \[\]|of\(\[\]\)"
0. Project Setup - CRITICAL
⚠️ IMPORTANT: This reference project has been validated with tested npm/Angular settings and library versions. NEVER reconfigure project structure or modify package.json / angular.json, or it will cause compilation errors.
Step 1: Clone the reference project
git clone https://github.com/jrjohn/arcana-angular.git [new-project-directory]
cd [new-project-directory]
Step 2: Reinitialize Git (remove original repo history)
rm -rf .git
git init
git add .
git commit -m "Initial commit from arcana-angular template"
Step 3: Modify project name
Only modify the following required items:
name field in package.json
- Project name in
angular.json
<title> in src/index.html
- Update related settings in environment configuration files
Step 4: Clean up example code
The cloned project contains example UI (e.g., Arcana User Management). Clean up and replace with new project screens:
Core architecture files to KEEP (do not delete):
src/app/core/ - Common utilities (Guards, Interceptors, Services)
src/app/shared/ - Shared components and Pipes
src/app/data/local/ - IndexedDB (Dexie) base configuration
src/app/data/repositories/ - Repository base classes
src/app/app.component.ts - App entry point
src/app/app.routes.ts - Route configuration (modify routes)
Example files to REPLACE:
src/app/presentation/ - Delete all example screens, create new project Components
src/app/domain/models/ - Delete example Models, create new Domain Models
src/app/data/remote/ - Modify API endpoints
src/assets/ - Update resource files
Step 5: Install dependencies and verify build
npm install
npm run build
❌ Prohibited Actions
- DO NOT create new Angular project from scratch (ng new)
- DO NOT modify version numbers in
package.json
- DO NOT add or remove npm dependencies (unless explicitly required)
- DO NOT modify build settings in
angular.json
- DO NOT reconfigure Bootstrap, Dexie, ng-bootstrap, or other library settings
✅ Allowed Modifications
- Add business-related TypeScript code (following existing architecture)
- Add Components, Services, ViewModels
- Add Domain Models, Repository
- Modify resources in
src/assets/
- Add routing modules
1. TDD & Spec-Driven Development Workflow - MANDATORY
⚠️ CRITICAL: All development MUST follow this TDD workflow. Every Spec requirement must have corresponding tests BEFORE implementation.
🚨 ABSOLUTE RULE: TDD = Tests + Implementation. Writing tests without implementation is INCOMPLETE. Every test file MUST have corresponding production code that passes the tests.
┌─────────────────────────────────────────────────────────────────┐
│ TDD Development Workflow │
├─────────────────────────────────────────────────────────────────┤
│ Step 1: Analyze Spec → Extract all SRS & SDD requirements │
│ Step 2: Create Tests → Write tests for EACH Spec item │
│ Step 3: Verify Coverage → Ensure 100% Spec coverage in tests │
│ Step 4: Implement → Build features to pass tests ⚠️ MANDATORY │
│ Step 5: Mock APIs → Use mock data for unfinished Cloud APIs │
│ Step 6: Run All Tests → ALL tests must pass before completion │
│ Step 7: Verify 100% → Tests written = Features implemented │
└─────────────────────────────────────────────────────────────────┘
⛔ FORBIDDEN: Tests Without Implementation
⛔ Placeholder Component Policy
Placeholder components are ONLY allowed as a temporary route during active development. They are FORBIDDEN as a final state.
{ path: 'training', component: PlaceholderComponent }
{ path: 'training', component: TrainingComponent }
Placeholder Check Command:
grep -rn "PlaceholderComponent\|throw.*NotImplemented\|TODO.*implement\|即將推出\|Coming Soon" src/app/
Step 1: Analyze Spec Documents (SRS & SDD)
Before writing any code, extract ALL requirements from both SRS and SDD:
Step 2: Create Test Cases for Each Spec Item
import { TestBed } from '@angular/core/testing';
import { LoginViewModel } from './login.viewmodel';
import { AuthRepository } from '../../domain/repositories/auth.repository';
describe('LoginViewModel', () => {
let viewModel: LoginViewModel;
let mockAuthRepository: jasmine.SpyObj<AuthRepository>;
beforeEach(() => {
mockAuthRepository = jasmine.createSpyObj('AuthRepository', ['login', 'isLoggedIn']);
TestBed.configureTestingModule({
providers: [
LoginViewModel,
{ provide: AuthRepository, useValue: mockAuthRepository }
]
});
viewModel = TestBed.inject(LoginViewModel);
});
it('should login successfully with valid credentials', async () => {
mockAuthRepository.login.and.returnValue(Promise.resolve());
viewModel.onInput({ type: 'updateEmail', value: 'test@test.com' });
viewModel.onInput({ type: 'updatePassword', value: 'password123' });
await viewModel.onInput({ type: 'submit' });
expect(viewModel.output().isLoginSuccess).toBeTrue();
expect(viewModel.output().error).toBeNull();
});
it('should show error with invalid credentials', async () => {
mockAuthRepository.login.and.returnValue(Promise.reject(new Error('Invalid credentials')));
await viewModel.onInput({ type: 'submit' });
expect(viewModel.output().isLoginSuccess).toBeFalse();
expect(viewModel.output().error).toBeTruthy();
});
});
Step 3: Spec Coverage Verification Checklist
Before implementation, verify ALL SRS and SDD items have tests:
Step 4: Mock API Implementation - MANDATORY
⚠️ CRITICAL: Every Service/Repository method MUST return valid mock data. NEVER leave methods throwing NotImplementedError.
Rules for Mock Services:
- ALL service methods must return valid mock data (Promise.resolve or Observable.of)
- Use
setTimeout() or delay() to simulate network latency (500-1000ms)
- Mock data must match the interface structure exactly
- Check TypeScript enums exist before using them
- Include all required properties for interfaces
For APIs not yet available from Cloud team, implement mock services:
import { Injectable } from '@angular/core';
import { AuthRepository } from '../../../domain/repositories/auth.repository';
interface MockUser {
email: string;
password: string;
name: string;
}
@Injectable()
export class MockAuthRepository implements AuthRepository {
private static readonly MOCK_USERS: MockUser[] = [
{ email: 'test@test.com', password: 'password123', name: 'Test User' },
{ email: 'demo@demo.com', password: 'demo123', name: 'Demo User' }
];
async login(email: string, password: string): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 1000));
const user = MockAuthRepository.MOCK_USERS.find(
u => u.email === email && u.password === password
);
if (user) {
localStorage.setItem('access_token', `mock_token_${Date.now()}`);
localStorage.setItem('user_name', user.name);
} else {
throw new Error('Invalid email or password');
}
}
isLoggedIn(): boolean {
return !!localStorage.getItem('access_token');
}
}
import { environment } from '../../../environments/environment';
export const repositoryProviders = [
{
provide: AuthRepository,
useClass: environment.production
? AuthRepositoryImpl
: MockAuthRepository
}
];
Step 5: Run All Tests Before Completion
npm run test
npm run test -- --code-coverage
npm run test -- --watch=false --browsers=ChromeHeadless
npm run e2e
npm run test -- --watch=false && npm run e2e
Test Directory Structure
src/app/
├── presentation/
│ ├── auth/
│ │ ├── login.viewmodel.ts
│ │ ├── login.viewmodel.spec.ts # Unit test
│ │ ├── login.component.ts
│ │ └── login.component.spec.ts # Component test
│ └── dashboard/
│ ├── dashboard.viewmodel.spec.ts
│ └── dashboard.component.spec.ts
├── domain/
│ └── services/
│ ├── user.service.ts
│ └── user.service.spec.ts
├── data/
│ └── repositories/
│ ├── auth.repository.impl.ts
│ ├── auth.repository.spec.ts
│ └── mock/
│ └── mock-auth.repository.ts
e2e/
├── login.e2e-spec.ts
└── dashboard.e2e-spec.ts
2. Project Structure
src/
├── app/
│ ├── presentation/ # UI Layer
│ │ ├── components/ # Smart & Dumb Components
│ │ ├── layouts/ # Page Layouts
│ │ └── forms/ # Form Components
│ ├── domain/ # Domain Layer
│ │ ├── models/ # Domain Models
│ │ ├── services/ # Business Services
│ │ └── repositories/ # Repository Interfaces
│ └── data/ # Data Layer
│ ├── repositories/ # Repository Implementations
│ ├── local/ # IndexedDB (Dexie)
│ └── remote/ # API Client
├── assets/
└── styles/ # SCSS with Bootstrap
3. ViewModel Input/Output/Effect Pattern with Signals
import { Injectable, signal, computed } from '@angular/core';
import { Subject } from 'rxjs';
export type UserInput =
| { type: 'UPDATE_NAME'; name: string }
| { type: 'UPDATE_EMAIL'; email: string }
| { type: 'SUBMIT' };
export interface UserOutput {
name: string;
email: string;
isLoading: boolean;
error: string | null;
}
export type UserEffect =
| { type: 'NAVIGATE_BACK' }
| { type: 'SHOW_SNACKBAR'; message: string };
@Injectable()
export class UserViewModel {
private readonly _name = signal('');
private readonly _email = signal('');
private readonly _isLoading = signal(false);
private readonly _error = signal<string | null>(null);
readonly output = computed<UserOutput>(() => ({
name: this._name(),
email: this._email(),
isLoading: this._isLoading(),
error: this._error(),
}));
private readonly _effect = new Subject<UserEffect>();
readonly effect$ = this._effect.asObservable();
constructor(private readonly userService: UserService) {}
onInput(input: UserInput): void {
switch (input.type) {
case 'UPDATE_NAME':
this._name.set(input.name);
break;
case 'UPDATE_EMAIL':
this._email.set(input.email);
break;
case 'SUBMIT':
this.submit();
break;
}
}
private async submit(): Promise<void> {
this._isLoading.set(true);
this._error.set(null);
try {
await this.userService.updateUser({
name: this._name(),
email: this._email(),
});
this._effect.next({ type: 'NAVIGATE_BACK' });
} catch (error) {
this._error.set(error instanceof Error ? error.message : 'Unknown error');
} finally {
this._isLoading.set(false);
}
}
}
4. Four-Layer Offline-First Caching
import { Injectable } from '@angular/core';
import Dexie from 'dexie';
interface CacheEntry<T> {
value: T;
timestamp: number;
}
@Injectable({ providedIn: 'root' })
export class CacheManager<T> {
private memoryCache = new Map<string, CacheEntry<T>>();
private lruCache = new Map<string, CacheEntry<T>>();
private readonly maxLruSize = 100;
private readonly ttlMs = 5 * 60 * 1000;
private db: Dexie;
constructor(private readonly apiClient: ApiClient) {
this.db = new Dexie('AppCache');
this.db.version(1).stores({
cache: 'key, value, timestamp',
});
}
async get(key: string, loader: () => Promise<T>): Promise<T> {
const now = Date.now();
const memEntry = this.memoryCache.get(key);
if (memEntry && now - memEntry.timestamp < this.ttlMs) {
return memEntry.value;
}
const lruEntry = this.lruCache.get(key);
if (lruEntry && now - lruEntry.timestamp < this.ttlMs) {
this.memoryCache.set(key, lruEntry);
return lruEntry.value;
}
const dbEntry = await this.db.table('cache').get(key);
if (dbEntry && now - dbEntry.timestamp < this.ttlMs) {
const entry = { value: dbEntry.value, timestamp: dbEntry.timestamp };
this.memoryCache.set(key, entry);
this.addToLru(key, entry);
return dbEntry.value;
}
const value = await loader();
const entry = { value, timestamp: now };
this.memoryCache.set(key, entry);
this.addToLru(key, entry);
await this.db.table('cache').put({ key, ...entry });
return value;
}
private addToLru(key: string, entry: CacheEntry<T>): void {
if (this.lruCache.size >= this.maxLruSize) {
const oldestKey = this.lruCache.keys().next().value;
if (oldestKey) this.lruCache.delete(oldestKey);
}
this.lruCache.set(key, entry);
}
}
5. Offline-First Repository
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import Dexie from 'dexie';
enum SyncStatus {
SYNCED = 'synced',
PENDING = 'pending',
FAILED = 'failed',
}
@Injectable({ providedIn: 'root' })
export class UserRepository {
private db: Dexie;
private usersSubject = new BehaviorSubject<User[]>([]);
constructor(
private readonly apiClient: ApiClient,
private readonly syncManager: SyncManager
) {
this.db = new Dexie('UsersDB');
this.db.version(1).stores({
users: 'id, name, email, syncStatus, updatedAt',
});
this.loadFromLocal();
}
getUsers(): Observable<User[]> {
return this.usersSubject.asObservable();
}
async updateUser(user: User): Promise<void> {
const entity = {
...user,
syncStatus: SyncStatus.PENDING,
updatedAt: Date.now(),
};
await this.db.table('users').put(entity);
this.loadFromLocal();
this.syncManager.scheduleSync();
}
async syncPendingChanges(): Promise<void> {
const pendingUsers = await this.db
.table('users')
.where('syncStatus')
.equals(SyncStatus.PENDING)
.toArray();
for (const user of pendingUsers) {
try {
await this.apiClient.updateUser(user);
await this.db.table('users').update(user.id, {
syncStatus: SyncStatus.SYNCED,
});
} catch {
}
}
this.loadFromLocal();
}
private async loadFromLocal(): Promise<void> {
const users = await this.db.table('users').toArray();
this.usersSubject.next(users);
}
}
6. Component with OnPush Change Detection
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { UserViewModel, UserInput } from './user.viewmodel';
@Component({
selector: 'app-user',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
type="text"
[value]="vm.output().name"
(input)="onNameChange($event)"
/>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
[value]="vm.output().email"
(input)="onEmailChange($event)"
/>
</div>
@if (vm.output().error) {
<div class="error">{{ vm.output().error }}</div>
}
<button type="submit" [disabled]="vm.output().isLoading">
@if (vm.output().isLoading) {
Loading...
} @else {
Submit
}
</button>
</form>
`,
providers: [UserViewModel],
})
export class UserComponent {
protected readonly vm = inject(UserViewModel);
constructor() {
this.vm.effect$.subscribe((effect) => {
switch (effect.type) {
case 'NAVIGATE_BACK':
break;
case 'SHOW_SNACKBAR':
break;
}
});
}
onNameChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.vm.onInput({ type: 'UPDATE_NAME', name: input.value });
}
onEmailChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.vm.onInput({ type: 'UPDATE_EMAIL', email: input.value });
}
onSubmit(): void {
this.vm.onInput({ type: 'SUBMIT' });
}
}
7. Type-Safe Navigation (NavGraphService)
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class NavGraphService {
private readonly router = inject(Router);
toHome(): Promise<boolean> {
return this.router.navigate(['/']);
}
toUserList(): Promise<boolean> {
return this.router.navigate(['/users']);
}
toUserDetail(userId: string): Promise<boolean> {
return this.router.navigate(['/users', userId]);
}
toUserEdit(userId: string): Promise<boolean> {
return this.router.navigate(['/users', userId, 'edit']);
}
toProjectList(): Promise<boolean> {
return this.router.navigate(['/projects']);
}
toProjectDetail(projectId: string): Promise<boolean> {
return this.router.navigate(['/projects', projectId]);
}
back(): void {
window.history.back();
}
}
8. Security - Input Sanitization
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Injectable({ providedIn: 'root' })
export class SanitizationService {
constructor(private readonly sanitizer: DomSanitizer) {}
sanitizeHtml(html: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(this.stripDangerousTags(html));
}
private stripDangerousTags(html: string): string {
const dangerousTags = ['script', 'iframe', 'object', 'embed', 'form'];
let result = html;
for (const tag of dangerousTags) {
const regex = new RegExp(`<${tag}[^>]*>.*?</${tag}>`, 'gi');
result = result.replace(regex, '');
}
result = result.replace(/\s*on\w+="[^"]*"/gi, '');
result = result.replace(/\s*on\w+='[^']*'/gi, '');
return result;
}
sanitizeInput(input: string): string {
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
}
9. HTTP Interceptors
Modern (primary): functional interceptors with HttpInterceptorFn + withInterceptors. Match what the reference repo uses; modern Angular prefers functional.
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req);
};
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
retry(1),
catchError((error: HttpErrorResponse) => {
const errorMessage =
error.error instanceof ErrorEvent
? error.error.message
: `Error Code: ${error.status}\nMessage: ${error.message}`;
console.error(errorMessage);
return throwError(() => new Error(errorMessage));
})
);
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
],
};
Legacy alternative: class-based HttpInterceptor (only if the reference repo still uses it — registered via HTTP_INTERCEPTORS multi-provider):
import { Injectable } from '@angular/core';
import {
HttpInterceptor,
HttpRequest,
HttpHandler,
HttpEvent,
HttpErrorResponse,
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private readonly authService: AuthService) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getToken();
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
return next.handle(req);
}
}
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(req).pipe(
retry(1),
catchError((error: HttpErrorResponse) => {
let errorMessage = 'An unknown error occurred';
if (error.error instanceof ErrorEvent) {
errorMessage = error.error.message;
} else {
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
}
console.error(errorMessage);
return throwError(() => new Error(errorMessage));
})
);
}
}
10. Form Validation
import { signal, computed } from '@angular/core';
export class FormState {
private readonly _email = signal('');
private readonly _emailTouched = signal(false);
readonly email = this._email.asReadonly();
readonly emailError = computed(() => {
if (!this._emailTouched()) return null;
const email = this._email();
if (!email) return 'Email is required';
if (!this.isValidEmail(email)) return 'Invalid email format';
return null;
});
readonly isValid = computed(() => {
const email = this._email();
return !!email && this.isValidEmail(email);
});
setEmail(value: string): void {
this._email.set(value);
}
markEmailTouched(): void {
this._emailTouched.set(true);
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
return emailRegex.test(email);
}
}
Navigation Wiring Verification Guide
🚨 The Navigation Wiring Blind Spot
Angular Components often declare @Output navigation events that need parent binding:
@Component({...})
export class SettingsComponent {
@Output() onNavigateToAccountInfo = new EventEmitter<void>();
@Output() onNavigateToChangePassword = new EventEmitter<void>();
@Output() onNavigateToUserList = new EventEmitter<void>();
goToAccountInfo(): void {
this.onNavigateToAccountInfo.emit();
}
}
Problem: If the parent Component's template doesn't bind these @Output events, the buttons appear functional but do nothing when clicked!
Detection Patterns
grep -rn "@Output().*Navigate" src/app/presentation/
grep -rn "(onNavigate" src/app/**/*.html
grep -rn "path:" src/app/app.routes.ts
Verification Checklist
-
Count @Output navigation events in each Component:
grep -c "@Output().*Navigate" src/app/presentation/settings/settings.component.ts
-
Count bound events where Component is used:
grep -c "(onNavigate" src/app/presentation/home/home.component.html
-
Counts MUST match! Any mismatch = unwired navigation
Correct Wiring Example
@Component({ selector: 'app-settings', ... })
export class SettingsComponent {
@Output() onNavigateToAccountInfo = new EventEmitter<void>();
@Output() onNavigateToChangePassword = new EventEmitter<void>();
@Output() onNavigateToUserList = new EventEmitter<void>();
}
<app-settings
(onNavigateToAccountInfo)="navGraph.toAccountInfo()"
(onNavigateToChangePassword)="navGraph.toChangePassword()"
(onNavigateToUserList)="navGraph.toUserList()">
</app-settings>
export const routes: Routes = [
{ path: 'account-info', component: AccountInfoComponent },
{ path: 'change-password', component: ChangePasswordComponent },
{ path: 'user-list', component: UserListComponent },
];
toAccountInfo(): Promise<boolean> { return this.router.navigate(['/account-info']); }
toChangePassword(): Promise<boolean> { return this.router.navigate(['/change-password']); }
toUserList(): Promise<boolean> { return this.router.navigate(['/user-list']); }
Code Review Checklist
Required Items
Performance Checks
Security Checks
Common Issues
Signal Update Issues
- Ensure signals are updated within the Angular zone
- Use computed() for derived state
- Avoid mutating signal values directly
IndexedDB Issues
- Handle version upgrades properly with Dexie
- Use transactions for batch operations
- Implement proper error handling
Build Optimization
- Enable production mode
- Configure tree shaking
- Use lazy loading for feature modules
Spec Gap Prediction System
When Spec is incomplete, use these universal rules to predict and supplement missing UI/UX elements.
Screen Type → Required States (Universal)
| Screen Type | Required States | Auto-Predict |
|---|
| List Screen | Loading, Error, Empty, Content | Virtual scroll, Search/Filter |
| Detail Screen | Loading, Error, Content | Back navigation, Edit action |
| Form Screen | Validation, Submit Loading, Success, Error | Input validation, Cancel action |
| Dashboard | Loading, Error, Content | Refresh, Section navigation |
| Settings | Content | Back navigation, Section headers |
| Auth Screen | Loading, Error, Success | Forgot password link, Terms link |
Flow Completion Prediction
┌─────────────────────────────────────────────────────────────────┐
│ Flow Completion Rules │
├─────────────────────────────────────────────────────────────────┤
│ IF Spec has Login: │
│ → PREDICT: Register, Forgot Password, Logout │
│ │
│ IF Spec has Register: │
│ → PREDICT: Onboarding flow after registration │
│ → PREDICT: Email verification (if email-based) │
│ │
│ IF Spec has List: │
│ → PREDICT: Detail view for list items │
│ → PREDICT: Search/Filter functionality │
│ → PREDICT: Empty state when no items │
│ │
│ IF Spec has Settings: │
│ → PREDICT: Account info edit │
│ → PREDICT: Change password │
│ → PREDICT: Notification preferences │
│ → PREDICT: Logout confirmation │
│ │
│ IF Spec has any data display: │
│ → PREDICT: Offline cached view │
│ → PREDICT: Sync status indicator │
└─────────────────────────────────────────────────────────────────┘
Data Operation Prediction (CRUD)
| Spec Mentions | Auto-Predict Operations |
|---|
| "Display items" | Read + Loading + Error + Empty states |
| "Add item" | Create + Validation + Success feedback |
| "Edit item" | Update + Validation + Optimistic UI |
| "Delete item" | Delete + Confirmation dialog + Undo option |
| "Search" | Debounced input + No results state |
| "Filter" | Filter UI + Clear filter + Active filter indicator |
Navigation Completeness Prediction
export const routes: Routes = [
{ path: 'login', ... },
{ path: 'dashboard', ... },
{ path: 'items', ... },
{ path: 'settings', ... },
];
UI State Prediction Matrix
| Data Source | Success | Empty | Error | Loading |
|---|
| API Call | Content | Empty view + CTA | Error view + Retry | Spinner |
| IndexedDB | Content | Empty view + CTA | Error view + Retry | Spinner |
| User Input | Show result | Prompt input | Validation error | Submit loading |
Spec Gap Detection Commands
grep -L "isLoading\|loading" src/app/presentation/**/*.viewmodel.ts
grep -L "error\|Error" src/app/presentation/**/*.viewmodel.ts
grep -l "ngFor\|*ngFor" src/app/presentation/**/*.html | \
xargs grep -L "empty\|Empty\|length === 0"
grep -l "formControl\|ngModel" src/app/presentation/**/*.html | \
xargs grep -L "invalid\|error\|valid"
echo "=== Auth Flow Check ===" && \
grep -q "login" src/app/app.routes.ts && \
(grep -q "register" src/app/app.routes.ts || echo "⚠️ Missing: Register route") && \
(grep -q "forgot-password" src/app/app.routes.ts || echo "⚠️ Missing: Forgot Password route")
echo "=== CRUD Completeness ===" && \
grep -rh "get.*\|fetch.*\|load.*" src/app/domain/repositories/*.ts | head -5 && \
grep -rh "create.*\|add.*\|save.*" src/app/domain/repositories/*.ts | head -5 && \
grep -rh "update.*\|edit.*" src/app/domain/repositories/*.ts | head -5 && \
grep -rh "delete.*\|remove.*" src/app/domain/repositories/*.ts | head -5
Prediction Implementation Example
When implementing a List screen from Spec:
@Component({
selector: 'app-item-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- 1. LOADING - Always needed for API/DB calls -->
@if (vm.output().isLoading) {
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
}
<!-- 2. ERROR - Always needed for API/DB calls -->
@else if (vm.output().error) {
<div class="alert alert-danger">
{{ vm.output().error }}
<button class="btn btn-primary" (click)="onInput({ type: 'RETRY' })">
Retry
</button>
</div>
}
<!-- 3. EMPTY - Always needed for list screens -->
@else if (vm.output().items.length === 0) {
<div class="empty-state text-center">
<h3>No Items</h3>
<p>Add your first item to get started</p>
<button class="btn btn-primary" (click)="onInput({ type: 'ADD_CLICKED' })">
Add Item
</button>
</div>
}
<!-- 4. CONTENT - The actual list -->
@else {
<ul class="list-group">
@for (item of vm.output().items; track item.id) {
<li class="list-group-item" (click)="onInput({ type: 'ITEM_CLICKED', id: item.id })">
{{ item.name }}
</li>
}
</ul>
}
`,
providers: [ItemListViewModel],
})
export class ItemListComponent {
protected readonly vm = inject(ItemListViewModel);
protected onInput(input: ItemListInput): void {
this.vm.onInput(input);
}
}
Tech Stack Reference
| Technology | Recommended Version |
|---|
| Angular | 20.3+ |
| TypeScript | 5.7+ |
| RxJS | 7.8+ |
| Bootstrap | 5.0+ |
| ng-bootstrap | 19.0+ |
| Dexie | 4.0+ |
| @ngx-translate | Latest |