Document 05 · Detailed implementation analysis
Milestone survey, mapping, and content distribution
How milestone questions are chosen, stored, mapped into five Portrait domains, and reused by profile, play, and update flows.
Evidence: the reachable milestone engine, onboarding flow, profile screens, repositories, and src/data/consolidatedMilestones.json. Counts use the logic age band that the app actually selects against.
1. Key findings
810milestone rungs in the bundled corpus
74skill labels; 66 after collapsing staged variants
4 → 5survey areas mapped to Portrait domains
3–43questions shown at ages 0–58, depending on month
25fixed cap from 59 through 73 months
0questions after 73 months
- The onboarding survey is age-filtered but not adaptive. Answers do not add, remove, or change later questions.
- The current screen uses every active corpus rung. A separate 8-Physical/5-other helper exists, but the rendered section path bypasses those limits.
- The questionnaire does not deduplicate staged rungs by skill. At one month, for example, it shows 34 distinct questions drawn from only 9 skill labels.
- No answer is required. “Blank = not sure” means leaving a question untouched; the onboarding buttons do not clear a previous answer.
- The four survey areas become five Portrait spokes because Physical is split by
motorType into Gross Motor and Fine Motor.
- Milestone answers affect the Portrait star and bundled activity ordering. The Portrait “Next skills” list itself comes from the backend read model.
- Milestone writes are inconsistent across surfaces: the Portrait domain sheet persists fully; “Mark achieved” skips the local journal; Home Updates only changes a draft and can complete its queue item without writing the milestone API.
2. Milestone content model
| Field | Current use |
id | Stable corpus code and answer-map key; sent as milestone_id during onboarding and used by by-code API writes. |
skill, title, text | Grouping, display, sorting, matching, and local fallback activity text. |
questionText | Converted into a caregiver question such as “Can they…?” or “Do they…?”. |
whyWeAsk, whatCounts, example(s) | Milestone information modal. |
area | One of Physical, Cognitive, Linguistic, Social Emotional; defines onboarding section and base domain mapping. |
motorType | Physical-only split into gross_motor or fine_motor. |
usuallyAchievedAt | Original parseable age window. |
ageBandEstimate.estimatedAgeBand | Overrides the original window for selection, context, ordering, star calculations, and activity ranking. |
ageBandEstimate.confidence | Controls whether the estimated or original range is shown publicly. Threshold is 80. |
bandReliability, excludeFromLevelling | Used in corpus summary counts. They do not remove a row from the current questionnaire, star, domain sheets, or future-milestone pools. |
contentStatus, contentReview | Quality metadata; neither currently filters runtime content. |
Age-label split: 284 rows have estimated logic bands with confidence 65–75. All are below the display threshold of 80. The app therefore selects those rows using the estimated band but its public-age helper returns the original band.
3. How the onboarding questionnaire is selected
flowchart TD
DOB[Birth date] --> AGE[Integer age in months]
CORPUS[810 bundled milestone rungs] --> LOGIC[For each row: estimated band if present, otherwise usuallyAchievedAt]
AGE --> FILTER[Keep rows where minMonths ≤ age ≤ maxMonths]
LOGIC --> FILTER
FILTER --> FIVE{Selected rows touch the 59–70 or 71–73 reference bands and exceed 25?}
FIVE -- No --> POOL[Keep the full current-age pool]
FIVE -- Yes --> BALANCE[Round-robin across the four areas to 25]
POOL --> AREA[Split into four area screens]
BALANCE --> AREA
AREA --> QUESTION[Convert questionText to caregiver prompt]
QUESTION --> UI[Yes / Not yet / untouched blank]
Exact selection rules
- Age is calculated as an integer month count from the profile birth date.
- Every parseable corpus row is converted to an assessment row.
- The logic band is
ageBandEstimate.estimatedAgeBand ?? usuallyAchievedAt.
- A row is selected only when the child's month is inside that band, inclusive. The normal pool does not add earlier or later “nearby” rows.
- Only the 59–73 month reference ranges have a question cap. When the pool exceeds 25, rows are taken round-robin in area order: Physical, Cognitive, Linguistic, Social Emotional.
- The selected pool is split into four screens. Within the pool, sorting favors current-band midpoint proximity, area order, age, skill, then text.
- All four screens can be advanced with zero answers.
Copy versus behavior: onboarding says “a few milestone questions” and explains that nearby rows may appear. The implemented pool contains only exact-current rows, and the observed count is commonly 20–40 questions.
4. Answer lifecycle
flowchart TD
subgraph Onboarding
OUI[Yes / Not yet / blank] --> STATE[React answers map keyed by corpus id]
STATE --> MAP[yes→yes; not_yet→not_yet; not_sure→dont_know; unanswered omitted]
MAP --> REG[Register request with onboarding payload or POST /v1/onboarding]
end
subgraph Server_hydration[Sign-in and child switch]
GET[GET child milestones] --> STATUS[achieved→yes; not_started/regressed→not_yet; other statuses ignored]
STATUS --> MATCH[Map server UUID/code/text to corpus id where possible]
MATCH --> STATE
STATUS --> REMOTE[Remote checks with server domain and age bounds]
end
subgraph Profile_edit[Portrait domain sheet]
DRAFT[Draft answer] --> SAVE[Save]
SAVE --> STATE
SAVE --> JOURNAL[Append local timestamped journal]
SAVE --> WRITE{Blank?}
WRITE -- Yes --> DELETE[DELETE by corpus code]
WRITE -- No --> PUT[PUT achieved or not_started by corpus code]
PUT --> REFRESH[Refresh Portrait and milestone hydration]
DELETE --> REFRESH
end
Answer/status mapping
| UI/local value | Onboarding payload | Direct observation write | Hydrated back from API |
yes | yes | status: achieved | achieved → yes |
not_yet | not_yet | status: not_started | not_started → not_yet |
not_sure | dont_know | Domain-sheet Save treats it as blank and deletes the by-code observation. | No hydrated answer. |
unanswered or missing | Omitted | Domain-sheet Save deletes the by-code observation. | No hydrated answer. |
| — | — | API also supports emerging, in_progress, skipped, and not_applicable. | Those statuses are ignored by the client answer mapper. regressed hydrates as not_yet. |
Persistence by entry point
| Entry point | React answer | Local journal | Milestone API | Other effect |
| Initial onboarding | Immediate | The call has no child id yet, so the journal service ignores it. | Sent later inside registration/onboarding payload. | Bundled activities are reordered immediately. |
| Authenticated child setup | Immediate | Appends when a current/selected child id exists. | Sent in the final onboarding payload. | Enters Portrait and refreshes account state. |
| Portrait domain sheet | Merged on Save | Appends every changed value, including blank. | PUT achieved/not_started, or DELETE for blank. | Refreshes Portrait read model and star hydration asynchronously. |
| Portrait “Already does this” | Sets yes | No journal call. | PUT by UUID or by corpus code as achieved. | Refreshes Portrait read model. |
| Home milestone update | Writes shared draft only | No journal call. | No observation call from this screen. | May mark the care-queue item complete once its draft questions are filled. |
Current persistence gap: a milestone answered inside Home Updates can disappear from the visible queue because the shared draft is no longer blank, and the queue action may be completed, while the milestone itself has not been copied into answers, journaled, or sent to the observation API. It persists only if that draft is later saved through the Portrait domain sheet.
5. Corpus, server, and domain mapping
flowchart LR
C[Corpus milestone id/code] --> A[answers corpus-key entry]
S[Server milestone UUID] --> B[answers UUID entry]
S --> M{code matches corpus id?}
M -- Yes --> A
M -- No --> T{normalized caregiver prompt or title exactly matches corpus text/title?}
T -- Yes --> A
T -- No --> R[Keep only UUID answer and remote star check]
AREA[Server area_code + motor_type] --> DOMAIN[Five-domain mapping]
DOMAIN --> STAR[Portrait star]
| Survey area / API area | Condition | Portrait domain |
Physical / physical | motorType or motor_type is fine | fine_motor |
Physical / physical | Anything else | gross_motor |
Cognitive / cognitive | — | cognitive |
Linguistic / linguistic or language | — | language |
Social Emotional / social_emotional | — | social_emotional |
On hydration, the app first stores the server UUID answer. It then tries to find the bundled row by server code, followed by exact normalized prompt/title matching. A failed corpus match still contributes to the star through a remote check if the server supplied domain and age bounds, but bundled checklists will not share that answer key.
6. Where milestone data is used
| Consumer | Implemented rule |
| Portrait star | Uses current, previous-three-month, and future milestones by five domains. Formula is detailed below. |
| Portrait domain sheet | Shows up to 8 “right now” rows per domain, prioritizing not_yet, then unanswered current/nearby rows. It also exposes previous and all future rows. |
| Portrait Next skills | Uses backend portrait.skill_pot. The client removes items already marked yes. |
| Milestone-based Play | Looks for a Home suggestion whose target_milestone_id equals the selected skill-pot milestone. If none matches, it builds a local generic activity around that milestone. |
| Bundled activity ordering | Reorders activities within the already age-safe bundle by domain need. It does not add older/younger activities. |
| Home Updates | Care-queue milestone ids are resolved against active, coming-soon, recently-passed, and future corpus rows. Answered rows are hidden from check-in items. |
| Child switching | Clears prior answers, then hydrates the newly selected child's observations. This prevents one child's star from leaking into another's. |
| Analytics | Completion sends only assessment.completed with area_count: 4; individual answer values are not included in that event. |
| Trial offer | Not calculated from a score. It depends on the backend's profile_ready_trial decision returned after onboarding. |
Portrait star formula
currentFill = currentYes / currentTotal
behind = previousNotYet / previousTotal
progress = clamp(currentFill - 0.25 × behind, 0, 1)
beyondProgress = min(1, futureYes / 5)
visual current spoke = 0.25 + 0.75 × progress
- Current: the child's month is inside the milestone's logic band.
- Previous: the band ended within the prior three months.
- Future: every later band can contribute once marked yes; five yes answers fill the outer “beyond” effect.
- A domain with no current rows gets
currentFill = 1, although maxed remains false because its denominator is zero.
Bundled activity ranking formula
answer weight: yes = 0; not_yet = 1; blank/not_sure = 0.6
domainNeed = 2 × mean(current-band answer weights)
+ 1 × mean(next-three-month answer weights)
Activities are sorted by their domain's need score; ties preserve source order. The average is used so a domain with more milestone rows does not win merely because its corpus is denser.
7. Overall content distribution
Four survey areas
Social Emotional175 · 21.6%
Five Portrait domains
| Domain | Rows | Share |
| Gross Motor | 156 | 19.3% |
| Fine Motor | 81 | 10.0% |
| Cognitive | 254 | 31.4% |
| Language | 144 | 17.8% |
| Social Emotional | 175 | 21.6% |
Rows by logic-band starting window
Each corpus row is counted once, according to the start month of the logic band used by the app.
| Band starts | Physical | Cognitive | Linguistic | Social Emotional | Total |
| 0–11 months | 90 | 24 | 21 | 23 | 158 |
| 12–23 months | 42 | 27 | 23 | 15 | 107 |
| 24–35 months | 40 | 42 | 24 | 17 | 123 |
| 36–47 months | 10 | 15 | 11 | 13 | 49 |
| 48–58 months | 6 | 8 | 4 | 7 | 25 |
| 59–73 months | 49 | 138 | 61 | 100 | 348 |
Content is concentrated at both ends: 19.5% of all rows start in the first year, while 43.0% start at 59–73 months. The middle 36–58 month range contains only 74 rows (9.1%).
Reliability/levelling flags by area
| Area | Ordered and included | Reference / excluded | Total |
| Physical | 180 | 57 | 237 |
| Cognitive | 105 | 149 | 254 |
| Linguistic | 80 | 64 | 144 |
| Social Emotional | 66 | 109 | 175 |
| Total | 431 · 53.2% | 379 · 46.8% | 810 |
Runtime implication: the current survey and star calculations do not check these reliability/exclusion fields. Reference/excluded rows stay eligible anywhere their age band matches.
8. Questionnaire distribution by child age
These counts reproduce the implemented pool, including overlapping age bands and the 25-question five-plus cap.
| Child age | Average questions/month | Monthly range | Coverage gaps |
| 0–11 months | 35.8 | 22–40 | No Linguistic question at month 0. |
| 12–23 months | 27.8 | 14–40 | All four areas present. |
| 24–35 months | 28.0 | 16–43 | All four areas present. |
| 36–47 months | 16.8 | 6–42 | All four areas present, but volume drops sharply after month 38. |
| 48–58 months | 8.6 | 3–14 | No Physical at month 55; no Linguistic at months 56–58. |
| 59–73 months | 25.0 | 25–25 | Artificially balanced by the special cap. |
| 74–95 months | 0 | 0 | Profile date picker permits these ages, but no questionnaire rows match. |
Monthly questionnaire counts, 0–73 months
P = Physical, C = Cognitive, L = Linguistic, S = Social Emotional.
Repeated skill ladders inside the survey
| Child age | Avg. questions | Avg. unique area+skill labels | Avg. extra staged rungs |
| 0–11 | 35.8 | 13.0 | 22.8 |
| 12–23 | 27.8 | 16.3 | 11.6 |
| 24–35 | 28.0 | 16.7 | 11.3 |
| 36–47 | 16.8 | 12.1 | 4.7 |
| 48–58 | 8.6 | 8.0 | 0.6 |
| 59–73 | 25.0 | 15.8 | 9.2 |
These are not duplicate question strings; they are distinct stage questions under the same skill label. The section-building path does not run the engine's skill-level deduplication helper.
9. Content-quality metadata in the active corpus
- All 810 rows have top-level
contentStatus: generated_needs_vetting.
- All seven per-field review flags—title, age band, question text, why we ask, what counts, example, and examples—are marked
vetted on all 810 rows.
- 284 rows have estimated logic bands. Confidence ranges from 65 to 75, average 66.2; none reaches the public-display threshold of 80.
- The corpus itself warns that the 59–70 and 71–73 month ranges contain most known defects and should be treated as unordered/reference rather than levelling signal.
- Despite those flags, the runtime selection functions include the rows. The special behavior is a 25-question balanced cap, not exclusion.
10. What the current logic means
| Observation | Practical effect |
| Question volume varies from 3 to 43 before the five-plus cap. | Two families can experience very different onboarding length based on a one-month age difference. |
| Early infancy is Physical-heavy. | Physical answers dominate the survey signal even though activity need uses per-domain averages. |
| Physical is split after surveying. | One onboarding section feeds two independent star denominators; the split depends entirely on motorType. |
| Selection uses low-confidence estimated bands while display favors original bands. | A row can be asked because of one age window while its information view presents another. |
| Reference/excluded rows are not filtered at runtime. | The quality flags describe the corpus but do not protect the questionnaire or star calculation. |
| Server-to-corpus matching is partly textual. | A changed title or prompt can separate the server UUID answer from the bundled checklist row, although the remote star check can still count it. |
| Three editing surfaces have different write paths. | The in-memory star, local journal, server observation, and care queue can temporarily—or in Home Updates, persistently—disagree. |
| Backend Portrait fields are read models. | The client can show and filter emerging and skill_pot, but their server-side selection logic is not present in this repository and cannot be inferred from the app alone. |
11. Code map
| Concern | Primary implementation |
| Corpus and fields | src/data/consolidatedMilestones.json, src/types/milestone.ts |
| Age selection, domains, star and mapping | src/services/milestoneEngine.ts |
| Questionnaire UI | src/features/onboarding/OnboardingFlow.tsx, selection wired in App.tsx |
| Registration payload | src/services/onboardingRegister.ts |
| Server observations | src/data/growthRepository.ts |
| Hydration | src/services/portraitStar.ts |
| Portrait display and mark-achieved | src/screens/PortraitScreen.tsx, src/screens/WebDomainSheet.tsx |
| Activity ranking | src/services/activityPlanner.ts |
| Care-queue milestone updates | src/screens/HomeScreen.tsx |
| Local history | src/services/milestoneJournal.ts |