From fbb251ea72f08e8161ff2fdbf9b5445396bd17e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 17:29:28 +0000 Subject: [PATCH] feat(worker): verify-translations + set-test-locale (kvalitetsrapport & marknadstest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify-translations.ts: läser DB och rapporterar täckning per språk, hur många som föll på verifieringen, titel=svenska-heuristik och stickprov (titel + steg 1). Kör efter svepet för att kvalitetskolla utan att ögna alla recept i appen. - set-test-locale.ts: sätter språk/region/valuta för ett testkonto utan psql, för att simulera olika marknader (t.ex. EUR så SEK-receptpriset döljs). Co-Authored-By: Claude --- apps/worker/scripts/set-test-locale.ts | 81 ++++++++++ apps/worker/scripts/verify-translations.ts | 165 +++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 apps/worker/scripts/set-test-locale.ts create mode 100644 apps/worker/scripts/verify-translations.ts diff --git a/apps/worker/scripts/set-test-locale.ts b/apps/worker/scripts/set-test-locale.ts new file mode 100644 index 0000000..62fce32 --- /dev/null +++ b/apps/worker/scripts/set-test-locale.ts @@ -0,0 +1,81 @@ +/** + * Sätt locale-preferenser för ett testkonto (språk / region / valuta) – för att + * simulera olika marknader utan psql. Skriver ut det sparade resultatet. + * + * pnpm --filter @app/worker exec tsx scripts/set-test-locale.ts [regionCode] [currencyCode] + * + * Exempel: + * # Full italiensk marknad (italienska + EUR + inga SEK-priser + IT-högtider): + * ... set-test-locale.ts dev@hypbit.com it-IT IT EUR + * # Bara dölja SEK-priset men behålla svenska marknaden/högtiderna: + * ... set-test-locale.ts dev@hypbit.com it-IT SE EUR + * # Tillbaka till svenskt: + * ... set-test-locale.ts dev@hypbit.com sv-SE SE SEK + * + * region/valuta är valfria – utelämnas de rörs de inte (bara språket byts). + * Byt sedan språk i appens profil (eller starta om appen) så cachen rensas. + * + * Körs i ett vanligt fönster (inte Fönster A/B/C). Kräver DATABASE_URL (.env). + */ +import { config as loadDotenv } from "dotenv"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { eq } from "drizzle-orm"; +import { createDatabase, schema } from "@app/database"; + +for (const candidate of [".env", "../.env", "../../.env"]) { + const p = path.resolve(process.cwd(), candidate); + if (existsSync(p)) { + loadDotenv({ path: p }); + break; + } +} + +async function main() { + if (!process.env.DATABASE_URL) throw new Error("Sätt DATABASE_URL."); + const [email, languageTag, regionCode, currencyCode] = process.argv.slice(2); + if (!email || !languageTag) { + console.error( + "Användning: set-test-locale.ts [regionCode] [currencyCode]", + ); + console.error("Exempel: set-test-locale.ts dev@hypbit.com it-IT IT EUR"); + process.exit(1); + } + + const { db, pool } = createDatabase(process.env.DATABASE_URL); + const [user] = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + if (!user) throw new Error(`Hittar ingen användare med e-post "${email}".`); + + const patch = { + languageTag, + ...(regionCode ? { regionCode } : {}), + ...(currencyCode ? { currencyCode } : {}), + }; + const [row] = await db + .insert(schema.userLocalePreferences) + .values({ userId: user.id, ...patch }) + .onConflictDoUpdate({ + target: schema.userLocalePreferences.userId, + set: { ...patch, updatedAt: new Date() }, + }) + .returning(); + + console.log(`\n✓ Uppdaterade locale för ${email}:`); + console.log(` language_tag = ${row?.languageTag}`); + console.log(` region_code = ${row?.regionCode}`); + console.log(` currency_code = ${row?.currencyCode} ${row?.currencyCode === "SEK" ? "(SEK-pris VISAS)" : "(receptpris DÖLJS tills riktpriser finns)"}`); + console.log(`\nByt språk i appens profil (eller starta om appen) så cachen rensas och det slår igenom.`); + + await pool.end(); +} +main().then( + () => process.exit(0), + (e) => { + console.error(e); + process.exit(1); + }, +); diff --git a/apps/worker/scripts/verify-translations.ts b/apps/worker/scripts/verify-translations.ts new file mode 100644 index 0000000..8d2c1ff --- /dev/null +++ b/apps/worker/scripts/verify-translations.ts @@ -0,0 +1,165 @@ +/** + * Kvalitetsrapport för receptöversättningarna – kör EFTER svepet så du slipper + * ögna igenom alla recept i appen. Läser bara (ändrar inget). + * + * pnpm --filter @app/worker exec tsx scripts/verify-translations.ts + * + * Visar per språk: + * • täckning – antal recept med PUBLICERAD översättning / totalt, + * • utkast – översättningar som inte är publicerade, + * • föll_verif – översättningar som föll på den deterministiska verifieringen + * (de publiceras aldrig automatiskt och faller tillbaka på svenska), + * • titel=sv – heuristik: publicerad titel identisk med svenska källan + * (ofta egennamn som Tiramisu/Pizza, men värt att titta på), + * och några stickprov (titel + steg 1, svenska → översatt) att läsa direkt. + * + * Körs i ett vanligt fönster (inte A/B/C). Kräver DATABASE_URL (.env). + */ +import { config as loadDotenv } from "dotenv"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { and, eq, sql } from "drizzle-orm"; +import { createDatabase, schema } from "@app/database"; + +for (const candidate of [".env", "../.env", "../../.env"]) { + const p = path.resolve(process.cwd(), candidate); + if (existsSync(p)) { + loadDotenv({ path: p }); + break; + } +} + +const LANGS = ["en", "da", "de", "es", "fi", "fr", "it", "nb", "nl", "pl", "pt"]; +const short = (s: string, n = 88) => (s.length > n ? s.slice(0, n - 1) + "…" : s); + +async function main() { + if (!process.env.DATABASE_URL) throw new Error("Sätt DATABASE_URL."); + const { db, pool } = createDatabase(process.env.DATABASE_URL); + + const [tot] = await db + .select({ n: sql`count(*)::int` }) + .from(schema.recipes) + .where(eq(schema.recipes.status, "published")); + const total = tot?.n ?? 0; + + const agg = (rows: { lang: string; n: number }[]) => new Map(rows.map((r) => [r.lang, r.n])); + + const published = agg( + await db + .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) + .from(schema.recipeTranslations) + .innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId)) + .where( + and( + eq(schema.recipeTranslations.status, "published"), + eq(schema.recipes.status, "published"), + ), + ) + .groupBy(schema.recipeTranslations.languageTag), + ); + const drafts = agg( + await db + .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) + .from(schema.recipeTranslations) + .where(eq(schema.recipeTranslations.status, "draft_ai")) + .groupBy(schema.recipeTranslations.languageTag), + ); + const failed = agg( + await db + .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) + .from(schema.recipeTranslations) + .where(sql`${schema.recipeTranslations.verification}->>'ok' = 'false'`) + .groupBy(schema.recipeTranslations.languageTag), + ); + const identical = agg( + await db + .select({ lang: schema.recipeTranslations.languageTag, n: sql`count(*)::int` }) + .from(schema.recipeTranslations) + .innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId)) + .where( + and( + eq(schema.recipeTranslations.status, "published"), + eq(schema.recipes.status, "published"), + sql`${schema.recipeTranslations.title} = ${schema.recipes.titleSv}`, + ), + ) + .groupBy(schema.recipeTranslations.languageTag), + ); + + console.log(`\nPublicerade recept totalt: ${total}\n`); + console.log("=== Täckning & flaggor per språk ==="); + console.log(" språk publicerade utkast föll_verif titel=sv"); + for (const lang of LANGS) { + const p = published.get(lang) ?? 0; + const pct = total ? Math.round((p / total) * 100) : 0; + const cov = `${p}/${total} (${pct}%)`; + console.log( + ` ${lang.padEnd(6)} ${cov.padEnd(15)} ${String(drafts.get(lang) ?? 0).padStart(5)} ${String( + failed.get(lang) ?? 0, + ).padStart(9)} ${String(identical.get(lang) ?? 0).padStart(8)}`, + ); + } + + console.log("\n=== Stickprov (svenska → översatt) ==="); + for (const lang of LANGS) { + const titles = await db + .select({ + id: schema.recipes.id, + sv: schema.recipes.titleSv, + tr: schema.recipeTranslations.title, + }) + .from(schema.recipeTranslations) + .innerJoin(schema.recipes, eq(schema.recipes.id, schema.recipeTranslations.recipeId)) + .where( + and( + eq(schema.recipeTranslations.languageTag, lang), + eq(schema.recipeTranslations.status, "published"), + eq(schema.recipes.status, "published"), + ), + ) + .orderBy(schema.recipes.titleSv) + .limit(2); + if (titles.length === 0) { + console.log(` ${lang}: (inga publicerade översättningar)`); + continue; + } + console.log(` ${lang}:`); + for (const t of titles) console.log(` titel: ${t.sv} → ${t.tr}`); + const [step] = await db + .select({ sv: schema.recipeSteps.instructionSv, tr: schema.recipeStepTranslations.instruction }) + .from(schema.recipeStepTranslations) + .innerJoin( + schema.recipeSteps, + and( + eq(schema.recipeSteps.recipeId, schema.recipeStepTranslations.recipeId), + eq(schema.recipeSteps.stepNumber, schema.recipeStepTranslations.stepNumber), + ), + ) + .where( + and( + eq(schema.recipeStepTranslations.recipeId, titles[0]!.id), + eq(schema.recipeStepTranslations.languageTag, lang), + eq(schema.recipeStepTranslations.stepNumber, 1), + ), + ) + .limit(1); + if (step) { + console.log(` steg 1: ${short(step.sv)}`); + console.log(` → ${short(step.tr)}`); + } + } + + console.log( + "\nTolkning: 'föll_verif' går aldrig live (faller på svenska) – granska i admin vid behov. " + + "'titel=sv' är en heuristik, ofta egennamn. Stämmer stickproven och täckningen är hög är svepet friskt.", + ); + + await pool.end(); +} +main().then( + () => process.exit(0), + (e) => { + console.error(e); + process.exit(1); + }, +);