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:
2026-06-18 14:04:49 +12:00
co-authored by Claude Opus 4.8
parent e7a7b11589
commit 1062c038e8
8 changed files with 133 additions and 90 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.29",
"version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.29",
"version": "0.1.30",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.29",
"version": "0.1.30",
"private": true,
"type": "module",
"scripts": {
@@ -1,14 +1,11 @@
<script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let {
today,
weekRangeLabel,
heroStats,
mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate,
formatNumber
}: {
@@ -16,7 +13,6 @@
weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props();
@@ -26,18 +22,19 @@
<div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<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>
<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">
<div class="fact">
<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>
</div>
</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>
<style>
@@ -106,41 +91,6 @@
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,
.mix-facts {
display: grid;
@@ -149,8 +99,13 @@
padding: 1rem;
}
/* Mix cards lead the overview, so they carry the top padding; the hero
figures follow and hug up against them. */
.mix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.facts {
padding-top: 0;
}
@@ -243,12 +198,12 @@
}
@media (max-width: 760px) {
.facts {
.mix-facts {
grid-template-columns: 1fr;
padding: 0.9rem;
}
.mix-facts {
.facts {
grid-template-columns: 1fr;
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 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) {
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
}
+3 -7
View File
@@ -12,7 +12,6 @@
import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte';
import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte';
import {
MIX_RANGES,
addDays,
ausToday,
buildConfetti,
@@ -431,15 +430,13 @@
return norm.includes('phf') && norm.includes('horse');
}
let mixRangeKey = $state<(typeof MIX_RANGES)[number]['key']>('4w');
const mixRange = $derived(MIX_RANGES.find((r) => r.key === mixRangeKey) ?? MIX_RANGES[1]);
// Today's split only: Horse Mix (PHF Horsemix) vs Grain Mix (everyone else).
const mixTotals = $derived.by(() => {
const cutoff = toISODate(addDays(ausToday(), -(mixRange.days - 1)));
const todayStr = toISODate(ausToday());
let horse = 0;
let grain = 0;
for (const entry of statsEntries) {
if (entry.production_date < cutoff) continue;
if (entry.production_date !== todayStr) continue;
const kg = entry.calculated_kg || 0;
const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null;
if (isHorseMixClient(client)) horse += kg;
@@ -531,7 +528,6 @@
{weekRangeLabel}
{heroStats}
{mixTotals}
bind:mixRangeKey
{formatDate}
{formatNumber}
/>