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
| Concern | Legacy — uhakiki-v2 | Target — uhakiki-v3 |
|---|---|---|
| Framework | Angular 18.2, zone.js, NgModules (13) | Angular 22.0.5, standalone-only, zoneless-ready, OnPush by default |
| Language | TypeScript 5.5 | TypeScript 6, strict + strictTemplates |
| Rendering | SSR via @angular/ssr 18 + Express | SSR (RenderMode.Server) + provideClientHydration(withEventReplay()) |
| UI libraries | ng-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 |
| Styling | Bootstrap + Tailwind + component CSS mixed | Tailwind v4 + design tokens in styles.css; scoped component styles |
| State | Mutable service fields, BehaviorSubjects, ~650 subscribe() calls | Signals + computed; httpResource/rxResource in feature data services |
| Forms | Reactive Forms + Template-driven mixed | Signal Forms (@angular/forms/signals) for all new forms |
| Auth token | AES-encrypted (hardcoded key in source) in localStorage | In-memory signal in AuthService; refresh token to move to HttpOnly cookie |
| Interceptors / guards | Class-based auth.interceptor.ts, auth.guard.ts | Functional HttpInterceptorFn (auth + centralized 401) and CanActivateFn |
| Routing | Eager + partially lazy NgModule routes | Every feature lazy (loadChildren → default-export routes, loadComponent per page) |
| Testing | Karma + Jasmine | Vitest (40 specs passing) |
| i18n | ngx-translate (+ http-loader) | Not yet decided — see Section 11 |
| Charts / PDF | CanvasJS, chart.js, jsPDF, pdfmake | Not yet decided — see Section 11 |
| Node / tooling | Node 18 types, Karma | Node 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
- Standalone by default — never write
standalone: true; never writechangeDetection: OnPush(it is the v22 default). - Class names without the
Componentsuffix (IndividualDashboard, notIndividualDashboardComponent). inject()everywhere; no constructor logic; nongOnInitdata loading — resources andcomputedreplace it.- Signal-based
input()/output()/model(); native control flow (@if/@forwithtrack);hostobject instead of@HostBinding/@HostListener; class/style bindings instead ofngClass/ngStyle.
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.
| Area | Decision / 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
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 widget | Zard replacement (@/shared/components/…) | Notes |
|---|---|---|
nz-table, mat-table | table (+ pagination) | Zard table is presentational — sorting/filtering move into computed signals in the data service. |
nz-form, mat-form-field | form + input, input-group, select, checkbox, radio, switch | Pair with Signal Forms; validation messages via the form field components. |
nz-modal, MatDialog | dialog / sheet | Use sheet for side panels (legacy drawer usage). |
Swal.fire(...) (SweetAlert2, 46 files) | alert-dialog for confirmations; toast for success/error notices | Largest mechanical replacement. Wrap once in a small shared/ui notification service so pages never talk to Zard primitives directly for this. |
ngx-toastr | toast | Same wrapper as above. |
nz-select with search | combobox / command | |
nz-date-picker, mat-datepicker | date-picker / calendar | |
nz-tabs, mat-tabs | tabs / segmented | Legacy TabSelectionService becomes a signal in the owning feature service. |
nz-spin, custom spinner | loader / skeleton / progress-bar | Prefer resource isLoading() + skeleton over full-screen spinners. |
nz-steps (registration wizards) | breadcrumb or composed segmented + custom step header | No 1:1 steps component; build one small shared wizard-header on Zard primitives (put it in shared/ui). |
nz-notification, nz-alert | alert, toast | |
nz-dropdown, Bootstrap dropdowns | dropdown / menu / popover | |
nz-avatar, nz-badge, nz-tag | avatar, badge | |
nz-collapse | accordion | |
nz-tree | tree | |
nz-carousel (landing) | carousel | |
nz-empty | empty | |
| FontAwesome icons | lucide via @ng-icons | Already 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/utilities | Tailwind utilities | Rewrite layout with Tailwind flex/grid; no Bootstrap CSS in v3. |
ngx-material-intl-tel-input | input-group + google-libphonenumber validation | Keep 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 pattern | v3 replacement |
|---|---|
http.get(...).subscribe(d => this.data = d) in ngOnInit | httpResource(() => url) in the feature data service; component reads service.x.value() / .isLoading() / .error(). Always set defaultValue. |
| Search box + debounce + switchMap | rxResource with the existing operator chain as loader, params from a signal. |
| POST/PUT/DELETE then manual refetch | Service 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 templates | computed() — 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 forms | Signal 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.
| Legacy | v3 (scaffolded / planned) |
|---|---|
Access token AES-"encrypted" in localStorage | Done In-memory signal in AuthService, exposed asReadonly(); isAuthenticated is computed. |
Class-based auth.interceptor.ts | Done Functional authInterceptor attaches Authorization: Bearer. |
| Per-component 401 handling | Done errorInterceptor centralizes 401 → clear session → /auth/login?returnUrl=…. |
auth.guard.ts | Done authGuard: CanActivateFn on all non-public features. |
Refresh handling / token.watcher.ts + getTokenExpiry.ts | Phase 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.ts | Phase 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.
Foundation — complete
done 2026-07-02Angular 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.
Shared shell & auth flow
~1 week- Port
shared/header+shared/footermarkup (Bootstrap/ng-zorro → Tailwind + Zardmenu/dropdown/avatar), including auth-aware nav driven byauthService.isAuthenticated(). - Build the shared notification service wrapping Zard
toast+alert-dialog(replaces SweetAlert2/ngx-toastr everywhere downstream). - Port the login page to Signal Forms; wire real login endpoint via
AuthService; submit disabled while in flight. - Implement refresh flow: silent refresh in
errorInterceptor, HttpOnly refresh cookie (coordinate the Spring Boot change), porttoken-expire-timeras a signal-driven countdown. - Port
spinnerusage policy: route-levelloader, data-levelskeleton. - Add
proxy.conf.jsonequivalent 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.
Public funnel — landing + individual new registration
~2 weeks- Landing page: static content,
carousel, CTA routing; useNgOptimizedImagefor all imagery;@deferbelow-the-fold sections. 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 inIndividualServicesignals; phone input composite withgoogle-libphonenumber.continue-registration: resume-by-reference flow reusing the same wizard.- Port
GeolocationServiceusage (SSR-safe, browser-only).
Exit criteria: an unauthenticated user can land, register, and continue a registration end-to-end on v3.
Employer module (smallest authenticated slice)
~1 weekemployer-registration(reuses the wizard composite from Phase 2).employer-dashboard: first realhttpResource+ Zardtable+paginationscreen — 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.
Individual module — core member slice
~3 weeksindividual-dashboard(member overview;chain()member → dependents).member-application,change-request(form-heavy; Signal Forms).payment-information,download-card(PDF decision from Section 11 needed here).member-notifications,my-reports.
Individual module — verification & chat
~2 weeksdata-entry-verification,dependent-physical-verification(workflow screens; camera/geolocation touches need SSR-safe wrappers).chat: genuine event stream — RxJS/websocket withtakeUntilDestroyed(); messages render from a signal fed by the stream.
Facility + sponsor modules
~2–3 weeks- Facility:
facility-new-registration→facility-continue-registration→facility-dashboard→facility-progress(charts decision from Section 11 needed for progress). - Sponsor:
sponsor-registration→continue-guarantor→validate-identification→sponsor-dashboard. - Fold
facilityGlobal.services.tsstate intoFacilityServicesignals.
Home, hardening & cutover
~1 weekhomepage (role-based entry hub).- Accessibility pass: AXE clean, WCAG AA focus/contrast/ARIA on every page.
- Performance pass: run the CLAUDE.md pre-merge checklist repo-wide; set bundle budgets in
angular.json; virtualize large tables. - i18n implementation if Section 11 decision is "yes".
- 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.
- Read the legacy component (
modules/<domain>/<screen>): list its API calls, route params, form fields, dialogs, and cross-component state. - Model first: add/extend the interfaces in
features/<domain>/data/<domain>.model.tsfrom the actual API payloads (noany). - Data service: add resources (reads) and async mutation methods (writes) to the feature data service; derived state as
computed. No HTTP in the component. - Template: rewrite the HTML with Zard components + Tailwind using the Section 6 map; native control flow only; every
@fortracked by a stable id; loading viaisLoading()+skeleton; errors via the shared notification service. - Forms: Signal Forms with schema validation mirroring the legacy validators; server remains source of truth; submit disabled in flight.
- Kill the leftovers: no
subscribe()withouttakeUntilDestroyed()(and only for true event streams), no jQuery, noSwal, no Bootstrap classes, noconsole.log. - Specs: Vitest spec for the page (renders, key states) and for any new service logic.
- Checklist: run the CLAUDE.md performance checklist + an AXE pass on the page.
- Commit: one page per signed commit (
gsskill,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
| Item | Impact | Recommendation |
|---|---|---|
| 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)
- Feature parity with the legacy screen verified against the same backend.
- Zero legacy UI dependencies (ng-zorro, Material, PrimeNG, Bootstrap, jQuery, SweetAlert2, ngx-toastr) in
package.json. - All reads via
httpResource/rxResource; all derived statecomputed; no un-torn-down subscriptions. - Signal Forms on every new form; classic Reactive Forms nowhere.
- Every feature in its own lazy chunk; bundle budgets green; heavy content behind
@defer. - AXE clean, WCAG AA met; SSR renders without browser-API crashes.
- Vitest suite green in CI; each page has at least a render + primary-flow spec.
- Auth: in-memory access token, HttpOnly refresh cookie, centralized 401, guards on every private route.
- v2 traffic fully drained and repository archived.