v0.1.30 - Throughput overview today-only mix cards; mix formula save 500 fix
Throughput Overview: Horse Mix and Grain Mix are now the first two cards and
show TODAY's output only. Removed the 7d/4w/6w/12w range selector; the cards
are fixed to Horse mix today, Grain mix today, Today, This week, 4-week average.
Mix Editor formula save: fix HTTP 500 on PUT /editor/mixes/{id}/formula. The
audit-diff path read the resolved formula by attribute, but the resolver returns
dicts -> AttributeError. Read by key and expire stale ORM state so the response
reflects the just-saved rows. Adds regression tests for both save branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,6 @@ from app.schemas.editor import (
|
|||||||
EditorProductRow,
|
EditorProductRow,
|
||||||
EditorProductUpdate,
|
EditorProductUpdate,
|
||||||
EditorResolvedMixFormula,
|
EditorResolvedMixFormula,
|
||||||
EditorResolvedMixIngredient,
|
|
||||||
)
|
)
|
||||||
from app.services.change_log import (
|
from app.services.change_log import (
|
||||||
ENTITY_INGREDIENT,
|
ENTITY_INGREDIENT,
|
||||||
@@ -162,12 +161,16 @@ def _format_kg(value: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _formula_deltas(
|
def _formula_deltas(
|
||||||
before: list[EditorResolvedMixIngredient] | list,
|
before: list[dict],
|
||||||
after: list[EditorResolvedMixIngredient] | list,
|
after: list[dict],
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Per-ingredient before/after deltas between two resolved formulas."""
|
"""Per-ingredient before/after deltas between two resolved formulas.
|
||||||
before_map = {row.raw_material_name: row.quantity_kg for row in before}
|
|
||||||
after_map = {row.raw_material_name: row.quantity_kg for row in after}
|
`resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too),
|
||||||
|
so read the rows by key, not attribute.
|
||||||
|
"""
|
||||||
|
before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before}
|
||||||
|
after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after}
|
||||||
deltas: list[dict] = []
|
deltas: list[dict] = []
|
||||||
for name in sorted(set(before_map) | set(after_map)):
|
for name in sorted(set(before_map) | set(after_map)):
|
||||||
old = before_map.get(name)
|
old = before_map.get(name)
|
||||||
@@ -640,9 +643,12 @@ def replace_editor_mix_formula(
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# Drop now-stale ORM state so the re-resolve reads the rows we just wrote
|
||||||
|
# rather than the formerly-loaded ingredient collections from the identity map.
|
||||||
|
db.expire_all()
|
||||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||||
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||||
deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients)
|
deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"])
|
||||||
if deltas:
|
if deltas:
|
||||||
record_change(
|
record_change(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hunter-backend"
|
name = "hunter-backend"
|
||||||
version = "0.1.29"
|
version = "0.1.30"
|
||||||
description = "Costing platform MVP backend (API for Hunter)"
|
description = "Costing platform MVP backend (API for Hunter)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ product is chosen the way the calculator chooses it.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.api.deps import AuthSession
|
||||||
|
from app.api.editor import replace_editor_mix_formula
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.mix import Mix, MixIngredient
|
from app.models.mix import Mix, MixIngredient
|
||||||
from app.models.product import Product, ProductIngredient
|
from app.models.product import Product, ProductIngredient
|
||||||
from app.models.raw_material import RawMaterial
|
from app.models.raw_material import RawMaterial
|
||||||
|
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
|
||||||
from app.services.mix_calculator_service import (
|
from app.services.mix_calculator_service import (
|
||||||
resolve_editor_mix_formula,
|
resolve_editor_mix_formula,
|
||||||
resolve_representative_product,
|
resolve_representative_product,
|
||||||
@@ -22,6 +25,17 @@ from app.services.mix_calculator_service import (
|
|||||||
TENANT = "hunter-premium-produce"
|
TENANT = "hunter-premium-produce"
|
||||||
|
|
||||||
|
|
||||||
|
def _editor_session() -> AuthSession:
|
||||||
|
return AuthSession(
|
||||||
|
role="internal",
|
||||||
|
email="editor@hunter.test",
|
||||||
|
name="Editor",
|
||||||
|
tenant_id=TENANT,
|
||||||
|
client_role="admin",
|
||||||
|
user_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _session() -> Session:
|
def _session() -> Session:
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -82,6 +96,85 @@ def test_falls_back_to_mix_master_when_no_product_formula():
|
|||||||
assert formula["ingredients"][0]["mix_percentage"] == 100.0
|
assert formula["ingredients"][0]["mix_percentage"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_mix_master_formula_returns_fresh_rows():
|
||||||
|
"""PUT formula on a mix without a product writes the mix master and the
|
||||||
|
response reflects the just-saved rows (not the stale pre-save collection).
|
||||||
|
|
||||||
|
Regression: the diff path read the resolved formula by attribute, but the
|
||||||
|
resolver returns dicts, which raised AttributeError -> HTTP 500 on save.
|
||||||
|
"""
|
||||||
|
db = _session()
|
||||||
|
maize = _raw(db, "Maize")
|
||||||
|
barley = _raw(db, "Barley")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=100))
|
||||||
|
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Percentages need not total 100% — kg is canonical.
|
||||||
|
payload = EditorMixFormulaReplace(
|
||||||
|
rows=[
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=maize.id, quantity_kg=330.0, notes=None),
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=barley.id, quantity_kg=140.0, notes="confirmed"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
|
||||||
|
|
||||||
|
assert result["source"] == "mix"
|
||||||
|
assert result["total_kg"] == 470.0
|
||||||
|
by_name = {row["raw_material_name"]: row for row in result["ingredients"]}
|
||||||
|
assert by_name["Maize"]["quantity_kg"] == 330.0
|
||||||
|
assert by_name["Barley"]["quantity_kg"] == 140.0
|
||||||
|
|
||||||
|
persisted = db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
|
||||||
|
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
|
||||||
|
(maize.id, 330.0),
|
||||||
|
(barley.id, 140.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_product_formula_writes_product_ingredients():
|
||||||
|
"""When a representative product owns the formula, PUT replaces the product's
|
||||||
|
ingredients (the source the calculator reads) and returns the fresh rows."""
|
||||||
|
db = _session()
|
||||||
|
bayley = _raw(db, "Bayley")
|
||||||
|
filler = _raw(db, "Filler")
|
||||||
|
canola = _raw(db, "Canola")
|
||||||
|
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
|
||||||
|
db.add(mix)
|
||||||
|
db.flush()
|
||||||
|
product = Product(
|
||||||
|
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
|
||||||
|
unit_of_measure="20kg bag", visible=True,
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=10, sort_order=1))
|
||||||
|
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=10, sort_order=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
payload = EditorMixFormulaReplace(
|
||||||
|
rows=[
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=bayley.id, quantity_kg=600.0, notes=None),
|
||||||
|
EditorMixFormulaRowInput(raw_material_id=canola.id, quantity_kg=200.0, notes=None),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
|
||||||
|
|
||||||
|
assert result["source"] == "product"
|
||||||
|
assert result["product_id"] == product.id
|
||||||
|
assert result["total_kg"] == 800.0
|
||||||
|
|
||||||
|
persisted = db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
|
||||||
|
# Filler dropped, Canola added; mix master is untouched.
|
||||||
|
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
|
||||||
|
(bayley.id, 600.0),
|
||||||
|
(canola.id, 200.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_representative_product_prefers_20kg_bag():
|
def test_representative_product_prefers_20kg_bag():
|
||||||
db = _session()
|
db = _session()
|
||||||
maize = _raw(db, "Maize")
|
maize = _raw(db, "Maize")
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.29",
|
"version": "0.1.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.29",
|
"version": "0.1.30",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.29",
|
"version": "0.1.30",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
|
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
|
||||||
|
|
||||||
import { MIX_RANGES } from '$lib/components/throughput/utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
today,
|
today,
|
||||||
weekRangeLabel,
|
weekRangeLabel,
|
||||||
heroStats,
|
heroStats,
|
||||||
mixTotals,
|
mixTotals,
|
||||||
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
|
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNumber
|
formatNumber
|
||||||
}: {
|
}: {
|
||||||
@@ -16,7 +13,6 @@
|
|||||||
weekRangeLabel: string;
|
weekRangeLabel: string;
|
||||||
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
|
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
|
||||||
mixTotals: { horse: number; grain: number };
|
mixTotals: { horse: number; grain: number };
|
||||||
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
|
|
||||||
formatDate: (value: string) => string;
|
formatDate: (value: string) => string;
|
||||||
formatNumber: (value: number | null | undefined, digits?: number) => string;
|
formatNumber: (value: number | null | undefined, digits?: number) => string;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -26,18 +22,19 @@
|
|||||||
<div class="summary-heading">
|
<div class="summary-heading">
|
||||||
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
|
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
|
||||||
<h2>Throughput Overview</h2>
|
<h2>Throughput Overview</h2>
|
||||||
<div class="range-select" role="group" aria-label="Customer mix date range">
|
|
||||||
{#each MIX_RANGES as range (range.key)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="range-option"
|
|
||||||
class:active={mixRangeKey === range.key}
|
|
||||||
aria-pressed={mixRangeKey === range.key}
|
|
||||||
onclick={() => (mixRangeKey = range.key)}
|
|
||||||
>{range.label}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="mix-facts" aria-label="Throughput by customer today">
|
||||||
|
<div class="fact">
|
||||||
|
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
|
||||||
|
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
|
||||||
|
<p class="fact-sub">PHF Horsemix · {formatDate(today)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
|
||||||
|
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
|
||||||
|
<p class="fact-sub">All other customers · {formatDate(today)}</p>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
<dl class="facts">
|
<dl class="facts">
|
||||||
<div class="fact">
|
<div class="fact">
|
||||||
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
|
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
|
||||||
@@ -55,18 +52,6 @@
|
|||||||
<p class="fact-sub">Per week, last 4 weeks</p>
|
<p class="fact-sub">Per week, last 4 weeks</p>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<dl class="mix-facts" aria-label="Throughput by customer">
|
|
||||||
<div class="fact">
|
|
||||||
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
|
|
||||||
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
|
|
||||||
<p class="fact-sub">PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
|
|
||||||
</div>
|
|
||||||
<div class="fact">
|
|
||||||
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
|
|
||||||
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
|
|
||||||
<p class="fact-sub">All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -106,41 +91,6 @@
|
|||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-select {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0.28rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
|
||||||
border-radius: 0.8rem;
|
|
||||||
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-option {
|
|
||||||
padding: 0.5rem 0.95rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-option:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-option.active {
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.facts,
|
.facts,
|
||||||
.mix-facts {
|
.mix-facts {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -149,8 +99,13 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mix cards lead the overview, so they carry the top padding; the hero
|
||||||
|
figures follow and hug up against them. */
|
||||||
.mix-facts {
|
.mix-facts {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,12 +198,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.facts {
|
.mix-facts {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mix-facts {
|
.facts {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 0 0.9rem 0.9rem;
|
padding: 0 0.9rem 0.9rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ export type ConfettiPiece = {
|
|||||||
|
|
||||||
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
|
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
|
||||||
|
|
||||||
export const MIX_RANGES = [
|
|
||||||
{ key: '7d', label: '7 days', sub: 'last 7 days', days: 7 },
|
|
||||||
{ key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 },
|
|
||||||
{ key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 },
|
|
||||||
{ key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 }
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function compareText(a: string | null | undefined, b: string | null | undefined) {
|
export function compareText(a: string | null | undefined, b: string | null | undefined) {
|
||||||
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
|
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte';
|
import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte';
|
||||||
import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte';
|
import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte';
|
||||||
import {
|
import {
|
||||||
MIX_RANGES,
|
|
||||||
addDays,
|
addDays,
|
||||||
ausToday,
|
ausToday,
|
||||||
buildConfetti,
|
buildConfetti,
|
||||||
@@ -431,15 +430,13 @@
|
|||||||
return norm.includes('phf') && norm.includes('horse');
|
return norm.includes('phf') && norm.includes('horse');
|
||||||
}
|
}
|
||||||
|
|
||||||
let mixRangeKey = $state<(typeof MIX_RANGES)[number]['key']>('4w');
|
// Today's split only: Horse Mix (PHF Horsemix) vs Grain Mix (everyone else).
|
||||||
const mixRange = $derived(MIX_RANGES.find((r) => r.key === mixRangeKey) ?? MIX_RANGES[1]);
|
|
||||||
|
|
||||||
const mixTotals = $derived.by(() => {
|
const mixTotals = $derived.by(() => {
|
||||||
const cutoff = toISODate(addDays(ausToday(), -(mixRange.days - 1)));
|
const todayStr = toISODate(ausToday());
|
||||||
let horse = 0;
|
let horse = 0;
|
||||||
let grain = 0;
|
let grain = 0;
|
||||||
for (const entry of statsEntries) {
|
for (const entry of statsEntries) {
|
||||||
if (entry.production_date < cutoff) continue;
|
if (entry.production_date !== todayStr) continue;
|
||||||
const kg = entry.calculated_kg || 0;
|
const kg = entry.calculated_kg || 0;
|
||||||
const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null;
|
const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null;
|
||||||
if (isHorseMixClient(client)) horse += kg;
|
if (isHorseMixClient(client)) horse += kg;
|
||||||
@@ -531,7 +528,6 @@
|
|||||||
{weekRangeLabel}
|
{weekRangeLabel}
|
||||||
{heroStats}
|
{heroStats}
|
||||||
{mixTotals}
|
{mixTotals}
|
||||||
bind:mixRangeKey
|
|
||||||
{formatDate}
|
{formatDate}
|
||||||
{formatNumber}
|
{formatNumber}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user