T14–T18 · proposed fix · not applied to either repository

Top bar fix — how each number is worked out

The Live Project header’s on-site count, its new totals line and the audit figures: the rule behind each number, then the exact change to capExpertAPI and capExpertApp. Nothing has been applied — both patches apply cleanly to main as of 26 September 2026.

Files changed
42 in the API, 2 in the app
New banner fields
5departments ×2, audits ×3
Leaves the banner
333% Complete, Projected finish, remaining
Migrations
0no schema change, no new endpoint
the short answer

T14 — on-site counts the people rostered for today plus anyone who captured today, instead of only people who captured in the last 60 minutes. T15–T16 — the banner drops “33% Complete” and the Projected finish box. T17 — the line gains departments complete and the audit figures, in the doc’s order. T18 — audits pending is worked out per person: 5 each, less the audits that person has had, never below 0, added up over the team.

How each number is worked outthe rules

on-site              = | rostered on today's visits  ∪  captured today |
departments complete = departments whose every room is complete   (of departments with a project room)
audits passed        = Σ over the team   audits of that person's captures that matched
audits done          = Σ over the team   audits of that person's captures
audits pending       = Σ over the team   max(0, 5 − audits done for that person)
N capturers on-siteChanged. Everyone on a visit dated today (capture_visit_members.work_date), plus anyone who captured today without being rostered for it. onSiteToday()
N of M departments completeNew. M is every department holding at least one of the project’s rooms; a department is complete when every one of its rooms is. Rooms with no department are left out. buildBanner()
N of M locations completeUnchanged — rooms marked complete, of all the project’s rooms.
N capturesUnchanged — the project’s captures in the run’s scope.
N SKUs · N EAUnchanged figures, consumables runs only; relabelled from “SKU” and “EA total”, SKUs now first.
N of M audits passedNew. Per person, the products-in-a-room they captured that a supervisor has audited (latest consumable_audits row for that product and room); passed when the audited count equals the system count. Summed over the team — the Audit tiles’ own figures. buildTeam()
N audits pendingNew. For each person on the team, 5 minus their audits, never below 0; added up. buildTeam()

T14 · The on-site countbug

Cause. The banner calls onSiteToday(), which only counts people whose last capture was within the day-end limit — the “Day ends after no captures for” pace setting, 60 minutes by default. An hour after the last scan everybody drops out and the banner reads 0 while rooms are still open, which is what the screenshot shows.

Fix. The 4 the doc expects includes Danielle, who has no captures at all, so the count cannot come from captures. It is the roster — everyone on today’s visits — with captures adding anyone who scanned today without being rostered for it.

SELECT COUNT(*)::int AS n
  FROM (
    SELECT m.user_id                              -- rostered on one of today's visits
      FROM capture_visit_members m
      JOIN capture_visits v ON v.id = m.capture_visit_id AND v.deleted_at IS NULL
     WHERE v.capture_project_id = :projectId AND m.deleted_at IS NULL
       AND m.work_date = (now() AT TIME ZONE :tz)::date
    UNION                                         -- plus anyone who captured today
    SELECT k.created_by
      FROM inventory_stickers k
      JOIN inventory_locations l ON l.id = k.location_id
     WHERE <project rooms> AND k.deleted_at IS NULL AND k.clone_id IS NULL AND <project captures>
       AND <run scope>
       AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date
  ) today
what stays, and a limit

The day-end limit still decides when a person’s own card reads Finished rather than On break. Its hint in Pace settings also says it takes them off site; the patch drops that clause.

Until T10 (end of day) lands, a rostered person counts as on-site until midnight in the tab’s zone (UTC), even after the team has gone home.

T15–T16 · Removalsfront end only

The template drops the percentage and the Projected finish box, and with them the “remaining” chip, which T17’s line no longer lists. The API keeps both fields: completePct still sizes the progress bar, and forecast still feeds the “Projected finish … is past the estimate” red flag.

With the box gone, the rule that pushed the progress bar onto its own row below xl goes too: the bar sits beside the day line from md up, and the divider between them moves to md. The loading skeleton loses its forecast placeholder so it keeps the banner’s shape.

T17 · The new linebanner totals

In the doc’s order, each as a dot, a bold figure and a label, like the chips today. SKUs, EA and the two audit figures show on consumables runs only, as SKU and EA do now. With the screenshot’s data it would read:

0 of 1 departments complete · 3 of 9 locations complete · 136 captures · 70 SKUs · 1,212 EA · 3 of 5 audits passed · 15 audits pending

Departments complete is two scalar subqueries on the banner’s existing query, so it costs no extra round trip:

(SELECT COUNT(DISTINCT l.department_id) FROM inventory_locations l
  WHERE <project rooms> AND l.department_id IS NOT NULL)::int AS departments_total,
(SELECT COUNT(*) FROM (
   SELECT l.department_id FROM inventory_locations l
    WHERE <project rooms> AND l.department_id IS NOT NULL
    GROUP BY l.department_id
   HAVING BOOL_AND(COALESCE(l.completed, FALSE))      -- every room in it is complete
 ) done)::int AS departments_complete

T18 · Audits pending5 per person

“Every person” is the team panel: everyone rostered on the project’s visits — the only people whose captures count at all. The audit figures are the ones each person’s Audit tile already shows, so the banner is their sum and the two cannot disagree; the query that builds the team rows adds them up in the same pass, and getLive() puts them on the banner for consumables runs.

Per person matters: someone with 7 audits does not cover someone with none. The doc’s example is 4 people × 5 = 20 needed, 4 done, 16 pending. The screenshot’s own cards — 1/2, 1/2, 1/1 and 0 — give 3 of 5 passed and 3 + 3 + 4 + 5 = 15 pending.

-- one row per person on the team; audit_shown = passed, audit_total = audited (consumables)
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
  FROM measured
)

The 5 is AUDITS_PER_CAPTURER in the service. If Q10 on the task list says it varies by client, it moves into the project’s pace settings.

API patchbackendApi · capExpertAPI

capture-live.dto.tsbackendApi/src/primary/modules/capture-dashboard/capture-live/dto/capture-live.dto.ts · +17 −6

--- a/src/primary/modules/capture-dashboard/capture-live/dto/capture-live.dto.ts+++ b/src/primary/modules/capture-dashboard/capture-live/dto/capture-live.dto.ts@@ -26,11 +26,7 @@   @ApiProperty({ description: 'The entire pill sentence including the number and unit. There is no variance formula.' })   varianceLabel: string; }-/**- * Every field is seeded. The banner looks computed — 46% is close to 88/192 — but- * nothing here is derived, and the only arithmetic the frontend does is- * locationsTotal minus locationsComplete.- */+/** The Live tab's header: the day line, the progress bar and the totals printed under it. */ export class CaptureLiveBannerDto {   @ApiProperty()   eyebrow: string;@@ -38,6 +34,12 @@   @ApiProperty({ description: 'Pre-formatted. Do not re-derive from the project daysElapsed/daysTotal.' })   dayLine: string; +  @ApiProperty({ description: 'Departments whose every room is complete.' })+  departmentsComplete: number;++  @ApiProperty({ description: 'Departments holding at least one of the project’s rooms.' })+  departmentsTotal: number;+   @ApiProperty()   locationsComplete: number; @@ -56,6 +58,15 @@   @ApiPropertyOptional({ description: 'Consumables runs only: distinct products counted across every room, each once however many rooms stock it.' })   skuTotal?: number; +  @ApiPropertyOptional({ description: 'Consumables runs only: audits whose count matched, summed over the team as its Audit tiles show them.' })+  auditsPassed?: number;++  @ApiPropertyOptional({ description: 'Consumables runs only: audits done, summed over the team.' })+  auditsDone?: number;++  @ApiPropertyOptional({ description: 'Consumables runs only: audits still owed. Every person on the team owes the same number, and one person’s extra audits do not cover another’s.' })+  auditsPending?: number;+   @ApiProperty({ description: 'Rooms done out of all rooms, 0–100 — the same count as locationsComplete / locationsTotal.' })   completePct: number; @@ -63,7 +74,7 @@   forecast: CaptureLiveForecastDto;    @ApiPropertyOptional({-    description: 'False while the run is under the forecast floors; the tab hides the projected-finish block rather than showing an unearned date.',+    description: 'False while the run is under the forecast floors; the behind-estimate red flag waits for it rather than judging an unearned date.',   })   forecastReady?: boolean; }

capture-live.service.tsbackendApi/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts · +69 −16

--- a/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts+++ b/src/primary/modules/capture-dashboard/capture-live/capture-live.service.ts@@ -104,6 +104,9 @@ /** Banner copy. Fixed labels rather than data, but they belong with the panel that prints them. */ const BANNER_EYEBROW = 'Live capture in progress'; +/** Audits every person on a consumables team owes; the banner's pending figure counts down from it. */+const AUDITS_PER_CAPTURER = 5;+ /** A capture the team could not confirm or tag — the quality signal the clean-capture rate counts against. */ const FLAGGED_STICKER = '"InventorySticker"."unable_to_confirm" OR "InventorySticker"."is_missing_asset_tag" OR "InventorySticker"."is_missing_biomed_tag"'; @@ -112,6 +115,8 @@ interface BannerRow {   days_total: number;   days_elapsed: number;+  departments_total: number;+  departments_complete: number;   locations_total: number;   locations_complete: number;   captured: number;@@ -170,6 +175,16 @@   insight_type: CaptureInsightTypeEnum;   account_type: string | null;   team_median: string | null;+  audits_passed: number;+  audits_done: number;+  audits_pending: number;+}++/** The team's audits, summed as its Audit tiles show them. */+interface TeamAudits {+  auditsPassed: number;+  auditsDone: number;+  auditsPending: number; }  interface NthWorkingDayRow {@@ -401,7 +416,7 @@     const consumables = isConsumableRun(project.captureDomain);     const pace = resolvePaceSettings(project.paceConfig);     const [banner, groups, team, stale] = await Promise.all([-      this.buildBanner(project.id, consumables, pace),+      this.buildBanner(project.id, consumables),       this.listLocationGroups(project.id, consumables, { pageNumber: 0, pageSize: LIVE_FACILITY_PAGE_SIZE }),       this.buildTeam(project.id, consumables, pace),       this.staleRooms(project.id, consumables),@@ -410,7 +425,8 @@     const redFlags = await this.buildRedFlags(project.id, consumables, banner, stale, team.rows, pace);      return {-      banner,+      // The audit totals come off the team rows, so the banner and the Audit tiles cannot disagree.+      banner: consumables ? { ...banner, ...team.audits } : banner,       // The tab renders `locationGroups`; this keeps the flat field the DTO declares, taken from the       // rows already fetched rather than paying for a second scan of the same rooms.       locations: groups.groups.flatMap((group) => group.rows),@@ -491,7 +507,7 @@    // ── Banner ──────────────────────────────────────────────────────────────── -  private async buildBanner(projectId: number, consumables: boolean, pace: PaceSettings): Promise<CaptureLiveBannerDto> {+  private async buildBanner(projectId: number, consumables: boolean): Promise<CaptureLiveBannerDto> {     const replacements = { projectId, tz: this.projectTimezone(), denovoExcluded: DENOVO_EXCLUDED };     // Read off the rooms' own groups, so the banner totals and the room chips cannot disagree.     const shelfCte = consumables ? `, ${CONSUMABLE_GROUP_CTE}` : '';@@ -527,6 +543,14 @@                 (SELECT MAX(day) FROM working)::text AS window_end,                 (SELECT avg_start_hours FROM day_shape) AS avg_start_hours,                 (SELECT avg_active_hours FROM day_shape) AS avg_active_hours,+                (SELECT COUNT(DISTINCT l.department_id) FROM inventory_locations l+                  WHERE ${projectRoomsSql()} AND l.department_id IS NOT NULL)::int AS departments_total,+                (SELECT COUNT(*) FROM (+                   SELECT l.department_id FROM inventory_locations l+                    WHERE ${projectRoomsSql()} AND l.department_id IS NOT NULL+                    GROUP BY l.department_id+                   HAVING BOOL_AND(COALESCE(l.completed, FALSE))+                 ) done)::int AS departments_complete,                 (SELECT COUNT(*) FROM inventory_locations l                   WHERE ${projectRoomsSql()})::int AS locations_total,                 (SELECT COUNT(*) FROM inventory_locations l@@ -539,7 +563,7 @@                   WHERE capture_project_id = :projectId AND deleted_at IS NULL)::int AS expected${shelfTotalsSql}`,         { type: QueryTypes.SELECT, replacements },       ),-      this.onSiteToday(projectId, consumables, pace.dayEndMinutes * 60),+      this.onSiteToday(projectId, consumables),     ]);      const row = window[0];@@ -553,6 +577,8 @@     return {       eyebrow: BANNER_EYEBROW,       dayLine: `Day ${daysElapsed} of ${daysTotal} · ${onSite} capturer${onSite === 1 ? '' : 's'} on-site`,+      departmentsComplete: Number(row?.departments_complete) || 0,+      departmentsTotal: Number(row?.departments_total) || 0,       locationsComplete,       locationsTotal,       assetsCaptured,@@ -579,17 +605,28 @@     return [String(Number(projectId)), db.sequelize.escape(this.projectTimezone())];   } -  /** Distinct project capturers whose last capture today is within the day-end limit, clones excluded. */-  private async onSiteToday(projectId: number, consumables: boolean, dayEndSecs: number): Promise<number> {+  /**+   * Everyone rostered on today's visits, plus anyone who captured today without being rostered for it.+   * The day-end limit marks a quiet capturer Finished on their card; it does not take them off site.+   */+  private async onSiteToday(projectId: number, consumables: boolean): Promise<number> {     const [row] = await db.sequelize.query<{ n: number }>(-      `SELECT COUNT(DISTINCT k.created_by)::int AS n-         FROM inventory_stickers k-         JOIN inventory_locations l ON l.id = k.location_id-        WHERE ${projectRoomsSql()} AND k.deleted_at IS NULL AND k.clone_id IS NULL AND ${projectCapturesSql()}-          AND ${stickerScopeSql(consumables)}-          AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date-          AND k.created_at >= now() - make_interval(secs => :dayEndSecs)`,-      { type: QueryTypes.SELECT, replacements: { projectId, tz: this.projectTimezone(), dayEndSecs } },+      `SELECT COUNT(*)::int AS n+         FROM (+           SELECT m.user_id+             FROM capture_visit_members m+             JOIN capture_visits v ON v.id = m.capture_visit_id AND v.deleted_at IS NULL+            WHERE v.capture_project_id = :projectId AND m.deleted_at IS NULL+              AND m.work_date = (now() AT TIME ZONE :tz)::date+           UNION+           SELECT k.created_by+             FROM inventory_stickers k+             JOIN inventory_locations l ON l.id = k.location_id+            WHERE ${projectRoomsSql()} AND k.deleted_at IS NULL AND k.clone_id IS NULL AND ${projectCapturesSql()}+              AND ${stickerScopeSql(consumables)}+              AND (k.created_at AT TIME ZONE :tz)::date = (now() AT TIME ZONE :tz)::date+         ) today`,+      { type: QueryTypes.SELECT, replacements: { projectId, tz: this.projectTimezone() } },     );     return row?.n ?? 0;   }@@ -1099,9 +1136,10 @@    * 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.    */-  private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null }> {+  private async buildTeam(projectId: number, consumables: boolean, pace: PaceSettings): Promise<{ rows: CaptureTeamPerfRowDto[]; median: number | null; audits: TeamAudits }> {     const replacements = {       projectId,+      auditsPerCapturer: AUDITS_PER_CAPTURER,       tz: this.projectTimezone(),       ...paceReplacements(pace),       minLineGaps: pace.minLineGaps,@@ -1157,8 +1195,16 @@        ),        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+       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+         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_shown, m.audit_base, m.clean_rate, med.team_median,+              ta.audits_passed, ta.audits_done, ta.audits_pending,               CASE                 WHEN m.clean_rate < :qualityFloor THEN :qualityWarn                 WHEN m.captures_per_minute IS NULL OR med.team_median IS NULL THEN :noPace@@ -1168,12 +1214,19 @@               END AS insight_type        FROM measured m        CROSS JOIN med+       CROSS JOIN team_audit ta        ORDER BY m.captures_per_minute DESC NULLS LAST, m.name ASC`,       { type: QueryTypes.SELECT, replacements },     ); +    const first = rows[0];     return {-      median: rows[0]?.team_median == null ? null : round1(Number(rows[0].team_median)),+      median: first?.team_median == null ? null : round1(Number(first.team_median)),+      audits: {+        auditsPassed: Number(first?.audits_passed) || 0,+        auditsDone: Number(first?.audits_done) || 0,+        auditsPending: Number(first?.audits_pending) || 0,+      },       rows: rows.map((row) => ({         id: String(row.user_id),         name: row.name?.trim() || `User ${row.user_id}`,

Front-end patchcapExpertApp

capture-project-view.component.htmlcapExpertApp/src/app/modules/capture-dashboard/project-view/capture-project-view.component.html · +29 −47

--- a/src/app/modules/capture-dashboard/project-view/capture-project-view.component.html+++ b/src/app/modules/capture-dashboard/project-view/capture-project-view.component.html@@ -359,8 +359,7 @@       <div         class="mb-4 flex flex-col gap-3 rounded-xl border border-ce-light-blue/30 bg-ce-bg-card px-4 py-2.5 shadow-[0_0_16px_rgba(121,158,211,0.16)] sm:px-4 md:flex-row md:flex-wrap md:items-center md:gap-x-5 md:gap-y-3 xl:flex-nowrap dark:shadow-none"       >-        <!-- Below xl the day line and the forecast share the first line and the progress takes the second. -->-        <div class="min-w-0 shrink-0 xl:border-r xl:border-line xl:pr-5">+        <div class="min-w-0 shrink-0 md:border-r md:border-line md:pr-5">           <div class="flex items-center gap-2">             <span class="h-2 w-2 shrink-0 animate-cp-pulse rounded-full bg-ce-green [--cp-pulse-color:rgba(39,174,96,0.55)]"></span>             <span class="text-[11.5px] font-medium text-ink">{{ b.eyebrow }}</span>@@ -373,11 +372,17 @@           </div>         </div> -        <div class="min-w-0 flex-1 md:max-xl:order-last md:max-xl:basis-full">+        <div class="min-w-0 flex-1">           <div class="h-1.5 overflow-hidden rounded-full bg-line dark:bg-white/10">             <div class="h-full rounded-full bg-ce-light-blue transition-[width] duration-500" [style.width.%]="b.completePct"></div>           </div>           <div class="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-ink-muted">+            <span class="inline-flex items-center gap-1.5">+              <span class="h-2 w-2 shrink-0 rounded-full bg-ce-purple"></span>+              <span+                ><strong class="font-semibold text-ink">{{ b.departmentsComplete }} of {{ b.departmentsTotal }}</strong> departments complete</span+              >+            </span>             <span class="inline-flex items-center gap-1.5">               <span class="h-2 w-2 shrink-0 rounded-full bg-ce-green"></span>               <span@@ -390,59 +395,37 @@                 ><strong class="font-semibold text-ink">{{ b.assetsCaptured | number }}</strong> captures</span               >             </span>+            @if (b.skuTotal !== undefined) {+              <span class="inline-flex items-center gap-1.5">+                <span class="h-2 w-2 shrink-0 rounded-full bg-ce-light-blue/50 dark:bg-ce-light-blue/70"></span>+                <span+                  ><strong class="font-semibold text-ink">{{ b.skuTotal | number }}</strong> SKUs</span+                >+              </span>+            }             @if (b.eaTotal !== undefined) {               <span class="inline-flex items-center gap-1.5">                 <span class="h-2 w-2 shrink-0 rounded-full bg-ce-dark-blue dark:bg-white/70"></span>                 <span-                  ><strong class="font-semibold text-ink">{{ b.eaTotal | number }}</strong> EA total</span+                  ><strong class="font-semibold text-ink">{{ b.eaTotal | number }}</strong> EA</span                 >               </span>             }-            @if (b.skuTotal !== undefined) {+            @if (b.auditsDone !== undefined) {               <span class="inline-flex items-center gap-1.5">-                <span class="h-2 w-2 shrink-0 rounded-full bg-ce-light-blue/50 dark:bg-ce-light-blue/70"></span>+                <span class="h-2 w-2 shrink-0 rounded-full bg-ce-green"></span>                 <span-                  ><strong class="font-semibold text-ink">{{ b.skuTotal | number }}</strong> SKU</span+                  ><strong class="font-semibold text-ink">{{ b.auditsPassed | number }} of {{ b.auditsDone | number }}</strong> audits passed</span+                >+              </span>+              <span class="inline-flex items-center gap-1.5">+                <span class="h-2 w-2 shrink-0 rounded-full bg-ce-amber"></span>+                <span+                  ><strong class="font-semibold text-ink">{{ b.auditsPending | number }}</strong> audits pending</span                 >               </span>             }-            <span class="inline-flex items-center gap-1.5">-              <span class="h-2 w-2 shrink-0 rounded-full bg-ce-amber"></span>-              <span-                ><strong class="font-semibold text-ink">{{ b.locationsTotal - b.locationsComplete }}</strong> remaining</span-              >-            </span>-            <span class="ml-auto inline-flex items-baseline gap-1.5">-              <span class="text-[15px] font-bold tabular-nums text-ce-dark-blue dark:text-white">{{ b.completePct }}%</span>-              <span class="text-[12px] text-ink-muted">Complete</span>-            </span>-          </div>-        </div>--        <div class="flex shrink-0 items-start gap-2.5 rounded-lg bg-ce-light-blue/10 px-3 py-2 md:ml-auto md:min-w-[200px] dark:bg-ce-light-blue/15">-          <svg [lucideIcon]="'calendar'" [size]="16" class="mt-0.5 shrink-0 text-ce-light-blue"></svg>-          @if (b.forecastReady !== false) {-            <div class="min-w-0">-              <div class="text-[12px] font-semibold text-ink">{{ b.forecast.eyebrow }}</div>-              <div class="flex flex-wrap items-baseline gap-x-1.5">-                <span class="text-[14px] font-bold text-ink">{{ b.forecast.projectedDate }}</span>-                <span class="text-[12px] text-ink-muted">{{ b.forecast.projectedTime }}</span>-              </div>-              <div class="text-[10px] text-ink-muted">{{ b.forecast.originalLine }}</div>-              <span-                class="mt-1 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold"-                [class]="b.forecast.onTrack ? 'bg-ce-green/10 text-ce-green dark:bg-ce-green/20' : 'bg-ce-red/10 text-ce-red dark:bg-ce-red/20'"-              >-                <svg [lucideIcon]="b.forecast.onTrack ? 'circle-check-big' : 'triangle-alert'" [size]="10"></svg>-                {{ b.forecast.varianceLabel }}-              </span>-            </div>-          } @else {-            <div class="min-w-0">-              <div class="text-[12px] font-semibold text-ink">Projected finish</div>-              <div class="text-[12px] text-ink-muted">Not enough capture yet</div>-            </div>-          }+          </div>         </div>       </div>     } @else {@@ -450,15 +433,14 @@       <div         class="mb-4 flex flex-col gap-3 rounded-xl border border-ce-light-blue/30 bg-ce-bg-card px-4 py-2.5 sm:px-4 md:flex-row md:flex-wrap md:items-center md:gap-x-5 md:gap-y-3 xl:flex-nowrap"       >-        <div class="shrink-0 xl:border-r xl:border-line xl:pr-5">+        <div class="shrink-0 md:border-r md:border-line md:pr-5">           <div class="h-3 w-36 animate-pulse rounded bg-line dark:bg-white/10"></div>           <div class="mt-2 h-5 w-44 animate-pulse rounded bg-line dark:bg-white/10"></div>         </div>-        <div class="min-w-0 flex-1 md:max-xl:order-last md:max-xl:basis-full">+        <div class="min-w-0 flex-1">           <div class="h-1.5 w-full animate-pulse rounded-full bg-line dark:bg-white/10"></div>           <div class="mt-2 h-3 w-full max-w-md animate-pulse rounded bg-line dark:bg-white/10"></div>         </div>-        <div class="h-[44px] w-full animate-pulse rounded-lg bg-line md:ml-auto md:w-[200px] dark:bg-white/10"></div>       </div>     } 

capture-pace-settings.component.tscapExpertApp/src/app/modules/capture-dashboard/project-view/capture-pace-settings.component.ts · +1 −1

--- a/src/app/modules/capture-dashboard/project-view/capture-pace-settings.component.ts+++ b/src/app/modules/capture-dashboard/project-view/capture-pace-settings.component.ts@@ -91,7 +91,7 @@       {         key: 'dayEndMinutes',         label: 'Day ends after no captures for',-        hint: 'After this long without a capture, the person shows as “Finished” instead of “On break now” and no longer counts as on-site. Must be at least the in-room break limit.',+        hint: 'After this long without a capture, the person shows as “Finished” instead of “On break now”. Must be at least the in-room break limit.',         example: '60 → last capture at 3:00 PM, shown as finished from 4:00 PM.',         unit: 'min',         integer: true,

Apply and checkin this order

  1. cd backendApi && git apply top-bar-api.patch. Both API watchers rebuild; wait for Found 0 errors and Nest application successfully started in .run/primary.log.
  2. Check the spec carries the five fields: curl -sf -u root:root http://localhost:8080/api/v1/swagger-json | jq '.components.schemas.CaptureLiveBannerDto.properties | keys' lists departmentsComplete, departmentsTotal, auditsPassed, auditsDone and auditsPending.
  3. cd capExpertApp && git apply top-bar-app.patch, then restart the front end so the swagger client regenerates: free :4200 and run ./run.sh. .run/fe.log should show [swagger:primaryApi] spec changed — regenerating, then Application bundle generation complete.
  4. Open the Live tab on a consumables project: the day line counts today’s roster, the line reads in the new order, audits passed is the sum of the Audit tiles’ first figures, and pending is the sum of 5 minus each tile’s second figure, never below 0.
  5. Before committing: npm run quality in the API, and a CAP-<n>: subject in both repositories — the hooks enforce it.

What was checkedand what was not

Both patches applyYes — git apply --check passes against main in both repositories.
API compilesPartly. Every change except the buildTeam() part compiled in the running watcher before it was reverted; that part was checked by reading, not compiled.
Front end compilesNot yet — the template reads fields the swagger client only has after step 3.
SQL against dataNot run. No database was queried; step 4 is the first real check of the numbers.

Decisions and limitsso they are not rediscovered

  1. 1On-site holds until midnight in the tab’s zone. Taking people off site when the day is really over is T10’s job.
  2. 2“Today” is UTC, like the rest of the Live tab — capture_projects has no timezone column yet.
  3. 3Audit figures show on consumables runs only. The 5-audit rule is a consumables rule, and an asset run’s Audit tile is a required-field check, not a supervisor audit.
  4. 4If two people captured the same product in the same room, one audit counts for both of them — the same way their tiles already count it.
  5. 5A room flagged inaccessible keeps its department open, exactly as it keeps the location count short.
  6. 6The 5 is a constant for now (Q10). No migration, no new endpoint; the banner DTO gains two fields and three optional ones, so the front end must regenerate its client.