Task Breakdown — AI Agent Impact Report, Phase 1: Live Impact Report
Source RFC:
phase-1-live-impact-report.md(IDEA/draft, 8.0/10 PROCEED-with-notes) Review:phase-1-live-impact-report-review.mdMode: Horizontal (Phase 1 = FE UI with mocked API · Phase 2 = BE implementation + FE API integration). Blocked tasks included inline (full picture). Scope: all 5 impact stories. Repos (verified local siblings):chatbot(Rails 7.1 / Grape BE) ·chatbot-fe(Nuxt 3 / Vue 3 / Pinia FE) ·qontak-designer(visual reference only, no build input). All file paths below were verified by codebase reconnaissance on 2026-07-08. Deploy order per RFC §4 is BE-first; the horizontal build order is FE-UI-with-mocks in parallel, then BE, then FE-wiring — see Ordering rationale.
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — UI (mocked) | 13 | — | 3 | 16 |
| Phase 2 — API integration | 2.5 | 10 | 5 | 17.5 |
| Grand total | 15.5 | 10 | 8 | 33.5 |
Confidence: medium. The estimate is well-grounded on the FE side (every pattern verified against real code) and on the BE scaffolding, but three items carry real risk: the aggregator correctness core (Task 2.2 — reopen self-join REV-1, turns-derivation REV-3, cross-DB sentiment join) is the single biggest unknown; the forecast (Task 2.6) is fully blocked until REV-2/OQ-10 names an algorithm; and OQ-1 (AI add-on feature
code) / OQ-2 (canonical FE role strings) gate the entitlement + role-gate tasks.roomshas no supporting indexes (REV-4), so the nightly scan cost is a genuine open risk sized in Task 2.1.
Phase 1 — UI (APIs mocked)
Task 1.1: [FE] Impact-report Pinia store + service + endpoint + mock fixtures (IMPACT-S01..S05)
Foundation: a data layer every tile reads from, returning fixture data so all UI can be built and tested before the BE exists.
Status: ✅ Actionable
Design reference: n/a — no UI (data layer). DS version: @mekari/pixel3@^1.0.12.
What to build
A 6-file Pinia store store/ai-agent-impact/ mirroring store/report/ (uses extractStore, actions sub-store returning { fetch, request: controller }, action-name constants in types.ts), a service common/services/main/v1/ai-agent-impact.ts returning { fetch, controller } with a per-method AbortController, and endpoint registration. In Phase 1 the service resolves from a local mock fixture; Phase 2 (Task 2.7) points it at the real endpoint.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | store/ai-agent-impact/index.ts | defineStore("ai-agent-impact", () => ({ ...extractStore(useState()), ...extractStore(useGetters()), ...extractStore(useActions()) })) |
| create | store/ai-agent-impact/{state,getters,actions,interface,types}.ts | fetchStatus machine, report/costAssumption state, action constants |
| create | common/services/main/v1/ai-agent-impact.ts | getReport(nuxtApp, params) + getCostAssumption/putCostAssumption; each returns { fetch, controller } |
| modify | common/services/main/endpoint.ts | add ai_agent_impact: { report: "v1/reports/ai_agent_impact", cost_assumption: "v1/reports/ai_agent_impact/cost_assumption" } under the v1 key |
| modify | common/services/main/index.ts | register the new ./v1/ai-agent-impact service in the aggregator |
| create | store/ai-agent-impact/__mocks__/report.fixture.ts | fixture matching the RFC §2.4 response envelope (net/gross/journey/quality/trend/work_absorbed/forecast) |
| create | common/services/main/v1/ai-agent-impact.spec.ts | co-located service test ({fetch,controller} shape, abort) |
| create | tests/unit/store/ai-agent-impact/index.spec.ts | store fetch machine test |
Implementation steps
- Explore — open
store/report/{index,actions,state,types}.tsandcommon/services/main/v1/report.ts; copy theextractStorecomposition + the{ fetch, request: controller }action return exactly. Note endpoints are referenced asendpoint.v1.<feature>.<key>and consumed viaservices.mainService.default.<feature>.<method>. - Write failing tests (red) — create the store + service specs; run
pnpm test -- ai-agent-impactand confirm red. - Scaffold — create the 6 store files + service, wiring action constants from
types.ts. - Wire state — action calls the service,
$patchesreport/fetchStatus(pending→resolved/rejected). Stash theAbortControllerfor unmount abort. - Mock — resolve the service from
report.fixture.tsbehind a build-time flag/injected stub so tiles render real-shaped data. Note: real call added in Task 2.7. - Go green →
pnpm test -- ai-agent-impact. - Quality gate →
pnpm lint.
Acceptance criteria
- Store exposes
report,costAssumption,fetchStatusand a fetch action following thestore/reportpattern. - Service method returns
{ fetch, controller }and creates its ownAbortController. -
fetchStatustransitionspending → resolved(fixture) andpending → rejected(forced error). - Endpoint keys registered under
endpoint.v1.ai_agent_impact.*.
Test strategy
vitest: mock $apiMain to resolve the fixture; assert the action sets fetchStatus=resolved and populates report; assert rejected on throw. Service spec asserts the returned shape + that controller.abort() cancels.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | — |
| Total | 1.5 |
Assumptions: direct reuse of the verified 6-file
store/reportpattern; nopersist:true(report store doesn't use it); snake_case consumed raw (OQ-5, verified low-risk).
Run to verify
pnpm test -- ai-agent-impact && pnpm lint
Depends on
- None (foundation).
Task 1.2: [FE] Feature + role gate middleware (IMPACT-S01-NEG, IMPACT-S02-NEG)
A non-AI account or an ineligible role (Agent) never sees the report entry — navigation is aborted before the page renders.
Status: ⚠️ Partially blocked — the flag-gate half is fully actionable now; the exact AI add-on feature code (OQ-1/REV-5) and canonical role strings (OQ-2/REV-6) must be confirmed to finalize the predicate. Build against placeholders and note the two constants.
Design reference: n/a — no UI. DS version: @mekari/pixel3@^1.0.12.
What to build
middleware/ai-agent-impact-feature.ts extending the verified flag-gate template with an added role check. Aborts with createError({ statusCode: 404 }) when the AI add-on flag is off OR the role is not in {owner, admin, supervisor}.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | middleware/ai-agent-impact-feature.ts | flag check (subscriptionData.features.find(code===<OQ-1>).enabled) + role check (authenticationStore().profile.data.role ∈ allowed) → abortNavigation(createError({statusCode:404})) |
| create | tests/unit/middleware/ai-agent-impact-feature.spec.ts | flag-off → abort; agent role → abort; owner + flag-on → pass |
Implementation steps
- Explore — read
middleware/ai-assist-dynamic-kb-feature.ts(flag-gate pattern) and howauthenticationStore().profile.data.roleis read incommon/utils/tracking.ts:102(role is nested underprofile.data, untyped). - Write failing tests (red) — three cases above;
pnpm test -- ai-agent-impact-feature. - Implement — copy the flag-gate abort, add the role predicate. Use a named constant
AI_ADDON_FEATURE_CODE(placeholder until OQ-1) andALLOWED_ROLES(placeholder casing until OQ-2). - Go green →
pnpm test. - Quality gate →
pnpm lint.
Acceptance criteria
- Flag off →
abortNavigation(404). - Role = agent →
abortNavigation(404). - Owner/admin/supervisor + flag on → navigation proceeds.
- (pending OQ-1) feature
codeconstant wired to the real value. - (pending OQ-2) role strings match canonical casing.
Test strategy
vitest: stub subscriptionStore/authenticationStore; assert abortNavigation called with a 404 error for each deny case, not called on allow.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| Backend | — |
| QA | — |
| Total | 0.5 |
Assumptions: role predicate is net-new (existing middleware is flag-only, verified); casing/code are one-line swaps once OQ-1/OQ-2 land.
Run to verify
pnpm test -- ai-agent-impact-feature && pnpm lint
Depends on
- [External: OQ-1 feature code, OQ-2 role strings] — non-blocking to build; blocks final correctness.
Task 1.3: [FE] Report page + view shell + baseline-forming + error/retry states (IMPACT-S01/AC-6, IMPACT-S01/ERR-1)
The admin opens
/reports/ai-agent-impactand always gets a coherent screen — a loading skeleton, a baseline-forming empty state when data is thin, or an error+retry when the load fails — never a blank page or a fabricated number.
Status: ✅ Actionable
Design reference: n/a — design pending (OQ-6). DS version: @mekari/pixel3@^1.0.12. Reference: mekari-taste plain-language wireframe + PRD Appendix A Stitch prompt #1.
What to build
The thin page, the composition view (ai-agent-impact.vue), the new module components/ folder (does not exist yet under modules/report/), a new reusable error+retry component (none exists in the repo — verified), reuse of no-data.vue for baseline-forming, the MpSkeleton loading state, and the mount-time fetch + AI_AGENT_IMPACT_VIEWED event.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | pages/reports/ai-agent-impact/index.vue | thin page → mounts the module view; applies ai-agent-impact-feature middleware |
| create | modules/report/views/ai-agent-impact.vue | composition root: fetch on mount, fetchStatus switch (loading/baseline/error/success), date-range control, abort on unmount |
| create | modules/report/components/ai-agent-impact/ErrorRetry.vue | new: MpBanner + MpButton retry (net-new — no reusable error+retry exists) |
| create | modules/report/components/ai-agent-impact/BaselineForming.vue | wraps common/components/error/no-data.vue (volume-only, no %) |
| modify | common/contants/mixpanel-events.ts | add AI_AGENT_IMPACT_VIEWED, IMPACT_LOAD_FAILED, IMPACT_BASELINE_FORMING ([CHATBOT] ... strings) — note the real misspelled contants/ dir |
| create | modules/report/views/ai-agent-impact.spec.ts | co-located view test for the state machine |
Implementation steps
- Explore — read
pages/report/index.vue+modules/report/views/bot-peformance.vuefor the thin-page→view + fetchStatus (idle/pending/resolved/rejected) convention; readcommon/components/error/no-data.vueprops (title/description/svgName) andcommon/utils/tracking.ts(trackEvent(name, props, jimo)). Create themodules/report/components/ai-agent-impact/dir (new). - Write failing tests (red) — view renders skeleton on
pending,BaselineFormingwhenreport.baseline_forming,ErrorRetryonrejected, tiles slot onresolved;pnpm test -- ai-agent-impact. - Scaffold — page + view shell + the two state components; import store from Task 1.1 (fixture-backed).
- Wire state — fetch on mount via store action, stash
AbortController, abort on unmount; fireAI_AGENT_IMPACT_VIEWEDwith{org_id, role, date_range, has_forecast}. - Implement — retry re-invokes the fetch; date-range change refetches.
- Go green →
pnpm test. - Quality gate →
pnpm lint && pnpm build.
Acceptance criteria
- Loading →
MpSkeletontiles + skeleton chart. -
baseline_forming:true→ volume-only empty state, no percentages,impact_report_baseline_formingfired. -
rejected→ error+retry; Retry re-fetches; no partial fabricated data. -
AI_AGENT_IMPACT_VIEWEDfires once on successful mount. - Date-range change triggers a refetch.
Test strategy
vitest: drive fetchStatus through the store; assert each branch renders its component; assert trackEvent called with the view event name on resolve. Mock trackEvent.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions:
no-data.vuereused as-is; error+retry is a small new component; view is the composition root that later tile tasks slot into (they don't re-own this file).
Run to verify
pnpm test -- ai-agent-impact && pnpm lint && pnpm build
Depends on
- [Task 1.1] (store), [Task 1.2] (middleware).
Task 1.4: [FE] Value-delivered grid tiles — hero + volume + after-hours + work-absorbed + headline (IMPACT-S01/AC-1..5)
The admin sees, at a glance, an honest containment hero (net of reopens, with the false-resolution gap), plus volume, after-hours, and money-saved tiles and a plain-language headline — every figure carrying a verdict, never a bare number.
Status: ⚠️ Partially blocked — the after-hours tile is gated on OQ-4 (per-org business-hours config not found; PRD §16 mitigation is to ship without it). Build the tile behind a hasAfterHours guard that hides it when the field is null; everything else is fully actionable.
Design reference: n/a — design pending (OQ-6). DS version: @mekari/pixel3@^1.0.12. Reference: PRD Appendix A Stitch prompt #1. Brand color on the hero number only.
What to build
HonestContainmentTile (net % hero + verdict pill + "leaves out repeat-askers" note), VolumeTile, AfterHoursTile (null-guarded), WorkAbsorbedTile (shows a "set assumption" prompt when work_absorbed is null — no fabricated figure), PlainHeadlineSummary, composed in a ValueDeliveredGrid, all built on MpBox/MpText/MpTag.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | modules/report/components/ai-agent-impact/HonestContainmentTile.vue | props { net; gross; reopenGap; verdict; baselineForming }; hero + pill + gap note |
| create | modules/report/components/ai-agent-impact/VolumeTile.vue | volume count tile |
| create | modules/report/components/ai-agent-impact/AfterHoursTile.vue | props { count; pct }; rendered only when field non-null (OQ-4) |
| create | modules/report/components/ai-agent-impact/WorkAbsorbedTile.vue | `{ hours; rupiah } |
| create | modules/report/components/ai-agent-impact/PlainHeadlineSummary.vue | plain-language sentence from the metrics |
| create | modules/report/components/ai-agent-impact/ValueDeliveredGrid.vue | lays out the tiles; slots into the view (Task 1.3) |
| create | modules/report/components/ai-agent-impact/*.spec.ts | one spec per tile (verdict-not-number, null degrade) |
Implementation steps
- Explore — read
modules/settings/views/ai-assist.vuefor pixel3MpBox/MpText/MpTagusage + import style (@/, from@mekari/pixel3). - Write failing tests (red) — hero renders verdict text not a bare number; after-hours hidden when null; work-absorbed shows prompt when null;
pnpm test. - Scaffold — each tile with typed
defineProps. - Implement — verdict pill logic, reopen-gap note, null guards; grid composition.
- Go green →
pnpm test. - Quality gate →
pnpm lint && pnpm build.
Acceptance criteria
- Hero shows the net % + verdict pill + reopen-gap note; never a gross-only bare number (Success Criteria #5).
- After-hours tile is absent when the field is null (OQ-4 mitigation), present with count+% otherwise.
- Work-absorbed null → "set assumption" prompt, no fabricated rupiah (IMPACT-S04/AC-3).
- Plain headline renders a full-sentence summary.
Test strategy
vitest: mount each tile with fixture props; assert verdict text present, bare-number absent; assert conditional rendering for the two nullable tiles.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: pure presentational tiles on pixel3 primitives; no new state beyond props; after-hours built now, shown conditionally.
Run to verify
pnpm test -- ai-agent-impact && pnpm lint
Depends on
- [Task 1.3] (view shell). WorkAbsorbedTile's prompt wires to [Task 1.8] (modal).
Task 1.5: [FE] Quality grid tiles — reopen rate + sentiment delta + turns (IMPACT-S03/AC-1..4)
The admin can judge whether the AI is doing a good job — repeat-ask (reopen) rate, sentiment of AI-contained vs escalated chats, and how many turns it takes to resolve — with each tile degrading to "not available" rather than lying when its data is missing.
Status: ✅ Actionable (UI). Note: the sentiment tile must render a reduced-coverage note when coverage is low and "not available" when the field is null — the coverage threshold that flips the note is undefined (review Vague-word #3); use a placeholder constant and flag it.
Design reference: n/a — design pending (OQ-6). DS version: @mekari/pixel3@^1.0.12.
What to build
ReopenRateTile, SentimentDeltaTile (coverage-aware), TurnsTile, composed in a QualityGrid, each with a per-tile degrade path.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | modules/report/components/ai-agent-impact/ReopenRateTile.vue | { reopenRate } + verdict |
| create | modules/report/components/ai-agent-impact/SentimentDeltaTile.vue | `{ delta; coverage } |
| create | modules/report/components/ai-agent-impact/TurnsTile.vue | { turnsAvg } |
| create | modules/report/components/ai-agent-impact/QualityGrid.vue | lays out the three tiles into the view |
| create | modules/report/components/ai-agent-impact/*.spec.ts | per-tile specs incl. sentiment null/low-coverage |
Implementation steps
- Explore — reuse the tile idioms established in Task 1.4.
- Write failing tests (red) — sentiment null → "not available"; low coverage → note; reopen/turns render values;
pnpm test. - Scaffold + implement the three tiles + grid.
- Go green →
pnpm test. - Quality gate →
pnpm lint.
Acceptance criteria
- Reopen-rate tile renders rate + verdict.
- Sentiment tile shows "not available" on null, reduced-coverage note below threshold, delta otherwise.
- Turns tile renders the average.
- A missing tile never blocks the rest of the grid.
Test strategy
vitest: mount with null/low-coverage/normal fixtures; assert the three sentiment branches; assert independent degrade.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Assumptions: presentational tiles; coverage threshold is a one-line constant pending a product decision.
Run to verify
pnpm test -- ai-agent-impact && pnpm lint
Depends on
- [Task 1.3] (view shell).
Task 1.6: [FE] BlendedJourneyBar — custom SVG 3-segment bar (IMPACT-S02/AC-1..3, ERR-1)
The admin sees how conversations split across AI-resolved-alone / AI-assisted-then-human / escalated as one honest room-grain bar with plain labels.
Status: ✅ Actionable
Design reference: n/a — design pending (OQ-6). Custom SVG/CSS (no chart lib — Decision 7; verified none in package.json). Reference: qontak-designer hand-SVG charts (visual only). Design QA: TBD (OQ-6).
What to build
A hand-built SVG 3-segment horizontal bar summing to 100%, plain labels ("AI resolved alone" / "AI assisted, human closed" / "Escalated to human"), role="img" + aria-label value summary, and a placeholder when journey data is missing.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | modules/report/components/ai-agent-impact/BlendedJourneyBar.vue | props { aiOnly; aiAssisted; escalated }; custom SVG; a11y label; missing-data placeholder |
| create | modules/report/components/ai-agent-impact/BlendedJourneyBar.spec.ts | segments sum to 100%; plain labels; placeholder on missing |
Implementation steps
- Explore — review a
qontak-designerhand-SVG chart for the geometry approach (reference only; not imported); confirm no chart dep exists. - Write failing tests (red) — segment widths proportional and sum to 100%; labels are plain-language; placeholder when all-null;
pnpm test. - Scaffold + implement the SVG with pixel3 segment colors + Tailwind.
- Go green →
pnpm test. - Quality gate →
pnpm lint && pnpm build(watch bundle delta — no new dep).
Acceptance criteria
- Three segments render with widths proportional to the counts and sum to 100%.
- Labels are plain-language, not raw enum values.
-
role="img"+aria-labelsummarizes the split. - Missing journey data → placeholder, rest of report unaffected.
Test strategy
vitest: mount with a known split; assert computed segment widths and the aria-label string; assert placeholder branch.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: hand-built SVG (no charting lib); geometry is simple proportional widths.
Run to verify
pnpm test -- BlendedJourneyBar && pnpm lint
Depends on
- [Task 1.3] (view shell).
Task 1.7: [FE] ForecastPanel — custom SVG trend + flag-gated projection (IMPACT-S05/AC-1..4)
The admin sees the containment trend vs the onboarding baseline, and — only when the forecast flag is on — a dashed next-period projection.
Status: ⚠️ Partially blocked — the UI (solid historical line + dashed-projection rendering, flag gating) is fully actionable against trend[] + a forecast.projected_net prop. The projection value itself is blocked on REV-2/OQ-10 (forecast algorithm unspecified); the panel renders whatever forecast the BE supplies, so build the UI now and the number arrives via Task 2.6.
Design reference: n/a — design pending (OQ-6). Custom SVG/CSS (Decision 7). Design QA: TBD (OQ-6).
What to build
A hand-built SVG line: solid past points from trend[], dashed projection appended only when flagOn && forecast; baseline-forming note when trend is too short; historical-only when flag off; prefers-reduced-motion respected.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | modules/report/components/ai-agent-impact/ForecastPanel.vue | props { trend:{date;net}[]; forecast?:{projectedNet}; flagOn }; solid + dashed SVG |
| modify | common/contants/mixpanel-events.ts | add IMPACT_FORECAST_RENDERED |
| create | modules/report/components/ai-agent-impact/ForecastPanel.spec.ts | dashed hidden when flag off / no forecast; historical always renders |
Implementation steps
- Explore — reuse the SVG approach from Task 1.6.
- Write failing tests (red) — dashed projection absent when
flagOn=falseorforecastundefined; solid trend always present;pnpm test. - Scaffold + implement the line geometry + dashed segment + baseline note.
- Fire
IMPACT_FORECAST_RENDEREDwhen the projection shows. - Go green →
pnpm test. - Quality gate →
pnpm lint.
Acceptance criteria
- Solid historical trend renders from
trend[]. - Dashed projection renders only when
flagOn && forecastpresent. - Flag off / insufficient trend → historical-only, no projection.
-
IMPACT_FORECAST_RENDEREDfires when projection shown.
Test strategy
vitest: mount with flag on/off × forecast present/absent; assert dashed path presence per matrix.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: panel is presentational; the forecast number is a BE responsibility (Task 2.6). Bundle-safe (no chart lib).
Run to verify
pnpm test -- ForecastPanel && pnpm lint
Depends on
- [Task 1.3] (view shell). Projection value: [Task 2.6] (blocked on REV-2/OQ-10).
Task 1.8: [FE] CostAssumptionModal — pixel3 modal + inline validation (IMPACT-S04/AC-1..3, ERR-1)
An owner/admin can set the agent-hour rate and minutes-per-conversation that turn contained conversations into a rupiah "work absorbed" figure; supervisors/agents see the figure but no editor.
Status: ✅ Actionable (UI + validation). The real save is wired in Task 2.8.
Design reference: n/a — design pending (OQ-6). Build on MpModal* compound (per modules/settings/views/ai-assist.vue). Reference: PRD Appendix A Stitch prompt #2. DS version: @mekari/pixel3@^1.0.12.
What to build
CostAssumptionModal using the pixel3 MpModal/MpModalContent/Header/Body/Footer/Overlay compound; numeric fields with inline non-negative + bounds validation; a hidden editor for non-owner/admin (canEdit=false); first-time blank + "why we ask" helper; save spinner. Emits save/close.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | modules/report/components/ai-agent-impact/CostAssumptionModal.vue | props { isOpen; value:{agentHourRate?; minutesPerConversation?}; canEdit }; emits save,close; inline 422-style validation |
| modify | common/contants/mixpanel-events.ts | add COST_ASSUMPTION_UPDATED |
| create | modules/report/components/ai-agent-impact/CostAssumptionModal.spec.ts | validation + canEdit gating + emits |
Implementation steps
- Explore — read
modules/settings/views/ai-assist.vue:474-513for theMpModal*compound +:is-open+ local open ref. - Write failing tests (red) — negative/non-numeric → inline error, no
saveemit;canEdit=false→ no editor; valid →savepayload;pnpm test. - Scaffold + implement the modal, fields, validation, helper copy, spinner.
- Go green →
pnpm test. - Quality gate →
pnpm lint.
Acceptance criteria
- Negative/non-numeric input → inline field error, nothing emitted.
-
canEdit=false→ editor hidden (read-only figure only). - Valid save emits
{ agentHourRate, minutesPerConversation }and firesCOST_ASSUMPTION_UPDATED. - First-time (no value) → blank fields + "why we ask" helper.
Test strategy
vitest: mount with canEdit true/false; submit invalid + valid; assert inline error, emit payloads, and editor visibility.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Assumptions: reuses the verified
MpModal*pattern; client validation mirrors the BE 422 bounds (upper bounds pending OQ-3).
Run to verify
pnpm test -- CostAssumptionModal && pnpm lint
Depends on
- [Task 1.3] (view shell). Real persistence: [Task 2.8].
Phase 2 — API Integration (BE implementation + FE wiring)
Task 2.1: [BE] Migrations — ai_activity_logs + ai_cost_assumptions + rooms supporting indexes (IMPACT-S01..S05, REV-4)
Stand up the report's read spine and the cost-assumption store, and add the missing
roomsindexes the nightly aggregation needs so it doesn't table-scan.
Status: ✅ Actionable
Design reference: n/a — BE only.
What to build
Two additive tables per the RFC §2.3 DDL, plus — verified critical (REV-4) — new concurrent composite indexes on rooms. Recon confirmed rooms has no index on contact_id, closed_at, or created_at, and zero composite indexes; the aggregator's org+date filter and the reopen self-join on contact_id would otherwise scan.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migrate/<ts>_create_ai_activity_logs.rb | 16-col table + UNIQUE(organization_id, activity_date); disable_ddl_transaction! + algorithm: :concurrently |
| create | db/migrate/<ts>_create_ai_cost_assumptions.rb | table + UNIQUE(organization_id) + CHECK(>=0) on rate/minutes |
| create | db/migrate/<ts>_add_impact_indexes_to_rooms.rb | concurrent composite index (organization_id, closed_at) (+ (organization_id, created_at) if the aggregator groups on created_at) and an index supporting the contact_id + time self-join |
| verify | db/schema.rb | regenerated with new tables + indexes |
Implementation steps
- Explore — read
db/migrate/20260624000001_add_related_key_and_related_type_to_attachments.rbfor house style (ActiveRecord::Migration[7.1],disable_ddl_transaction!,add_index ..., algorithm: :concurrently, if_not_exists: true); readdb/schema.rb:1761(rooms) to confirm current index gaps before adding. - Write the three migrations following that style; counts
NOT NULL DEFAULT 0. - Run —
RAILS_ENV=test bundle exec rails db:migrate, thendb:rollbackto confirm reversibility. - Quality gate —
bundle exec rubocopon the migration files.
Acceptance criteria
- Both tables + unique indexes exist;
db:rollbackreverts cleanly. - New
roomscomposite index(es) created concurrently,if_not_exists: true. -
CHECK (>= 0)present onagent_hour_rateandminutes_per_conversation.
Test strategy
Migration test: db:migrate up + db:rollback down clean; schema reflects unique + composite indexes.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | — |
| Total | 1 |
Assumptions: additive only; the REV-4 index migration is the reason this is 1 day not 0.5. Final index column set confirmed once the aggregator's group-by column (
closed_atvscreated_at) is fixed in Task 2.2.
Run to verify
RAILS_ENV=test bundle exec rails db:migrate && RAILS_ENV=test bundle exec rails db:rollback
Depends on
- None.
Task 2.2: [BE] Models + aggregation repository — containment, journey, reopen, sentiment, turns (IMPACT-S01/AC-1..4, S02, S03)
The correctness core: compute per-org-per-day honest containment (net of 48h reopens), the journey split, reopen rate, the cross-DB sentiment delta, and turns-to-resolve — the numbers the whole report exists to make trustworthy.
Status: ⚠️ Partially blocked — must resolve two review findings before the acceptance criteria are frozen: REV-3/OQ-3 (turns source) and the aggregation half of REV-1 (reopen window). Both have a verified path (below), so build can start; the ACs must encode the resolution.
Design reference: n/a — BE only.
What to build
AiActivityLog + AiCostAssumption AR models and a Repositories::AiAgentImpact::Aggregate that computes a day's counts from rooms using the verified COUNT(...) FILTER (WHERE ...) idiom, plus:
- Containment:
RESOLVE_AI∧assign_channel_agent_id IS NULL∧closed_at IS NOT NULL; excludechannel='bot_preview'andclosed_reason='SPAM'; bareRESOLVEis not an AI win. - Journey: closed_reason →
ai_only/ai_assisted/escalated, summing to volume. - Reopen (REV-1): same-
contact_idnew conversation ≤ 48h →reopened_48h;contained_ai_net = gross − reopened_48h. - Sentiment (cross-DB): app-level join
omnichannel_room_summaries.room_id (string)↔rooms.channel_room_id (string); record coverage; exclude unknown/missing (L-1). - Turns (REV-3): no counter column exists — derive
turns_sum/turns_countasCOUNT(histories)per room. Note the join-key asymmetry:histories.room_idis bigint → rooms.id, a different key than the string-keyed sentiment join. The aggregator therefore runs two distinct join strategies.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/models/ai_activity_log.rb | AR model, primary DB, validations |
| create | app/models/ai_cost_assumption.rb | AR model + >=0 validations |
| create | app/core/repositories/ai_agent_impact/aggregate.rb | the COUNT FILTER + reopen self-join + sentiment join + turns COUNT(histories) |
| create | spec/core/repositories/ai_agent_impact/aggregate_spec.rb | fixture-org correctness specs |
Implementation steps
- Explore — read
app/core/repositories/custom_report/generate.rb:108-120(theRoom.select("COUNT(id) FILTER (WHERE ...)").where(organization_id:).group("DATE(created_at)")idiom to reuse); readdb/schema.rb:1761(rooms cols),:567(histories:room_idbigint),db/chatbot_gpt_schema.rb:252(summariessentiment+ stringroom_id); confirmclosed_reasonliterals inconfig/locales/en.yml:345-350— note only 4 are defined there;SPAM/WAITING_ASSIGN_AGENTcome from write-sites, treat as string constants and centralize them. - Resolve OQ-3 (REV-3) — confirm
historiesis the turns source and the bigint join; document the two-join approach in the spec. - Resolve REV-1 (aggregation half) — compute
reopened_48hfor a day by looking 48h forward from each contained room'sclosed_at; the finalization-across-batches half is Task 2.3. - Write failing tests (red) — on a fixture org: net/gross correct;
bot_preview/SPAM/bareRESOLVEexcluded; journey sums to volume; reopen self-join within 48h; sentiment coverage recorded + unknown excluded; turns avg = sum/count.bundle exec rspec spec/core/repositories/ai_agent_impact. - Implement the repository (parameterized AR only — no SQL string interpolation of inputs).
- Go green + RuboCop.
Acceptance criteria
- Net/gross containment correct on the fixture;
bot_preview+SPAM+ bareRESOLVEexcluded (Success Criteria #1). - Journey segments sum to volume and map closed_reason correctly.
- Reopen self-join counts same-
contact_idreconversation ≤ 48h. - Sentiment via app-level string join; coverage recorded; unknown values excluded (L-1, IMPACT-S03/AC-4).
- (REV-3) Turns derived as
COUNT(histories)on the bigintroom_idjoin;turns_sum/turns_countpopulated. - Only the 4 en.yml literals + the 2 write-site literals are treated as known; unknown closed_reason → "other", excluded from AI wins (A2).
Test strategy
rspec on a hand-built fixture org spanning bot_preview/SPAM/RESOLVE/RESOLVE_AI + a 48h reopen + rooms with/without sentiment + rooms with N histories; assert every count. Key mocks: seed rooms, histories, omnichannel_room_summaries.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 3 |
| QA | 1 |
| Total | 4 |
Assumptions: reuses the verified
COUNT FILTERidiom; the two-join asymmetry (REV-3) + reopen window (REV-1) are the reason this is the largest BE task. Cross-DB join is app-level (no SQL join across DBs).
Run to verify
RAILS_ENV=test bundle exec rspec spec/core/repositories/ai_agent_impact && bundle exec rubocop
Depends on
- [Task 2.1] (tables). [External: OQ-3 turns source confirmation — path verified, decision pending].
Task 2.3: [BE] Aggregator worker + cron + trailing-day reopen recompute (IMPACT-S01, REV-1)
A nightly job materializes each org's daily row, and — crucially — recomputes the trailing days so a reopen that lands 1–2 days later correctly lowers the earlier day's honest-containment hero.
Status: ⚠️ Partially blocked — the worker scaffold is actionable; the REV-1 trailing-day recompute must be encoded as an acceptance criterion (it is not in the original RFC job spec). Verified support: the (org, activity_date) idempotent upsert already permits re-writing prior days.
Design reference: n/a — BE only.
What to build
AiActivityLogAggregatorWorker (sidekiq, retry: 3) that calls the Task 2.2 repository and upserts one row per org-day; a sidekiq-cron entry at 0 1 * * * Asia/Jakarta on the application_maintenance queue (verified to exist — used by DeleteOldLogsWorker/PartitionMaintenanceWorker); recompute the trailing 2 activity_date rows each run so reopened_48h/contained_ai_net finalize across the batch boundary; 13-month TTL cleanup.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/workers/ai_activity_log_aggregator_worker.rb | include Sidekiq::Worker; sidekiq_options queue: :application_maintenance, retry: 3; perform(activity_date = Date.yesterday) + recompute D-1, D-2 |
| modify | config/schedule.yml | add ai_activity_log_aggregator cron entry (cron/class/queue) |
| create | spec/app/worker/ai_activity_log_aggregator_worker_spec.rb | note: worker specs live at spec/app/worker/ (singular, verified) |
Implementation steps
- Explore — read
app/workers/assign_agent_worker.rb(worker +sidekiq_options) andconfig/schedule.yml(sidekiq-cron YAML entries +Asia/JakartaTZ). Confirmapplication_maintenancequeue usage on the two maintenance workers. - Write failing tests (red) — upserts one row/org-day; idempotent re-run yields identical values; a reopen landing on D+2 decrements day D's net on the next run (REV-1 criterion); sentiment coverage recorded; missing sentiment excluded.
bundle exec rspec spec/app/worker/ai_activity_log_aggregator_worker_spec.rb. - Implement — loop orgs, call the repository, upsert on the unique key, recompute trailing 2 days, run 13-month TTL cleanup.
- Poison handling — after
retry: 3→ dead set + Rollbar; next night recomputes (idempotent). - Go green + RuboCop.
Acceptance criteria
- Upserts exactly one row per org per day; idempotent re-run is a no-op on values.
- (REV-1) Each run recomputes the trailing 2 days; a D+2 reopen lowers day D's
contained_ai_net. - Sentiment coverage recorded; missing sentiment excluded, not zero-filled.
- 13-month rows past TTL cleaned nightly.
- Failure → dead set + Rollbar; next run self-heals.
Test strategy
rspec: seed two consecutive days with a cross-boundary reopen; run worker for D then D+2; assert day D's net drops on the second run. Assert idempotency by running twice.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses the verified sidekiq-cron pattern; trailing-day recompute rides the existing idempotent upsert. TTL cleanup in the same job.
Run to verify
RAILS_ENV=test bundle exec rspec spec/app/worker/ai_activity_log_aggregator_worker_spec.rb && bundle exec rubocop
Depends on
- [Task 2.2] (repository).
Task 2.4: [BE] Report GET endpoint + use-case + entity + entitlement + role gate (IMPACT-S01..S05, S01-NEG, S02-NEG)
Serve the assembled report over
GET /v1/reports/ai_agent_impact— org-scoped, role-gated, entitlement-gated — returning the full nested envelope the FE renders.
Status: ⚠️ Partially blocked — endpoint/use-case/entity fully actionable; the entitlement check needs the AI add-on feature code (OQ-1/REV-5, DB data — verified not in source). Build the gate against a named constant and flag it.
Design reference: n/a — BE only.
What to build
A Grape GET ai_agent_impact in report.rb (mounted via api.rb mount V1::Report => '/v1/reports'), gated by use Middlewares::Ownership (L6) + set_role(%w[owner supervisor admin]) (inside the get block) + an OrganizationFeature entitlement check; a dry-monads use-case that reads ai_activity_logs + ai_cost_assumptions, derives baseline_forming, trend[], and work_absorbed (null when no assumption); and a response entity.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | app/api/frontend_service/v1/report.rb | add GET /ai_agent_impact with Ownership + set_role + entitlement |
| create | app/core/use_cases/api/frontend_service/v1/ai_agent_impact/show.rb | dry-monads: validate range (≤12mo, start≤end), assemble report |
| create | app/api/frontend_service/v1/entities/ai_agent_impact/get_response.rb | nested envelope (containment/journey/quality/trend/work_absorbed?/forecast?) |
| create | spec/api/frontend_service/v1/ai_agent_impact/show_spec.rb | auth matrix + baseline + null work_absorbed |
Implementation steps
- Explore — read
app/api/frontend_service/v1/report.rb(gate placement),app/core/use_cases/api/frontend_service/v1/report/get.rb(dry-monadscontract/result/ResultMatcher),app/api/frontend_service/v1/entities/report/report_url.rb(entity convention),subscription_detail.rb:37-52(entitlementOrganizationFeature.where(company_id:, order_id: orders.pluck(:id), enabled:true); feature code is DB data → OQ-1). - Write failing tests (red) — owner/admin/supervisor → 200; agent → 403; non-AI org → 403; bad range → 400;
baseline_formingwhen < min;work_absorbednull when no assumption.bundle exec rspec spec/api/frontend_service/v1/ai_agent_impact. - Implement the use-case (parameterized AR, org from session never client param), entity, gate.
- Go green + RuboCop.
Acceptance criteria
- owner/admin/supervisor → 200; agent → 403; non-AI org → 403 (entitlement).
- span > 12mo or start > end → 400.
-
baseline_forming:trueunder the min threshold (volume only). -
work_absorbednull when no cost assumption. - Every query
where(organization_id:)-scoped; org fromenv['user']['chatbot_organization_id']. - (pending OQ-1) entitlement feature
codewired to the real value.
Test strategy
rspec request specs across the role×entitlement matrix; assert status + envelope shape + nullability; assert no cross-org leakage.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses verified Ownership + set_role + dry-monads + entity patterns; trend/baseline derived at read time (no new storage).
Run to verify
RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1/ai_agent_impact && bundle exec rubocop
Depends on
- [Task 2.2] (models/repo). [External: OQ-1 feature code].
Task 2.5: [BE] Cost-assumption GET/PUT endpoints + validation (IMPACT-S04/AC-1..3, ERR-1)
Owners/admins persist the cost assumption; the money figure recomputes on the next read. Supervisors read; agents are refused.
Status: ⚠️ Partially blocked — fully actionable except the sane upper bounds for agent_hour_rate / minutes_per_conversation (OQ-3, second half). Ship >= 0 now; add the caps when confirmed.
Design reference: n/a — BE only.
What to build
GET/PUT /v1/reports/ai_agent_impact/cost_assumption; GET gated set_role(%w[owner supervisor admin]), PUT gated set_role(%w[owner admin]); PUT validates non-negative (+ upper bounds pending OQ-3) and upserts on (organization_id); a use-case + entity.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | app/api/frontend_service/v1/report.rb | add GET + PUT cost_assumption with the two role gates |
| create | app/core/use_cases/api/frontend_service/v1/ai_agent_impact/cost_assumption/{show,upsert}.rb | dry-monads validate + upsert |
| create | app/api/frontend_service/v1/entities/ai_agent_impact/cost_assumption.rb | response entity (`data |
| create | spec/api/frontend_service/v1/ai_agent_impact/cost_assumption_spec.rb | 403/422 matrix + upsert |
Implementation steps
- Explore — reuse the use-case/entity/gate patterns from Task 2.4.
- Write failing tests (red) — PUT owner/admin persists; supervisor/agent → 403; negative/non-numeric → 422 nothing saved; GET returns record or null.
bundle exec rspec .../cost_assumption. - Implement validation + upsert on the unique
(organization_id)index (strong read-after-write). - Go green + RuboCop.
Acceptance criteria
- PUT owner/admin → 200 persist; supervisor/agent → 403.
- negative/non-numeric → 422, nothing persisted.
- GET → saved record or
null. - Upsert keyed on
(organization_id); last-write-wins. - (pending OQ-3) upper-bound caps added to validation.
Test strategy
rspec: role matrix on PUT; invalid-input 422 leaves table unchanged; GET null before first save, record after.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: single-row upsert; reuses verified gate/use-case patterns.
Run to verify
RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1/ai_agent_impact/cost_assumption_spec.rb && bundle exec rubocop
Depends on
- [Task 2.1] (table).
Task 2.6: [BE] Forecast derivation (flag-gated) (IMPACT-S05/AC-2..3)
When the forecast flag is on, the report returns a next-period
projected_netderived from the trend.
Status: 🚫 Blocked — REV-2 / OQ-10: the forecasting algorithm is unspecified (only gating, the forecast:{projected_net} output field, and the MAE stop-condition exist). An agent cannot implement this without inventing the model. Unblock: Decision 5 must name the algorithm (e.g. least-squares linear fit over the last N daily net points), the minimum trend length, and the projected_net [0,1] bound. Contained by flag-OFF default, so it lands last.
Design reference: n/a — BE only.
What to build (once unblocked)
The forecast computation behind SystemPreference(rollout/ai_agent_impact_report_forecast), added to the report use-case (Task 2.4); omit when the flag is off or the trend is shorter than the minimum; bound the projection to [0,1].
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | app/core/use_cases/api/frontend_service/v1/ai_agent_impact/show.rb | add flag-gated forecast derivation |
| modify | app/api/frontend_service/v1/entities/ai_agent_impact/get_response.rb | expose forecast when present |
| create | spec/.../forecast_spec.rb | present iff flag on + enough trend; bounded [0,1] |
Implementation steps
- Blocked — obtain the algorithm + min trend length + bounds from Decision 5 (OQ-10 owner: Dimas + BE).
- Explore —
system_preference.rbflag lookup (find_by(group_code:'rollout', code:'ai_agent_impact_report_forecast', enabled:true)). - Write failing tests (red) — forecast present iff flag on and trend ≥ min; value in [0,1].
- Implement the named algorithm; go green + RuboCop.
Acceptance criteria
- (unblock) algorithm + min trend length + bounds specified in Decision 5.
-
forecastpresent iff flag on and trend ≥ minimum. -
projected_netbounded to [0,1].
Test strategy
rspec: flag on/off × trend length above/below min; assert presence + bound.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: estimate assumes a simple linear fit once specified; a seasonal model would raise it.
Run to verify
RAILS_ENV=test bundle exec rspec spec/api/frontend_service/v1/ai_agent_impact && bundle exec rubocop
Depends on
- [Task 2.4] (report use-case). [External: REV-2 / OQ-10 forecast method — blocking].
Task 2.7: [FE] Wire store/service to the real report endpoint (IMPACT-S01..S05)
Swap the Phase-1 mock for the live
GET /v1/reports/ai_agent_impact; all tiles now render real data.
Status: ⚠️ Partially blocked — needs Task 2.4's live contract. Actionable the moment the BE endpoint is deployed behind its flag (deploy order BE-first).
Design reference: n/a — wiring only. DS version: @mekari/pixel3@^1.0.12.
What to build
Point the Task 1.1 service/store at the real endpoint (remove the fixture stub), pass start_date/end_date, and replace mock-based store assertions with real-contract assertions (snake_case consumed raw — OQ-5 verified low-risk).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | common/services/main/v1/ai-agent-impact.ts | call endpoint.v1.ai_agent_impact.report via $apiMain with range params |
| modify | store/ai-agent-impact/actions.ts | consume res.data (snake_case), set fetchStatus |
| modify | tests/unit/store/ai-agent-impact/index.spec.ts | assert real payload mapping + error envelope |
Implementation steps
- Explore — confirm
$apiMainreturnsresponse._dataand the Bearer injection inapi/mainResources.ts. - Write failing tests (red) — store maps a real-shaped 200 payload; 5xx →
rejected+impact_report_load_failed. - Implement the real call; delete the fixture branch.
- Go green →
pnpm test; quality gate →pnpm lint && pnpm build.
Acceptance criteria
- Report GET called with
start_date/end_date; tiles render live data. - snake_case consumed raw; nullable tiles degrade per the UI State Matrix.
- 5xx → error+retry +
impact_report_load_failed.
Test strategy
vitest with a mocked $apiMain returning the real envelope + an error case; assert mapping + fetchStatus.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: no camelCase mapper (OQ-5 verified); most work already done in Task 1.1.
Run to verify
pnpm test -- ai-agent-impact && pnpm lint && pnpm build
Depends on
- [Task 1.1], [Task 2.4] (live report contract).
Task 2.8: [FE] Wire CostAssumptionModal to GET/PUT + recompute (IMPACT-S04/AC-1..3, ERR-1)
Saving the modal persists via PUT, maps 422s to inline field errors, and the report's money figure recomputes on refetch.
Status: ⚠️ Partially blocked — needs Task 2.5's live contract.
Design reference: n/a — wiring only. DS version: @mekari/pixel3@^1.0.12.
What to build
Wire the Task 1.8 modal save to PUT .../cost_assumption, prefill from GET, map the exact 422 error.messages to field-level inline text (per §2.G), 403 → hide editor, then refetch the report so work_absorbed recomputes.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | common/services/main/v1/ai-agent-impact.ts | getCostAssumption / putCostAssumption |
| modify | modules/report/components/ai-agent-impact/CostAssumptionModal.vue | on save → PUT → on 200 refetch; map 422 to fields |
| modify | store/ai-agent-impact/actions.ts | cost-assumption fetch/save + report refetch |
| modify | modules/report/components/ai-agent-impact/CostAssumptionModal.spec.ts | 200/422/403 handling |
Implementation steps
- Explore — confirm the 422 envelope shape from Task 2.5 (
error.messages). - Write failing tests (red) — 200 closes + refetches; 422 → inline; 403 → editor hidden.
- Implement the PUT/GET wiring + refetch.
- Go green →
pnpm test; quality gate →pnpm lint.
Acceptance criteria
- Save → PUT; 200 closes modal and refetches report (work_absorbed recomputes).
- 422
error.messagesmapped to field-level inline text, nothing saved. - 403 → editor hidden.
Test strategy
vitest with mocked service for 200/422/403; assert refetch on 200, inline on 422, hidden editor on 403.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 0.5 |
| QA | 0.5 |
| Total | 1 |
Assumptions: modal UI + validation already built in Task 1.8; this is wiring + error mapping.
Run to verify
pnpm test -- CostAssumptionModal && pnpm lint
Depends on
- [Task 1.8], [Task 2.5] (live cost contract).
Task 2.9: [FE] Reports nav link + end-to-end tests (IMPACT-S01..S05, all NEG)
The report is reachable from the sidebar for eligible users, and an E2E suite proves the whole flow: owner sees tiles, agent sees no entry, cost save recomputes.
Status: ✅ Actionable once the FE feature is wired (Tasks 2.7/2.8). Note: tests/e2e/ is scaffolded (playwright.config testDir: ./tests/e2e) but currently has no specs — this task adds the first.
Design reference: n/a — nav + tests. DS version: @mekari/pixel3@^1.0.12.
What to build
A new entry in the listMenu computed in layouts/default.vue (flag-gated via an enable flag, following the existing pattern — there is currently no /report menu entry), and tests/e2e/ai-agent-impact.spec.ts.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| modify | layouts/default.vue | add { id, name, label, url:'/reports/ai-agent-impact', enable: aiAgentEnabled } to listMenu |
| create | tests/e2e/ai-agent-impact.spec.ts | owner → tiles; agent → no entry; baseline-forming; cost save recompute |
Implementation steps
- Explore — read
layouts/default.vuelistMenushape + howenableflags gate items;playwright.config.tstestDir. - Write E2E — seed a BE (or mock) for the report GET contract; scenarios above.
- Implement the menu entry with its enable flag.
- Quality gate →
pnpm lint && pnpm test:e2e.
Acceptance criteria
- Eligible user sees the sidebar link; ineligible/agent does not.
- E2E: owner opens report → tiles render; agent → no entry, no surfaced 403.
- E2E: cost save → money figure recomputes.
Test strategy
Playwright against a seeded BE (report GET contract); assert entry visibility per role and the cost-save recompute.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: first E2E spec in the repo (scaffolding exists); nav entry is a small array addition.
Run to verify
pnpm lint && pnpm test:e2e
Depends on
- [Task 2.7], [Task 2.8].
Ordering rationale
- Two tracks run in parallel. Phase 1 (FE, mocked) has no BE dependency and can start immediately alongside Phase 2's BE work — the mock fixture (Task 1.1) is the contract seam. This is the horizontal-mode payoff for a full-stack RFC whose deploy order is nevertheless BE-first.
- Critical path is the BE aggregator chain 2.1 → 2.2 → 2.3 → 2.4, then FE wiring 2.7. Task 2.2 is the single riskiest task (containment correctness, the REV-3 two-join turns derivation, the REV-1 reopen window) — front-load the OQ-3 confirmation and REV-1 decision so its acceptance criteria can be frozen before coding.
- Resolve the three review findings on their own clocks: REV-1 (reopen window) + REV-3 (turns) before Task 2.2; REV-2 (forecast method) before Task 2.6 — the only fully-blocked task, safely last behind a default-OFF flag.
- OQ-1 (feature code) / OQ-2 (role strings) gate the final correctness of Tasks 1.2 and 2.4 but not their construction — build against named constants and swap when confirmed.
- Deploy BE-first (migrations → aggregator → endpoints, flags OFF), then FE; roll back by toggling
ai_agent_impact_reportOFF (no deploy). Push externally on OQ-1/OQ-2/OQ-3 and the REV-1/REV-2 decisions to unblock the aggregator and forecast.
Skipped stories
None fully omitted — per request, all five impact stories and the NEG stories are broken down, with blocked/partially-blocked items included inline. The table below summarizes every task carrying a blocker so the gates are visible at a glance.
| Task / Story | Status | Unblocking condition |
|---|---|---|
| 2.6 — Forecast (IMPACT-S05/AC-2..3) | 🚫 Blocked | REV-2 / OQ-10 — Decision 5 must name the forecast algorithm, min trend length, [0,1] bound (safe last; flag-OFF default) |
| 2.2 — Aggregator core (IMPACT-S03/AC-3 turns; S01 reopen) | ⚠️ Partial | REV-3 / OQ-3 turns source confirmation (path verified: histories, bigint join) + REV-1 reopen-window decision |
| 2.3 — Aggregator worker (REV-1) | ⚠️ Partial | REV-1 — trailing-day recompute must be an acceptance criterion |
| 1.2 — Middleware; 2.4 — Report entitlement (IMPACT-S01-NEG) | ⚠️ Partial | OQ-1 AI add-on feature code + OQ-2 canonical FE role strings |
| 1.4 — After-hours tile (IMPACT-S01) | ⚠️ Partial | OQ-4 — per-org business-hours config (PRD §16 mitigation: ship without the tile) |
| 2.5 — Cost validation caps (IMPACT-S04) | ⚠️ Partial | OQ-3 (2nd half) — sane upper bounds for rate/minutes |
| 1.3–1.8 — pixel/contrast visual QA | ⚠️ Design-gated | OQ-6 — Figma frames (layout may proceed against wireframe) |
| IMPACT-S03-NEG, S04-NEG | n/a — not built | Out of scope (no CSAT/quality UI; web-only, no mobile) — correctly excluded from tasks |