agmission/server/docs/ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md

34 KiB
Raw Permalink Blame History

Advanced Reports — Implementation Plan (Phase 1)

Version: 1.0

Date: July 9, 2026

Status: Draft — derived from the approved planning set

Related Documents: ADVANCED_REPORTS_FEASIBILITY.md, ADVANCED_REPORTS_FUNCTIONAL.md, ADVANCED_REPORTS_NON_FUNCTIONAL.md, ADVANCED_REPORTS_API.md, ADVANCED_REPORTS_PROPOSAL.md


1. Approach

Build the Advanced Application Report on the existing report pipeline: the server computes analytics, renders map images, and writes a datasource; the client Stimulsoft viewer renders and exports. One analytics engine feeds every page (FR-5.4); content options are applied by shaping the datasource, not by swapping templates.

Guiding principles:

  • Pure-function analytics core — testable without HTTP, Mongo, or Puppeteer (NFR-6.3).
  • Legacy untouched — no shared-code change may alter preAppReport/loadsheet behaviour (NFR-5.1).
  • ApplicationDetail is read once per report (single pass, streaming cursor, projected fields only — NFR-1.2/1.4).
  • One Chromium instance per report for all captures (NFR-1.3).

2. Deliverables map

# Deliverable New/Changed files
D1 Analytics engine helpers/report_util.js (new)
D2 Endpoint + datasource builder controllers/advanced_report.js (new), routes/job.js (route), model/job.js (report-contents options persistence)
D3 Map page variants + multi-capture public/sprayMap.html (variants), helpers/web_util.js (browser reuse)
D4 Template + validation reports/app_advanced.mrt (authored in the Stimulsoft designer), scripts/validate_advanced_report_template.js (new)
D5 Frontend wiring client repo: Report Settings "Report Contents" panel, advanced-report option, viewer call
D6 Tests + offline harness tests/ fixtures + unit/integration tests, offline Stimulsoft harness
D7 Rollout backfill verification, template deploy to REPORT_DIR, release notes

Sequencing: D1 → D2 → (D3 ∥ D4) → D5 → D6 → D7. D3 and D4 are independent once D2 fixes the datasource shape. D6 grows alongside every deliverable (D1 unit tests land with D1).

3. D1 — Analytics engine (helpers/report_util.js)

Pure functions over point arrays; no I/O.

  1. Line segmentation — group ApplicationDetail points by llnum; sprayStat == 3 marks line start, spray-on = sprayStat ∈ {1, 3} (pattern: getSprayOnSegments, controllers/job.js:604).
  2. Zone assignment — point-in-polygon (turf) of each line's points against job.sprayAreas. A cheap sampled check first asks whether the line's points touch more than one real zone at all; if not, majority wins as before (FR-5.2). If it genuinely straddles two zones, the line is split into one segment per zone actually crossed (via a full, unsampled per-point pass), instead of handing the whole line to whichever zone the sample favors — fixes a real case where a small zone lost most of its coverage credit to a much larger neighbor across a shared boundary (FR-5.2 refinement, helpers/report_util.js: zoneOfPoint/straddlesMultipleZones/splitPassByZone). The single GPS interval that actually crosses the boundary is deliberately not counted in either segment's length/area/volume — a documented, bounded (one interval per crossing) trade-off rather than added complexity to split it between two zones.
  3. Per-line stats — start time, spray time, length (geoUtil.distance()km), avg speed (mean grSpeed over every sprayStat>0 record, sprayStat==3 marker included — matches the actual legacy avgSpraySpeed code, workers/job_worker.js:1470-1472; AGGREGATED_FIELDS_CALCULATION.md's prose description of this rule is wrong — it conflates the marker exclusion that applies to spray-time, not speed — verify against the real code, not that doc, if this ever comes up again), area = length × swath, app rate, avg |xTrack|, turn time (gap to next line, attributed to the zone the preceding pass was assigned to — a reused line number in a different zone must not inherit another zone's turn; pattern: turn-time loop workers/job_worker.js:1486).
  4. Zone roll-ups — sprayed area, coverage %, volume, flight/spray time, avg turn time, avg height, avg XT, avg flow rate (mean lminApp — completely new calculation, degrade when flat 0).
  5. Mission totals — sums/weighted averages of zone values (must equal page-1 figures exactly, NFR-3.3); ferry time/distance = flight spray. Total Volume / Avg App Rate dash at the mission level under the same no-flow-data condition zones/lines already do — no configured-rate (job.appRate × sprayedArea) fallback is substituted, since that figure is a pre-flight target, not a measurement, and would silently break the mission ≡ Σ(zones) invariant this section exists to guarantee (a manual rptOp.actualVol override is unaffected, being user-entered rather than assumption-based).
  6. Planned areas — turf area from polygon geometry, net of any intersecting job.excludedAreas (mirrors jobUtil.calcTTSprayAreas, matching the job's own Job.ttSprArea); sprayAreas[].properties.area is used as a shortcut only when there are no exclusion zones to net out (it's absent in most live data anyway, and can't be trusted once there's overlap to subtract). Mission Coverage % sums each zone's sprayed area capped at that zone's own planned area before dividing by total planned area — an overlapped/oversprayed zone must never numerically stand in for a zone that was never sprayed at all (that would let Coverage read 100% while zonesSprayed < zonesTotal, i.e. zones were plainly left untouched). Display then also caps at 100.0% as a defensive floor, though the per-zone cap already guarantees the ratio can't exceed it. A zone's own coverage % (shown on its Zone Detail page / Mission Coverage Grid card), by contrast, is not capped — real overspray from swath overlap, turns, or re-flown sections is normal and legitimate, and can genuinely exceed 100% of that zone's own plan; showing the true figure is more useful than flattening it to a look-alike "100%". When a manual Report Settings override supplies the planned/sprayed totals directly (no per-zone breakdown to cap against), that fallback ratio is likewise not capped, for the same reason — the override's own numbers can legitimately claim more was sprayed than planned, and hiding that behind "100%" reproduces the exact problem this section exists to avoid.
  7. WeatherjobUtil.getDataWeatherInfoPerField(fileIds) (helpers/job_util.js), added in place of the original getDataWeatherInfo reuse: each of windSpd/windDir/temp/humid is validated and dashed independently, so one implausible sensor field (e.g. a stuck temperature reading) doesn't blank the other three the way the legacy all-or-nothing $match filter still does; manual job.weatherInfo override takes precedence when set. temp follows the job's measureUnit (°F/°C) like every other quantity, matching legacy's own formatting.

Unit-test fixtures (with D1): typical multi-zone job, no-flow-controller job (lminApp = 0), SatLoc-style job (no xTrack/turn data), unsprayed zone, single-zone job, boundary-straddling line (majority-zone case: leaves a zone into empty space, stays whole), genuine cross-zone-boundary line (splits into one segment per zone actually crossed).

4. D2 — Endpoint + datasource (controllers/advanced_report.js)

  1. POST /preAdvancedReport in routes/job.js, same auth middleware as preAppReport (NFR-4.1). Returns { rid, path, c } (FR-1.1).
  2. Controller flow (mirrors preAppReport_post): load job with populated refs → persist Report Settings incl. Report Contents selections (job.rptOp pattern, FR-7.5) → stream ApplicationDetail by the job's fileIds with field projection (NFR-1.2) → run D1 engine → render maps via D3 → write rptDS.json → select template.
  3. Datasource shape (all display values pre-localized strings, FR-1.3):
    • mission — header/info block, KPI tiles, statistics, generation date. mission.farm (added post-Phase-1; API changelog 1.13) sources job.farm, the same field the legacy report already labels "Farm:".
    • zones[] — per-zone info + stats + map image refs; empty-state zones carry placeholder values (FR-4.6); filtered per Report Contents options. zones[].farm (added alongside mission.farm) repeats the same mission-wide value on every zone, the same pattern already used for zones[].crop/product.
    • lines[] nested per zone — flight-line table rows; single placeholder row for unsprayed zones; omitted when Flight Line Statistics is off, purely per the caller's own Report Contents choice — this table is never role-gated. The report's map captures, separately, omit the ferry/flight-path polylines (spray lines are unaffected) for a role not authorized to view flight paths (flightPathViewRoles, helpers/constants.js — the same APP/APP_ADM/OFFICER set the client's Job Map gates its Flight Paths overlay behind) — this is scoped to the map imagery only, mirroring the Job Map's own scope (the overlay there is a map layer, not a data table).
    • products[], weather (suppressed when unavailable), coverageCards[] (all zones, always).
  4. Template selection: app_advanced_<applicatorId>.mrt else app_advanced.mrt; applicator id sanitized to hex ObjectId (NFR-4.2).
  5. Structure the generation function so a worker can call it without the HTTP layer (NFR-2.3); a simple in-process counter limits it to max 2 concurrent generations (NFR-2.2).
  6. Per-phase pino logging: query, data aggregation, each capture, datasource write (NFR-7.1).
  7. Generation cache (implemented post-Phase-1 — resolves Open Item below; API changelog 1.11): before touching ApplicationDetail or Chromium, hash everything that would actually change the output — zone/exclusion geometry, job.rptOp/useCustWI/weatherInfo, applicator, an imported-data fingerprint (each App.updateDate, since ApplicationDetail rows carry no timestamp of their own), Report Contents, dataOp, language, and the requester's flight-path-visibility role — against a Job.advRptCache hash saved from the previous generation. On a match, and only if that prior run's rptDS.json is still on disk, return the previous {rid, path, c} directly, skipping the analytics engine and every map capture entirely. Template selection is always recomputed fresh either way, since a .mrt file can be added/removed independently of anything that would invalidate the cache.

5. D3 — Maps (public/sprayMap.html variants + helpers/web_util.js)

  1. Extend web_util to open one Chromium instance and capture multiple pages/states per report (NFR-1.3).
  2. Mission map variant: fitBounds over all zones; numbered markers + zone/field names + acreage labels; legend/scale/north arrow. Mode switch (FR-2.3.3): compute zone pixel footprint at fitted zoom — below threshold (~25 px) render locator badges (divIcon, zone numbers) instead of polygons (FR-2.3.2). Spray corridors and flight-path lines are not shown on this top-level overview capture, for any user role — plain zone polygons + number/name/area labels only (changed this session); that detail is already shown per-zone on the Mission Coverage thumbnails and Zone Detail maps (item 3 below), and missionOverview() explicitly hides every _zoneIdx-tagged layer before this capture is taken.
  3. Zone detail variant: per-zone fitBounds, boundary + spray lines + dashed ferry lines, neighbouring zones faded into the background; unsprayed zones render boundary + ferry only.
  4. Background toggle: when Hide Map Background is selected (FR-7.4), all variants skip the satellite tile layer and render on the plain dark-green background used in the mockups (a fixed CSS background on the map container) — no tile downloads during capture.
  5. Thumbnails: the same per-zone focusZone fit/refit used for that zone's own Zone Detail page, but captured as its own dedicated file (zone_thumb_<n>.jpg, distinct from Zone Detail's zone_<n>.jpg) — skipped entirely above 12 zones (FR-3.5) or when zone pages are excluded (Report Contents). Revised this session (controllers/advanced_report.js, public/sprayMapAdvanced.html) away from an earlier approach that cropped a rect out of the single all-zones capture (sized via each zone's pixel footprint at the shared mission-wide zoom): that crop's native resolution and framing were both at the mercy of whatever zoom the mission view had to use to fit every zone at once, which produced inconsistent thumbnails across jobs — blurry/thick boundaries for small zones next to large ones (Job #105), and crops dominated by ferry-track clutter for zones with a widely-separated sibling forcing a very zoomed-out mission view (Job #108). Reusing focusZone's independent per-zone fitBounds gives every zone its own correctly-scaled capture regardless of the other zones' size or spread, eliminating the need for the crop/aspect-ratio/minimum-size-floor logic the earlier approach required. applyZoneFocusStyle (also added this session) shows only the focused zone's own spray corridors and flight-path segments — both now carry a zoneIdx tag (mirroring each other) — fading/hiding neighbouring zones' data instead of showing everything in range. The thumbnail capture briefly bumps the zone/exclusion polygon boundary stroke via window.setZoneStrokeWeight (a later revision this session) before its shot, resetting it back for the Zone Detail shot — the Mission Coverage card embeds the same source image at a much smaller physical size (~57×36mm vs Zone Detail's ~190×135mm), so a shared fixed-pixel stroke width would print roughly 3x thinner on the card than on the Mission Overview/Zone Detail pages; the two captures could not stay a single shared file once this diverged.
  6. Zone capture failure → placeholder image + log, report continues; mission map failure → request fails (NFR-3.1).
  7. Capture timing (revised post-Phase-1; API changelog 1.11): the fixed post-fitBounds settle delays described above (and 1.10's later "cancel any pending settle timer" patch) were replaced with an adaptive mechanism (waitForMapIdle/waitForBasemapReady, sprayMapAdvanced.html) that waits for each basemap's own authoritative "finished" signal instead of a delay of any kind: Google's real tilesloaded event for the premium satellite basemap (captured once via GoogleMutant's one-time spawned event and reused for every later refit — initMapBaseLayer, utils.js, now returns its layer reference(s) for exactly this) and Leaflet's own repeatable load event for the plain Esri/OSM layers, falling back to a DOM-mutation watch (filtered to <img>-specific activity, so Leaflet's own non-image grid-layer scaffolding can't fool it into declaring "done" before a single tile has actually arrived) only when neither basemap reference is available. A window.loaded property gate absorbs initMapBaseLayer's own premature write — tied to spawned, not to when tiles actually render — so it can't win the race against the real signal. Fixed a related bug found on a real 5-zone job: refocusing the same zone a second time for its Mission Coverage thumbnail (identical camera position to the Zone Detail shot just captured) never gets a new tile-load event at all, since an unchanged view requests no new tiles; focusZone now recognizes a same-zone refocus and skips straight to a short fixed settle instead of waiting on a signal that will never arrive. Verified against that job's real data: the full 12-capture batch (map + 5 zone details + 5 thumbnails) went from 602s wall-clock with 10 of 12 shots failing outright (idling out to the 60s per-shot timeout) down to ~5.4s with all 12 succeeding — this was the actual cause of the blank/missing Mission Coverage thumbnails seen in production, not a template or datasource defect. Explored and reverted, not shipped: generating the Mission Coverage thumbnail by compositing the already-captured Zone Detail frame (redrawing just the heavier boundary stroke on top via an in-page canvas/SVG overlay, screenshotted directly) instead of a second live focusZone capture — fully implemented (window.renderZoneThumbOverlay/clearZoneThumbOverlay, a new shot.fn escape hatch in web_util.js's webShotBatch) and verified correct in isolation, then reverted at product's request; the same-zone-refocus fix above addresses the performance/reliability problem this was originally meant to solve, so the added complexity wasn't kept.

6. D4 — Template (reports/app_advanced.mrt, authored in the Stimulsoft designer)

  1. Three page designs authored manually in the embedded Stimulsoft designer: Mission Overview, Mission Coverage, Zone Detail (master band per zone, flight-line table as StiPanel-wrapped child band). Mission Coverage's grid density now scales in three tiers by zone count, mutated on the loaded Stimulsoft report object at render time (report.component.ts, not baked into the .mrt as separate layouts) rather than the earlier binary "grid ≤12 / compact table >12" split: ≤6 zones get 2 columns with large cards (the template's own baked-in default, card-to-card gap unified to a consistent 6mm both horizontally and vertically); 7-12 zones get 3 columns at the original card size; >12 zones get a compact, map-free text-only grid (thumbnail collapsed to zero height, same 60mm column width as the 7-12 tier so its already-correct text-box widths are reused rather than re-derived). Verified via a Puppeteer harness driving the real Stimulsoft engine — confirmed Columns/ColumnWidth/component left/top/width/height are plain settable properties post-load and the render actually reflects the mutation, not just the property read-back — before landing in report.component.ts. A follow-up to also split Sprayed/Planned into separate fields and add Crop/Volume Applied to the >12-zone card was implemented, verified, and then reverted at product's request pending a team-lead decision on the compact tier's page-space usage (see the >12 tier's still-noticeable blank space below a short zone list, e.g. 13-14 zones) — not currently in either template or report.component.ts. Zone Detail's Zone Info panel gained a Product row (mission-wide active-ingredient list, positioned after Crop) and its Flight Statistics box was reorganized into two even columns (Start/End Time, Flight/Spray Time, Avg Turn Time on the left; Avg Speed/Height/XT Error/App Rate/Flow Rate on the right) with uniform 2mm padding. Mission Overview's Remark line, together with the fixed page-wide vertical rhythm (Mission Facts/Map/KPI-cards/Mission-Statistics/Products/Weather gaps), needed rework after discovering MissionBand's lack of CanGrow let a job with more products/weather rows than the template was originally sized for push Remark past the band's own declared height and force an unwanted near-empty continuation page — fixed with CanGrow: true plus tightened gaps (see API doc §12 changelog 1.9 for exact values); relocating Remark onto the Mission Coverage page instead was prototyped and verified working as an alternative fix, then not shipped in favor of keeping it on Mission Overview. All three pages' header banners also got their logo/title margins tightened (8mm→6mm) and the "Advanced Application Report" title enlarged and vertically re-centered against the logo's actual optical center.

  2. Validation script scripts/validate_advanced_report_template.js (NFR-5.2/6.1), run after every designer save and before deploy. Checks: no empty {} collections in the .mrt JSON; GlobalizationStrings for en-US / pt-PT / es-ES with non-empty Items targeting existing components; unique component names; every band's DataSourceName / MasterComponent / DataRelationName resolves against the Dictionary; DataBands nested inside DataBands are StiPanel-wrapped; every {table.column} expression references a declared Dictionary column.

  3. Section suppression via empty datasets; dash placeholder rows come from the datasource, not template logic.

  4. Committed .mrt is the source of truth (NFR-6.1). Footer Created <date> + page/totalPages; header band per page type.

  5. Stimulsoft band positioning gotcha (discovered 2026-07-31, worth knowing before touching any page-level spacing): a band's own ClientRectangle Y coordinate is not what places it on the page — the render engine stacks each band immediately after the actual rendered height of whatever band precedes it. Only static (non-band) child components — e.g. a plain StiText/StiPanel like pnlZnZone — honor their own authored relative Y offset once their parent band's position is resolved; a nested band (like the Flight Line Statistics header+data band pair) discards its own and its ancestors' declared offsets entirely when it resumes on a continuation page, resuming flush against whatever precedes it. Practical upshot: the gap below a repeating page header (StiPageHeaderBand) is controlled purely by that header band's own Height versus its visible content's height — not by the following content band's Y — and that same header Height simultaneously controls the gap on both the page's first occurrence and any later continuation page. Each page type (ReportTitleBand1+MissionBand, PageHeaderBand2+coverageBand, PageHeaderBand3+ZoneBand) needs this calibrated the same way for consistent gaps across pages. Separately, a data band's declared height is only respected if CanShrink is false — with CanShrink: true (the default used on coverageBand), the band silently collapses back down to fit its tallest child regardless of the declared height, which is why a naive "just make the band taller" fix for row spacing has no visible effect until CanShrink is turned off.

  6. Farm field + Mission Statistics rebalance (implemented post-Phase-1; API changelog 1.13): a new "Farm:" row was added to the Mission Facts panel on Mission Overview (right after "Job:", before "Crop:") and to the Zone Info panel on Zone Detail (right after "Zone:", before "Crop:") — pnlFarm/pnlZnFarm panels each containing a label StiText and an Expression-type value StiText bound to {mission.farm}/{zones.farm}, following the exact same label+value StiPanel shape as the existing rows in each panel. Every component positioned below the new row within its own panel and within the page's overall vertical stack was shifted down 5mm (one row height) to preserve existing spacing exactly — computed from each panel's established row-height convention rather than eye-balled, then verified against real job data via a purpose-built offline Stimulsoft-rendering harness (loads the live .mrt + a real job's rptDS.json through the same StiReport/StiViewer sequence report.component.ts uses, screenshotted via Puppeteer) before either live template file was touched. Separately, per product request: Mission Statistics' "AppRate:" row was removed (the mission-level configured/overridden rate, mission.appRate — distinct from the flow-derived mission.avgAppRate, which stays and is unaffected) and the remaining rows rebalanced from an uneven 5/4/4 column split into three even 4-row columns — column 3's Avg AppRate/Avg Flow Rate/Total Volume each moved up one row into AppRate's vacated slots, "Ferry Time" moved from column 1 into column 2's newly free 4th row, and "Swath Width" moved from column 2 into column 3's newly free 4th row; column divider lines shortened to match the new uniform 4-row height. mission.appRate itself is still computed and present in the datasource, just no longer rendered. New GlobalizationStrings added for lbFarm.Text/lbZnFarm.Text across en-US/pt-PT/es-ES ("Farm:"/"Fazenda:"/"Finca:"). Applied directly to both live REPORT_DIR .mrt files (base + per-applicator override) after taking backups; the branch's own version-controlled .mrt copies under reports/ remain out of sync with those live files, per the pre-existing, deliberately-deferred branch-vs-trunk reconciliation noted elsewhere in this doc.

  7. Mission Duration row removed (implemented post-Phase-1; API changelog 1.14): dropped from the Mission Facts panel as redundant with "Total Flight Time:" already shown in Mission Statistics. pnlDuration deleted, pnlMissionFacts shrunk by one row height, and everything below it in the page's vertical stack shifted back up 5mm — exactly reversing the downward shift item 6's Farm-row insertion required, so the rest of the page (Products, Weather, Remark) is untouched. mission.duration stays in the datasource unused, per the same precedent as mission.appRate in item 6. Verified the same way, against the live override file and real job data.

  8. Mission Statistics bottom whitespace tightened (implemented post-Phase-1; API changelog 1.15): items 6 and 7 above left the panel's 4 content rows (20mm) inside an unchanged 30mm-tall box. pnlMissionStats height reduced to 25 (a consistent 3mm margin above and below the content), Products/Weather/Remark shifted up 5mm to close the gap. Verified the same way.

  9. Remark relocated to Mission Coverage page when it would overflow (implemented post-Phase-1; API changelog 1.16): a long product list (real case: job 106, 6 products) can push Remark past Mission Overview's fixed page budget, spilling it alone onto a near-empty continuation page. Rather than emulating Stimulsoft's text-layout engine to predict the overflow exactly, mission.remarkOnCoverage (controllers/advanced_report.js) is a simple deterministic proxy: true when products.length > 5. Both live .mrt files gained a mirrored Remark row (pnlRemark2/lbRemark2/txtRemark2) as a standalone component on the Mission Coverage page, next to (not nested in) coverageBand. report.component.ts's existing pre-render mutation function (§5 D3 item 6 / §6 D4 item 1) now also toggles .enabled on whichever Remark row applies and, for the relocated case, computes pnlRemark2's absolute top from coverageBand.top plus the expected grid height (Math.ceil(zoneCount / columns) * rowHeight, same per-tier constants as the coverage-grid-density mutation) — since a plain StiPanel on a page doesn't auto-stack after a preceding repeating data band the way two Bands would. Verified via the render harness for both the ≤5-product and >5-product cases against the live override .mrt and job 106's real data. This one requires a client rebuild to take effect, unlike the live-file-only template edits in items 6-8.

  10. Mission Coverage card spacing tightened (implemented post-Phase-1; API changelog 1.17): the ≤6-zone tier's card layout (the .mrt's own baked-in default, §6 D4 item 1) used a looser rhythm than the rest of the report — a 6.23mm name row plus two 4.98mm rows with ~1.25mm gaps between them, instead of the 5mm-contiguous-row convention used in Mission Statistics and Flight Statistics. Retiled the three text rows (zone name, Sprayed/Planned, Coverage %) to 5mm each, back-to-back. Margins went through three rounds of feedback before landing on explicit values: 4mm left/right, 3.75mm bottom (1mm → 4.63mm-to-match-left/right → 3.5mm → 3.75mm as the final call). pnlCard/coverageBand's heights shrunk by the net amount saved, preserving the existing gap between grid rows; cardThumb itself was never touched. The 7-12 and >12 tiers (already hand-tuned tight in report.component.ts) were untouched. Verified via the render harness, before/after, against real job data.

  11. Remark-relocation regression fixed (API changelog 1.18): item 9's pnlRemark2 positioning read coverageBand.height as the row pitch, which drifted stale the moment item 10 retuned that height, and turned out to be an unreliable proxy regardless — CanShrink collapses the band's declared height to the card's real rendered size at render time, so even a fresh copy overestimates the true pitch. On job 108 (5 zones, 6 products) this pushed Remark past the visible page, reproducing the exact overflow bug item 9 was meant to fix. Fixed by reading pnlCard.height instead, the same property the grid-density mutation already sets per tier — can't drift out of sync with whatever tier is active. Verified against job 108's real data via the render harness.

  12. Remark anchored to the footer instead of the grid (API changelog 1.19): per product feedback, relocated Remark should sit just above the page footer rather than directly below the grid — a low-row-count tier (job 96's 9-row >12-zone grid, job 109's 3-row 7-12-zone grid) otherwise leaves a large, inconsistent gap. Reads PageFooterBand2.top dynamically (differs between the base and per-applicator .mrt) and reserves a fixed 20mm text budget + 3mm gap above it, falling back to right-after-the-grid only when the grid already extends past that point (a near-full 7-12-zone tier). Verified against jobs 108, 96, and 109's real data via the render harness.

  13. Mission Overview map enlarged, overflow threshold retuned (API changelog 1.20): missionMap height 104→110mm, everything below it on the page shifted down 6mm to match. This consumed 6mm of the same page-space margin item 9's overflow fix depends on; reverified via the render harness (job 108 data, trimmed to 2-6 products) that the safe cutover moved from 5 to 4 products with the taller map. Retuned mission.remarkOnCoverage's threshold from >5 to >4 products accordingly and reconfirmed the previously-safe 5-product case now relocates cleanly instead of overflowing, without disturbing the real 6-product job108 case items 9/11/12 already cover.

  14. Mission map height corrected 110→107mm (API changelog 1.21): recovers 3mm of item 13's margin spend; everything below the map shifted up 3mm to match. Left the remarkOnCoverage threshold at >4 rather than loosening it back — over-relocating costs nothing, unlike the under-relocation item 13 had to fix. Reverified the 4-product boundary and the real 6-product job 108 case via the render harness.

  15. Mission map height 107→106mm, map-to-KPI gap 3→3.5mm (API changelog 1.22): net -0.5mm shift on everything below the map (widens the overflow margin slightly, so no threshold change needed). Verified via the render harness against real job 106 and job 108 data.

  16. remarkOnCoverage threshold loosened back to >5 (API changelog 1.23): 1.21/1.22's map-height reductions recovered enough margin that 5 products now fits Mission Overview cleanly again (it needed relocation under 1.20's >4, set when the map was still taller/tighter); 6 products still genuinely overflows if forced to stay, reverified with job 108's real 2-line remark text via the render harness. A visible blank gap below Weather on job 108's Mission Overview render turned out to be leftover slack after Remark was already excluded, not room additional to it.

  17. remarkOnCoverage now weighs remark length too (API changelog 1.24): caught a gap where a long remark alone (job 108's duplicated 3-line remark) overflowed at only 5 products — a count item 16 had just confirmed safe for the usual 2-line case. Modeled as a shared budget: each product row and each estimated remark line beyond the first costs one unit, calibrated against three real data points (5p/2-line=safe, 6p/2-line=overflow, 5p/3-line=overflow) that all land on a budget of 5. Line count estimated from character length ÷ 100, deliberately conservative. Verified via the render harness across all four boundary cases.

  18. "Farm:" rendering blank, fixed (API changelog 1.25): the datasource was correct (mission.farm/zones[].farm verified present in a live-cached job 108 generation) — the bug was that 1.13's original Farm-field work never registered farm as a declared column on the mission/zones Dictionary data sources in the .mrt. Stimulsoft binds {table.column} expressions against that design-time-declared schema, not the runtime JSON, so the expression silently resolved to nothing regardless of the actual data. Fixed by adding the missing column declarations to both tables; also fixed txtFarm missing the HorAlignment: "Right" its sibling Mission Facts fields have. Reverified on both Mission Overview and Zone Detail with real job 108 data.

7. D5 — Frontend (client repo)

  1. Report Settings dialog: add right-side Report Contents panel — Include All Zone Detail (default on), nested Sprayed Zones Only (default off), Include Flight Line Statistics (default on), Hide Map Background (default off), info tooltips (FR-7.4); restore last selections per job.
  2. Advanced Report as a report option alongside the legacy report; on Preview call preAdvancedReport and hand {rid, path} to the existing viewer unchanged.

8. D6 — Testing & verification

  1. D1 unit tests over fixtures (all FR-8 degradation rows covered, NFR-3.4).
  2. Cross-page consistency test: mission totals ≡ zone roll-ups (NFR-3.3).
  3. Offline Stimulsoft harness (file:// + stimulsoft.reports.pack.js) loading the real .mrt + generated rptDS.json — reproduces viewer load/localize/render without the app (NFR-6.2); Trial watermark acceptable in tests.
  4. Integration run against a live-like multi-zone job; visual check of all three page types, both map modes, >12-zone compact layout, both unit systems, all three cultures.
  5. Performance measurement against NFR-1.1 (~35 s per 10 zones; ~15 s typical 3-zone job) with per-phase timings from NFR-7.1 logs.

9. D7 — Rollout

  1. Verify production aggregate coverage (avgXtError, avgSpraySpeed, totalFlightLength) on recent Applications; re-run scripts/migrate_applications.js only if gaps found (NFR-8.1).
  2. Deploy app_advanced*.mrt to the environment's REPORT_DIR (may be outside this repo — NFR-6.4).
  3. Release notes: flow-rate fields require a flow controller; SatLoc-sourced applications omit XT/turn statistics (NFR-8.2).

10. Open items

  • F-OQ-1 Page orientation (portrait-only vs landscape variant) — blocks D4 template freeze; portrait assumed until the PO decides.
  • F-OQ-2 Compact coverage layout threshold (more than 12 vs 12-and-above; threshold value) — affects D3 thumbnail logic and the D4 coverage page; "more than 12" assumed until the PO decides.
  • Regeneration reuse/caching for repeat downloads of unchanged reports — Implemented post-Phase-1 (see D2 item 7 above; API changelog 1.11), not deferred after all.