v0.1.28 - Version bump and editor/throughput updates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:51:29 +12:00
co-authored by Claude Opus 4.8
parent 3f8279af10
commit 1dd48bc771
10 changed files with 395 additions and 72 deletions
+37 -13
View File
@@ -86,12 +86,17 @@ def _serialize_product_formula(product: Product) -> dict:
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
# Status is product-driven once a mix has products (Active = at least one
# visible product). A mix with no products yet has nothing to fan out to, so
# it falls back to its own `status` column — that's what lets a brand-new
# mix read as Active instead of being stuck Inactive and hidden.
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"visible": visible_count > 0,
"visible": visible,
"product_count": product_count,
"visible_product_count": visible_count,
"notes": mix.notes,
@@ -319,6 +324,9 @@ def create_editor_mix(
client_name=payload.client_name.strip(),
name=payload.name.strip(),
notes=payload.notes,
# Active by default so a freshly created mix shows under the default
# "Active" filter rather than being hidden until it has a visible product.
status="active",
)
db.add(mix)
db.flush()
@@ -348,27 +356,43 @@ def update_editor_mix(
raise HTTPException(status_code=404, detail="Mix not found")
updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: it fans out to the visibility of every product
# under the mix rather than mapping to a mix column.
# `visible` is a virtual field: for a mix with products it fans out to the
# visibility of every product; for a product-less mix it maps to the mix's
# own `status` column so the toggle still persists.
visible = updates.pop("visible", None)
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
before = {field: getattr(mix, field) for field in updates}
if visible is not None:
visible_before = db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
)
before["visible"] = bool(visible_before)
if product_total > 0:
visible_before = db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
)
before["visible"] = bool(visible_before)
else:
before["visible"] = mix.status == "active"
for field, value in updates.items():
setattr(mix, field, value)
if visible is not None:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
if product_total > 0:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
else:
mix.status = "active" if visible else "inactive"
after = dict(updates)
if visible is not None:
+61 -2
View File
@@ -66,9 +66,15 @@ class MigrationReport:
created_tables: tuple[str, ...] = ()
added_columns: tuple[str, ...] = ()
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
resynced_sequences: tuple[str, ...] = ()
def has_changes(self) -> bool:
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows)
return bool(
self.created_tables
or self.added_columns
or self.synced_tenant_rows
or self.resynced_sequences
)
def summary(self) -> str:
parts: list[str] = []
@@ -79,6 +85,8 @@ class MigrationReport:
if self.synced_tenant_rows:
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
parts.append(f"synced tenant rows: {counts}")
if self.resynced_sequences:
parts.append(f"resynced sequences: {', '.join(self.resynced_sequences)}")
return "; ".join(parts) if parts else "schema already up to date"
@@ -435,7 +443,58 @@ def sync_product_visibility(engine: Engine) -> int:
return result.rowcount or 0
def resync_identity_sequences(engine: Engine) -> tuple[str, ...]:
"""Realign Postgres identity sequences with each table's current MAX(id).
After a bulk import that carries original primary keys across (the SQLite
Postgres migration inserts rows with their existing ids), every table's
sequence still points at its starting value. The next INSERT then reuses an
id that already exists and fails with ``duplicate key value violates unique
constraint`` which is why creating a new mix/ingredient/product saved fine
on SQLite but not on production Postgres.
This advances each ``id`` sequence to MAX(id) so the next INSERT continues
cleanly. It is a no-op on SQLite and idempotent on Postgres, so it is safe to
run on every startup. A per-table failure is skipped rather than aborting the
whole boot.
"""
if engine.dialect.name != "postgresql":
return ()
resynced: list[str] = []
inspector = inspect(engine)
with engine.begin() as connection:
for table_name in inspector.get_table_names():
if not any(column["name"] == "id" for column in inspector.get_columns(table_name)):
continue
try:
sequence = connection.execute(
text("SELECT pg_get_serial_sequence(:table, 'id')"),
{"table": table_name},
).scalar()
if not sequence:
continue
max_id = connection.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
if max_id is None:
continue
connection.execute(
text("SELECT setval(:sequence, :value, true)"),
{"sequence": sequence, "value": int(max_id)},
)
resynced.append(table_name)
except Exception:
# A single problematic table must not block startup; the others
# still get realigned.
continue
return tuple(resynced)
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
created_tables = ensure_metadata_tables(engine, metadata)
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
return MigrationReport(created_tables=created_tables, added_columns=added_columns)
resynced_sequences = resync_identity_sequences(engine)
return MigrationReport(
created_tables=created_tables,
added_columns=added_columns,
resynced_sequences=resynced_sequences,
)
+1
View File
@@ -117,6 +117,7 @@ def ensure_database_ready() -> MigrationReport:
**tenant_sync_report,
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
},
resynced_sequences=schema_report.resynced_sequences,
)
logger.info("Database startup checks complete: %s", report.summary())
_database_ready = True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hunter-backend"
version = "0.1.27"
version = "0.1.28"
description = "Costing platform MVP backend (API for Hunter)"
requires-python = ">=3.11"
dependencies = [
+19 -11
View File
@@ -457,19 +457,27 @@ def migrate():
# Re-enable FK checks
dst_conn.execute(text("SET session_replication_role = 'origin'"))
# Reset auto-increment sequences
# Reset auto-increment sequences for EVERY table with an id sequence — not
# just the ones we copied above — so later inserts (e.g. editor_change_events)
# don't collide with pre-existing ids. Leaving a sequence behind MAX(id) is
# what makes "create new mix" fail with a duplicate-key error on Postgres.
print("\n Resetting sequences...")
with dst.begin() as conn:
for table_name in TABLE_ORDER:
try:
conn.execute(text(
f"SELECT setval("
f" pg_get_serial_sequence('{table_name}', 'id'),"
f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)"
f")"
))
except Exception:
pass
all_tables = inspect(dst).get_table_names()
for table_name in all_tables:
sequence = conn.execute(text(
"SELECT pg_get_serial_sequence(:table, 'id')"
), {"table": table_name}).scalar()
if not sequence:
continue
max_id = conn.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
if max_id is None:
continue
conn.execute(text("SELECT setval(:sequence, :value, true)"), {
"sequence": sequence,
"value": int(max_id),
})
print(f" SEQ {table_name:<45} -> {max_id}")
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
return totals
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.27",
"version": "0.1.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.27",
"version": "0.1.28",
"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.27",
"version": "0.1.28",
"private": true,
"type": "module",
"scripts": {
+8 -6
View File
@@ -133,21 +133,23 @@
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (costing
// tools plus throughput), then the standalone ordering/insights modules. Built
// from the same access-filtered items, so a role only ever sees the families it
// may open.
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
// calculator plus throughput), a "Costing" family (product costing and the
// editors), then the standalone ordering/insights modules. Built from the same
// access-filtered items, so a role only ever sees the families it may open.
const navEntries = $derived(
buildClientNavEntries({
dashboard: visibleDashboardItem,
costing: [
operations: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : [])
],
costing: [
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
@@ -292,15 +292,14 @@ export const baseSearchItems: SearchItem[] = [
* Callers pass only the modules the current session may see; empty families
* collapse away so a role with one costing tool never gets an empty group.
*
* Workflow-family layout: Dashboard, then an "Operations" group (the calculator,
* costing, editor, master tools, and throughput), then Ordering and Insights
* modules. Costing tools live inside Operations for the time being until they
* grow into a family of their own.
* Workflow-family layout: Dashboard, then an "Operations" group (the mix
* calculator and throughput) and a "Costing" group (product costing, mix and
* ingredient editors, and master tools), then Ordering and Insights modules.
*/
export function buildClientNavEntries(visible: {
dashboard?: NavItem | null;
operations: NavItem[];
costing: NavItem[];
throughput?: NavItem | null;
ordering?: NavEntry | null;
reporting?: NavItem | null;
}): NavEntry[] {
@@ -310,14 +309,17 @@ export function buildClientNavEntries(visible: {
entries.push({ kind: 'item', item: visible.dashboard });
}
const operationsChildren = [
...visible.costing,
...(visible.throughput ? [visible.throughput] : [])
];
if (operationsChildren.length) {
if (visible.operations.length) {
entries.push({
kind: 'group',
group: { id: 'operations', label: 'Operations', icon: Layers, children: operationsChildren }
group: { id: 'operations', label: 'Operations', icon: Layers, children: visible.operations }
});
}
if (visible.costing.length) {
entries.push({
kind: 'group',
group: { id: 'costing', label: 'Costing', icon: BadgeDollarSign, children: visible.costing }
});
}
@@ -408,15 +410,15 @@ export function pageMeta(pathname: string): PageMeta {
}
if (pathname.startsWith('/product-costing')) {
return { title: productCostingItem.label, category: 'Operations', icon: productCostingItem.icon };
return { title: productCostingItem.label, category: 'Costing', icon: productCostingItem.icon };
}
if (pathname.startsWith('/editor')) {
return { title: editorItem.label, category: 'Operations', icon: editorItem.icon };
return { title: editorItem.label, category: 'Costing', icon: editorItem.icon };
}
if (pathname.startsWith('/ingredients')) {
return { title: ingredientsEditorItem.label, category: 'Operations', icon: ingredientsEditorItem.icon };
return { title: ingredientsEditorItem.label, category: 'Costing', icon: ingredientsEditorItem.icon };
}
if (pathname.startsWith('/raw-materials')) {
+249 -22
View File
@@ -12,7 +12,7 @@
EditorMixUpdateInput,
RawMaterial
} from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
@@ -38,11 +38,18 @@
let expandedMixId = $state<number | null>(null);
let activeFormula = $state<EditorResolvedMixFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]);
// Snapshot of the loaded formula, so we can tell whether the open panel has
// unsaved edits before letting the user leave it.
let ingredientBaseline = $state<DraftIngredient[]>([]);
// The reference total used to convert between % and kg. Editing a kg cell
// redefines it (kg is the source of truth); editing the Total mix field
// rescales every row's kg from its %.
let totalReference = $state(0);
// The row the user is trying to open/close while the current panel has unsaved
// ingredient edits (null = no pending switch). Drives the confirm dialog.
let pendingRow = $state<EditableRow | null>(null);
// The mix whose change history is open in the modal (null = closed).
let historyMix = $state<EditableRow | null>(null);
@@ -92,6 +99,8 @@
activeFormula = formula;
totalReference = formula.total_kg || 0;
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
// Reset the baseline so the freshly loaded (or just-saved) formula reads clean.
ingredientBaseline = ingredientDrafts.map((row) => ({ ...row }));
}
// kg overrides %: recompute the reference total from the kg column, then
@@ -105,16 +114,46 @@
}));
}
// % overrides kg: convert this row's percentage to kg against the locked
// reference total. Other rows are untouched, so the percentage total will
// read off 100 until the rest are adjusted (the save guard enforces 100%).
// Editing a row's % sets it to that share of the mix and spreads the rest
// across the other rows in their existing proportions, so the column always
// re-totals 100 and kg stays the canonical value. The total mix kg is held
// constant; only the split moves. Without this rebalance a single % edit
// would leave the total off 100 and the save guard would block the change.
function applyPercentEdit(index: number) {
const total = totalReference;
ingredientDrafts = ingredientDrafts.map((row, rowIndex) =>
rowIndex === index
? { ...row, quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0 }
: row
const total = Number(totalReference || 0);
const rows = ingredientDrafts;
const count = rows.length;
// Clamp to a sane 0100 share.
let target = Number(rows[index].percentage || 0);
if (!Number.isFinite(target) || target < 0) target = 0;
if (target > 100) target = 100;
// A single ingredient is always the whole mix.
if (count === 1) {
ingredientDrafts = rows.map((row) => ({
...row,
percentage: 100,
quantity_kg: round4(total)
}));
return;
}
const remaining = 100 - target;
const otherOldSum = rows.reduce(
(sum, row, rowIndex) => (rowIndex === index ? sum : sum + Number(row.percentage || 0)),
0
);
ingredientDrafts = rows.map((row, rowIndex) => {
if (rowIndex === index) {
return { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) };
}
// Keep the other rows' relative split; if they were all at 0, share evenly.
const share =
otherOldSum > 0 ? (Number(row.percentage || 0) / otherOldSum) * remaining : remaining / (count - 1);
return { ...row, percentage: round4(share), quantity_kg: round4((share / 100) * total) };
});
}
// Editing the Total mix (kg) rescales every row's kg from its current %.
@@ -211,11 +250,28 @@
}
}
// Gate panel switches: if the open formula has unsaved edits, ask before
// leaving it (opening another row, or closing this one, both discard them).
function requestToggleIngredients(row: EditableRow) {
if (expandedMixId !== null && ingredientsDirty) {
pendingRow = row;
return;
}
toggleIngredients(row);
}
function confirmDiscardChanges() {
const target = pendingRow;
pendingRow = null;
if (target) toggleIngredients(target);
}
async function toggleIngredients(row: EditableRow) {
if (expandedMixId === row.id) {
expandedMixId = null;
activeFormula = null;
ingredientDrafts = [];
ingredientBaseline = [];
return;
}
@@ -351,6 +407,22 @@
);
const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1);
// True when the open ingredient panel differs from the formula we loaded.
// kg is the canonical value, so comparing kg (plus raw material and notes)
// captures both % and kg edits.
const ingredientsDirty = $derived.by(() => {
if (ingredientBaseline.length !== ingredientDrafts.length) return true;
return ingredientDrafts.some((row, index) => {
const base = ingredientBaseline[index];
return (
!base ||
row.raw_material_id !== base.raw_material_id ||
round4(Number(row.quantity_kg || 0)) !== round4(Number(base.quantity_kg || 0)) ||
(row.notes ?? '') !== (base.notes ?? '')
);
});
});
// Jump back to the first page whenever the filtered set changes.
$effect(() => {
query;
@@ -456,11 +528,17 @@
<div class="create-fields">
<label>
<span>Client</span>
<input bind:value={newMixClient} list="editor-client-options" placeholder="Client name" />
<span>Client <span class="req">*</span></span>
<input
bind:value={newMixClient}
list="editor-client-options"
placeholder="Search clients or type a new one"
autocomplete="off"
/>
<small class="field-hint">Pick an existing client from the list, or type a new client name.</small>
</label>
<label>
<span>Mix name</span>
<span>Mix name <span class="req">*</span></span>
<!-- svelte-ignore a11y_autofocus -->
<input bind:value={newMixName} placeholder="Mix name" autofocus />
</label>
@@ -531,7 +609,7 @@
</div>
<div class="row-actions">
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}>
<button class="clear-button" type="button" onclick={() => requestToggleIngredients(row)}>
<FlaskConical size={16} strokeWidth={2.2} />
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button>
@@ -577,7 +655,7 @@
<div class="ingredient-grid">
<span class="grid-label">Raw material</span>
<span class="grid-label">%</span>
<span class="grid-label">kg</span>
<span class="grid-label">kg (auto)</span>
<span class="grid-label">Notes</span>
<span class="grid-label">Remove</span>
@@ -595,14 +673,12 @@
step="0.0001"
aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`}
/>
<input
bind:value={ingredient.quantity_kg}
onchange={applyKgEdit}
type="number"
min="0"
step="0.0001"
aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}
/>
<!-- kg is derived from % against the Total mix anchor and stays the
canonical saved value; it is read-only here so % is the single
point of entry (the two cells used to conflict). -->
<span class="kg-readout" aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}>
{Number(ingredient.quantity_kg || 0).toFixed(2)}
</span>
<input bind:value={ingredient.notes} aria-label={`Notes for ${rawMaterialName(ingredient.raw_material_id)}`} />
<button class="clear-button remove-button" type="button" onclick={() => removeIngredient(index)}>Remove</button>
{/each}
@@ -635,6 +711,30 @@
onClose={() => (historyMix = null)}
/>
{/if}
{#if pendingRow}
<div class="modal-backdrop" role="presentation" onclick={() => (pendingRow = null)}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="unsaved-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') pendingRow = null; }}
>
<div class="modal-icon"><TriangleAlert size={22} strokeWidth={2.2} /></div>
<h2 id="unsaved-title" class="modal-title">Unsaved changes</h2>
<p class="modal-text">
You have unsaved ingredient changes. Leaving this mix will discard them.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={() => (pendingRow = null)}>Keep editing</button>
<button type="button" class="modal-confirm" onclick={confirmDiscardChanges}>Discard changes</button>
</div>
</div>
</div>
{/if}
</AppSecondaryRailLayout>
<style>
@@ -816,6 +916,19 @@
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
gap: 0.75rem;
align-items: start;
}
.req {
color: var(--color-error);
font-weight: 700;
}
.field-hint {
margin-top: 0.1rem;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 500;
}
.create-actions {
@@ -1260,6 +1373,19 @@
color: var(--color-error);
}
/* Read-only kg cell: looks like a quiet field, not an input, so it reads as a
calculated value rather than something editable. */
.kg-readout {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0.38rem 0.5rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.footer-total {
margin-right: auto;
color: var(--color-text-secondary);
@@ -1351,4 +1477,105 @@
text-align: left;
}
}
/* Unsaved-changes confirm dialog, mirroring the throughput delete dialog. */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--color-border);
border-radius: 1rem;
background: var(--color-bg-surface);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: var(--color-warning-tint);
color: var(--color-warning-text);
}
.modal-title,
.modal-text {
margin: 0;
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text-primary);
}
.modal-text {
font-size: 0.98rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
min-height: 44px;
padding: 0.6rem 1.15rem;
border-radius: 0.7rem;
font-size: 0.98rem;
font-weight: 650;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
.modal-cancel {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.modal-cancel:hover {
color: var(--color-text-primary);
border-color: var(--color-text-muted);
}
.modal-confirm {
background: var(--color-error);
border: 1px solid var(--color-error);
color: #fff;
}
.modal-confirm:hover {
background: color-mix(in srgb, var(--color-error) 85%, black);
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
</style>