Day 2 feedback · T32 · proposed fix · not applied
Team Performance starts blank each day
On day 2 the Team Performance cards still showed day 1’s speed, hours, audits and verdicts beside “0 captures today”. The fix makes every figure on the panel today’s own, so a new day starts blank, while the top bar, the room list and the red flags keep the project to date. API only — the screen and its client do not change.
- Files changed
- 4all in the API
- Front-end changes
- 0same fields, same generated client
- Keeps the project to date
- 3top bar, rooms, red flags
- Migrations
- 0no schema change
The team query falls back to the project to date for anyone who has not captured today — that is why day 2 opened on day 1’s numbers. The patch scopes the panel to today for everyone: speed, hours on site, the Audit cell and the verdict. The top bar’s audit totals and the failed-audit red flag move onto their own to-date figures, so they do not reset with it.
The askday 2 feedback
this dashboard was the most misleading of day - for day 2 be nice if this part was refreshed for starts so its blank for day 2, while all the other data stays (top bar and room data)
Day 2 feedback · 27 Sep
The screenshot behind it: every card read Captures 0 · Today, yet still carried a speed (5.9, 5.6, 3.4 captures/min), on-site hours such as 5.7 / 8, audits such as 2/2 and an Ahead or On pace verdict — with the header claiming a team speed of 21.3 captures/min and 1,278 projected per hour. All of it was day 1.
Day 2, first thingwhat each part reads
Why day 2 opened on day 1cause
The panel’s pace, hours and on-site time come from paceCte(). Its day filter keeps today for anyone who has captured today and every day so far for anyone who has not — so at 8 AM on day 2, before the first capture, every card showed day 1’s figures under a “Today” label.
-- pace.sql.ts, scoped_day — which days a person's pace, hours and on-site time cover
WHERE CASE
WHEN EXISTS (SELECT 1 FROM worked_today w WHERE w.user_id = s.user_id) THEN s.day = <today>
ELSE TRUE -- not captured today: every day of the project so far
END
The Audit cell had the same shape: both audit queries (consumable_audits on a consumables run, the required-field check on an asset run) cover the whole project, with no day in them. The verdict then judged those day-1 figures against a day-1 median.
The fix gives the panel its own scope rather than changing the filter itself, because the Speed side sheet leans on the same filter to fill its day picker:
-- the team panel asks for today only; the Speed side sheet keeps today-or-to-date
paceCte(consumables, { todayOnly: true }) → WHERE s.day = <today>
paceCte(consumables, { oneUser: true }) → WHERE CASE … END (unchanged)
What keeps the project to dateunchanged on screen
The changefour files, API only
paceCte takes { oneUser, todayOnly }; todayOnly keeps every person to today.{ oneUser: true } — same behaviour as today.today flag (audits made today). The list of products each person captured is shared, so the two audits cost one scan of it.audited (to date) beside audited_today. Cells and verdict read today; the banner totals and the red flags read to date.No DTO changes, so the swagger spec is identical and the front end needs neither a code change nor a client regeneration.
API patchbackendApi · capExpertAPI
capture-live-pace.service.tsbackendApi/src/primary/modules/capture-dashboard/capture-live/capture-live-pace.service.ts · +1 −1
--- a/src/primary/modules/capture-dashboard/capture-live/capture-live-pace.service.ts+++ b/src/primary/modules/capture-dashboard/capture-live/capture-live-pace.service.ts@@ -86,7 +86,7 @@ const iso = (col: string) => `to_char(${col} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`; const [row] = await db.sequelize.query<PaceDetailRow>(- `WITH ${paceCte(consumables, true)},+ `WITH ${paceCte(consumables, { oneUser: true })}, pick AS ( SELECT COALESCE((SELECT day FROM scoped_day WHERE day = CAST(:day AS date)), (SELECT MAX(day) FROM scoped_day)) AS day ),
capture-live.service.tsbackendApi/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts · +50 −38
--- a/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts+++ b/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts@@ -8,7 +8,7 @@ import { DenovoInventoryStatusEnum, ScopeType } from 'src/shared/utils/enum'; import { readableWhere, resolveCaptureScope } from '../access/capture-scope'; import { CaptureColorKeyEnum, CaptureInsightTypeEnum, CaptureLocationStatusEnum, CaptureSeverityEnum } from '../dto/capture-dashboard.enum';-import { CONSUMABLE_GROUP_CTE, CONSUMABLE_ROOM_CTE, CONSUMABLE_TEAM_AUDIT_CTE } from './consumable-scope.sql';+import { CONSUMABLE_CAPTURER_SKU_CTE, CONSUMABLE_GROUP_CTE, CONSUMABLE_ROOM_CTE, consumableTeamAuditCte } from './consumable-scope.sql'; import { paceCte, paceReplacements, PaceSettings, resolvePaceSettings } from './pace.sql'; import { projectCapturesSql, projectFacilitiesSql, projectRoomsSql, projectStartSql, projectTeamSql, stickerScopeSql } from './project-rooms.sql'; import {@@ -173,6 +173,7 @@ audit_shown: number; audit_base: number; clean_rate: string;+ project_audit_failed: number; insight_type: CaptureInsightTypeEnum; account_type: string | null; team_median: string | null;@@ -181,13 +182,20 @@ audits_pending: number; } -/** The team's audits, summed as its Audit tiles show them. */+/** The team's audits to date, summed person by person. */ interface TeamAudits { auditsPassed: number; auditsDone: number; auditsPending: number; } +/** A person's failed audits to date — what the quality red flag reads, whatever today holds. */+interface TeamQuality {+ id: string;+ name: string;+ auditFailed: number;+}+ interface NthWorkingDayRow { day: string | null; }@@ -332,18 +340,24 @@ /** * What the team tile's audit rate is measured over: required fields on an asset run, the room audits- * of the capturer's products on a consumables one. Both hand back `total` and `needs_review` per capturer.+ * of the capturer's products on a consumables one. Both hand back `total` and `needs_review` per+ * capturer, twice: `audited` for the project to date and `audited_today` for today's work. */ private auditedCte(consumables: boolean): string {- return consumables ? CONSUMABLE_TEAM_AUDIT_CTE : this.assetAuditCte(consumables);+ return consumables+ ? `${CONSUMABLE_CAPTURER_SKU_CTE}, ${consumableTeamAuditCte('audited')}, ${consumableTeamAuditCte('audited_today', true)}`+ : `${this.assetAuditCte(consumables, 'audited')}, ${this.assetAuditCte(consumables, 'audited_today', true)}`; } /**- * The Audit cell's figures, `audit_shown` over `audit_base`: audited over captured on an asset run,- * SKUs whose audit matched the system count over the SKUs audited on a consumables one.+ * Today's Audit cell, `audit_shown` over `audit_base`: audited over captured on an asset run, SKUs+ * whose audit matched the system count over the SKUs audited on a consumables one. Plus the passes to+ * date the banner totals read — a consumables figure, so an asset run carries 0. */ private auditFiguresSql(consumables: boolean): string {- return consumables ? 'COALESCE(au.passed, 0)::int AS audit_shown, COALESCE(au.total, 0)::int AS audit_base' : 'COALESCE(au.total, 0)::int AS audit_shown, COALESCE(ps.total, 0)::int AS audit_base';+ return consumables+ ? 'COALESCE(aut.passed, 0)::int AS audit_shown, COALESCE(aut.total, 0)::int AS audit_base, COALESCE(au.passed, 0)::int AS project_audit_passed'+ : 'COALESCE(aut.total, 0)::int AS audit_shown, COALESCE(ps.today_count, 0)::int AS audit_base, 0 AS project_audit_passed'; } /**@@ -353,11 +367,11 @@ * An asset with an inventory row is read from its columns, a standalone processed sticker from its * `extracted_data`. A known model answers manufacturer, model and model number at once, and * declaring a tag missing answers that tag. Consumables and items no longer on the floor are out of- * the population rather than counted against it.+ * the population rather than counted against it. Written as CTE `name`; `today` keeps it to today's captures. */- private assetAuditCte(consumables: boolean): string {+ private assetAuditCte(consumables: boolean, name: string, today = false): string { return `- audited AS (+ ${name} AS ( SELECT k.created_by AS user_id, COUNT(*)::int AS total, COUNT(*) FILTER (@@ -389,7 +403,7 @@ COALESCE((CASE WHEN pa.id IS NOT NULL THEN pa.additional_details ->> 'biomedTagEnabled' ELSE a.additional_details ->> 'biomedTagEnabled' END)::boolean, false) AS biomed_on ) tag WHERE ${projectRoomsSql()} AND k.deleted_at IS NULL AND ${projectCapturesSql()}- AND ${stickerScopeSql(consumables)}+ AND ${stickerScopeSql(consumables)}${today ? ' AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date' : ''} AND ( (k.inventory_id IS NOT NULL AND i.id IS NOT NULL AND i.discontinue_date IS NULL@@ -423,7 +437,7 @@ this.staleRooms(project.id, consumables), ]); // Reads the banner's forecast, so it runs once that has resolved.- const redFlags = await this.buildRedFlags(project.id, consumables, banner, stale, team.rows, pace);+ const redFlags = await this.buildRedFlags(project.id, consumables, banner, stale, team.quality, pace); return { // The audit totals come off the team rows, so the banner and the Audit tiles cannot disagree.@@ -1045,14 +1059,7 @@ * Each one is a condition the data actually shows — inaccessible rooms nobody revisited, a * capturer whose clean rate slipped, rooms left mid-capture, and a run tracking behind its window. */- private async buildRedFlags(- projectId: number,- consumables: boolean,- banner: CaptureLiveBannerDto,- stale: StaleRoomRow[],- team: CaptureTeamPerfRowDto[],- pace: PaceSettings,- ): Promise<CaptureRedFlagDto[]> {+ private async buildRedFlags(projectId: number, consumables: boolean, banner: CaptureLiveBannerDto, stale: StaleRoomRow[], quality: TeamQuality[], pace: PaceSettings): Promise<CaptureRedFlagDto[]> { const flags: CaptureRedFlagDto[] = []; // Rooms the team could not enter, and how many of those still have nothing captured. The@@ -1091,9 +1098,8 @@ }); } - // The team rows already carry each person's clean-capture rate; flagging off them costs no- // second pass over the stickers, and the two panels cannot disagree on the number.- const failing = team.filter((row) => row.auditFailed >= pace.maxFailedAudits);+ // Failed audits to date, off the team query: a quality concern outlasts the day its audits failed.+ const failing = quality.filter((row) => row.auditFailed >= pace.maxFailedAudits); for (const person of failing.sort((a, b) => b.auditFailed - a.auditFailed)) { flags.push({ id: `quality-${person.id}`,@@ -1132,11 +1138,12 @@ /** * One row per person who captured on this project or was rostered onto one of its visits. *- * Pace covers today when they scanned today, the project to date otherwise. `capturesPerMinute` is- * captures per active minute, null until there is enough to measure. The team median spans the rows- * with a pace and is what each verdict is judged against.+ * Every figure on a row covers today, so a new day starts blank: pace, hours on site and the Audit+ * cell. `capturesPerMinute` is captures per active minute, null until today holds enough to measure.+ * The team median spans today's paces and is what each verdict is judged against. `audits` and+ * `quality` are the project to date, for the banner totals and the red flags. */- private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null; audits: TeamAudits }> {+ private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null; audits: TeamAudits; quality: TeamQuality[] }> { const replacements = { projectId, auditsPerCapturer: AUDITS_PER_CAPTURER,@@ -1155,7 +1162,7 @@ }; const rows = await db.sequelize.query<TeamRow>(- `WITH ${paceCte(consumables)}, ${this.auditedCte(consumables)},+ `WITH ${paceCte(consumables, { todayOnly: true })}, ${this.auditedCte(consumables)}, project_stickers AS ( SELECT k.created_by AS user_id, COUNT(*)::int AS total,@@ -1182,29 +1189,32 @@ ROUND(COALESCE(sc.active_hours, 0), 1)::numeric AS active_hours, COALESCE(ps.today_count, 0)::int AS today_count, ROUND(COALESCE(sc.hours, 0), 1)::numeric AS hours_on_site,- COALESCE(au.total, 0)::int AS audit_total,- COALESCE(au.needs_review, 0)::int AS audit_failed,+ COALESCE(aut.total, 0)::int AS audit_total,+ COALESCE(aut.needs_review, 0)::int AS audit_failed, ${this.auditFiguresSql(consumables)},- CASE WHEN COALESCE(au.total, 0) = 0 THEN 1::numeric- ELSE 1 - (au.needs_review::numeric / au.total) END AS clean_rate+ CASE WHEN COALESCE(aut.total, 0) = 0 THEN 1::numeric+ ELSE 1 - (aut.needs_review::numeric / aut.total) END AS clean_rate,+ COALESCE(au.total, 0)::int AS project_audit_done,+ COALESCE(au.needs_review, 0)::int AS project_audit_failed FROM people p LEFT JOIN users u ON u.id = p.user_id LEFT JOIN accounts acc ON acc.id = u.account_id LEFT JOIN project_stickers ps ON ps.user_id = p.user_id LEFT JOIN audited au ON au.user_id = p.user_id+ LEFT JOIN audited_today aut ON aut.user_id = p.user_id LEFT JOIN scoped sc ON sc.user_id = p.user_id ), med AS ( SELECT (PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY captures_per_minute))::numeric AS team_median FROM measured ),- -- per person, so one capturer's extra audits do not cover another's+ -- to date and per person, so one capturer's extra audits do not cover another's team_audit AS (- SELECT COALESCE(SUM(audit_shown), 0)::int AS audits_passed,- COALESCE(SUM(audit_total), 0)::int AS audits_done,- COALESCE(SUM(GREATEST(:auditsPerCapturer - audit_total, 0)), 0)::int AS audits_pending+ SELECT COALESCE(SUM(project_audit_passed), 0)::int AS audits_passed,+ COALESCE(SUM(project_audit_done), 0)::int AS audits_done,+ COALESCE(SUM(GREATEST(:auditsPerCapturer - project_audit_done, 0)), 0)::int AS audits_pending FROM measured )- SELECT m.user_id, m.name, m.account_type, m.captures_per_minute, m.active_hours, m.today_count, m.hours_on_site, m.audit_total, m.audit_failed, m.audit_shown, m.audit_base, m.clean_rate, med.team_median,+ SELECT m.user_id, m.name, m.account_type, m.captures_per_minute, m.active_hours, m.today_count, m.hours_on_site, m.audit_total, m.audit_failed, m.audit_shown, m.audit_base, m.clean_rate, m.project_audit_failed, med.team_median, ta.audits_passed, ta.audits_done, ta.audits_pending, CASE WHEN m.audit_failed >= :maxFailedAudits THEN :qualityWarn@@ -1221,6 +1231,7 @@ ); const first = rows[0];+ const nameOf = (row: TeamRow): string => row.name?.trim() || `User ${row.user_id}`; return { median: first?.team_median == null ? null : round1(Number(first.team_median)), audits: {@@ -1228,9 +1239,10 @@ auditsDone: Number(first?.audits_done) || 0, auditsPending: Number(first?.audits_pending) || 0, },+ quality: rows.map((row) => ({ id: String(row.user_id), name: nameOf(row), auditFailed: Number(row.project_audit_failed) || 0 })), rows: rows.map((row) => ({ id: String(row.user_id),- name: row.name?.trim() || `User ${row.user_id}`,+ name: nameOf(row), capturesPerMinute: row.captures_per_minute == null ? null : round1(Number(row.captures_per_minute)), activeHours: round1(Number(row.active_hours) || 0), todayCount: Number(row.today_count) || 0,
consumable-scope.sql.tsbackendApi/src/primary/modules/capture-dashboard/capture-live/consumable-scope.sql.ts · +20 −14
--- a/src/primary/modules/capture-dashboard/capture-live/consumable-scope.sql.ts+++ b/src/primary/modules/capture-dashboard/capture-live/consumable-scope.sql.ts@@ -91,34 +91,40 @@ GROUP BY g.location_id )`; +/** The SKUs — a live product in a room, the unit an audit covers — each capturer captured into. */+export const CONSUMABLE_CAPTURER_SKU_CTE = `+ capturer_sku AS (+ SELECT DISTINCT t.created_by AS user_id, l.id AS location_id, t.consumables_id+ ${PROJECT_STOCK_FROM}+ JOIN consumables c ON c.id = t.consumables_id AND c.deleted_at IS NULL+ ${PROJECT_STOCK_WHERE}+ )`;+ /**- * The team tile's audit for a consumables run, per capturer, in SKUs — a live product in a room, the- * unit an audit covers — they captured into: `total` those audited, `passed` the ones whose latest- * audit counted exactly its system count and `needs_review` the ones that did not match.+ * The team tile's audit for a consumables run, per capturer, over `capturer_sku`: `total` the SKUs+ * audited, `passed` the ones whose latest audit counted exactly its system count and `needs_review`+ * the ones that did not match. Written as CTE `name`; `today` counts only audits made today. * * The mobile audit screen's rule — an audit is one product in one room, the latest one is its state, * and it passes when `audited_count` equals `system_count`. `total` and `needs_review` are the shape * the asset audit hands the tile too. */-export const CONSUMABLE_TEAM_AUDIT_CTE = `- latest_audit AS (+export const consumableTeamAuditCte = (name: string, today = false): string => {+ const madeToday = today ? ' AND (a.audited_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date' : '';+ return `+ ${name}_latest AS ( SELECT DISTINCT ON (a.location_id, a.consumables_id) a.location_id, a.consumables_id, a.system_count, a.audited_count FROM consumable_audits a- WHERE a.account_id IN (${projectFacilitiesSql()}) AND a.deleted_at IS NULL AND a.consumables_id IS NOT NULL+ WHERE a.account_id IN (${projectFacilitiesSql()}) AND a.deleted_at IS NULL AND a.consumables_id IS NOT NULL${madeToday} ORDER BY a.location_id, a.consumables_id, a.audited_at DESC ),- capturer_sku AS (- SELECT DISTINCT t.created_by AS user_id, l.id AS location_id, t.consumables_id- ${PROJECT_STOCK_FROM}- JOIN consumables c ON c.id = t.consumables_id AND c.deleted_at IS NULL- ${PROJECT_STOCK_WHERE}- ),- audited AS (+ ${name} AS ( SELECT s.user_id, COUNT(la.consumables_id)::int AS total, COUNT(*) FILTER (WHERE la.audited_count = la.system_count)::int AS passed, COUNT(*) FILTER (WHERE la.audited_count <> la.system_count)::int AS needs_review FROM capturer_sku s- LEFT JOIN latest_audit la ON la.location_id = s.location_id AND la.consumables_id = s.consumables_id+ LEFT JOIN ${name}_latest la ON la.location_id = s.location_id AND la.consumables_id = s.consumables_id GROUP BY s.user_id )`;+};
pace.sql.tsbackendApi/src/primary/modules/capture-dashboard/capture-live/pace.sql.ts · +6 −7
--- a/src/primary/modules/capture-dashboard/capture-live/pace.sql.ts+++ b/src/primary/modules/capture-dashboard/capture-live/pace.sql.ts@@ -65,11 +65,13 @@ * On-site runs from one usual line before the first scan to now while today is open (to the last * scan once the day has ended), capped by the planned hours. Active time drops breaks but keeps the * walk between rooms; `gap_kind` splits every gap into counting, walking and break seconds.- * The scope is today for anyone who scanned today, the project to date otherwise. `oneUser`- * narrows it to `:userId`; limits are per person, so that person's figures do not change.+ * The scope is today for anyone who scanned today, the project to date otherwise; `todayOnly` keeps+ * everyone to today, so a new day starts empty. `oneUser` narrows it to `:userId`; limits are per+ * person, so that person's figures do not change. */-export const paceCte = (consumables: boolean, oneUser = false): string => {+export const paceCte = (consumables: boolean, { oneUser = false, todayOnly = false }: { oneUser?: boolean; todayOnly?: boolean } = {}): string => { const today = '(now() AT TIME ZONE :tz)::date';+ const dayScope = todayOnly ? `s.day = ${today}` : `CASE WHEN EXISTS (SELECT 1 FROM worked_today w WHERE w.user_id = s.user_id) THEN s.day = ${today} ELSE TRUE END`; return ` scan AS ( SELECT k.id, k.created_by AS user_id, (k.created_at AT TIME ZONE :tz)::date AS day, k.created_at, k.location_id@@ -101,10 +103,7 @@ LEFT JOIN plan_day p ON p.user_id = s.user_id AND p.day = s.day -- a day stays open until the capturer has been silent past the day-end limit CROSS JOIN LATERAL (SELECT s.day = ${today} AND now() - s.last_scan <= make_interval(secs => :dayEndSecs) AS open) o- WHERE CASE- WHEN EXISTS (SELECT 1 FROM worked_today w WHERE w.user_id = s.user_id) THEN s.day = ${today}- ELSE TRUE- END+ WHERE ${dayScope} ), gap AS ( SELECT g.user_id, g.day, g.secs, g.moved, g.at, g.prev_at, g.location_id, g.prev_location_id
Apply and checkin this order
cd backendApi && git apply day-reset-api.patch. Both API watchers rebuild; wait forFound 0 errorsandNest application successfully startedin.run/primary.log.- No swagger change is expected:
CaptureTeamPerfRowDtoandCaptureLiveBannerDtokeep every field, so the front end keeps running as it is. - Open the Live tab on a running project before anyone has captured today: every card is blank and the header reads “Team speed —”, while the top bar, the room list and the red flags read as before.
- After the first captures of the day, only those people’s cards fill in, and they move to the top.
- Before committing:
npm run quality, and aCAP-<n>:subject — the hooks enforce it.
What was checkedand what was not
git apply --check passes against main.Decisionsso they are not rediscovered
- 1A day is the tab’s day — UTC, like the rest of the Live tab, until projects carry a timezone.
- 2Today’s audits on a consumables run are audits made today, credited to whoever captured that product in that room, whichever day they captured it.
- 3On an asset run the Audit cell counts today’s captures, over today’s captures.
- 4The panel still lists the whole team; anyone not working today shows as a blank card rather than disappearing.
- 5Red flags and the top bar’s audits pending (5 per person) stay project to date.