Uhakiki Migration Guide: v2 → v3

A strategic plan for migrating the legacy Angular 18 / NgModule application to the newly scaffolded Angular 22 + Zard UI architecture — covering the decisions already made, the target patterns, and a prioritized feature-by-feature migration path.

Generated 2026-07-02 · Legacy: uhakiki-v2/ (Angular 18.2) · Target: uhakiki-v3/ (Angular 22.0.5)

1Executive Summary

The legacy Uhakiki application is an Angular 18 SSR app built on 13 NgModules and 105 components, mixing five UI toolkits (ng-zorro-antd, Angular Material, PrimeNG, Bootstrap 5 / ngx-bootstrap, plus jQuery and SweetAlert2). State is managed through mutable service fields and ~650 manual subscribe() calls; the JWT access token is AES-encrypted with a hardcoded key and persisted in localStorage.

The replacement, uhakiki-v3, has been scaffolded on Angular 22.0.5 with SSR + client hydration, Vitest, TypeScript 6 strict mode, and Zard UI (46 shadcn-style components vendored into src/app/shared/components, styled with Tailwind CSS v4). The scaffold implements the full architectural skeleton mandated by the project standards: 7 lazy-loaded features, a core/ layer with signal-based auth, functional interceptors and guards, and 28 empty page components mirroring the legacy route surface. The scaffold builds cleanly, passes its 40 specs, and SSR-renders with working guard redirects.

Where we are: architecture and tooling are settled and verified. What remains is porting behavior — screen by screen — from legacy modules into the empty v3 pages, replacing every legacy UI widget, subscription, and storage access with its v3 equivalent as defined in this guide.

The migration is organized into seven phases (Section 9), ordered so that shared infrastructure lands first, the highest-traffic public funnel (landing → auth → registration) lands early, and the largest module (individual, 12 pages) is split into independently shippable slices. Each page follows the same mechanical recipe (Section 10), which keeps reviews predictable.

2System Comparison

ConcernLegacy — uhakiki-v2Target — uhakiki-v3
FrameworkAngular 18.2, zone.js, NgModules (13)Angular 22.0.5, standalone-only, zoneless-ready, OnPush by default
LanguageTypeScript 5.5TypeScript 6, strict + strictTemplates
RenderingSSR via @angular/ssr 18 + ExpressSSR (RenderMode.Server) + provideClientHydration(withEventReplay())
UI librariesng-zorro-antd (90 templates), Angular Material (8), PrimeNG, Bootstrap 5, ngx-bootstrap, FontAwesome, jQuery, SweetAlert2 (46 files)Zard UI only — 46 vendored components + Tailwind CSS v4 tokens (oklch), lucide icons via @ng-icons
StylingBootstrap + Tailwind + component CSS mixedTailwind v4 + design tokens in styles.css; scoped component styles
StateMutable service fields, BehaviorSubjects, ~650 subscribe() callsSignals + computed; httpResource/rxResource in feature data services
FormsReactive Forms + Template-driven mixedSignal Forms (@angular/forms/signals) for all new forms
Auth tokenAES-encrypted (hardcoded key in source) in localStorageIn-memory signal in AuthService; refresh token to move to HttpOnly cookie
Interceptors / guardsClass-based auth.interceptor.ts, auth.guard.tsFunctional HttpInterceptorFn (auth + centralized 401) and CanActivateFn
RoutingEager + partially lazy NgModule routesEvery feature lazy (loadChildren → default-export routes, loadComponent per page)
TestingKarma + JasmineVitest (40 specs passing)
i18nngx-translate (+ http-loader)Not yet decided — see Section 11
Charts / PDFCanvasJS, chart.js, jsPDF, pdfmakeNot yet decided — see Section 11
Node / toolingNode 18 types, KarmaNode 24.18 (volta), npm 11, Vitest, project-local CLI via npx

3Architectural Decisions (Scaffolding Phase)

Every decision below is already implemented in the scaffold and traces to the project standards (CLAUDE.md). They are binding for all migrated code.

3.1 Feature-based structure, all lazy

The legacy modules/ directory was analyzed and mapped to 7 lazy features: landing, auth, home, individual (12 pages), employer (2), facility (4), sponsor (4). Each feature owns a default-exported *.routes.ts (loaded via loadChildren), a data/ layer (service + model), and pages/ with the 3-file component split. The build confirms each feature emits its own chunk. Features never import another feature's internals; anything shared moves to shared/ or core/.

3.2 Signals-first state, no store library

No NgRx/Redux. Feature data services own state as private writable signals exposed via asReadonly(); derived values are computed(); reads from the server are httpResource/rxResource; mutations are plain HttpClient calls followed by resource.reload(). AuthService already models this pattern.

3.3 Functional HTTP pipeline, no hardcoded URLs

provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])) is configured once in app.config.ts. authInterceptor attaches the Bearer header from the token signal; errorInterceptor centralizes 401 handling (clear session → redirect to /auth/login with returnUrl) so no component handles 401 itself. All service URLs derive from the API_BASE_URL injection token, backed by src/environments/ (apiBaseUrl: '/api' — mirror the legacy proxy.conf.json during development).

3.4 Component conventions

3.5 @Injectable({ providedIn: 'root' }), not @Service()

The Angular 22 CLI now generates the new @Service() decorator. Per the org standard, all scaffolded services were converted to @Injectable({ providedIn: 'root' }) for consistency — keep doing this for every migrated service until the org tooling formally adopts @Service.

3.6 SSR posture

app.routes.server.ts uses RenderMode.Server (the app is auth-gated and dynamic — prerendering is wrong for it). Hydration with event replay is on. Browser-only APIs must go through SSR-safe wrappers: StorageService (guards with isPlatformBrowser) already exists; follow the same pattern for geolocation, device detection, and anything touching window/document. The dark-mode class is applied by an inline script in index.html before first paint to avoid a flash.

4Tooling Decisions & Known Gotchas

These were discovered the hard way during scaffolding — respect them to avoid re-losing the time.

AreaDecision / Gotcha
Zard CLI zard-cli init hangs on interactive prompts in non-TTY shells. The init was done manually (components.json, theme in styles.css, utils, PostCSS config); zard-cli add <component> --yes works fine for adding/updating components later.
TypeScript 6 baseUrl is deprecated (TS5101). tsconfig.json uses ./-relative paths only: @/shared/* and src/* (the latter because vendored Zard components import via src/app/shared/… specifiers). Do not reintroduce baseUrl.
Zard local patches Two local fixes live in the vendored components: pagination.component.ts (removed an explicit zPageIndexChange output that duplicated the model() binding — NG1054 in Angular 22) and shared/utils (added noopFn, isElementContentTruncated, number.ts from Zard master). Re-running zard-cli add for these components will overwrite the patches — re-apply or upstream them.
Tailwind v4 Configured via .postcssrc.json + @tailwindcss/postcss; theme is CSS-first (@theme inline, oklch variables, @custom-variant dark) in styles.css. There is no tailwind.config.js — extend the theme in CSS.
Testing Vitest replaces Karma/Jasmine. Legacy specs are not portable as-is; write new specs alongside each migrated page (component render + data-service behavior).
Commits Per org standard: run the gs skill before committing and always sign (git commit -S). The v3 repo currently has only the initial ng new commit.

5Target Structure Reference

src/app/ ├── core/ # singletons, imported once │ ├── config/api.config.ts # API_BASE_URL InjectionToken → environment.apiBaseUrl │ ├── guards/auth-guard.ts # CanActivateFn, redirects with returnUrl │ ├── interceptors/ # authInterceptor (Bearer), errorInterceptor (central 401) │ └── services/ # AuthService (token signal), StorageService (SSR-safe), GeolocationService ├── features/ │ ├── landing/ # public │ ├── auth/ # public — pages/login │ ├── home/ # guarded │ ├── individual/ # guarded — 12 pages + data/individual.service|model │ ├── employer/ # guarded — 2 pages + data/ │ ├── facility/ # guarded — 4 pages + data/ │ └── sponsor/ # guarded — 4 pages + data/ │ └── (each feature) ── *.routes.ts (default export, loadComponent per page) │ ── data/*.service.ts + *.model.ts │ └─ pages/<page>/<page>.ts|.html|.css ├── shared/ │ ├── components/ # 46 vendored Zard UI components │ ├── core/ # provideZard(), Zard directives │ ├── layout/header, layout/footer │ ├── services/dark-mode.ts # ZardDarkMode │ ├── ui/spinner, ui/token-expire-timer │ └── utils/merge-classes.ts, number.ts ├── app.config.ts # router + http(withInterceptors) + hydration + provideZard ├── app.routes.ts # '' → /landing; guarded loadChildren per feature; '**' → landing └── app.routes.server.ts # RenderMode.Server

Legacy → v3 route mapping: modules/landing-page → features/landing, shared/login → features/auth/pages/login, modules/home → features/home, each modules/<domain>/<screen>features/<domain>/pages/<screen> (dashboards renamed: individual/individual → individual-dashboard, etc.).

6UI Component Migration Map

The legacy app uses ng-zorro in ~90 templates, Material in 8, SweetAlert2 in 46 files, plus Bootstrap/ngx-bootstrap markup and jQuery. All of it maps onto the vendored Zard set. None of the legacy UI packages are installed in v3 — a template does not compile until fully converted, which is the intended forcing function.

Legacy widgetZard replacement (@/shared/components/…)Notes
nz-table, mat-tabletable (+ pagination)Zard table is presentational — sorting/filtering move into computed signals in the data service.
nz-form, mat-form-fieldform + input, input-group, select, checkbox, radio, switchPair with Signal Forms; validation messages via the form field components.
nz-modal, MatDialogdialog / sheetUse sheet for side panels (legacy drawer usage).
Swal.fire(...) (SweetAlert2, 46 files)alert-dialog for confirmations; toast for success/error noticesLargest mechanical replacement. Wrap once in a small shared/ui notification service so pages never talk to Zard primitives directly for this.
ngx-toastrtoastSame wrapper as above.
nz-select with searchcombobox / command
nz-date-picker, mat-datepickerdate-picker / calendar
nz-tabs, mat-tabstabs / segmentedLegacy TabSelectionService becomes a signal in the owning feature service.
nz-spin, custom spinnerloader / skeleton / progress-barPrefer resource isLoading() + skeleton over full-screen spinners.
nz-steps (registration wizards)breadcrumb or composed segmented + custom step headerNo 1:1 steps component; build one small shared wizard-header on Zard primitives (put it in shared/ui).
nz-notification, nz-alertalert, toast
nz-dropdown, Bootstrap dropdownsdropdown / menu / popover
nz-avatar, nz-badge, nz-tagavatar, badge
nz-collapseaccordion
nz-treetree
nz-carousel (landing)carousel
nz-emptyempty
FontAwesome iconslucide via @ng-iconsAlready wired through Zard's NgIcon usage; do not add FontAwesome to v3.
jQuery DOM manipulation— delete —Replace with signals + template bindings; jQuery must not be installed in v3.
Bootstrap grid/utilitiesTailwind utilitiesRewrite layout with Tailwind flex/grid; no Bootstrap CSS in v3.
ngx-material-intl-tel-inputinput-group + google-libphonenumber validationKeep google-libphonenumber as a plain dependency; UI is a small shared component.

Rule of thumb: if the same composite (wizard header, confirm-dialog flow, phone input, file upload) appears in ≥ 2 features, build it once in shared/ui on top of Zard primitives. Never copy a composite between features.

7State Management & Data-Fetching Migration

The legacy codebase has ~650 subscribe() calls across 91 files, mostly subscribe-in-ngOnInit HTTP reads plus cross-component BehaviorSubject buses (global.services.ts, facilityGlobal.services.ts, transfer.service.ts, TabSelectionService). The v3 rules eliminate almost all of them:

Legacy patternv3 replacement
http.get(...).subscribe(d => this.data = d) in ngOnInithttpResource(() => url) in the feature data service; component reads service.x.value() / .isLoading() / .error(). Always set defaultValue.
Search box + debounce + switchMaprxResource with the existing operator chain as loader, params from a signal.
POST/PUT/DELETE then manual refetchService method using HttpClient (async), then resource.reload(). Mutations are never resources.
Dependent calls (load member → load dependents)chain(), or the second resource's params read the first resource's value signal.
BehaviorSubject "global" buses (transfer.service, TabSelectionService)Writable signal in the owning feature service, exposed asReadonly(); cross-feature state (only if genuinely needed) goes to a core service.
Derived fields recomputed in handlers or templatescomputed() — never hand-synced fields, never method calls in bindings.
State that resets when a selection changes (e.g. page index per tab)linkedSignal(() => source()).
Genuine event streams (token expiry countdown, websocket chat)RxJS retained, but every manual subscribe gets takeUntilDestroyed().
Reactive/template-driven formsSignal Forms (@angular/forms/signals); submit button disabled while the mutation is in flight.

Canonical feature data service shape

@Injectable({ providedIn: 'root' })
export class IndividualService {
  private readonly http = inject(HttpClient);
  private readonly apiBaseUrl = inject(API_BASE_URL);

  readonly selectedMemberId = signal<string | null>(null);

  readonly member = httpResource<Member | null>(
    () => this.selectedMemberId()
      ? `${this.apiBaseUrl}/members/${this.selectedMemberId()}`
      : undefined,
    { defaultValue: null },
  );

  readonly dependents = computed(() => this.member.value()?.dependents ?? []);

  async updateMember(changes: Partial<Member>): Promise<void> {
    await firstValueFrom(
      this.http.put(`${this.apiBaseUrl}/members/${this.selectedMemberId()}`, changes),
    );
    this.member.reload();
  }
}

8Authentication & Security Migration

Do not port the legacy storage scheme. Legacy storage.service.ts AES-encrypts values with a key hardcoded in the client bundle (keys = '*n%^+-$#@$^@1ERF') — this is obfuscation, not security, and it keeps the JWT in localStorage (XSS-readable). None of this moves to v3.

Legacyv3 (scaffolded / planned)
Access token AES-"encrypted" in localStorageDone In-memory signal in AuthService, exposed asReadonly(); isAuthenticated is computed.
Class-based auth.interceptor.tsDone Functional authInterceptor attaches Authorization: Bearer.
Per-component 401 handlingDone errorInterceptor centralizes 401 → clear session → /auth/login?returnUrl=….
auth.guard.tsDone authGuard: CanActivateFn on all non-public features.
Refresh handling / token.watcher.ts + getTokenExpiry.tsPhase 1 Refresh token in an HttpOnly cookie (Spring Boot backend change); errorInterceptor attempts silent refresh before redirecting; expiry countdown feeds shared/ui/token-expire-timer via a signal.
lockScreen.service.tsPhase 2+ Reimplement as an idle-timeout signal in core only if the product still requires it — confirm before porting.
Non-sensitive persisted prefs (theme, language)Done SSR-safe StorageService (plain, unencrypted — nothing sensitive goes in it).

Guard placement is already correct in app.routes.ts: landing and auth are public; home, individual, employer, facility, sponsor sit behind canActivate: [authGuard]. Never rely on hiding buttons as authorization.

9Prioritized Migration Plan

Ordering rationale: shared plumbing first (everything depends on it), then the public acquisition funnel (landing → login → new registration) because it has the most traffic and no auth dependencies, then authenticated dashboards by module size — smallest first (employer) to bake the recipe before the 12-page individual module. Each phase is independently shippable and ends with build + tests green and a signed commit.

Phase 0

Foundation — complete

done 2026-07-02

Angular 22 scaffold, Zard UI (46 components), Tailwind v4 theme, core layer (auth signal, functional interceptors, guard, API_BASE_URL), 7 lazy features with 28 empty pages, SSR verified, 40/40 specs passing.

Phase 1

Shared shell & auth flow

~1 week
  1. Port shared/header + shared/footer markup (Bootstrap/ng-zorro → Tailwind + Zard menu/dropdown/avatar), including auth-aware nav driven by authService.isAuthenticated().
  2. Build the shared notification service wrapping Zard toast + alert-dialog (replaces SweetAlert2/ngx-toastr everywhere downstream).
  3. Port the login page to Signal Forms; wire real login endpoint via AuthService; submit disabled while in flight.
  4. Implement refresh flow: silent refresh in errorInterceptor, HttpOnly refresh cookie (coordinate the Spring Boot change), port token-expire-timer as a signal-driven countdown.
  5. Port spinner usage policy: route-level loader, data-level skeleton.
  6. Add proxy.conf.json equivalent for dev (/api → backend) matching legacy.

Exit criteria: real login/logout round-trips against the backend; header reflects session state; token refresh survives a forced 401.

Phase 2

Public funnel — landing + individual new registration

~2 weeks
  1. Landing page: static content, carousel, CTA routing; use NgOptimizedImage for all imagery; @defer below-the-fold sections.
  2. individual-new-registration: the multi-step wizard. Build the shared wizard-header composite (Section 6) first; each step is a Signal Forms group; step state lives in IndividualService signals; phone input composite with google-libphonenumber.
  3. continue-registration: resume-by-reference flow reusing the same wizard.
  4. Port GeolocationService usage (SSR-safe, browser-only).

Exit criteria: an unauthenticated user can land, register, and continue a registration end-to-end on v3.

Phase 3

Employer module (smallest authenticated slice)

~1 week
  1. employer-registration (reuses the wizard composite from Phase 2).
  2. employer-dashboard: first real httpResource + Zard table + pagination screen — this is the template for all later dashboards.

Exit criteria: the per-page recipe (Section 10) is validated on a full authenticated CRUD screen; adjust the recipe here, not later.

Phase 4

Individual module — core member slice

~3 weeks
  1. individual-dashboard (member overview; chain() member → dependents).
  2. member-application, change-request (form-heavy; Signal Forms).
  3. payment-information, download-card (PDF decision from Section 11 needed here).
  4. member-notifications, my-reports.
Phase 5

Individual module — verification & chat

~2 weeks
  1. data-entry-verification, dependent-physical-verification (workflow screens; camera/geolocation touches need SSR-safe wrappers).
  2. chat: genuine event stream — RxJS/websocket with takeUntilDestroyed(); messages render from a signal fed by the stream.
Phase 6

Facility + sponsor modules

~2–3 weeks
  1. Facility: facility-new-registrationfacility-continue-registrationfacility-dashboardfacility-progress (charts decision from Section 11 needed for progress).
  2. Sponsor: sponsor-registrationcontinue-guarantorvalidate-identificationsponsor-dashboard.
  3. Fold facilityGlobal.services.ts state into FacilityService signals.
Phase 7

Home, hardening & cutover

~1 week
  1. home page (role-based entry hub).
  2. Accessibility pass: AXE clean, WCAG AA focus/contrast/ARIA on every page.
  3. Performance pass: run the CLAUDE.md pre-merge checklist repo-wide; set bundle budgets in angular.json; virtualize large tables.
  4. i18n implementation if Section 11 decision is "yes".
  5. Parallel-run v2/v3 behind the reverse proxy; route real traffic per feature; decommission v2.

10Per-Page Migration Recipe

Every legacy screen migrates through the same nine steps. Do not batch multiple pages into one PR.

  1. Read the legacy component (modules/<domain>/<screen>): list its API calls, route params, form fields, dialogs, and cross-component state.
  2. Model first: add/extend the interfaces in features/<domain>/data/<domain>.model.ts from the actual API payloads (no any).
  3. Data service: add resources (reads) and async mutation methods (writes) to the feature data service; derived state as computed. No HTTP in the component.
  4. Template: rewrite the HTML with Zard components + Tailwind using the Section 6 map; native control flow only; every @for tracked by a stable id; loading via isLoading() + skeleton; errors via the shared notification service.
  5. Forms: Signal Forms with schema validation mirroring the legacy validators; server remains source of truth; submit disabled in flight.
  6. Kill the leftovers: no subscribe() without takeUntilDestroyed() (and only for true event streams), no jQuery, no Swal, no Bootstrap classes, no console.log.
  7. Specs: Vitest spec for the page (renders, key states) and for any new service logic.
  8. Checklist: run the CLAUDE.md performance checklist + an AXE pass on the page.
  9. Commit: one page per signed commit (gs skill, git commit -S); the lazy-chunk build output should show the feature chunk grew as expected and the initial bundle did not.

11Risks, Gaps & Open Decisions

ItemImpactRecommendation
i18n — legacy uses ngx-translate Blocks Phase 7 cutover if the product is bilingual (likely Swahili/English) Decide early. Options: ngx-translate (fastest port), Angular built-in $localize (build-time, SSR-friendly), or Transloco. If translation files exist in legacy assets/i18n, ngx-translate is the pragmatic choice.
Charts — CanvasJS + chart.js in legacy (facility-progress, dashboards) Phase 6 Standardize on one library. CanvasJS is commercial — verify the license before reuse; chart.js v4 (tree-shakeable) behind @defer is the default recommendation.
PDF generation — jsPDF + pdfmake both present (download-card, my-reports) Phase 4 Pick one (pdfmake if layouts are declarative) and lazy-load it, or move generation server-side — decide when porting download-card.
Refresh-token backend change — HttpOnly cookie requires Spring Boot work Phase 1 dependency Coordinate now; until it ships, the v3 session dies on reload (in-memory token only), which is acceptable for dev but not cutover.
Encrypted-storage compatibility Cutover v3 deliberately drops the AES-localStorage scheme. Users will simply re-authenticate on first v3 visit; no data migration needed. Confirm nothing else (e.g. saved drafts) lives in legacy encrypted storage before decommissioning.
Zard vendored patches (pagination NG1054, utils) Ongoing zard-cli add overwrites local fixes — document per component (done in memory notes), consider upstreaming the pagination fix to Zard.
Legacy API contract drift — models scaffolded from legacy code, not live API Every phase Validate each model against real responses during step 2 of the recipe; prefer generating types from the Spring Boot OpenAPI spec if one exists.
lockScreen / device-detector features Unknown product requirement Confirm with product whether idle-lock and device detection are still required before spending effort porting them.

12Definition of Done (per page and for cutover)