Skip to content

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:

  1. Only one of the two transitions is a user action. Due → Done fires when the engineer submits something. Coming up → Due fires 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.

  2. 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:

EventactionFires whenProperties
task_to_doopenedEngineer taps a tiletask_name, job_id, job_type, product_type, job_date
task_to_docompletedEngineer submits the tasktask_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.

  1. 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.

  2. 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.

  3. 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 observe Coming up → Due time-based transitions, risks missing an action as the forms surface evolves (open-ended maintenance burden), and duplicates card-side derivation in a second place.

  4. 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).

  5. 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.

  6. 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.

  7. 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 action for 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 captureOpened call in its CTA and one captureCompleted call at its submission seam.

Harder / lost

  • The Coming up → Due transition is no longer an event. Where needed, it is derivable at query time using job_date (a property on the event) and the timestamp of the prior prerequisite's *_submitted event. PostHog's "Time to convert" funnel view handles the common case directly.
  • previous_state / new_state properties 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_do with action: 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, with action ∈ {opened, completed}.
  • No detail events change in this PR. delivery_check_submitted and risk_assessment_submitted semantics are unchanged, and arrival keeps its existing Segment b_ETA_shared stream — a PostHog detail event for arrival is deferred to APY-1988 to avoid double-tracking against task_to_do.

Relevant Jira Tickets

  • APY-1961 — Tasks to do tracking (primary)
  • APY-1954 — Track materials delivery for install jobs (epic)
  • APY-1956 — Materials delivery check screen
  • APY-1957 — Materials delivery check tracking