You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: SOLUTION.md
+66-35Lines changed: 66 additions & 35 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,6 +6,16 @@
6
6
7
7
---
8
8
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
+
9
19
## 1. Challenge Interpretation
10
20
11
21
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:
20
30
21
31
## 2. Architecture Overview
22
32
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.
groups signal → UserListComponent (CDK virtual scroll)
55
+
groups signal ──► UserListComponent
56
+
(CDK virtual scroll viewport,
57
+
flat VirtualRow[] of headers + rows)
38
58
│
39
59
▼
40
-
UserItemComponent (single row)
60
+
UserItemComponent (one row)
41
61
│
42
-
click ───► UserDetailComponent (right drawer)
62
+
click ───► UserDetailComponent
63
+
(right slide-in drawer)
43
64
```
44
65
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.
46
67
47
68
---
48
69
@@ -54,29 +75,29 @@ All state lives in `FilterService` as a single `signal<FilterState>`. Components
54
75
55
76
**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.
56
77
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.
58
79
59
80
---
60
81
61
82
### 3.2 Web Worker for Grouping and Filtering
62
83
63
84
**Decision:** all filter/sort/group logic runs in a dedicated Web Worker (`grouping.worker.ts`), never on the main thread.
64
85
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.
66
87
67
88
**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.
68
89
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.
70
91
71
92
---
72
93
73
94
### 3.3 Signal-First State — no NgRx, no BehaviorSubject
74
95
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.
76
97
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 v16give 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.
78
99
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.
80
101
81
102
---
82
103
@@ -94,16 +115,14 @@ All state lives in `FilterService` as a single `signal<FilterState>`. Components
94
115
95
116
**Decision:** the gender donut, age histogram, and nationality bar chart are not read-only visualisations — clicking any segment applies it as a filter.
96
117
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.
98
119
99
120
---
100
121
101
122
### 3.6 Natural Language Parsing (two layers)
102
123
103
124
**Decision:** implement NL parsing twice — once as a local regex token parser, and once as an LLM-backed agent mode.
104
125
105
-
**Argument:**
106
-
107
126
| Layer | Technology | When to use |
108
127
|---|---|---|
109
128
| 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
119
138
120
139
**Decision:** allow users to name and persist any combination of filter state to `localStorage`, then restore or delete presets.
121
140
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.
123
142
124
143
---
125
144
@@ -137,18 +156,18 @@ The local parser handles ~80% of realistic queries (`"female under 30 from Germa
137
156
138
157
**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.
139
158
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.
141
160
142
161
---
143
162
144
163
## 4. Testing Strategy
145
164
146
165
### Unit tests (Jest) — logic, not DOM
147
166
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:
149
168
150
169
-`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
152
171
- Component rendering tests add Angular TestBed overhead without catching the classes of bugs that matter here (incorrect filter logic, schema mismatch, stale worker results)
153
172
154
173
### 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
163
182
164
183
| Decision | Rationale |
165
184
|---|---|
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 |
167
186
|**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.|
170
189
|**Vercel deployment**| SPA-native routing via `vercel.json` rewrites; preview URLs on every PR; zero-config for Angular output |
171
190
172
191
---
173
192
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
175
206
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:
177
208
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.
179
210
-**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).
180
211
-**`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.
181
212
-**`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)`.
182
213
-**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).
183
214
184
215
---
185
216
186
-
## 7. What Was Deliberately Not Built
217
+
## 8. What Was Deliberately Not Built
187
218
188
219
| Feature | Reason |
189
220
|---|---|
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|
191
222
|**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|
193
224
|**User editing / CRUD**| Out of scope for a read-only directory |
194
225
|**i18n**| Challenge is English-only; architecture supports `$localize` layering |
195
226
196
227
---
197
228
198
-
## 8. If I Had More Time
229
+
## 9. If I Had More Time
199
230
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
203
234
4.**Map view** — plot nationality pins on a world map; the location data is already on the model
204
235
5.**Export to CSV/JSON** — one-click download of the current filtered result set
0 commit comments