Skip to content

Commit f7bc05b

Browse files
author
Ahmed Zeno
committed
update solution doc: honest intro, architecture clarification, CI and SSR rationale
1 parent 58b22ad commit f7bc05b

1 file changed

Lines changed: 66 additions & 35 deletions

File tree

SOLUTION.md

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66

77
---
88

9+
## A Note on My Angular Experience
10+
11+
I want to be upfront: I don't have extensive production experience with Angular. My background is primarily in React — building applications with Next.js or Vite — and that is where I feel most comfortable today.
12+
13+
When I saw that this challenge required Angular, I didn't treat that as a blocker. I treated it as an opportunity. I have been keen to sharpen my Angular knowledge and work more with it, so I decided the best way to learn was by doing. I spent time reading the Angular documentation, studied the latest patterns (standalone components, signals, the new application builder), and used AI as a pair-engineering tool to help answer questions and validate my thinking as I went.
14+
15+
My goal was not to fake Angular expertise I don't have, but to take the best practices I already know from React — unidirectional data flow, co-located state, separation of concerns, performance at scale — and apply them deliberately in Angular's idiom. I believe the result reflects that approach honestly.
16+
17+
---
18+
919
## 1. Challenge Interpretation
1020

1121
The brief asked for an Angular application that fetches users from the Random User API and presents them in a useful way. Rather than building the minimum viable list, I treated it as a product problem: *"What would a real team ship if they needed engineers to explore, filter, and compare a large user dataset daily?"*
@@ -20,29 +30,40 @@ That interpretation drove three priorities:
2030

2131
## 2. Architecture Overview
2232

33+
The architecture is derived from three things together: the **folder structure** (which reflects the feature boundaries), the **system data flow** (how data travels from the API to the screen), and the **UI structure** (which components exist and how they nest). All three informed each other.
34+
35+
The data flow from API to UI:
36+
2337
```
24-
Random User API
38+
Random User API (randomuser.me)
2539
2640
27-
UsersService (HTTP + Zod + shareReplay)
41+
UsersService HTTP fetch + Zod validation + shareReplay(1) cache
2842
29-
├──► allUsers → LocationFilterComponent (dropdown options)
43+
├──► allUsers (page 1 only)
44+
│ └──► LocationFilterComponent
45+
│ (country / state / city <select> dropdowns,
46+
│ populated from the live dataset)
3047
31-
└──► users$ ──► combineLatest(filterState$)
48+
└──► users$ ──► combineLatest([users$, filterState$])
3249
3350
34-
GroupingService (Web Worker bridge)
51+
GroupingService — Web Worker bridge
52+
(filter + group + sort runs off main thread)
3553
3654
37-
groups signal → UserListComponent (CDK virtual scroll)
55+
groups signal ──► UserListComponent
56+
(CDK virtual scroll viewport,
57+
flat VirtualRow[] of headers + rows)
3858
3959
40-
UserItemComponent (single row)
60+
UserItemComponent (one row)
4161
42-
click ───► UserDetailComponent (right drawer)
62+
click ───► UserDetailComponent
63+
(right slide-in drawer)
4364
```
4465

45-
All state lives in `FilterService` as a single `signal<FilterState>`. Components read from it; nothing writes to it except `FilterService.update()`. This makes the data flow unidirectional and easy to trace.
66+
`FilterService` holds a single `signal<FilterState>` that sits outside this flow and feeds into `combineLatest` via `toObservable()`. Any component that calls `filterService.update()` triggers a new grouping pass through the worker — without re-fetching data from the API.
4667

4768
---
4869

@@ -54,29 +75,29 @@ All state lives in `FilterService` as a single `signal<FilterState>`. Components
5475

5576
**Argument:** Pagination forces the user to decide how to navigate before they know what they're looking for. Virtual scroll lets you scan continuously while the DOM stays at ~20 nodes — the same memory cost regardless of dataset size. Angular CDK's `cdk-virtual-scroll-viewport` handles this with a single flat `VirtualRow[]` array that interleaves group headers and user rows.
5677

57-
**Trade-off accepted:** Jump-to-letter loses meaning without visible group boundaries. Solved by the letter-jump input (`A–Z` sidebar → single character scrolls directly to that group).
78+
**Trade-off accepted:** Jump-to-letter loses meaning without visible group boundaries. Solved by the letter-jump input — a single character input that scrolls the virtual list directly to the matching group.
5879

5980
---
6081

6182
### 3.2 Web Worker for Grouping and Filtering
6283

6384
**Decision:** all filter/sort/group logic runs in a dedicated Web Worker (`grouping.worker.ts`), never on the main thread.
6485

65-
**Argument:** Filtering 5,000 objects with multiple predicates, grouping them, sorting groups and items, then flattening into a `VirtualRow[]` is O(n log n) work. On a mid-range device that takes 8–15 ms — enough to drop a frame and cause a visible jank on every keypress. Moving it off-thread means the UI stays at 60 fps while the worker computes.
86+
**Argument:** Filtering 5,000 objects with multiple predicates, grouping them, sorting groups and items, then flattening into a `VirtualRow[]` is O(n log n) work. On a mid-range device that takes 8–15 ms — enough to drop a frame and cause visible jank on every keypress. Moving it off-thread means the UI stays at 60 fps while the worker computes.
6687

6788
**Concurrency design:** each request gets a monotonic `__id`. The worker echoes it back; `GroupingService` resolves only the `Subject` matching that id via a `Map<id, Subject>`. Rapid filter changes cannot produce stale results because `switchMap` in the container cancels superseded requests before they resolve.
6889

69-
**Trade-off accepted:** Worker construction fails in Jest's jsdom environment. A synchronous `runGrouping()` fallback in `GroupingService` activates automatically when `new Worker()` throws, keeping tests simple and fast without mocking the worker protocol.
90+
**Trade-off accepted:** Worker construction fails in Jest's jsdom environment. A synchronous `runGrouping()` fallback in `GroupingService` activates automatically when `new Worker()` throws, keeping tests simple without mocking the worker protocol.
7091

7192
---
7293

7394
### 3.3 Signal-First State — no NgRx, no BehaviorSubject
7495

75-
**Decision:** `FilterService` exposes a single `signal<FilterState>`. Components call `filterService.update({ key: value })` to patch state.
96+
**Decision:** `FilterService` exposes a single `signal<FilterState>`. Components call `filterService.update({ key: value })` to patch state. No `BehaviorSubject`, no `NgRx` store is used anywhere in the application.
7697

77-
**Argument:** NgRx would be the right call at 10+ developers or when state needs cross-module serialisation. For a single-page tool with one data domain, the overhead of actions, reducers, selectors, and effects is ceremony without benefit. Angular Signals introduced in v16 give the same guarantees (immutable snapshots, computed derivations, change propagation only to interested consumers) with zero boilerplate.
98+
**Argument:** NgRx would be the right call at 10+ developers or when state needs cross-module serialisation. For a single-page tool with one data domain, the overhead of actions, reducers, selectors, and effects is ceremony without benefit. Angular Signals (introduced in v16, stable in v17+) give the same guarantees immutable snapshots, computed derivations, change propagation only to interested consumers with zero boilerplate.
7899

79-
`computed()` replaces selectors. `toObservable()` bridges signals to RxJS where async operators like `switchMap` and `combineLatest` are needed. The result is ~300 fewer lines of code than an equivalent NgRx implementation.
100+
`computed()` replaces selectors. `toObservable()` bridges signals to RxJS where async operators like `switchMap` and `combineLatest` are genuinely needed. The result is roughly 300 fewer lines of code than an equivalent NgRx implementation with no loss of correctness or predictability.
80101

81102
---
82103

@@ -94,16 +115,14 @@ All state lives in `FilterService` as a single `signal<FilterState>`. Components
94115

95116
**Decision:** the gender donut, age histogram, and nationality bar chart are not read-only visualisations — clicking any segment applies it as a filter.
96117

97-
**Argument:** in a data-exploration tool, the gap between "I see a spike in 30–35 year olds" and "let me see who those people are" should be zero clicks. Making charts interactive removes that gap. The implementation is simple: each chart element calls `filterService.update()` with the corresponding filter value. No special event bus or callback chain is needed.
118+
**Argument:** in a data-exploration tool, the gap between "I see a spike in 30–35 year olds" and "let me see who those people are" should be zero clicks. Making charts interactive removes that gap. Each chart element calls `filterService.update()` with the corresponding filter value — no special event bus or callback chain needed.
98119

99120
---
100121

101122
### 3.6 Natural Language Parsing (two layers)
102123

103124
**Decision:** implement NL parsing twice — once as a local regex token parser, and once as an LLM-backed agent mode.
104125

105-
**Argument:**
106-
107126
| Layer | Technology | When to use |
108127
|---|---|---|
109128
| Local NL parser | Regex token consumption in `filter.service.ts` | Zero-latency, works offline, handles common patterns |
@@ -119,7 +138,7 @@ The local parser handles ~80% of realistic queries (`"female under 30 from Germa
119138

120139
**Decision:** allow users to name and persist any combination of filter state to `localStorage`, then restore or delete presets.
121140

122-
**Argument:** power users of exploration tools develop "favourite views" — `"German females under 40"`, `"All users, grouped by age, sorted by name"`. Without presets, they rebuild these filters from scratch every session. `localStorage` is the right storage here: no server required, survives page reload, scoped to the origin.
141+
**Argument:** power users of exploration tools develop "favourite views" — `"German females under 40"`, `"All users, grouped by age, sorted by name"`. Without presets they rebuild these from scratch every session. `localStorage` is the right storage here: no server required, survives page reload, scoped to the origin.
123142

124143
---
125144

@@ -137,18 +156,18 @@ The local parser handles ~80% of realistic queries (`"female under 30 from Germa
137156

138157
**Argument:** with 5,000 virtual rows, Angular's default change detection would dirty-check every component subtree on every event. OnPush limits checks to components whose inputs have changed references, reducing the cycle cost by an order of magnitude.
139158

140-
Lazy routing means the `UsersPageComponent` bundle (~203 kB) is only fetched when the user navigates to `/`. The 404 page (`~1 kB`) is a separate chunk that only loads if needed.
159+
Lazy routing means the `UsersPageComponent` bundle (~203 kB) is only fetched when the user navigates to `/`. The 404 page (~1 kB) is a separate chunk that only loads if needed.
141160

142161
---
143162

144163
## 4. Testing Strategy
145164

146165
### Unit tests (Jest) — logic, not DOM
147166

148-
The unit test suite targets pure logic and service contracts, not component rendering. Reasons:
167+
The unit test suite targets pure logic and service contracts, not component rendering:
149168

150169
- `grouping.logic.ts` is a pure function — trivial to test exhaustively without Angular
151-
- `parseNaturalLanguage()` has ~20 distinct input patterns; snapshot tests on the output string are more valuable than clicking through a UI
170+
- `parseNaturalLanguage()` has ~20 distinct input patterns; asserting on the output object is more valuable than clicking through a UI
152171
- Component rendering tests add Angular TestBed overhead without catching the classes of bugs that matter here (incorrect filter logic, schema mismatch, stale worker results)
153172

154173
### E2E tests (Playwright) — user journeys, mocked API
@@ -163,42 +182,54 @@ The fixture approach was chosen over a local mock server because Playwright's `p
163182

164183
| Decision | Rationale |
165184
|---|---|
166-
| **ESLint `--max-warnings=0`** | Zero-tolerance lint policy prevents gradual quality decay; the CI step fails if any warning is introduced |
185+
| **ESLint `--max-warnings=0`** | Zero-tolerance lint policy prevents gradual quality decay; CI fails if any warning is introduced |
167186
| **Prettier with `format:check` in CI** | Formatting disagreements in PRs waste review time; auto-format enforces one style |
168-
| **`legacy-peer-deps=true` in `.npmrc`** | `@typescript-eslint` peer dep ceiling lags behind Angular's TypeScript requirement; flag documented in `.npmrc` rather than hidden in CI flags |
169-
| **GitHub Actions CI** | Runs `lint → test:ci → build:prod` on every push and PR; catches regressions before merge |
187+
| **`legacy-peer-deps=true` in `.npmrc`** | `@typescript-eslint` peer dep ceiling lags behind Angular's TypeScript requirement; documented in `.npmrc` rather than hidden in CI flags |
188+
| **GitHub Actions CI** | Single-stage pipeline: `lint → test:ci → build:prod`. In a real production setup I would split this into multiple stages — lint and test in parallel, build only on success, then a separate deploy stage with environment approvals. For the scope of this challenge, a single clean stage that proves the full pipeline works end-to-end was the right call. |
170189
| **Vercel deployment** | SPA-native routing via `vercel.json` rewrites; preview URLs on every PR; zero-config for Angular output |
171190

172191
---
173192

174-
## 6. Accessibility Decisions
193+
## 6. On SSR — Why It Was Not Applicable Here
194+
195+
Angular SSR (server-side rendering) is often discussed as an SEO improvement. For this application it is neither necessary nor appropriate, for two reasons:
196+
197+
1. **The data source doesn't support it.** The Random User API returns randomised data on every request — there are no stable, crawlable URLs representing real people or persistent content. A search engine indexing `/users/abc123` would find different content every time it recrawled. SSR would add server infrastructure cost with zero SEO benefit.
198+
199+
2. **The use case is internal tooling, not public discovery.** A user directory built for a team to explore is not something you want indexed by Google. The value is in the interactive filtering experience, which requires JavaScript regardless.
200+
201+
If the data source were stable (a real user database with permanent profile URLs), SSR via `ng add @angular/ssr` would be a straightforward addition — the architecture is ready for it. But adding it here would be engineering theatre, not engineering judgement.
202+
203+
---
204+
205+
## 7. Accessibility Decisions
175206

176-
WCAG 2.1 AA was treated as a hard requirement, not an afterthought. Specific decisions:
207+
WCAG 2.1 AA was treated as a hard requirement. Specific non-obvious decisions:
177208

178-
- **`outline-offset: -2px` globally** — Chrome clips `outline` ink overflow inside `overflow: hidden` containers. A negative offset renders the ring inside the element's border box so it is never clipped by any ancestor.
209+
- **`outline-offset: -2px` globally** — Chrome clips `outline` ink overflow inside `overflow: hidden` containers. A negative offset renders the ring inside the element's border box so it is never clipped.
179210
- **HTML `inert` attribute on closed drawer** — removes all descendants from the tab order without DOM removal. Chosen over `display: none` (abrupt) and `tabindex="-1"` on every child (fragile).
180211
- **`setTimeout(0)` for focus after drawer open** — Angular signal updates are synchronous but the DOM attribute change needs a microtask to settle before `querySelector` finds focusable elements.
181212
- **`prefers-reduced-motion` wrapping all animations** — vestibular disorders affect ~35% of adults over 40. Every `@keyframes` block is inside `@media (prefers-reduced-motion: no-preference)`.
182213
- **Nat badge contrast fix**`$color-blue-900` (`#006dfa`) on a near-transparent background failed AA in dark mode. Replaced with `var(--accent-male)` border + text, which resolves to `#60a5fa` in dark mode (7.55:1 contrast ratio).
183214

184215
---
185216

186-
## 7. What Was Deliberately Not Built
217+
## 8. What Was Deliberately Not Built
187218

188219
| Feature | Reason |
189220
|---|---|
190-
| **Angular SSR** | Would double scope and obscure Angular proficiency decisions; architecture is SSR-ready |
221+
| **Angular SSR** | Not applicable for this data source and use case — see section 6 |
191222
| **NgRx** | Overhead without benefit for a single-domain tool; Signals cover the same guarantees |
192-
| **Real-time updates / WebSockets** | No API support; randomuser.me is a static fixture API |
223+
| **Real-time updates / WebSockets** | randomuser.me is a static fixture API with no push support |
193224
| **User editing / CRUD** | Out of scope for a read-only directory |
194225
| **i18n** | Challenge is English-only; architecture supports `$localize` layering |
195226

196227
---
197228

198-
## 8. If I Had More Time
229+
## 9. If I Had More Time
199230

200-
1. **URL-serialised filter state**encode `FilterState` as query params so any filtered view is bookmarkable and shareable
201-
2. **Angular SSR**unlock real SEO and sub-100 ms first paint
202-
3. **IndexedDB caching** — store the 5k users locally for instant repeat visits
231+
1. **Guided feature tour**an interactive step-by-step walkthrough that introduces new users to the filters, grouping, analytics, compare mode, and agent mode one feature at a time, with tooltips and highlights. Tools like Shepherd.js or a lightweight custom implementation would work well here. The goal: a user who opens the app for the first time should be able to discover what it can do without reading any documentation.
232+
2. **URL-serialised filter state**encode `FilterState` as query params so any filtered view is bookmarkable and shareable
233+
3. **IndexedDB caching** — store the 5k users locally for instant repeat visits with a stale-while-revalidate pattern
203234
4. **Map view** — plot nationality pins on a world map; the location data is already on the model
204235
5. **Export to CSV/JSON** — one-click download of the current filtered result set

0 commit comments

Comments
 (0)