0004. Tasks-to-do lifecycle tracking
Date: 2026-04-20
Author: Eugene Nagorny
Stakeholders: Product, Ops / Analytics
Status: Proposed
Context
The Tasks-to-do section on the Job Details screen shows four tiles: Arrival window, Risk assessment, Delivery check, and Forms and photos. Product wants to analyse when engineers open and complete these tasks through the day (APY-1961), and observe patterns within each task type (APY-1956, APY-1957).
APY-1961's original ask was a single task_to_do_state_change event fired on every transition between Coming up, Due, and Done, with previous_state and new_state properties. The intent was that we could reconstruct task progression from this one stream.
On implementation we hit two problems:
Only one of the two transitions is a user action.
Due → Donefires when the engineer submits something.Coming up → Duefires because the clock ticks past midnight on the job date, or because a prerequisite is satisfied server-side. Neither is an interaction with the tile, and PostHog's own guidance treats events as user interactions, not derived-state diffs.Client-side state watching is unreliable. Any approach that watches derived state on render and emits events via a cache keyed by
(job, task)loses transitions across screen unmounts. Expo Router unmounts screens on navigation, so the common workflow — open Job Details → navigate to the task screen → submit → return — resets the cache and never fires the completion event. Every alternative client-side observer (per-component ref, Redux listener middleware on actions, Redux listener middleware on state selectors, React Navigation focus events, stable-parent Context, persisted Redux) either has the same unmount/lifecycle race or introduces its own complexity (a fake midnight-tick action, for example). None produces analytics that would be trustworthy.
At the same time, three of the four completion signals already exist as discrete *_submitted events (delivery_check_submitted, risk_assessment_submitted, partial forms coverage via job_submission_success). The only gap on the PostHog side is arrival window, which fires to Segment only today.
Decision
Track Tasks-to-do analytics with a single lifecycle event, populated from real user actions, with no render-time state-diff machinery. Following the property-first model in APY-1988 (action is the event, the variant is a property), the interaction is carried on an action property rather than split across separate event names:
| Event | action | Fires when | Properties |
|---|---|---|---|
task_to_do | opened | Engineer taps a tile | task_name, job_id, job_type, product_type, job_date |
task_to_do | completed | Engineer submits the task | task_name, job_id, job_type, product_type, job_date |
task_name is one of Arrival window, Risk assessment, Delivery check, Forms and photos.
Implementation colocates the capture with the four tile CTAs (useArrivalCard, useRiskAssessmentCard, useDeliveryCheckCard, forms CTA) and with the four submission seams (arrival drawer, risk assessment context, delivery check screen, useSubmitJobWithLogging). A shared helper hook usePostHogCaptureTaskLifecycle(taskName, job) returns {captureOpened, captureCompleted} — both emit task_to_do with the action baked in — keeping the call sites one line each.
Detail events are out of scope here
Task-specific submission payloads (delivery_check_submitted, risk_assessment_submitted, forms submission events) are owned by their own features and by APY-1988's migration map (risk_assessment already appears there). This ADR deliberately does not add new detail events: the single task_to_do event answers the Time-of-Day and funnel questions APY-1961 is after, and adding a parallel per-task *_submitted event at the same seam would double-track. Arrival window in particular keeps its existing Segment b_ETA_shared stream (which carries previousETA / newETA); a PostHog detail event for arrival, if wanted, can land under APY-1988 alongside the others rather than being pre-empted here.
Alternatives Considered
Seven alternatives were explored via parallel design exploration before landing on the lifecycle model.
Module-level Map, hardened — a state-diff hook backed by a module-level cache with LRU eviction and completion-based cleanup. Rejected: does not solve the underlying problem that client-side state watching cannot observe time-based transitions, and adds complexity (LRU, dev hot-reload preservation) to chase a signal that is not a user action.
React Context at a stable parent — wrap the same cache in a Context provider mounted at
(app)/_layout.tsx. Rejected: materially the same as the module-level cache for this single consumer, with extra ceremony. Worth revisiting if a second "previous-state" analytics hook appears.Redux listener middleware on actions — subscribe to completion actions (
UPDATE_DELIVERY_CHECK_COMPLETED, risk-assessment mutations, ETA send, form submission actions) and emit events from middleware. Rejected: cannot observeComing up → Duetime-based transitions, risks missing an action as the forms surface evolves (open-ended maintenance burden), and duplicates card-side derivation in a second place.Redux listener middleware on state selectors — diff a derived-state selector on every store change. Rejected: needs a fake clock-tick action to catch midnight rollover, requires visible-job scoping to avoid noise, and re-derives from scratch what the cards already compute (drift risk).
React Navigation focus events — snapshot state on focus, diff on re-focus. Rejected: augments but does not replace the module-level cache (screens can still unmount via
dismissAll); adds moving parts without removing the fundamental problem.Persisted Redux slice — store previous task states in a new persisted reducer so transitions survive across app launches. Rejected: cross-launch "transitions" are misleading (they represent server-side data drift, not user activity) and would pollute funnels with phantom events on first launch after any schema change. Persistence also introduces a migration for every schema tweak.
Lifecycle + detail events (chosen) — pivot to user-action events, as described in Decision.
Consequences
Easier
- Events fire reliably at well-defined user-action seams; no render-time lifecycle edge cases.
- One event answers funnel and Time-of-Day questions with a uniform schema; segment on
actionfor opens vs completions. No event explosion, no multi-event selection to reconstruct a single journey. - Each tile tap is a distinct event — abandonment, re-opens, and time-to-complete-after-first-open all become queryable.
- Adding a new task tile in future is one
captureOpenedcall in its CTA and onecaptureCompletedcall at its submission seam.
Harder / lost
- The
Coming up → Duetransition is no longer an event. Where needed, it is derivable at query time usingjob_date(a property on the event) and the timestamp of the prior prerequisite's*_submittedevent. PostHog's "Time to convert" funnel view handles the common case directly. previous_state/new_stateproperties are gone. The framing was our inference of what the user action meant; the user action itself is the primary signal.- Task edits that do not change state (e.g. re-sharing an already-submitted arrival window) now fire a second
task_to_dowithaction: completed. Analysts need to dedupe by(distinct_id, job_id, task_name)if they want "first completion" semantics. This is also a gain: edits are now observable.
Ops communication
- One new event:
task_to_do, withaction∈ {opened,completed}. - No detail events change in this PR.
delivery_check_submittedandrisk_assessment_submittedsemantics are unchanged, and arrival keeps its existing Segmentb_ETA_sharedstream — a PostHog detail event for arrival is deferred to APY-1988 to avoid double-tracking againsttask_to_do.