fix(veckoplan+streckkod): byt ratt, planera om, variation, man-son + fota fram/bak

Veckoplan/kalender:
- Byt ratt: PATCH satter nu titeln (och portioner/status) efter det nya
  receptet, inte bara recipeId - annars visade kalendern kvar gamla ratten.
- Planera om veckan: ersatter nu veckans plan (raderar tidigare + entries via
  kaskad) sa man aldrig far dubbletter och alltid ser den nya planen. Nytt
  plan-id ger ocksa ny variation.
- Variation: liten deterministisk jitter per recept, seedad pa plan-id +
  veckostart, sa olika veckor OCH omgenereringar blir olika. Tackning/utgang
  dominerar fortfarande. Skalas av varietyLevel.
- Veckoplaneraren visar alltid mandag-sondag (mondayOf), oavsett dagens dag.

Streckkod (fota okand produkt):
- Fotar nu fram- OCH baksida (multi-foto) sa AI:n far bade namn (fram) och
  naring/innehall (bak). Prompt uppdaterad for flera bilder.
- Worker uppdaterar en befintlig delad produkt aven utan nytt namn (behaller
  namnet, fyller pa naring/innehall/allergener) - och sparar ny produkt aven
  fran baksidesfoto (fallback-namn) sa data inte tappas.
- Produktpanelen visar 'Fota baksidan (naring & innehall)' nar info saknas, sa
  man kan komplettera en halvfardig produkt. Fel visas som dialog utan att
  tappa vyn.
- 5 nya i18n-nycklar x 12 sprak.

Verifierat: typecheck (ai-contracts/worker/api/mobil), worker 23/23,
ai-contracts 24/24, api 104/104, samt repro mot testdatabas (byt ratt satter
titel; planera om ger 1 plan; 3 veckor + omgenerering varierar; delvis produkt
kompletteras och behaller namnet).
This commit is contained in:
Claude
2026-08-20 13:45:35 +00:00
parent 2f596060b4
commit cac98b8dc7
18 changed files with 204 additions and 34 deletions
+17 -7
View File
@@ -149,32 +149,41 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
}
: null;
let product: Record<string, unknown> | null = null;
if (gtin && o.productName) {
if (gtin) {
const [existing] = await ctx.db
.select()
.from(schema.products)
.where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo)))
.limit(1);
if (existing) {
// Uppdatera den DELADE produkten: fyll på/uppdatera de fält fotot gav.
// Behåll namnet om inget nytt lästes (t.ex. ett baksidesfoto som bara
// ger näring/innehåll) så kan man komplettera en halvfärdig produkt.
const mergedAllergens =
o.allergensDeclared && o.allergensDeclared.length > 0
? o.allergensDeclared
: existing.allergens;
const [updated] = await ctx.db
.update(schema.products)
.set({
name: o.productName,
name: o.productName ?? existing.name,
brand: o.brand ?? existing.brand,
ingredientsText: o.ingredientsText ?? existing.ingredientsText,
allergens: (o.allergensDeclared ?? existing.allergens) as never,
allergens: mergedAllergens as never,
nutrition: (nutrition ?? existing.nutrition) as never,
updatedAt: new Date(),
})
.where(eq(schema.products.id, existing.id))
.returning();
product = updated as Record<string, unknown>;
} else {
} else if (o.productName || nutrition || o.ingredientsText) {
// Ny produkt. Helst med namn (framsida); annars fallback så datan inte
// tappas nästa skanning med framsida fyller i det riktiga namnet.
const [inserted] = await ctx.db
.insert(schema.products)
.values({
gtin,
name: o.productName,
name: o.productName ?? `Produkt ${gtin}`,
brand: o.brand ?? null,
ingredientsText: o.ingredientsText ?? null,
allergens: (o.allergensDeclared ?? []) as never,
@@ -187,9 +196,10 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
.returning();
product = inserted as Record<string, unknown>;
}
} else if (o.productName) {
} else if (o.productName || nutrition || o.ingredientsText) {
// Ingen streckkod (fristående etikett) returnera transient info, spara ej.
product = {
name: o.productName,
name: o.productName ?? null,
brand: o.brand ?? null,
ingredientsText: o.ingredientsText ?? null,
allergens: o.allergensDeclared ?? [],
+24 -1
View File
@@ -158,6 +158,15 @@ export async function processGenerateWeekPlan(
unitInfo.set(r.id, { densityGPerMl: r.densityGPerMl, gramsPerPiece: r.gramsPerPiece });
}
// Variation mellan veckor OCH mellan omgenereringar: en liten deterministisk
// "jitter" per recept, seedad på planens id + veckostart. Täckning och
// utgångsdatum dominerar fortfarande (jitter är liten), men bland likvärdiga
// rätter växlar urvalet så två veckor och en "planera om" inte blir
// identiska. Nivån styrs av varietyLevel (kriteriet användaren valt).
const varietyAmp =
data.input.varietyLevel === "high" ? 0.35 : data.input.varietyLevel === "low" ? 0.1 : 0.2;
const varietySeed = `${weekPlanId}|${data.input.weekStartDate}`;
const scored = candidates
.filter((recipe) => {
const ings = allIngredients.filter((i) => i.recipeId === recipe.id);
@@ -198,7 +207,11 @@ export async function processGenerateWeekPlan(
return {
recipe,
coverage,
score: coverage.coverage + expiryBoost + (budgetOk ? 0 : -0.5),
score:
coverage.coverage +
expiryBoost +
(budgetOk ? 0 : -0.5) +
varietyAmp * hashUnit(`${recipe.id}|${varietySeed}`),
};
})
.sort((a, b) => b.score - a.score);
@@ -337,6 +350,16 @@ export async function processGenerateWeekPlan(
});
}
/** Deterministisk sträng-hash → [0,1). FNV-1a. Ger reproducerbar variation. */
function hashUnit(s: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return (h >>> 0) / 4294967296;
}
async function defaultPortions(ctx: WorkerContext, householdId: string): Promise<number> {
const [row] = await ctx.db
.select({ total: sql<number>`coalesce(sum(${schema.householdMembers.portionFactor}), 2)` })