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

2. Milestone content model

FieldCurrent use
idStable corpus code and answer-map key; sent as milestone_id during onboarding and used by by-code API writes.
skill, title, textGrouping, display, sorting, matching, and local fallback activity text.
questionTextConverted into a caregiver question such as “Can they…?” or “Do they…?”.
whyWeAsk, whatCounts, example(s)Milestone information modal.
areaOne of Physical, Cognitive, Linguistic, Social Emotional; defines onboarding section and base domain mapping.
motorTypePhysical-only split into gross_motor or fine_motor.
usuallyAchievedAtOriginal parseable age window.
ageBandEstimate.estimatedAgeBandOverrides the original window for selection, context, ordering, star calculations, and activity ranking.
ageBandEstimate.confidenceControls whether the estimated or original range is shown publicly. Threshold is 80.
bandReliability, excludeFromLevellingUsed in corpus summary counts. They do not remove a row from the current questionnaire, star, domain sheets, or future-milestone pools.
contentStatus, contentReviewQuality 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

  1. Age is calculated as an integer month count from the profile birth date.
  2. Every parseable corpus row is converted to an assessment row.
  3. The logic band is ageBandEstimate.estimatedAgeBand ?? usuallyAchievedAt.
  4. 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.
  5. 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.
  6. The selected pool is split into four screens. Within the pool, sorting favors current-band midpoint proximity, area order, age, skill, then text.
  7. 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 valueOnboarding payloadDirect observation writeHydrated back from API
yesyesstatus: achievedachieved → yes
not_yetnot_yetstatus: not_startednot_started → not_yet
not_suredont_knowDomain-sheet Save treats it as blank and deletes the by-code observation.No hydrated answer.
unanswered or missingOmittedDomain-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 pointReact answerLocal journalMilestone APIOther effect
Initial onboardingImmediateThe 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 setupImmediateAppends when a current/selected child id exists.Sent in the final onboarding payload.Enters Portrait and refreshes account state.
Portrait domain sheetMerged on SaveAppends 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 yesNo journal call.PUT by UUID or by corpus code as achieved.Refreshes Portrait read model.
Home milestone updateWrites shared draft onlyNo 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 areaConditionPortrait domain
Physical / physicalmotorType or motor_type is finefine_motor
Physical / physicalAnything elsegross_motor
Cognitive / cognitivecognitive
Linguistic / linguistic or languagelanguage
Social Emotional / social_emotionalsocial_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

ConsumerImplemented rule
Portrait starUses current, previous-three-month, and future milestones by five domains. Formula is detailed below.
Portrait domain sheetShows 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 skillsUses backend portrait.skill_pot. The client removes items already marked yes.
Milestone-based PlayLooks 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 orderingReorders activities within the already age-safe bundle by domain need. It does not add older/younger activities.
Home UpdatesCare-queue milestone ids are resolved against active, coming-soon, recently-passed, and future corpus rows. Answered rows are hidden from check-in items.
Child switchingClears prior answers, then hydrates the newly selected child's observations. This prevents one child's star from leaking into another's.
AnalyticsCompletion sends only assessment.completed with area_count: 4; individual answer values are not included in that event.
Trial offerNot 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

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

Physical
237 · 29.3%
Cognitive
254 · 31.4%
Linguistic
144 · 17.8%
Social Emotional
175 · 21.6%

Five Portrait domains

DomainRowsShare
Gross Motor15619.3%
Fine Motor8110.0%
Cognitive25431.4%
Language14417.8%
Social Emotional17521.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 startsPhysicalCognitiveLinguisticSocial EmotionalTotal
0–11 months90242123158
12–23 months42272315107
24–35 months40422417123
36–47 months1015111349
48–58 months684725
59–73 months4913861100348

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

AreaOrdered and includedReference / excludedTotal
Physical18057237
Cognitive105149254
Linguistic8064144
Social Emotional66109175
Total431 · 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 ageAverage questions/monthMonthly rangeCoverage gaps
0–11 months35.822–40No Linguistic question at month 0.
12–23 months27.814–40All four areas present.
24–35 months28.016–43All four areas present.
36–47 months16.86–42All four areas present, but volume drops sharply after month 38.
48–58 months8.63–14No Physical at month 55; no Linguistic at months 56–58.
59–73 months25.025–25Artificially balanced by the special cap.
74–95 months00Profile 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.

AgePCLSTotal

Repeated skill ladders inside the survey

Child ageAvg. questionsAvg. unique area+skill labelsAvg. extra staged rungs
0–1135.813.022.8
12–2327.816.311.6
24–3528.016.711.3
36–4716.812.14.7
48–588.68.00.6
59–7325.015.89.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

10. What the current logic means

ObservationPractical 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

ConcernPrimary implementation
Corpus and fieldssrc/data/consolidatedMilestones.json, src/types/milestone.ts
Age selection, domains, star and mappingsrc/services/milestoneEngine.ts
Questionnaire UIsrc/features/onboarding/OnboardingFlow.tsx, selection wired in App.tsx
Registration payloadsrc/services/onboardingRegister.ts
Server observationssrc/data/growthRepository.ts
Hydrationsrc/services/portraitStar.ts
Portrait display and mark-achievedsrc/screens/PortraitScreen.tsx, src/screens/WebDomainSheet.tsx
Activity rankingsrc/services/activityPlanner.ts
Care-queue milestone updatessrc/screens/HomeScreen.tsx
Local historysrc/services/milestoneJournal.ts