From 6c5b40dd574e0e0c084f3cda17d10eb5160a1911 Mon Sep 17 00:00:00 2001 From: Keybored <78739882+Keybored02@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:35:57 +0200 Subject: [PATCH] [Fix] Forecasting: Group spools by color and rework UI (#1814) --- backend/app/api/routes/inventory.py | 10 + backend/app/core/database.py | 93 +++++- backend/app/models/filament_sku_settings.py | 5 +- backend/app/models/shopping_list.py | 1 + frontend/src/api/client.ts | 3 + frontend/src/components/ForecastPanel.tsx | 350 ++++++++++++-------- frontend/src/i18n/locales/en.ts | 2 +- 7 files changed, 322 insertions(+), 142 deletions(-) diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 75c327360..23e453522 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -2164,6 +2164,7 @@ class FilamentSkuSettingsResponse(BaseModel): material: str subtype: str | None brand: str | None + color_name: str | None lead_time_days: int safety_margin_value: int safety_margin_unit: str @@ -2177,6 +2178,7 @@ class FilamentSkuSettingsUpsert(BaseModel): material: str subtype: str | None = None brand: str | None = None + color_name: str | None = None lead_time_days: int = 0 safety_margin_value: int = 14 safety_margin_unit: str = "days" @@ -2213,6 +2215,7 @@ async def upsert_sku_settings( FilamentSkuSettings.material == data.material, FilamentSkuSettings.subtype == data.subtype, FilamentSkuSettings.brand == data.brand, + FilamentSkuSettings.color_name == data.color_name, ) ) row = result.scalar_one_or_none() @@ -2226,6 +2229,7 @@ async def upsert_sku_settings( material=data.material, subtype=data.subtype, brand=data.brand, + color_name=data.color_name, lead_time_days=data.lead_time_days, safety_margin_value=data.safety_margin_value, safety_margin_unit=data.safety_margin_unit, @@ -2245,6 +2249,7 @@ class ShoppingListItemResponse(BaseModel): material: str subtype: str | None brand: str | None + color_name: str | None quantity_spools: int note: str | None status: str @@ -2259,6 +2264,7 @@ class ShoppingListItemCreate(BaseModel): material: str subtype: str | None = None brand: str | None = None + color_name: str | None = None quantity_spools: int = 1 note: str | None = None @@ -2283,6 +2289,7 @@ async def get_shopping_list( material=i.material, subtype=i.subtype, brand=i.brand, + color_name=i.color_name, quantity_spools=i.quantity_spools, note=i.note, status=i.status or "pending", @@ -2308,6 +2315,7 @@ async def add_to_shopping_list( material=data.material, subtype=data.subtype, brand=data.brand, + color_name=data.color_name, quantity_spools=data.quantity_spools, note=data.note, ) @@ -2319,6 +2327,7 @@ async def add_to_shopping_list( material=item.material, subtype=item.subtype, brand=item.brand, + color_name=item.color_name, quantity_spools=item.quantity_spools, note=item.note, status=item.status or "pending", @@ -2362,6 +2371,7 @@ async def update_shopping_list_status( material=item.material, subtype=item.subtype, brand=item.brand, + color_name=item.color_name, quantity_spools=item.quantity_spools, note=item.note, status=item.status or "pending", diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 1ba7d6c29..5b977f228 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -2638,9 +2638,10 @@ async def run_migrations(conn): lead_time_days INTEGER NOT NULL DEFAULT 0, safety_margin_value INTEGER NOT NULL DEFAULT 14, safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days', + color_name VARCHAR(100), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (material, subtype, brand) + UNIQUE (material, subtype, brand, color_name) )""", ) async with conn.begin_nested(): @@ -2654,6 +2655,9 @@ async def run_migrations(conn): await _safe_execute( conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0" ) + # Migration: add color_name so forecasts distinguish colours within a SKU. + await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN color_name VARCHAR(100)") + await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN color_name VARCHAR(100)") # Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild. # Only run if the stale column still exists. cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)")) @@ -2676,28 +2680,77 @@ async def run_migrations(conn): material VARCHAR(50) NOT NULL, subtype VARCHAR(50), brand VARCHAR(100), + color_name VARCHAR(100), lead_time_days INTEGER NOT NULL DEFAULT 0, safety_margin_value INTEGER NOT NULL DEFAULT 14, safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days', alerts_snoozed BOOLEAN NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (material, subtype, brand) + UNIQUE (material, subtype, brand, color_name) )""" ) ) await conn.execute( text( """INSERT INTO filament_sku_settings_new - (id, material, subtype, brand, lead_time_days, safety_margin_value, + (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value, safety_margin_unit, alerts_snoozed, created_at, updated_at) - SELECT id, material, subtype, brand, lead_time_days, safety_margin_value, + SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value, safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at FROM filament_sku_settings""" ) ) await conn.execute(text("DROP TABLE filament_sku_settings")) await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings")) + # Widen the unique key to include color_name on pre-existing tables. The + # auto-created UNIQUE index still covers only (material, subtype, brand) + # after the ADD COLUMN above, so rebuild the table to refresh it (#forecast + # -color-grouping). Detected by inspecting the index columns; skipped once + # color_name is already part of the key. + idx_rows = await conn.execute(text("PRAGMA index_list(filament_sku_settings)")) + needs_uq_rebuild = False + for idx in idx_rows.fetchall(): + if idx[3] != "u": # origin col: 'u' = UNIQUE constraint, 'c' = CREATE INDEX, 'pk' = primary key + continue + info = await conn.execute(text(f"PRAGMA index_info({idx[1]})")) + cols = {row[2] for row in info.fetchall()} + if "material" in cols and "color_name" not in cols: + needs_uq_rebuild = True + break + if needs_uq_rebuild: + async with conn.begin_nested(): + await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_uqfix")) + await conn.execute( + text( + """CREATE TABLE filament_sku_settings_uqfix ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + material VARCHAR(50) NOT NULL, + subtype VARCHAR(50), + brand VARCHAR(100), + color_name VARCHAR(100), + lead_time_days INTEGER NOT NULL DEFAULT 0, + safety_margin_value INTEGER NOT NULL DEFAULT 14, + safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days', + alerts_snoozed BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (material, subtype, brand, color_name) + )""" + ) + ) + await conn.execute( + text( + """INSERT INTO filament_sku_settings_uqfix + (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value, + safety_margin_unit, alerts_snoozed, created_at, updated_at) + SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value, + safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at + FROM filament_sku_settings""" + ) + ) + await conn.execute(text("DROP TABLE filament_sku_settings")) + await conn.execute(text("ALTER TABLE filament_sku_settings_uqfix RENAME TO filament_sku_settings")) await _safe_execute( conn, """CREATE TABLE IF NOT EXISTS filament_shopping_list ( @@ -2705,6 +2758,7 @@ async def run_migrations(conn): material VARCHAR(50) NOT NULL, subtype VARCHAR(50), brand VARCHAR(100), + color_name VARCHAR(100), quantity_spools INTEGER NOT NULL DEFAULT 1, note VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'pending', @@ -2732,9 +2786,10 @@ async def run_migrations(conn): lead_time_days INTEGER NOT NULL DEFAULT 0, safety_margin_value INTEGER NOT NULL DEFAULT 14, safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days', + color_name VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE (material, subtype, brand) + UNIQUE (material, subtype, brand, color_name) )""", ) async with conn.begin_nested(): @@ -2751,6 +2806,33 @@ async def run_migrations(conn): conn, "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE", ) + # Migration: add color_name and widen the unique key to include it so + # forecasts distinguish colours within a SKU (#forecast-color-grouping). + await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)") + await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)") + # Widen UNIQUE (material, subtype, brand) → (material, subtype, brand, color_name). + # The original constraint was declared with name="uq_filament_sku" in the + # model, so we drop/re-add by that name. Gated on a pg_constraint lookup so + # the rebuild only runs when color_name is missing from the key — without + # the gate, every startup would take an ACCESS EXCLUSIVE lock on the table + # and churn the constraint. + uq_check = await conn.execute( + text( + "SELECT 1 FROM pg_constraint c " + "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) " + "WHERE c.conname = 'uq_filament_sku' AND a.attname = 'color_name' LIMIT 1" + ) + ) + if uq_check.scalar_one_or_none() is None: + await _safe_execute( + conn, + "ALTER TABLE filament_sku_settings DROP CONSTRAINT IF EXISTS uq_filament_sku", + ) + await _safe_execute( + conn, + "ALTER TABLE filament_sku_settings ADD CONSTRAINT uq_filament_sku " + "UNIQUE (material, subtype, brand, color_name)", + ) # Only backfill from safety_margin_days if that column still exists (PostgreSQL). col_check = await conn.execute( text( @@ -2773,6 +2855,7 @@ async def run_migrations(conn): material VARCHAR(50) NOT NULL, subtype VARCHAR(50), brand VARCHAR(100), + color_name VARCHAR(100), quantity_spools INTEGER NOT NULL DEFAULT 1, note VARCHAR(500), status VARCHAR(20) NOT NULL DEFAULT 'pending', diff --git a/backend/app/models/filament_sku_settings.py b/backend/app/models/filament_sku_settings.py index 9c616e0d2..65f39dd76 100644 --- a/backend/app/models/filament_sku_settings.py +++ b/backend/app/models/filament_sku_settings.py @@ -13,13 +13,16 @@ class FilamentSkuSettings(Base): __table_args__ = ( # sqlite_where ensures NULL columns participate in uniqueness (NULLS NOT DISTINCT). # On PostgreSQL the partial index is not needed — standard UNIQUE handles it. - UniqueConstraint("material", "subtype", "brand", name="uq_filament_sku"), + # color_name is part of the key so forecasts distinguish e.g. White vs Black + # PLA Matte (#forecast-color-grouping). + UniqueConstraint("material", "subtype", "brand", "color_name", name="uq_filament_sku"), ) id: Mapped[int] = mapped_column(primary_key=True) material: Mapped[str] = mapped_column(String(50)) subtype: Mapped[str | None] = mapped_column(String(50)) brand: Mapped[str | None] = mapped_column(String(100)) + color_name: Mapped[str | None] = mapped_column(String(100)) lead_time_days: Mapped[int] = mapped_column(Integer, default=0) safety_margin_value: Mapped[int] = mapped_column(Integer, default=14) safety_margin_unit: Mapped[str] = mapped_column(String(10), default="days") diff --git a/backend/app/models/shopping_list.py b/backend/app/models/shopping_list.py index fefbe7aa3..2d4c600c7 100644 --- a/backend/app/models/shopping_list.py +++ b/backend/app/models/shopping_list.py @@ -15,6 +15,7 @@ class ShoppingListItem(Base): material: Mapped[str] = mapped_column(String(50)) subtype: Mapped[str | None] = mapped_column(String(50)) brand: Mapped[str | None] = mapped_column(String(100)) + color_name: Mapped[str | None] = mapped_column(String(100)) quantity_spools: Mapped[int] = mapped_column(Integer, default=1) note: Mapped[str | None] = mapped_column(String(500)) status: Mapped[str] = mapped_column(String(20), default="pending") # pending | purchased | received diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 90bea3d2a..2cdc0f362 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2792,6 +2792,7 @@ export interface FilamentSkuSettings { material: string; subtype: string | null; brand: string | null; + color_name: string | null; lead_time_days: number; safety_margin_value: number; safety_margin_unit: 'days' | 'g'; @@ -2803,6 +2804,7 @@ export interface ShoppingListItem { material: string; subtype: string | null; brand: string | null; + color_name: string | null; quantity_spools: number; note: string | null; status: 'pending' | 'purchased' | 'received'; @@ -2814,6 +2816,7 @@ export interface ShoppingListItemCreate { material: string; subtype: string | null; brand: string | null; + color_name: string | null; quantity_spools: number; note?: string | null; } 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 */}