Fix: AMS drying popover no longer renders off the bottom of the viewport (#1447 part 1)

The flame-icon onClick on PrintersPage computed popover position as a
  fixed { top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }
  with no viewport-overflow check. The flame icon sits at the bottom of
  the AMS info section on the printer card, so on most realistic viewports
  rect.bottom + 4 + popover_height (~320px) overruns viewport.height and
  the popover renders partially or entirely off-screen with the Start
  button unreachable. Reporter (kleinweby, P1S + AMS-HT) worked around it
  via DevTools to confirm the popover was actually there, just clipped.

  Extract a computePopoverPosition() helper in utils/popoverPosition.ts:
  - defaults to below + right-aligned to the trigger (preserves the
    original visual layout when there's room),
  - flips ABOVE the trigger when below would overflow AND above fits,
  - stays below in the degraded case (popover taller than the viewport) —
    at least the top is visible and the user can scroll inside the
    popover; flipping to a top-clipped position would lose the action
    buttons too,
  - clamps the left coordinate so a trigger near either viewport edge
    can't push the popover off-screen horizontally either.

  Both PrintersPage callsites (the compact AMS row at :3498 and the
  dual-nozzle layout at :4011) route through the helper.

  This is part 1 of #1447. The functional drying bug — printer receives
  the ams_filament_drying MQTT command, ACKs it, but never starts/stops
  drying on Bambuddy's request while the printer's own touchscreen works
  — is NOT addressed here. Diagnosing it needs the printer's actual
  response payload (whether result: "fail" and the specific reason code),
  which bambu_mqtt.py:918 currently doesn't log for ams_filament_drying.
  Punted to a follow-up where I'll extend the existing extrusion_cali_* /
  ams_filament_setting payload-logging path at :919-920 to cover
  ams_filament_drying too, ask the reporter to retry, and fix the
  command side based on what the printer actually returns.
This commit is contained in:
maziggy
2026-05-20 10:00:15 +02:00
parent fcee1a6f7e
commit 0f68039416
6 changed files with 630 additions and 421 deletions
+2
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Fixed
- **AMS drying popover no longer renders off the bottom of the viewport (#1447 part 1, reported by @kleinweby)** — Reporter on P1S + AMS-HT couldn't see the Start button on the drying popover; he worked around it via DevTools to confirm the popover was actually there, just clipped below the fold. Root cause in `frontend/src/pages/PrintersPage.tsx:3489 / :4002` (two identical sites — one for the compact AMS row, one for the dual-nozzle layout): the flame-icon onClick computed popover position as a fixed `{ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) }` with no viewport-overflow check. The flame icon sits at the bottom of the AMS info section on the printer card, so on most realistic viewports `rect.bottom + 4 + popover_height(~320px) > viewport.height` and the popover rendered partially or entirely off-screen. **Fix** extracts a small `computePopoverPosition()` helper in `frontend/src/utils/popoverPosition.ts` that defaults to placing the popover below + right-aligned to the trigger (preserving the original visual layout), flips ABOVE the trigger when below would overflow AND above would fit, stays below in the degraded case where neither fits (a popover taller than the viewport — at least the top is visible and the user can scroll inside), and clamps the left coordinate so a trigger near either viewport edge can't push the popover off-screen horizontally either. Both PrintersPage callsites now go through the helper. **NOTE — the underlying functional bug is NOT yet fixed**: kleinweby's support bundle shows the printer receives the `ams_filament_drying` MQTT command (multiple start / stop attempts on `ams_id=128`, P1S 01.10.00.00 firmware), the printer ACKs each one with `Received command response: ams_filament_drying`, but the AMS info field never changes — drying neither starts nor stops on Bambuddy's request, while pressing Start on the printer's own touchscreen worked immediately (so the hardware path is healthy). The Bambuddy command JSON matches the format documented as working on H2D, all required fields are present, types match BambuStudio. Diagnosing further needs the printer's actual response payload (whether `result: "fail"` and the specific `reason` code), and `bambu_mqtt.py:918` currently only logs the response *command name*, not the body — the existing `extrusion_cali_*` / `ams_filament_setting` debug path at `:919-920` is the template, just not extended to `ams_filament_drying`. Punted to a follow-up where I'll add the response-payload logging at INFO level (so it lands in support bundles), ask kleinweby to retry, and fix the actual command-side once we see what the printer is rejecting. The UI fix ships now because it's deterministic and unblocks the immediate "I can't see the button" symptom regardless of the MQTT outcome. **Tests** (8 new in `__tests__/utils/popoverPosition.test.ts`): below-has-room → places below; right-align to trigger; below overflows → flips above; degraded case (popover taller than viewport) → stays below; clamps right-edge and left-edge triggers; respects custom margin and gap. Frontend build clean.
- **Stats: Print Activity heatmap buckets prints by local date, not UTC date (#1446, reported and root-caused by @needo37)** — Reporter on CDT (UTC-5) noticed that prints finished in the local evening were jumping to "tomorrow's" cell on the GitHub-style contribution heatmap on the Stats page. He went through `frontend/src/components/PrintCalendar.tsx` and identified the root cause: line 30 split the raw ISO string on `'T'` to get a YYYY-MM-DD key, which always returns the **UTC** date — but the cell tooltip (line 161) rendered via `toLocaleDateString()`, which is **local-tz aware**. Same data, two renderers, only one was tz-correct. He confirmed with DB query: rows 29 and 30 stored as `2026-05-18 ... UTC` were both local `May 17` (20:46 CDT and 22:39 CDT), and the Archives → Print Log view formatted them correctly as May 17 via `toLocaleString()` while the heatmap split them onto May 18 via the raw-ISO shortcut. The component had two more instances of the same shape that I caught while applying the fix: line 152 built the per-cell lookup key via `day.toISOString().split('T')[0]` (the `day` Date objects produced by the calendar-generation loop are local-tz constructed via `new Date()` + `setDate`, so `toISOString()` shifted them back to UTC before the lookup — would have re-broken the join even after the bucketing fix), and line 154's "today" highlight comparison used `new Date().toISOString().split('T')[0]` too (so at e.g. 23:00 CDT the heatmap would have ringed UTC-tomorrow's cell instead of local-today's). **Fix** adds a `localDateKey(input: string | Date): string` helper in `frontend/src/utils/date.ts` that wraps `parseUTCDate()` and formats via the local-tz getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding), returning a stable comparable YYYY-MM-DD string. PrintCalendar.tsx uses it in all three spots — bucket key for input ISO strings, grid-cell lookup key, and "today" highlight — so the bucketing, the cell join, and the today ring all live on the same local-tz axis as the user's tooltip label. Backend stays UTC (`PrintLogEntry.created_at` unchanged); bucketing is a presentation concern and the browser already knows the user's tz. The reporter's broader point ("same fix needed anywhere else the frontend buckets timestamps to days") still has stragglers — `StatsPage.tsx:55-84` (computeDateRange) builds the dateFrom/dateTo strings for backend stats queries using `getUTC*` getters everywhere, so a "this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based ranges to the backend; that's a separate, deeper bug because it also requires the backend to filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting model today. Punted with a `localDateKey` helper available for reuse when that work lands. **Tests** (5 new in `__tests__/utils/date.test.ts`): keys a local-evening Date to its local date (the bug repro), reproduces the reporter's row-30 case (a moment whose UTC date is "tomorrow" keys to local "today"), pads single-digit month/day, handles null / undefined / empty defensively, and accepts both Date and ISO-string inputs end-to-end via `parseUTCDate`. 74 date-util tests green; frontend build clean. Tests are written tz-independently — they construct `new Date(2026, 4, 17, 22, 0, 0)` via the local-time constructor form so they assert correctly regardless of which tz the CI runner happens to be in.
- **Printers: Add Printer no longer hangs the container on P1S (#1445, reported by @psybernoid and confirmed by @thomassjogren)** — Regression introduced in 0.2.4.2 by the `fix(printers): refuse to add a printer when the MQTT probe fails` change (b51598ea). That commit added a pre-insert MQTT probe to `POST /printers/` via `printer_manager.test_connection()` to catch mistyped access codes before persisting an empty card — but the probe had two compounding bugs that bit P1S specifically. First, a fixed `await asyncio.sleep(2)` checked `state.connected` exactly once at t=2s: P1S firmware's broker / TLS handshake routinely needs 3–5s to surface a CONNACK on a cold MQTT session (same firmware family that already has the documented "broker stops publishing but TCP stays alive" quirk at `bambu_mqtt.py:3181`), so the probe falsely rejected a printer that would have connected fine. Second, the `finally: client.disconnect()` call ran synchronously on the asyncio thread — `BambuMQTTClient.disconnect()` ends in paho's `loop_stop()` which `join()`s the network thread, and if that thread was still mid-TLS-handshake to the slow P1S socket when teardown ran, the `join()` blocked the asyncio thread for as long as the handshake took to either complete or fail. POST `/printers/` therefore wedged, all other HTTP requests queued behind it, and Docker healthcheck timed out → user-visible symptom: "the container hangs." Reporter's workaround (downgrade to 0.2.4.1, add P1S, upgrade back) worked because 0.2.4.1's create-printer route skipped the probe entirely, so the row persisted immediately and the slow handshake happened on a fire-and-forget `connect_printer()` in the background. **Fix** swaps the fixed-sleep + sync-disconnect pair for a polling loop with an 8s budget (`PROBE_TIMEOUT_SECONDS`, configurable as class attributes for tests) that early-returns the moment `state.connected` flips True — so happy-path connects still finish in ~1–2s and slow brokers get the headroom they need — and moves `client.disconnect()` to `await asyncio.to_thread(client.disconnect)` so paho's thread-join can never block the event loop. The new `connect_printer` from-existing-row flow that runs after a successful probe is unchanged (still fire-and-forget). The empty-card-report-prevention goal of the original probe stays intact: a genuinely wrong access code still results in `connected=False` after 8s of polling, the 400 with `code=printer_connection_failed` still fires, the row is still never persisted. **Tests** (2 new in `test_printer_manager.py`): `test_test_connection_polls_and_returns_early_on_connect` simulates the P1S timing — `connected=False` at probe start, flips True ~500ms in — and asserts the probe early-returns in under 1.5s with `success=True` (a regression that reverts to the fixed sleep fails this immediately); `test_test_connection_disconnect_runs_off_loop` mocks a deliberately-slow blocking disconnect (mirrors paho's `loop_stop()` join semantics) and asserts (a) `disconnect` ran on a thread other than the asyncio thread, and (b) a concurrent heartbeat coroutine kept ticking while disconnect was blocking the worker thread, proving the event loop wasn't stalled. The existing `test_test_connection_failure` test was patched to override `PROBE_TIMEOUT_SECONDS` to 0.4s so the negative path still runs fast under CI. 6 printer-create integration tests still green; ruff clean.
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { computePopoverPosition } from '../../utils/popoverPosition';
/**
* Tests for #1447: the AMS drying popover was rendering off the bottom of
* the viewport with the Start button unreachable. The new helper must:
* - keep the popover below when below fits
* - flip above when below would overflow AND above fits
* - stay below (degraded) when neither side fits
* - clamp the horizontal position so a trigger near the viewport's right
* edge doesn't push the popover off-screen.
*/
describe('computePopoverPosition (#1447)', () => {
// Trigger positioned in the middle of a 1024x768 viewport.
const middleTrigger = { top: 300, bottom: 320, left: 400, right: 440 };
const viewport = { viewportWidth: 1024, viewportHeight: 768 };
it('places the popover below the trigger when below has room', () => {
const pos = computePopoverPosition({
triggerRect: middleTrigger,
popoverWidth: 240,
estimatedHeight: 320,
...viewport,
});
expect(pos.top).toBe(middleTrigger.bottom + 4); // 324
});
it('right-aligns the popover to the trigger by default', () => {
const pos = computePopoverPosition({
triggerRect: middleTrigger,
popoverWidth: 240,
estimatedHeight: 320,
...viewport,
});
expect(pos.left).toBe(middleTrigger.right - 240); // 200
});
it('flips above when the popover would overflow the bottom of the viewport', () => {
// Trigger near the bottom — bottom=700 + gap 4 + height 320 = 1024 > 768.
const bottomTrigger = { top: 680, bottom: 700, left: 400, right: 440 };
const pos = computePopoverPosition({
triggerRect: bottomTrigger,
popoverWidth: 240,
estimatedHeight: 320,
...viewport,
});
// Above placement: trigger.top - gap - height = 680 - 4 - 320 = 356.
expect(pos.top).toBe(356);
});
it('stays below when neither below nor above can fully fit (degraded)', () => {
// A popover taller than the viewport itself can never fit anywhere. Stay
// below so the user at least sees the top of the popover and can scroll
// through it — flipping to a top-clipped position would lose visibility
// of the action buttons at the bottom of the popover too.
const tallPopover = { estimatedHeight: 900 };
const trigger = { top: 380, bottom: 400, left: 400, right: 440 };
const pos = computePopoverPosition({
triggerRect: trigger,
popoverWidth: 240,
...tallPopover,
...viewport,
});
expect(pos.top).toBe(trigger.bottom + 4);
});
it('clamps horizontally when trigger sits near the right viewport edge', () => {
// Trigger.right=1020, popoverWidth=240. Default left would be 780; the
// popover would extend to 1020 which is within viewport=1024 minus the
// 8px margin -> 1016, so it overflows by 4px. Clamp pushes it left.
const rightEdgeTrigger = { top: 100, bottom: 120, left: 980, right: 1020 };
const pos = computePopoverPosition({
triggerRect: rightEdgeTrigger,
popoverWidth: 240,
estimatedHeight: 320,
...viewport,
});
expect(pos.left).toBeLessThanOrEqual(1024 - 240 - 8); // 776
expect(pos.left).toBeGreaterThanOrEqual(8);
});
it('clamps horizontally when trigger sits near the left viewport edge', () => {
// Trigger.right=120, popoverWidth=240. Default left would be -120 (off
// viewport). Clamp to the margin.
const leftEdgeTrigger = { top: 100, bottom: 120, left: 80, right: 120 };
const pos = computePopoverPosition({
triggerRect: leftEdgeTrigger,
popoverWidth: 240,
estimatedHeight: 320,
...viewport,
});
expect(pos.left).toBe(8); // default margin
});
it('respects a custom margin', () => {
const pos = computePopoverPosition({
triggerRect: { top: 100, bottom: 120, left: 80, right: 120 },
popoverWidth: 240,
estimatedHeight: 320,
margin: 16,
...viewport,
});
expect(pos.left).toBe(16);
});
it('respects a custom gap between trigger and popover', () => {
const pos = computePopoverPosition({
triggerRect: middleTrigger,
popoverWidth: 240,
estimatedHeight: 320,
gap: 12,
...viewport,
});
expect(pos.top).toBe(middleTrigger.bottom + 12); // 332
});
});
+11 -2
View File
@@ -1,6 +1,15 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { compareFwVersions } from '../utils/firmwareVersion';
import { formatPrintName } from '../utils/printName';
import { computePopoverPosition } from '../utils/popoverPosition';
// AMS drying popover dimensions — w-[240px] on the popover, estimated height
// covers header + filament select + temp slider + duration + rotate-tray
// toggle + buttons. Over-estimating is fine (flip-above kicks in slightly
// earlier); under-estimating leaves the popover clipped off the bottom (the
// original bug at #1447).
const DRYING_POPOVER_WIDTH = 240;
const DRYING_POPOVER_ESTIMATED_HEIGHT = 320;
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
@@ -3486,7 +3495,7 @@ function PrinterCard({
setDryingPopoverModuleType(ams.module_type);
setDryingPopoverAmsId(ams.id);
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });
setDryingPopoverPos(computePopoverPosition({ triggerRect: rect, popoverWidth: DRYING_POPOVER_WIDTH, estimatedHeight: DRYING_POPOVER_ESTIMATED_HEIGHT }));
}
}}
className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
@@ -3999,7 +4008,7 @@ function PrinterCard({
setDryingPopoverModuleType(ams.module_type);
setDryingPopoverAmsId(ams.id);
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });
setDryingPopoverPos(computePopoverPosition({ triggerRect: rect, popoverWidth: DRYING_POPOVER_WIDTH, estimatedHeight: DRYING_POPOVER_ESTIMATED_HEIGHT }));
}
}}
className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${
+82
View File
@@ -0,0 +1,82 @@
export interface PopoverPosition {
top: number;
left: number;
}
interface RectLike {
top: number;
bottom: number;
left: number;
right: number;
}
export interface ComputePopoverPositionOpts {
/** Trigger element's bounding rect (viewport coordinates). */
triggerRect: RectLike;
/** Popover width in CSS pixels. */
popoverWidth: number;
/**
* Estimated popover height in CSS pixels. Used to detect bottom-edge
* overflow so we can flip above the trigger. A conservative over-estimate
* is preferable to an under-estimate — over-estimating just flips slightly
* sooner, under-estimating leaves the popover clipped off the viewport.
*/
estimatedHeight: number;
/** Viewport height. Defaults to window.innerHeight. Injectable for tests. */
viewportHeight?: number;
/** Viewport width. Defaults to window.innerWidth. Injectable for tests. */
viewportWidth?: number;
/** Margin to keep between the popover and the viewport edges. */
margin?: number;
/** Gap between the trigger and the popover. */
gap?: number;
}
/**
* Compute fixed-positioning coordinates for a popover anchored to a trigger.
*
* Default placement is BELOW the trigger, right-aligned to the trigger. Flips
* to ABOVE the trigger when below would overflow the viewport (#1447 — the
* AMS drying popover on the printer card sits at the bottom of the AMS row
* and was rendering off the bottom of the viewport with the Start button
* unreachable on smaller screens).
*
* Horizontal axis right-aligns to triggerRect.right and clamps to the
* viewport with the configured margin so a trigger near the right edge
* doesn't push the popover off-screen.
*/
export function computePopoverPosition(opts: ComputePopoverPositionOpts): PopoverPosition {
const {
triggerRect,
popoverWidth,
estimatedHeight,
viewportHeight = window.innerHeight,
viewportWidth = window.innerWidth,
margin = 8,
gap = 4,
} = opts;
// Vertical: prefer below, flip to above only when below overflows AND
// above would actually fit. If neither fits (a popover taller than the
// viewport), stay below — at least the top of the popover is visible
// and the user can scroll inside it, which is better than flipping to a
// top-clipped position where the action buttons might also be unreachable.
let top = triggerRect.bottom + gap;
const wouldOverflowBottom = top + estimatedHeight > viewportHeight - margin;
if (wouldOverflowBottom) {
const aboveTop = triggerRect.top - gap - estimatedHeight;
if (aboveTop >= margin) {
top = aboveTop;
}
}
// Horizontal: right-align to trigger; clamp to viewport bounds.
let left = triggerRect.right - popoverWidth;
if (left < margin) {
left = margin;
} else if (left + popoverWidth > viewportWidth - margin) {
left = Math.max(margin, viewportWidth - popoverWidth - margin);
}
return { top, left };
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BrIgWKU8.js"></script>
<script type="module" crossorigin src="/assets/index-B3OGHT8z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>