diff --git a/.env.example b/.env.example index ba602fa2a..572ab248d 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,14 @@ LOG_TO_FILE=true # In Docker, also bind-mount the host path into the container at the same # location (see docker-compose.yml for the matching volume snippet). # BAMBUDDY_EXTERNAL_ROOTS= + +# Local-login recovery bypass (#1589) — set to "true" / "1" / "yes" to +# accept username + password credentials on /auth/login (and to allow the +# /auth/forgot-password flow) even when the in-app setting "Disable local +# login" is turned on. This is the documented "SSO is broken, let me back +# in" path for an operator whose only normal sign-in route is via OIDC. +# /auth/advanced-auth/status also reports local_login_enabled=true while +# this is set, so the login page shows the credentials form to match. +# LDAP is governed by its own ldap_enabled toggle and is not affected. +# Leave unset for normal operation. +# BAMBUDDY_LOCAL_LOGIN=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 18923f0b7..39b546cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,19 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added +- **Autologin via SSO + disable local login (#1589, requested by @einstux)** — Two related additions for operators who run their own OIDC SSO and want exactly one auth path. **Global setting `local_login_enabled`** (default True, preserves pre-#1589 behaviour) — when False, `POST /api/v1/auth/login` rejects username + password credentials with HTTP 401 (same wording as wrong-password to avoid leaking "local disabled" to credential-stuffing tools), `POST /api/v1/auth/forgot-password` rejects with HTTP 403 (the reset wouldn't grant access anyway), and the LoginPage hides the credentials form + Forgot Password link, leaving only the OIDC provider buttons. **Env-var recovery path** `BAMBUDDY_LOCAL_LOGIN=true` (also accepts `1` / `yes`, case-insensitive) bypasses the gate on both routes and flips the reported `local_login_enabled` flag on `/auth/advanced-auth/status` back to True so the LoginPage matches what the route actually accepts — a server admin whose SSO provider is unreachable can recover the install with one env var, no DB editing. LDAP keeps its own `ldap_enabled` switch and is not affected by this gate — a delegated directory has its own policy and lockouts and is closer to SSO than to local credentials. **Per-OIDC-provider `is_autologin` flag** — when set on an enabled provider, the LoginPage redirects unauthenticated visitors directly to that provider's authorize URL on mount instead of rendering the login form. At most one provider can carry the flag at a time (app-layer invariant enforced in both create and update routes: setting it on one provider clears it on every other). **Two-layer fallback for autologin** — the LoginPage races `getOIDCAuthorizeUrl` against a 5-second timeout; on success the browser navigates to the IdP, on timeout or fetch error the redirect is aborted, the page renders normally, and a sticky amber banner explains "Autologin to failed, pick a provider". A bookmarkable `/login?fallback=local` query param always skips the autologin redirect — paired with the `BAMBUDDY_LOCAL_LOGIN=true` env-var on the server, this is the documented "SSO is broken, let me back in" path. **Two safety refusals on disabling local login**: settings PUT returns HTTP 400 ("no OIDC provider is enabled") when no enabled OIDC provider exists, and HTTP 400 ("you would lock yourself out") when the calling admin has no `UserOIDCLink` row. Either failure mode would otherwise lock everyone out of the install. **Backend.** `local_login_enabled: bool = True` added to `AppSettings` + `AppSettingsUpdate` schemas and to the `_BOOL_KEYS` allowlist in `routes/settings.py`. `OIDCProvider.is_autologin: bool` column via `_safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...)` — SQLite `DEFAULT 0`, Postgres `DEFAULT false` per the project's existing boolean-migration pattern. New `OIDCProviderResponse.is_autologin` field threaded through `from_attributes=True`. `_local_login_env_bypass()` reads at call time (not import time) so tests can monkeypatch the env between cases. `/auth/advanced-auth/status` extended with `local_login_enabled` and `autologin_provider_id` so the LoginPage decides UI in one query — `autologin_provider_id` filters on `is_enabled=True AND is_autologin=True` so disabling a provider stops the autologin redirect even if the flag stays set. **Frontend.** `LoginPage.tsx` adds the autologin `useEffect` (skips redirect when `?fallback=local` is in the URL, when an OIDC token is already in the fragment, or when an `oidc_error` query param is present from a previous round trip), the autologin-failed banner, and a "Local sign-in disabled" notice that replaces the form when the flag is off. `SettingsPage.tsx` exposes the `local_login_enabled` toggle in the OIDC tab card above the existing provider list; `OIDCProviderSettings.tsx` adds the per-provider Autologin toggle in the form's flags row. `AppSettings`, `AdvancedAuthStatus`, `OIDCProvider`, and `OIDCProviderCreate` TypeScript interfaces extended to match. **i18n.** 6 new keys (`login.autologinFailed`, `login.localDisabledNotice`, `settings.localLogin.disable`, `settings.localLogin.disableHint`, `settings.oidc.form.autologin`, `settings.oidc.form.autologinDesc`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5375 leaves per locale, no English fallback. **Tests.** 6 new integration cases in `test_local_login_gate.py`: login default allows local, login rejected when flag off and no env bypass (with generic 401 wording asserted), env-var bypasses the gate, forgot-password rejected when flag off, status surfaces both new fields, env bypass flips the reported flag back to True. Full nearby suites green: `test_auth_api.py` 44/44, `test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py` 159/159. Backend `ruff check` clean. Frontend `npm run build` clean. **Scope.** No new permission — the existing `SETTINGS_UPDATE` permission gates the toggle. The migration is a single `ADD COLUMN` per backend; the `local_login_enabled` setting lives in the existing settings key-value table and needs no migration. Default behaviour is unchanged: fresh installs and upgrades see no difference until an admin explicitly enables the toggle or sets a provider as autologin. + +- **Cam Wall view on the Printers page** — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (`Cards` ↔ `Cam wall`). Reuses the existing per-printer FTP / RTSPS proxy on `/api/v1/printers/{id}/camera/stream`, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to `Max live streams` (default 4) at any moment — everything else falls back to per-tile snapshot polling against `/api/v1/printers/{id}/camera/snapshot` at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. **Architecture.** `frontend/src/components/CameraTile.tsx` is the leaf — three modes (`live` / `snapshot` / `paused`), a single `` element with `loading="lazy"`, an `onError` no-signal fallback, and a `useEffect` cleanup that POSTs `/camera/stop` (with `keepalive: true`) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same `/camera/stop` discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. `frontend/src/components/CameraWall.tsx` is the scheduler — an `IntersectionObserver` (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a `useMemo` walks the printer list in sort order and assigns the first N visible tiles to `live`, the rest of the visible set to `snapshot`, and off-screen tiles to `paused`. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same `['printerStatus', id]` React Query cache each `PrinterCard` already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing `Settings → camera_view_mode` preference — opens the floating `EmbeddedCameraViewer` when set to `embedded`, otherwise pops the `/camera/:id` window with the saved size/position from `cameraWindowState`. **Settings.** Both knobs are per-user, persisted in `localStorage` (`camWallMaxLive`, `camWallSnapshotSec`) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded `[1, 16]` for max live and `[2, 60]` seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on `camera:view`; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). **i18n.** 13 new keys (`printers.pageView.cards`, `printers.pageView.camWall`, `printers.camWall.{noPrinters,noSignal,live,snap,off,summary}`, `printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. **Tests.** 5 new vitest cases in `frontend/src/__tests__/components/CameraTile.test.tsx` cover live URL emission with `fps=8`, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the `/camera/stop` POST firing when the tile transitions out of live. **Scope.** No backend change. No DB migration. No new permission. The existing `EmbeddedCameraViewer` is untouched — Cam Wall is purely additive. The `printerPageView` toggle defaults to `cards`, so installs see no behaviour change until a user picks Cam Wall. + +- **AMS drying badge now shows the active cycle's filament + target temperature** — During an active drying cycle the AMS card on the printers page renders `Drying · PETG @ 65°C · 11h 35m left` (the loaded-filament line under the slots) instead of the bare `Drying · 11h 35m left`. Bambu's per-tick AMS push only carries the `dry_time` countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. `BambuMQTTClient.send_drying_command(mode=1, ...)` now caches `{ams_id: {filament, temp}}` on the client; the cache is cleared on `mode=0` and on the per-AMS `dry_time` falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). `PrinterManager.get_drying_targets(printer_id)` exposes it, `printer_state_to_dict` and `routes/printers.py::get_printer_status` thread it onto each AMS dict as `dry_target_temp` + `dry_filament`, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's `tray_type` + RFID-recommended `drying_temp` when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key `printers.drying.targetSummary` = `{{filament}} @ {{temp}}°C`, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in `TestSupportsDryingCommand` (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in `TestDryingTargetExposure` (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). **Note about Bambu's printer display.** A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG @ 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (`filament: "PETG"` sent, `result: success, filament: PETG, temp: 65` ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the `filament` field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice. + +- **Continue auto-drying while a print is running on capable hardware** — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New **Settings → Print Queue → "Continue drying while printing"** toggle (default OFF) lets the same scheduler evaluator also run on the *busy* printer set. Backend: `supports_drying_while_printing(model, firmware)` in `printer_manager.py` is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; **P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded** because the wiki is silent for them, and on those models the firmware would reject the command anyway via `dry_sf_reason=[0]` (TaskOccupied)). The capability is gated on both display names (`"H2D"`, `"X1C"`, ...) and internal SSDP / MQTT model codes (`"O1D"`, `"O1E"`, `"O2D"`, `"O1C"`, `"O1C2"`, `"O1S"`, `"N6"`, `"BL-P001"`, `"N7"`, `"N9"`) — the printer's `model` field can carry either, the existing `supports_drying` precedent uses both. `_check_auto_drying` in `print_scheduler.py` now resolves model + firmware up front for every printer and computes `mid_print = busy AND toggle_on AND supports_drying_while_printing`; when `mid_print` is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / `dry_sf_reason` / drying-presets / mode-1 send path takes over. **Safety: drying temp is capped at `max(40, preset_temp - 5)` for mid-print drying** — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when `print_drying_enabled` is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: `routes/printers.py::start_drying` has no Bambuddy-side `is_idle` gate; the "printer busy" rejection comes from firmware `dry_sf_reason=[0]`, which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on `PrinterStatus.supports_drying_while_printing` so the frontend can light up the AMS card affordances correctly. **Settings.** New `print_drying_enabled: bool = False` in `schemas/settings.py`, added to the boolean allowlist in `routes/settings.py` (`_BOOL_KEYS`), and threaded through the existing dirty-detection / save call in `SettingsPage.tsx`. **i18n.** 2 new keys (`settings.printDryingEnabled`, `settings.printDryingEnabledDescription`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. **Tests.** 7 new cases in `TestSupportsDryingWhilePrinting` cover every supported display name + internal code, below-min firmware, excluded models (`P1*`, `A1`, `A1 MINI`, `X1`, `X1E`), missing firmware, `None` model, case-insensitivity, and the strict unknown-model default (False — unlike `supports_drying` which leniently allows unknowns). 4 new scheduler integration cases in `TestMidPrintDrying` cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full `pytest -n 30` green (4251/4251 in 49 s). Backend `ruff` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via `dry_sf_reason` so being too permissive here costs nothing. + - **Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web)** — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. **Frontend.** A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with **Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection**. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new `BulkEditSpoolsModal` is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per `(printer, extruder, nozzle_diameter)` and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. **Clearing fields in bulk is intentionally NOT supported** (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. **Same dropdown controls the per-spool editor uses.** Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new `SearchableSelect` component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native ` { + const n = Math.min( + MAX_MAX_LIVE, + Math.max(MIN_MAX_LIVE, Number(e.target.value) || MIN_MAX_LIVE), + ); + onChangeMaxLive(n); + }} + className="w-full rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-sm text-white" + /> + + {t('printers.camWall.settings.maxLiveHint')} + + + + + )} + + + +
+ {printers.map((p) => { + const mode = modeByPrinter.get(p.id) ?? 'paused'; + return ( +
{ + tileRefs.current.set(p.id, el); + }} + data-printer-id={p.id} + > + onTileClick(p.id, p.name)} + /> +
+ ); + })} +
+ + ); +} diff --git a/frontend/src/components/ForecastPanel.tsx b/frontend/src/components/ForecastPanel.tsx index 4a2865a2f..45262ef86 100644 --- a/frontend/src/components/ForecastPanel.tsx +++ b/frontend/src/components/ForecastPanel.tsx @@ -24,6 +24,7 @@ interface SkuGroup { material: string; subtype: string | null; brand: string | null; + colorName: string | null; spools: InventorySpool[]; } @@ -59,8 +60,8 @@ const CHART_COLORS = ['#1DB954', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6']; // ── Pure helpers ────────────────────────────────────────────────────────────── -function skuKey(material: string, subtype: string | null, brand: string | null) { - return `${material}||${subtype ?? ''}||${brand ?? ''}`; +function skuKey(material: string, subtype: string | null, brand: string | null, colorName: string | null) { + return `${material}||${subtype ?? ''}||${brand ?? ''}||${colorName ?? ''}`; } function addDays(date: Date, days: number): Date { @@ -81,25 +82,39 @@ function formatDateShort(date: Date): string { * Compute a time-weighted daily consumption rate and standard deviation. * * Algorithm: - * 1. Sort all usage events by timestamp (oldest → newest). - * 2. Convert each event into a g/day intensity = weight_used / elapsed_days, - * where elapsed_days is the gap to the previous event (floor: 0.5d to - * avoid inflated rates from same-day prints). + * 1. Aggregate all usage events by calendar day (UTC date string), summing + * weight_used across all spools in the group that printed on that day. + * Day-bucketing fixes two problems: (a) concurrent prints from multiple + * spools in the same group no longer produce near-zero inter-event gaps + * that inflate per-interval rates; (b) the oldest event's weight is no + * longer silently dropped — it contributes to its day bucket. + * 2. Sort day buckets oldest → newest and compute inter-day g/day rates. + * The gap is in whole days (minimum 1) so same-day reprints don't + * create a zero-duration interval. * 3. Apply exponential age-decay: each observation is weighted by * exp(-λ * age_days) so recent prints dominate. λ = ln(2)/30 gives a * 30-day half-life — prints from a month ago count half as much. * 4. Compute the weighted mean and weighted variance → std dev. * - * Returns null when there is only one event (no gap to measure) — the - * delta-rate fallback handles that case. + * Returns null when there are fewer than 2 distinct days (no gap to measure) + * — the delta-rate fallback handles that case. */ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev: number } | null { if (records.length < 2) return null; - // Sort ascending by time - const sorted = [...records].sort( - (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), - ); + // Aggregate by UTC calendar day so concurrent multi-spool prints on the + // same day are summed before rate computation. + const byDay = new Map(); + for (const r of records) { + const day = r.created_at.slice(0, 10); // "YYYY-MM-DD" UTC — consistent with server timestamps + byDay.set(day, (byDay.get(day) ?? 0) + r.weight_used); + } + + if (byDay.size < 2) return null; + + const days = [...byDay.entries()] + .map(([day, totalG]) => ({ ms: new Date(day).getTime(), totalG })) + .sort((a, b) => a.ms - b.ms); const now = Date.now(); // λ for 30-day half-life: ln(2)/30 @@ -107,15 +122,12 @@ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev const observations: { rate: number; weight: number }[] = []; - for (let i = 1; i < sorted.length; i++) { - const prev = new Date(sorted[i - 1].created_at).getTime(); - const curr = new Date(sorted[i].created_at).getTime(); - const elapsedDays = Math.max((curr - prev) / 86400000, 0.5); // floor at 0.5d - const ageDays = (now - curr) / 86400000; + for (let i = 1; i < days.length; i++) { + const elapsedDays = Math.max((days[i].ms - days[i - 1].ms) / 86400000, 1); + const ageDays = (now - days[i].ms) / 86400000; - // g/day for this interval - const intervalRate = sorted[i].weight_used / elapsedDays; - // Exponential age-decay weight + // g/day for this inter-day interval: weight printed on day[i] / gap to previous day + const intervalRate = days[i].totalG / elapsedDays; const w = Math.exp(-lambda * ageDays); observations.push({ rate: intervalRate, weight: w }); @@ -181,6 +193,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { const [alertsOpen, setAlertsOpen] = useState(false); const [sortKey, setSortKey] = useState('material'); const [sortDir, setSortDir] = useState('asc'); + const [materialFilter, setMaterialFilter] = useState(''); + const [brandFilter, setBrandFilter] = useState(''); const [cartModal, setCartModal] = useState(null); const [listOpen, setListOpen] = useState(false); const [chartDays, setChartDays] = useState(30); @@ -194,7 +208,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { const settingsMap = useMemo(() => { const m = new Map(); - for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand), s); + for (const s of skuSettingsList) m.set(skuKey(s.material, s.subtype, s.brand, s.color_name), s); return m; }, [skuSettingsList]); @@ -212,8 +226,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { const map = new Map(); for (const spool of spools) { if (spool.archived_at) continue; - const key = skuKey(spool.material, spool.subtype, spool.brand); - const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, spools: [] }; + const key = skuKey(spool.material, spool.subtype, spool.brand, spool.color_name); + const g = map.get(key) ?? { key, material: spool.material, subtype: spool.subtype, brand: spool.brand, colorName: spool.color_name, spools: [] }; g.spools.push(spool); map.set(key, g); } @@ -224,7 +238,12 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { const today = new Date(); today.setHours(0, 0, 0, 0); return groups.map((group): SkuForecast => { - const skuSettings = settingsMap.get(group.key) ?? null; + // Fall back to the NULL-colour row that pre-upgrade users have so their + // lead-time / safety-margin overrides survive the first load after the + // color_name column is added (#forecast-color-grouping migration). + const skuSettings = + settingsMap.get(group.key) ?? + (group.colorName !== null ? settingsMap.get(skuKey(group.material, group.subtype, group.brand, null)) ?? null : null); const skuLeadTime = skuSettings?.lead_time_days ?? 0; const effectiveLeadTimeDays = Math.max(globalLeadTime, skuLeadTime); const marginValue = skuSettings?.safety_margin_value ?? 14; @@ -235,8 +254,15 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { // Consumed since baseline (post-reset); see InventoryPage stats calc (#1390). const totalUsedG = group.spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0); + // Only include history from spools that haven't been reset — pre-reset + // events on a reset spool have no anchor timestamp so they'd inflate the + // rate. Spools without a baseline are clean and keep their records. const groupHistory: SpoolUsageRecord[] = []; - for (const s of group.spools) groupHistory.push(...(usageBySpoolId.get(s.id) ?? [])); + for (const s of group.spools) { + if ((s.weight_used_baseline ?? 0) === 0) { + groupHistory.push(...(usageBySpoolId.get(s.id) ?? [])); + } + } let dailyRateG: number | null = null; let dailyRateStdDev: number | null = null; @@ -287,8 +313,18 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { }); }, [groups, settingsMap, usageBySpoolId, globalLeadTime]); + const uniqueMaterials = useMemo(() => + [...new Set(groups.map((g) => g.material))].sort(), + [groups]); + + const uniqueBrands = useMemo(() => + [...new Set(groups.map((g) => g.brand).filter(Boolean))].sort() as string[], + [groups]); + const sortedForecasts = useMemo(() => { - const arr = [...forecasts]; + let arr = [...forecasts]; + if (materialFilter) arr = arr.filter((f) => f.group.material === materialFilter); + if (brandFilter) arr = arr.filter((f) => f.group.brand === brandFilter); arr.sort((a, b) => { let va: number | string = 0; let vb: number | string = 0; @@ -311,7 +347,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { return sortDir === 'asc' ? cmp : -cmp; }); return arr; - }, [forecasts, sortKey, sortDir]); + }, [forecasts, sortKey, sortDir, materialFilter, brandFilter]); const alerts = useMemo(() => forecasts.filter((f) => !f.settings?.alerts_snoozed && (f.stockBreakAlert || f.reorderAlert)), [forecasts]); @@ -374,6 +410,34 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) { /> )} + {/* Material filter */} + + + {/* Brand filter */} + + {/* Shopping list toggle */}