add recipe recommendations
Deploy to Cloudflare Pages / deploy (push) Failing after 1m2s

This commit is contained in:
2026-08-24 04:38:06 +00:00 Unverified
parent e351416b59
commit 8b44ddd06b
25 changed files with 2836 additions and 151 deletions
+30
View File
@@ -18,12 +18,42 @@ jobs:
- name: Install Dependencies & Build - name: Install Dependencies & Build
run: | run: |
echo "VITE_GEMINI_API_KEY=${{ secrets.VITE_GEMINI_API_KEY }}" >> .env echo "VITE_GEMINI_API_KEY=${{ secrets.VITE_GEMINI_API_KEY }}" >> .env
echo "GEMINI_API_KEY=${{ secrets.VITE_GEMINI_API_KEY }}" >> .env
echo "GUIDE_JOB_SECRET=${{ secrets.GUIDE_JOB_SECRET }}" >> .env
bun i --frozen-lockfile bun i --frozen-lockfile
bun run build bun run build
- name: Configure Pages Secrets
run: |
printf '%s' "$GEMINI_API_KEY" | bunx wrangler pages secret put GEMINI_API_KEY --project-name=hotfridge
printf '%s' "$GUIDE_JOB_SECRET" | bunx wrangler pages secret put GUIDE_JOB_SECRET --project-name=hotfridge
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GEMINI_API_KEY: ${{ secrets.VITE_GEMINI_API_KEY }}
GUIDE_JOB_SECRET: ${{ secrets.GUIDE_JOB_SECRET }}
- name: Apply D1 Migrations
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: d1 migrations apply hotfridge --remote
- name: Deploy to Cloudflare Pages - name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3 uses: cloudflare/wrangler-action@v3
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy .svelte-kit/cloudflare --project-name=hotfridge command: pages deploy .svelte-kit/cloudflare --project-name=hotfridge
- name: Deploy Recommendation Workflow
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangler.workflow.jsonc
secrets: |
GUIDE_JOB_SECRET
env:
GUIDE_JOB_SECRET: ${{ secrets.GUIDE_JOB_SECRET }}
+1
View File
@@ -39,6 +39,7 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"deploy:workflow": "wrangler deploy --config wrangler.workflow.jsonc",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"lint": "oxlint .", "lint": "oxlint .",
+2
View File
@@ -21,6 +21,8 @@ declare global {
interface Platform { interface Platform {
env: { env: {
DB: D1Database; DB: D1Database;
GEMINI_API_KEY?: string;
GUIDE_JOB_SECRET?: string;
}; };
} }
} }
+28 -9
View File
@@ -1,14 +1,33 @@
import { defineRelations } from "drizzle-orm"; import { defineRelations } from "drizzle-orm";
import { tImages, tItems, type TImage, type TItem } from "./schema"; import {
tImages,
tItems,
tRecommendationBatches,
tRecommendations,
type TImage,
type TItem,
} from "./schema";
export const relations = defineRelations({ tItems, tImages }, (r) => ({ export const relations = defineRelations(
tItems: { { tItems, tImages, tRecommendationBatches, tRecommendations },
icon: r.one.tImages({ (r) => ({
from: r.tItems.iconId, tItems: {
to: r.tImages.id, icon: r.one.tImages({
}), from: r.tItems.iconId,
}, to: r.tImages.id,
})); }),
},
tRecommendationBatches: {
recommendations: r.many.tRecommendations(),
},
tRecommendations: {
batch: r.one.tRecommendationBatches({
from: r.tRecommendations.batchId,
to: r.tRecommendationBatches.id,
}),
},
}),
);
export type WithIcon<T extends TItem> = T & { icon: TImage | null }; export type WithIcon<T extends TItem> = T & { icon: TImage | null };
+46
View File
@@ -3,6 +3,10 @@ import { sqliteTable } from "drizzle-orm/sqlite-core/table";
export type ItemId = number & { __brand: "item_id" }; export type ItemId = number & { __brand: "item_id" };
export type ImageId = number & { __brand: "image_id" }; export type ImageId = number & { __brand: "image_id" };
export type RecommendationBatchId = number & {
__brand: "recommendation_batch_id";
};
export type RecommendationId = number & { __brand: "recommendation_id" };
export const tItems = sqliteTable("items", { export const tItems = sqliteTable("items", {
id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }), id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }),
@@ -22,5 +26,47 @@ export const tImages = sqliteTable("images", {
size: integer().notNull(), size: integer().notNull(),
}); });
export const tRecommendationBatches = sqliteTable("recommendation_batches", {
id: integer().$type<RecommendationBatchId>().primaryKey({ autoIncrement: true }),
sessionId: text("session_id").notNull(),
locale: text().notNull(),
generatedAt: integer("generated_at", { mode: "timestamp" }).notNull(),
});
export const tRecommendations = sqliteTable("recommendations", {
id: integer().$type<RecommendationId>().primaryKey({ autoIncrement: true }),
batchId: integer("batch_id")
.$type<RecommendationBatchId>()
.notNull()
.references(() => tRecommendationBatches.id, {
onUpdate: "cascade",
onDelete: "cascade",
}),
tier: text().notNull(),
title: text().notNull(),
description: text().notNull(),
cookMinutes: integer("cook_minutes").notNull(),
instructions: text().notNull(),
ingredients: text().notNull(),
imageUrl: text("image_url"),
});
export const tRecommendationJobs = sqliteTable("recommendation_jobs", {
sessionId: text("session_id").primaryKey(),
locale: text().notNull().default("en"),
dueAt: integer("due_at", { mode: "timestamp" }).notNull(),
attempts: integer().notNull().default(0),
revision: integer().notNull().default(1),
rateLimitKey: text("rate_limit_key").notNull().default("unknown"),
lastError: text("last_error"),
});
export const tRecommendationRateLimits = sqliteTable("recommendation_rate_limits", {
key: text().primaryKey(),
windowStartedAt: integer("window_started_at", { mode: "timestamp" }).notNull(),
count: integer().notNull().default(0),
});
export type TItem = typeof tItems.$inferSelect; export type TItem = typeof tItems.$inferSelect;
export type TImage = typeof tImages.$inferSelect; export type TImage = typeof tImages.$inferSelect;
export type TRecommendation = typeof tRecommendations.$inferSelect;
@@ -0,0 +1,27 @@
CREATE TABLE `recommendation_batches` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`session_id` text NOT NULL,
`locale` text NOT NULL,
`generated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `recommendation_jobs` (
`session_id` text PRIMARY KEY,
`locale` text DEFAULT 'en' NOT NULL,
`due_at` integer NOT NULL,
`attempts` integer DEFAULT 0 NOT NULL,
`last_error` text
);
--> statement-breakpoint
CREATE TABLE `recommendations` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`batch_id` integer NOT NULL,
`tier` text NOT NULL,
`title` text NOT NULL,
`description` text NOT NULL,
`cook_minutes` integer NOT NULL,
`instructions` text NOT NULL,
`ingredients` text NOT NULL,
`image_url` text,
CONSTRAINT `fk_recommendations_batch_id_recommendation_batches_id_fk` FOREIGN KEY (`batch_id`) REFERENCES `recommendation_batches`(`id`) ON UPDATE CASCADE ON DELETE CASCADE
);
@@ -0,0 +1,386 @@
{
"version": "7",
"dialect": "sqlite",
"id": "14fc125c-3c68-42d8-93f5-23fc9f580a4d",
"prevIds": [
"2ae7f3f4-d589-4da7-b78a-392fd615f656"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1 @@
ALTER TABLE `recommendation_jobs` ADD `revision` integer DEFAULT 1 NOT NULL;
@@ -0,0 +1,396 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f6169acd-f73e-4e45-9ef8-b35a9e38d6bc",
"prevIds": [
"14fc125c-3c68-42d8-93f5-23fc9f580a4d"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "1",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1,5 @@
CREATE TABLE `recommendation_rate_limits` (
`key` text PRIMARY KEY,
`window_started_at` integer NOT NULL,
`count` integer DEFAULT 0 NOT NULL
);
@@ -0,0 +1,439 @@
{
"version": "7",
"dialect": "sqlite",
"id": "7b679a63-4e38-4c77-9596-a5b8fc36512d",
"prevIds": [
"f6169acd-f73e-4e45-9ef8-b35a9e38d6bc"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendation_rate_limits",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "1",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "window_started_at",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "count",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "recommendation_rate_limits_pk",
"table": "recommendation_rate_limits",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
@@ -0,0 +1 @@
ALTER TABLE `recommendation_jobs` ADD `rate_limit_key` text DEFAULT 'unknown' NOT NULL;
@@ -0,0 +1,449 @@
{
"version": "7",
"dialect": "sqlite",
"id": "cd16457a-37ac-4d7b-9fc7-b10efa42c7e7",
"prevIds": [
"7b679a63-4e38-4c77-9596-a5b8fc36512d"
],
"ddl": [
{
"name": "images",
"entityType": "tables"
},
{
"name": "items",
"entityType": "tables"
},
{
"name": "recommendation_batches",
"entityType": "tables"
},
{
"name": "recommendation_jobs",
"entityType": "tables"
},
{
"name": "recommendation_rate_limits",
"entityType": "tables"
},
{
"name": "recommendations",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "images"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "mime",
"entityType": "columns",
"table": "images"
},
{
"type": "blob",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "images"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_id",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expiry",
"entityType": "columns",
"table": "items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "category",
"entityType": "columns",
"table": "items"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "generated_at",
"entityType": "columns",
"table": "recommendation_batches"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'en'",
"generated": null,
"name": "locale",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "due_at",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "attempts",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "1",
"generated": null,
"name": "revision",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'unknown'",
"generated": null,
"name": "rate_limit_key",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_error",
"entityType": "columns",
"table": "recommendation_jobs"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "window_started_at",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "count",
"entityType": "columns",
"table": "recommendation_rate_limits"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "batch_id",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "description",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cook_minutes",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "instructions",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "ingredients",
"entityType": "columns",
"table": "recommendations"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "image_url",
"entityType": "columns",
"table": "recommendations"
},
{
"columns": [
"icon_id"
],
"tableTo": "images",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_items_icon_id_images_id_fk",
"entityType": "fks",
"table": "items"
},
{
"columns": [
"batch_id"
],
"tableTo": "recommendation_batches",
"columnsTo": [
"id"
],
"onUpdate": "CASCADE",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_recommendations_batch_id_recommendation_batches_id_fk",
"entityType": "fks",
"table": "recommendations"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "images_pk",
"table": "images",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "items_pk",
"table": "items",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendation_batches_pk",
"table": "recommendation_batches",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "recommendation_jobs_pk",
"table": "recommendation_jobs",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "recommendation_rate_limits_pk",
"table": "recommendation_rate_limits",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "recommendations_pk",
"table": "recommendations",
"entityType": "pks"
}
],
"renames": []
}
+33 -9
View File
@@ -1,6 +1,14 @@
import { command, getRequestEvent, query } from "$app/server"; import { command, getRequestEvent, query } from "$app/server";
import { tImages, tItems, type ItemId } from "$lib/db/schema"; import {
tImages,
tItems,
type ItemId,
} from "$lib/db/schema";
import { getDb } from "$lib/server/db"; import { getDb } from "$lib/server/db";
import {
getRecommendationRateLimitKey,
scheduleRecommendationJob,
} from "$lib/server/recommendations";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export const getItems = query(() => { export const getItems = query(() => {
@@ -22,14 +30,20 @@ export const getItem = query("unchecked", (id: ItemId) => {
export const addItems = command( export const addItems = command(
"unchecked", "unchecked",
async ( async (
items: { {
name: string; items,
category: string; locale,
expiry: Date; }: {
icon: { mime: string; data: Uint8Array; size: number }; items: {
}[], name: string;
category: string;
expiry: Date;
icon: { mime: string; data: Uint8Array; size: number };
}[];
locale: string;
},
): Promise<ItemId[]> => { ): Promise<ItemId[]> => {
const { locals } = getRequestEvent(); const { locals, request } = getRequestEvent();
const db = getDb(); const db = getDb();
const ids: ItemId[] = []; const ids: ItemId[] = [];
@@ -47,12 +61,18 @@ export const addItems = command(
ids.push(inserted.id); ids.push(inserted.id);
} }
await scheduleRecommendationJob(db, locals.sessionId, {
locale,
delay: 5 * 60 * 1000,
rateLimitKey: await getRecommendationRateLimitKey(request),
});
return ids; return ids;
}, },
); );
export const removeItem = command("unchecked", async (id: ItemId) => { export const removeItem = command("unchecked", async (id: ItemId) => {
const { locals } = getRequestEvent(); const { locals, request } = getRequestEvent();
const db = getDb(); const db = getDb();
const item = await db.query.tItems.findFirst({ const item = await db.query.tItems.findFirst({
where: { id: { eq: id }, sessionId: { eq: locals.sessionId } }, where: { id: { eq: id }, sessionId: { eq: locals.sessionId } },
@@ -65,4 +85,8 @@ export const removeItem = command("unchecked", async (id: ItemId) => {
} else { } else {
await db.delete(tItems).where(eq(tItems.id, id)); await db.delete(tItems).where(eq(tItems.id, id));
} }
await scheduleRecommendationJob(db, locals.sessionId, {
delay: 5 * 60 * 1000,
rateLimitKey: await getRecommendationRateLimitKey(request),
});
}); });
+27 -1
View File
@@ -34,7 +34,33 @@
"saving": "Saving...", "saving": "Saving...",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"reload": "Reload" "reload": "Reload",
"close": "Close"
},
"guide": {
"eyebrow": "Your kitchen, planned",
"title": "What should we cook?",
"description": "Fresh ideas built around what is in your fridge and what should be used first.",
"refresh": "Refresh recommendations",
"generating": "Planning your next meals",
"generating_note": "This can take a moment while we consider your fridge.",
"generation_failed": "We couldn't prepare recommendations right now.",
"view_recipe": "View recipe",
"minutes": "{count} minutes",
"ingredients": "Ingredients",
"instructions": "Instructions",
"tier": {
"near_expiry_food": "Use these first",
"near_expiry_ingredients": "Rescue these ingredients",
"discovery": "Something different",
"fridge": "More from your fridge"
},
"tier_note": {
"near_expiry_food": "Meals centered on food closest to its expiry.",
"near_expiry_ingredients": "Ideas that put vulnerable ingredients to work.",
"discovery": "Interesting dishes for when you want a change.",
"fridge": "Good combinations from everything else you have."
}
}, },
"label": { "label": {
"food": "Food", "food": "Food",
+27 -1
View File
@@ -34,7 +34,33 @@
"saving": "กำลังบันทึก...", "saving": "กำลังบันทึก...",
"cancel": "ยกเลิก", "cancel": "ยกเลิก",
"delete": "ลบ", "delete": "ลบ",
"reload": "รีโหลด" "reload": "รีโหลด",
"close": "ปิด"
},
"guide": {
"eyebrow": "วางแผนจากของในครัว",
"title": "วันนี้ทำอะไรกินดี?",
"description": "เมนูแนะนำจากของในตู้เย็น โดยเน้นวัตถุดิบที่ควรใช้ก่อน",
"refresh": "สร้างคำแนะนำใหม่",
"generating": "กำลังวางแผนมื้อถัดไป",
"generating_note": "ระบบกำลังพิจารณาของในตู้เย็น อาจใช้เวลาสักครู่",
"generation_failed": "ไม่สามารถสร้างคำแนะนำได้ในขณะนี้",
"view_recipe": "ดูสูตรอาหาร",
"minutes": "{count} นาที",
"ingredients": "วัตถุดิบ",
"instructions": "วิธีทำ",
"tier": {
"near_expiry_food": "ควรใช้ก่อน",
"near_expiry_ingredients": "ช่วยวัตถุดิบเหล่านี้",
"discovery": "ลองอะไรใหม่ๆ",
"fridge": "เมนูจากของที่เหลือ"
},
"tier_note": {
"near_expiry_food": "เมนูจากอาหารที่ใกล้หมดอายุมากที่สุด",
"near_expiry_ingredients": "ไอเดียสำหรับใช้วัตถุดิบที่ควรรีบใช้",
"discovery": "เมนูน่าสนใจสำหรับวันที่อยากลองสิ่งใหม่",
"fridge": "จับคู่ของอื่นๆ ที่มีอยู่ในตู้เย็น"
}
}, },
"label": { "label": {
"food": "อาหาร", "food": "อาหาร",
+134
View File
@@ -0,0 +1,134 @@
import { command, getRequestEvent, query } from "$app/server";
import {
tRecommendationBatches,
tRecommendationJobs,
tRecommendations,
} from "$lib/db/schema";
import { getDb } from "$lib/server/db";
import {
getRecommendationApiKey,
getRecommendationRateLimitKey,
RecommendationRateLimitError,
runRecommendationJob,
scheduleRecommendationJob,
} from "$lib/server/recommendations";
import type { MenuIngredient, MenuTier } from "$lib/recommendations";
import { desc, eq } from "drizzle-orm";
import { error } from "@sveltejs/kit";
export const getRecommendations = query(async () => {
const { locals } = getRequestEvent();
const db = getDb();
const [[batch], [job]] = await Promise.all([
db
.select()
.from(tRecommendationBatches)
.where(eq(tRecommendationBatches.sessionId, locals.sessionId))
.orderBy(desc(tRecommendationBatches.generatedAt))
.limit(1),
db
.select({ attempts: tRecommendationJobs.attempts })
.from(tRecommendationJobs)
.where(eq(tRecommendationJobs.sessionId, locals.sessionId))
.limit(1),
]);
if (!batch) {
return {
generatedAt: null,
pending: Boolean(job && job.attempts < 3),
menus: [],
};
}
const [rows, items] = await Promise.all([
db
.select()
.from(tRecommendations)
.where(eq(tRecommendations.batchId, batch.id)),
db.query.tItems.findMany({
with: { icon: true },
where: { sessionId: { eq: locals.sessionId } },
}),
]);
const itemById = new Map(items.map((item) => [Number(item.id), item]));
return {
generatedAt: batch.generatedAt,
pending: Boolean(job && job.attempts < 3),
menus: rows.map((row) => {
const ingredients = JSON.parse(row.ingredients) as MenuIngredient[];
return {
id: row.id,
tier: row.tier as MenuTier,
title: row.title,
description: row.description,
cookMinutes: row.cookMinutes,
imageUrl: row.imageUrl ?? undefined,
instructions: JSON.parse(row.instructions) as string[],
ingredients: ingredients.map((ingredient) => ({
...ingredient,
fridgeItem: ingredient.itemId
? itemById.get(Number(ingredient.itemId))
: undefined,
})),
};
}),
};
});
export const generateRecommendations = command(
"unchecked",
async ({ locale, force = false }: { locale: string; force?: boolean }) => {
const event = getRequestEvent();
const db = getDb();
const normalizedLocale = /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) ? locale : "en";
const [existing] = await db
.select({
id: tRecommendationBatches.id,
generatedAt: tRecommendationBatches.generatedAt,
})
.from(tRecommendationBatches)
.where(eq(tRecommendationBatches.sessionId, event.locals.sessionId))
.orderBy(desc(tRecommendationBatches.generatedAt))
.limit(1);
if (
existing &&
(!force || existing.generatedAt.getTime() > Date.now() - 5 * 60 * 1000)
) {
return existing.id;
}
const [activeJob] = await db
.select({ dueAt: tRecommendationJobs.dueAt, attempts: tRecommendationJobs.attempts })
.from(tRecommendationJobs)
.where(eq(tRecommendationJobs.sessionId, event.locals.sessionId))
.limit(1);
if (activeJob && activeJob.attempts > 0 && activeJob.dueAt > new Date()) {
return existing?.id ?? null;
}
const rateLimitKey = await getRecommendationRateLimitKey(event.request);
await scheduleRecommendationJob(db, event.locals.sessionId, {
locale: normalizedLocale,
rateLimitKey,
});
let id: number | null;
try {
id = await runRecommendationJob(
db,
event.locals.sessionId,
getRecommendationApiKey(event.platform),
);
} catch (cause) {
if (cause instanceof RecommendationRateLimitError) {
error(429, "Recommendation generation limit reached. Try again later.");
}
throw cause;
}
void getRecommendations().refresh();
return id ?? existing?.id ?? null;
},
);
+30
View File
@@ -0,0 +1,30 @@
import type { ItemId, RecommendationId } from "$lib/db/schema";
export const menuTiers = [
"near_expiry_food",
"near_expiry_ingredients",
"discovery",
"fridge",
] as const;
export type MenuTier = (typeof menuTiers)[number];
export type MenuIngredient = {
amount: string;
itemId?: ItemId;
externalName?: string;
};
export type GeneratedMenu = {
tier: MenuTier;
title: string;
description: string;
cookMinutes: number;
instructions: string[];
ingredients: MenuIngredient[];
imageUrl?: string;
};
export type RecommendationMenu = GeneratedMenu & {
id: RecommendationId;
};
+6 -1
View File
@@ -1,7 +1,12 @@
import { getRequestEvent } from "$app/server"; import { getRequestEvent } from "$app/server";
import { relations } from "$lib/db/relations"; import { relations } from "$lib/db/relations";
import type { D1Database } from "@cloudflare/workers-types";
import { drizzle } from "drizzle-orm/d1"; import { drizzle } from "drizzle-orm/d1";
export function createDb(database: D1Database) {
return drizzle(database, { relations, logger: true });
}
export function getDb() { export function getDb() {
const { platform } = getRequestEvent(); const { platform } = getRequestEvent();
if (!platform) { if (!platform) {
@@ -9,5 +14,5 @@ export function getDb() {
"platform.env is unavailable — is the D1 binding configured in wrangler.jsonc?", "platform.env is unavailable — is the D1 binding configured in wrangler.jsonc?",
); );
} }
return drizzle(platform.env.DB, { relations, logger: true }); return createDb(platform.env.DB);
} }
+412
View File
@@ -0,0 +1,412 @@
import {
tItems,
tRecommendationBatches,
tRecommendationJobs,
tRecommendationRateLimits,
type ItemId,
} from "$lib/db/schema";
import {
menuTiers,
type GeneratedMenu,
type MenuIngredient,
type MenuTier,
} from "$lib/recommendations";
import { and, desc, eq, lt, lte, or, sql } from "drizzle-orm";
import type { createDb } from "$lib/server/db";
type Db = ReturnType<typeof createDb>;
const MODEL = "gemini-3.6-flash";
const RETRY_DELAY = 30 * 60 * 1000;
const JOB_LEASE = 15 * 60 * 1000;
const MAX_ATTEMPTS = 3;
const RATE_LIMIT_WINDOW = 60 * 60 * 1000;
const RATE_LIMIT_MAX = 6;
const RESPONSE_SCHEMA = {
type: "object",
properties: {
menus: {
type: "array",
minItems: 8,
maxItems: 16,
items: {
type: "object",
properties: {
tier: { type: "string", enum: menuTiers },
title: { type: "string" },
description: { type: "string" },
cookMinutes: { type: "integer" },
instructions: { type: "array", items: { type: "string" } },
ingredients: {
type: "array",
items: {
type: "object",
properties: {
amount: { type: "string" },
itemId: { type: "integer", nullable: true },
externalName: { type: "string", nullable: true },
},
required: ["amount"],
},
},
imageUrl: { type: "string", nullable: true },
},
required: [
"tier",
"title",
"description",
"cookMinutes",
"instructions",
"ingredients",
],
},
},
},
required: ["menus"],
};
function isTier(value: unknown): value is MenuTier {
return typeof value === "string" && menuTiers.includes(value as MenuTier);
}
function normalizeMenus(value: unknown, validItemIds: Set<number>) {
if (!value || typeof value !== "object") throw new Error("Invalid Gemini response");
const rawMenus = (value as { menus?: unknown }).menus;
if (!Array.isArray(rawMenus)) throw new Error("Gemini returned no menus");
return rawMenus.flatMap((raw): GeneratedMenu[] => {
if (!raw || typeof raw !== "object") return [];
const menu = raw as Record<string, unknown>;
if (
!isTier(menu.tier) ||
typeof menu.title !== "string" ||
typeof menu.description !== "string" ||
!Number.isFinite(menu.cookMinutes) ||
!Array.isArray(menu.instructions) ||
!Array.isArray(menu.ingredients)
) {
return [];
}
const ingredients = menu.ingredients.flatMap((rawIngredient): MenuIngredient[] => {
if (!rawIngredient || typeof rawIngredient !== "object") return [];
const ingredient = rawIngredient as Record<string, unknown>;
const itemId = Number(ingredient.itemId);
const externalName =
typeof ingredient.externalName === "string"
? ingredient.externalName.trim()
: "";
const amount =
typeof ingredient.amount === "string" ? ingredient.amount.trim() : "";
if (validItemIds.has(itemId)) {
return [{ amount, itemId: itemId as ItemId }];
}
if (externalName) return [{ amount, externalName }];
return [];
});
return [
{
tier: menu.tier,
title: menu.title.trim(),
description: menu.description.trim(),
cookMinutes: Math.max(1, Math.round(Number(menu.cookMinutes))),
instructions: menu.instructions.filter(
(step): step is string => typeof step === "string" && step.trim().length > 0,
),
ingredients,
imageUrl:
typeof menu.imageUrl === "string" && menu.imageUrl.startsWith("https://")
? menu.imageUrl
: undefined,
},
];
});
}
async function requestMenus(
items: (typeof tItems.$inferSelect)[],
locale: string,
apiKey: string,
) {
const inventory = items.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
expiry: item.expiry?.toISOString() ?? null,
}));
const prompt = [
"You create practical recipe recommendations for a fridge inventory app.",
`Write all user-facing content in locale ${locale}.`,
"Return 2-4 menus in each of these exact tiers:",
"near_expiry_food: complete meals centered on food expiring soon.",
"near_expiry_ingredients: recipes that consume individual ingredients expiring soon.",
"discovery: interesting unrelated dishes worth trying, even if groceries are needed.",
"fridge: recipes that combine other available fridge items.",
"For an ingredient already in inventory, return its numeric itemId and do not return externalName.",
"For anything that must be bought, return externalName and no itemId.",
"Only provide imageUrl when it is a direct, durable HTTPS image URL you are confident exists; otherwise omit it.",
`Today is ${new Date().toISOString()}. Inventory: ${JSON.stringify(inventory)}`,
].join("\n");
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${apiKey}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: RESPONSE_SCHEMA,
},
}),
},
);
if (!response.ok) {
throw new Error(`Gemini request failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const text = body.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("Gemini returned no recommendation content");
const menus = normalizeMenus(
JSON.parse(text),
new Set(items.map((item) => Number(item.id))),
);
if (menus.length === 0) throw new Error("Gemini returned no valid menus");
return menus;
}
export function getRecommendationApiKey(platform?: App.Platform) {
const key = platform?.env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY;
if (!key) throw new Error("GEMINI_API_KEY is not configured");
return key;
}
export class RecommendationRateLimitError extends Error {}
export async function getRecommendationRateLimitKey(request: Request) {
const source =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown";
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(source),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function consumeRecommendationQuota(db: Db, key: string) {
const now = new Date();
const cutoff = new Date(now.getTime() - RATE_LIMIT_WINDOW);
await db
.insert(tRecommendationRateLimits)
.values({ key, windowStartedAt: now, count: 0 })
.onConflictDoNothing();
const [updated] = await db
.update(tRecommendationRateLimits)
.set({
count: sql`case when ${tRecommendationRateLimits.windowStartedAt} <= ${cutoff} then 1 else ${tRecommendationRateLimits.count} + 1 end`,
windowStartedAt: sql`case when ${tRecommendationRateLimits.windowStartedAt} <= ${cutoff} then ${now} else ${tRecommendationRateLimits.windowStartedAt} end`,
})
.where(
and(
eq(tRecommendationRateLimits.key, key),
or(
lte(tRecommendationRateLimits.windowStartedAt, cutoff),
lt(tRecommendationRateLimits.count, RATE_LIMIT_MAX),
),
),
)
.returning({ count: tRecommendationRateLimits.count });
return Boolean(updated);
}
export async function generateRecommendationsForSession(
db: Db,
sessionId: string,
locale: string,
apiKey: string,
revision: number,
) {
const items = await db.select().from(tItems).where(eq(tItems.sessionId, sessionId));
const menus = await requestMenus(items, locale, apiKey);
const batchId = (crypto.getRandomValues(new Uint32Array(1))[0] & 0x7fffffff) || 1;
const statements = [
db.$client
.prepare(
"INSERT INTO recommendation_batches (id, session_id, locale, generated_at) " +
"SELECT ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM recommendation_jobs WHERE session_id = ? AND revision = ?)",
)
.bind(batchId, sessionId, locale, Math.floor(Date.now() / 1000), sessionId, revision),
...menus.map((menu) =>
db.$client
.prepare(
"INSERT INTO recommendations " +
"(batch_id, tier, title, description, cook_minutes, instructions, ingredients, image_url) " +
"SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS " +
"(SELECT 1 FROM recommendation_batches WHERE id = ?)",
)
.bind(
batchId,
menu.tier,
menu.title,
menu.description,
menu.cookMinutes,
JSON.stringify(menu.instructions),
JSON.stringify(menu.ingredients),
menu.imageUrl ?? null,
batchId,
),
),
db.$client
.prepare(
"DELETE FROM recommendation_batches WHERE session_id = ? AND id <> ? " +
"AND EXISTS (SELECT 1 FROM recommendation_batches WHERE id = ?)",
)
.bind(sessionId, batchId, batchId),
db.$client
.prepare("DELETE FROM recommendation_jobs WHERE session_id = ? AND revision = ?")
.bind(sessionId, revision),
];
const results = await db.$client.batch(statements);
return results[0]?.meta.changes ? batchId : null;
}
export async function scheduleRecommendationJob(
db: Db,
sessionId: string,
options: { locale?: string; delay?: number; rateLimitKey?: string } = {},
) {
let locale = options.locale;
if (!locale) {
const [latest] = await db
.select({ locale: tRecommendationBatches.locale })
.from(tRecommendationBatches)
.where(eq(tRecommendationBatches.sessionId, sessionId))
.orderBy(desc(tRecommendationBatches.generatedAt))
.limit(1);
locale = latest?.locale ?? "en";
}
const normalizedLocale = /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) ? locale : "en";
const dueAt = new Date(Date.now() + (options.delay ?? 0));
await db
.insert(tRecommendationJobs)
.values({
sessionId,
locale: normalizedLocale,
dueAt,
rateLimitKey: options.rateLimitKey ?? "unknown",
})
.onConflictDoUpdate({
target: tRecommendationJobs.sessionId,
set: {
locale: normalizedLocale,
dueAt,
attempts: 0,
revision: sql`${tRecommendationJobs.revision} + 1`,
rateLimitKey: options.rateLimitKey ?? "unknown",
lastError: null,
},
});
}
async function claimRecommendationJob(db: Db, sessionId: string) {
const [job] = await db
.update(tRecommendationJobs)
.set({
dueAt: new Date(Date.now() + JOB_LEASE),
attempts: sql`${tRecommendationJobs.attempts} + 1`,
})
.where(
and(
eq(tRecommendationJobs.sessionId, sessionId),
lte(tRecommendationJobs.dueAt, new Date()),
lt(tRecommendationJobs.attempts, MAX_ATTEMPTS),
),
)
.returning();
return job;
}
export async function runRecommendationJob(
db: Db,
sessionId: string,
apiKey: string,
) {
const job = await claimRecommendationJob(db, sessionId);
if (!job) return null;
try {
if (!(await consumeRecommendationQuota(db, job.rateLimitKey))) {
await db
.update(tRecommendationJobs)
.set({ attempts: MAX_ATTEMPTS, lastError: "Generation rate limit reached" })
.where(
and(
eq(tRecommendationJobs.sessionId, job.sessionId),
eq(tRecommendationJobs.revision, job.revision),
),
);
throw new RecommendationRateLimitError("Generation rate limit reached");
}
return await generateRecommendationsForSession(
db,
job.sessionId,
job.locale,
apiKey,
job.revision,
);
} catch (error) {
await db
.update(tRecommendationJobs)
.set({
dueAt: new Date(Date.now() + RETRY_DELAY),
lastError: error instanceof Error ? error.message.slice(0, 1000) : String(error),
})
.where(
and(
eq(tRecommendationJobs.sessionId, job.sessionId),
eq(tRecommendationJobs.revision, job.revision),
),
);
throw error;
}
}
export async function processDueRecommendationJobs(db: Db, apiKey: string) {
const jobs = await db
.select()
.from(tRecommendationJobs)
.where(
and(
lte(tRecommendationJobs.dueAt, new Date()),
lt(tRecommendationJobs.attempts, MAX_ATTEMPTS),
),
)
.limit(10);
let completed = 0;
for (const job of jobs) {
try {
if ((await runRecommendationJob(db, job.sessionId, apiKey)) !== null) {
completed += 1;
}
} catch {
// The claimed job records its own failure and retry time.
}
}
return { processed: jobs.length, completed };
}
@@ -0,0 +1,21 @@
import { createDb } from "$lib/server/db";
import {
getRecommendationApiKey,
processDueRecommendationJobs,
} from "$lib/server/recommendations";
import { json, type RequestHandler } from "@sveltejs/kit";
export const POST: RequestHandler = async ({ request, platform }) => {
if (!platform) return json({ error: "Cloudflare platform unavailable" }, { status: 503 });
const secret = platform.env.GUIDE_JOB_SECRET ?? process.env.GUIDE_JOB_SECRET;
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const result = await processDueRecommendationJobs(
createDb(platform.env.DB),
getRecommendationApiKey(platform),
);
return json(result);
};
+274 -126
View File
@@ -1,157 +1,305 @@
<script lang="ts"> <script lang="ts">
import BookImageIcon from "@lucide/svelte/icons/book-image"; import Image from "$lib/components/image.svelte";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right"; import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import type { WithIcon } from "$lib/db/relations";
import type { TItem } from "$lib/db/schema";
import {
generateRecommendations,
getRecommendations,
} from "$lib/recommendations.remote";
import type { MenuIngredient, MenuTier } from "$lib/recommendations";
import { toImageUrl } from "$lib/utils";
import ChefHatIcon from "@lucide/svelte/icons/chef-hat";
import Clock3Icon from "@lucide/svelte/icons/clock-3"; import Clock3Icon from "@lucide/svelte/icons/clock-3";
import SnowflakeIcon from "@lucide/svelte/icons/snowflake"; import ImageOffIcon from "@lucide/svelte/icons/image-off";
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
import RefreshCwIcon from "@lucide/svelte/icons/refresh-cw";
import SparklesIcon from "@lucide/svelte/icons/sparkles"; import SparklesIcon from "@lucide/svelte/icons/sparkles";
import TriangleAlertIcon from "@lucide/svelte/icons/triangle-alert"; import XIcon from "@lucide/svelte/icons/x";
import { onMount } from "svelte";
import { _, locale } from "svelte-i18n";
const quickGuides = [ type DisplayIngredient = MenuIngredient & {
fridgeItem?: WithIcon<TItem>;
};
type DisplayMenu = {
id: number;
tier: MenuTier;
title: string;
description: string;
cookMinutes: number;
imageUrl?: string;
instructions: string[];
ingredients: DisplayIngredient[];
};
const tiers: { id: MenuTier; label: string; note: string }[] = [
{ {
title: "Leafy greens", id: "near_expiry_food",
note: "Wrap with paper before chilling.", label: "guide.tier.near_expiry_food",
time: "3-5 days", note: "guide.tier_note.near_expiry_food",
accent: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300"
}, },
{ {
title: "Cooked rice", id: "near_expiry_ingredients",
note: "Seal fast while still slightly warm.", label: "guide.tier.near_expiry_ingredients",
time: "1-2 days", note: "guide.tier_note.near_expiry_ingredients",
accent: "bg-amber-500/12 text-amber-700 dark:text-amber-300"
}, },
{ {
title: "Fresh meat", id: "discovery",
note: "Keep on the coldest shelf, bottom tray.", label: "guide.tier.discovery",
time: "1-2 days", note: "guide.tier_note.discovery",
accent: "bg-rose-500/12 text-rose-700 dark:text-rose-300"
}, },
{ {
title: "Sauces", id: "fridge",
note: "Door shelf is fine once opened.", label: "guide.tier.fridge",
time: "1 month", note: "guide.tier_note.fridge",
accent: "bg-sky-500/12 text-sky-700 dark:text-sky-300" },
];
const recommendations = getRecommendations();
let selected = $state<DisplayMenu>();
let generating = $state(false);
let generationError = $state(false);
let mounted = false;
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollPendingRecommendations() {
let current = recommendations.current;
while (mounted && current?.pending && current.menus.length === 0) {
await wait(5000);
if (!mounted) return;
await recommendations.refresh();
current = recommendations.current;
} }
]; if (mounted && current && !current.pending && current.menus.length === 0) {
generationError = true;
const zones = [
{
title: "Top shelf",
items: "Leftovers, drinks, ready-to-eat meals",
tone: "Safe and easy to grab"
},
{
title: "Bottom shelf",
items: "Raw meat, fish, meal prep boxes",
tone: "Coldest zone"
},
{
title: "Crisper",
items: "Vegetables, herbs, fruit with airflow",
tone: "Humidity matters"
},
{
title: "Door",
items: "Condiments, jam, butter, soda",
tone: "Warmest spot"
} }
]; }
const warnings = [ async function regenerate(force: boolean) {
"Milk beside the door expires faster.", generating = true;
"Hot food raises the whole fridge temp.", generationError = false;
"Uncovered leftovers dry out and smell spreads.", try {
"Overpacking blocks cold air circulation." await generateRecommendations({ locale: $locale ?? "en", force });
]; await recommendations.refresh();
await pollPendingRecommendations();
} catch (error) {
console.error(error);
generationError = true;
} finally {
generating = false;
}
}
function closeOnBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget) selected = undefined;
}
onMount(() => {
mounted = true;
void (async () => {
try {
const current = await recommendations;
if (current.menus.length === 0) await regenerate(false);
} catch (error) {
console.error(error);
generationError = true;
}
})();
return () => {
mounted = false;
};
});
</script> </script>
<div class="flex flex-col gap-4 p-3"> <div class="flex min-h-full flex-col bg-background">
<section class="rounded-3xl border bg-card p-4 shadow-sm"> <header class="border-b bg-card px-4 py-5">
<div class="flex items-start justify-between gap-3"> <div class="mx-auto flex max-w-4xl items-start justify-between gap-4">
<div class="space-y-2"> <div class="space-y-1">
<div class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary"> <div class="flex items-center gap-2 text-primary">
<BookImageIcon class="size-3.5" /> <ChefHatIcon class="size-5" />
Fridge guide concept <span class="text-xs font-semibold uppercase tracking-[0.2em]">
{$_("guide.eyebrow")}
</span>
</div> </div>
<div> <h1 class="text-2xl font-semibold tracking-tight">{$_("guide.title")}</h1>
<h1 class="text-2xl font-semibold tracking-tight">Store smarter, waste less</h1> <p class="max-w-xl text-sm text-muted-foreground">
<p class="max-w-[28rem] text-sm text-muted-foreground"> {$_("guide.description")}
Quick visual cheatsheet for where food goes, how long it lasts, </p>
and what usually goes wrong. </div>
</p> <Button
variant="outline"
size="icon"
title={$_("guide.refresh")}
disabled={generating}
onclick={() => regenerate(true)}
>
<RefreshCwIcon class={["size-4", generating && "animate-spin"]} />
</Button>
</div>
</header>
<main class="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-7 p-4">
{#if (generating || recommendations.current?.pending) && !recommendations.current?.menus.length}
<div class="grid flex-1 place-items-center py-20 text-center">
<div class="flex max-w-xs flex-col items-center gap-3">
<div class="rounded-full bg-primary/10 p-4 text-primary">
<LoaderCircleIcon class="size-7 animate-spin" />
</div>
<div>
<p class="font-medium">{$_("guide.generating")}</p>
<p class="text-sm text-muted-foreground">{$_("guide.generating_note")}</p>
</div>
</div> </div>
</div> </div>
{:else if generationError || recommendations.error}
<div class="grid min-w-24 place-items-center rounded-2xl bg-primary p-3 text-primary-foreground"> <div class="rounded-2xl border border-destructive/30 bg-destructive/5 p-5">
<SnowflakeIcon class="size-7" /> <p class="font-medium text-destructive">{$_("guide.generation_failed")}</p>
<Button class="mt-3" variant="outline" onclick={() => regenerate(true)}>
{$_("button.try_again")}
</Button>
</div> </div>
</div> {:else}
</section> {#each tiers as tier (tier.id)}
{@const menus = (recommendations.current?.menus ?? []).filter(
(menu) => menu.tier === tier.id,
)}
{#if menus.length}
<section class="space-y-3">
<div class="px-1">
<h2 class="text-lg font-semibold">{$_(tier.label)}</h2>
<p class="text-sm text-muted-foreground">{$_(tier.note)}</p>
</div>
<section class="space-y-2"> <div class="columns-1 gap-3 min-[520px]:columns-2">
<div class="flex items-center justify-between px-1"> {#each menus as menu (menu.id)}
<h2 class="text-sm font-semibold">Quick guides</h2> <button
<span class="text-xs text-muted-foreground">2-col concept</span> type="button"
</div> class="mb-3 w-full break-inside-avoid overflow-hidden rounded-2xl border bg-card text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md"
onclick={() => (selected = menu)}
>
{#if menu.imageUrl}
<div class="aspect-[16/9] overflow-hidden bg-muted">
<Image
src={menu.imageUrl}
alt=""
class="size-full object-cover transition-transform duration-300 hover:scale-105"
/>
</div>
{/if}
<div class="space-y-2 p-4">
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold leading-tight">{menu.title}</h3>
<Badge variant="secondary" class="shrink-0 gap-1">
<Clock3Icon class="size-3" /> {menu.cookMinutes}m
</Badge>
</div>
<p class="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{menu.description}
</p>
<div class="flex items-center gap-1 text-xs font-medium text-primary">
<SparklesIcon class="size-3.5" />
{$_("guide.view_recipe")}
</div>
</div>
</button>
{/each}
</div>
</section>
{/if}
{/each}
{/if}
</main>
</div>
<div class="grid grid-cols-2 gap-2"> {#if selected}
{#each quickGuides as guide (guide.title)} <div
class="fixed inset-0 z-50 overflow-y-auto bg-black/65 p-3 backdrop-blur-sm sm:p-6"
role="presentation"
onclick={closeOnBackdrop}
>
<article class="mx-auto max-w-2xl overflow-hidden rounded-3xl bg-background shadow-2xl">
<div class="relative aspect-[16/9] bg-linear-to-br from-primary/25 via-muted to-accent">
{#if selected.imageUrl}
<Image src={selected.imageUrl} alt="" class="size-full object-cover" />
{:else}
<div class="grid size-full place-items-center text-primary/60">
<ChefHatIcon class="size-16" />
</div>
{/if}
<button <button
type="button" type="button"
class="flex min-h-38 flex-col rounded-2xl border bg-card p-3 text-left transition-colors hover:bg-accent" class="absolute right-3 top-3 rounded-full bg-background/90 p-2 shadow backdrop-blur"
aria-label={$_("button.close")}
onclick={() => (selected = undefined)}
> >
<div class="flex items-start justify-between gap-2"> <XIcon class="size-5" />
<div class={[
"inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-medium",
guide.accent
]}>
{guide.time}
</div>
<ChevronRightIcon class="mt-0.5 size-4 text-muted-foreground" />
</div>
<div class="mt-5 space-y-1">
<h3 class="text-sm font-semibold leading-tight">{guide.title}</h3>
<p class="text-xs leading-relaxed text-muted-foreground">
{guide.note}
</p>
</div>
</button> </button>
{/each} </div>
</div>
</section>
<section class="rounded-3xl border bg-card p-4"> <div class="space-y-7 p-5 sm:p-7">
<div class="mb-3 flex items-center gap-2"> <div class="space-y-2">
<SparklesIcon class="size-4 text-primary" /> <div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-semibold">Fridge zones</h2> <Badge>{$_(`guide.tier.${selected.tier}`)}</Badge>
</div> <Badge variant="outline" class="gap-1">
<Clock3Icon class="size-3" />
<div class="grid grid-cols-2 gap-2"> {$_("guide.minutes", { values: { count: selected.cookMinutes } })}
{#each zones as zone (zone.title)} </Badge>
<div class="rounded-2xl bg-muted/50 p-3">
<div class="mb-1 flex items-center justify-between gap-2">
<h3 class="text-sm font-medium">{zone.title}</h3>
<span class="text-[11px] text-muted-foreground">{zone.tone}</span>
</div> </div>
<p class="text-xs leading-relaxed text-muted-foreground">{zone.items}</p> <h2 class="text-2xl font-semibold tracking-tight">{selected.title}</h2>
<p class="leading-relaxed text-muted-foreground">{selected.description}</p>
</div> </div>
{/each}
</div>
</section>
<section class="rounded-3xl border border-amber-500/30 bg-amber-500/8 p-4"> <section class="space-y-3">
<div class="mb-3 flex items-center gap-2 text-amber-700 dark:text-amber-300"> <h3 class="text-lg font-semibold">{$_("guide.ingredients")}</h3>
<TriangleAlertIcon class="size-4" /> <div class="grid gap-2 sm:grid-cols-2">
<h2 class="text-sm font-semibold">Common mistakes</h2> {#each selected.ingredients as ingredient, i (i)}
</div> <div class="flex items-center gap-3 rounded-xl border bg-card p-2.5">
<div
class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
>
{#if ingredient.fridgeItem?.icon}
<Image
src={toImageUrl(ingredient.fridgeItem.icon)}
alt=""
class="size-full object-cover"
/>
{:else}
<ImageOffIcon class="size-4 text-muted-foreground" />
{/if}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{ingredient.fridgeItem?.name ?? ingredient.externalName}
</p>
<p class="text-xs text-muted-foreground">{ingredient.amount}</p>
</div>
</div>
{/each}
</div>
</section>
<div class="space-y-2"> <section class="space-y-3">
{#each warnings as warning (warning)} <h3 class="text-lg font-semibold">{$_("guide.instructions")}</h3>
<div class="flex items-start gap-2 rounded-2xl bg-background/70 px-3 py-2"> <ol class="space-y-3">
<Clock3Icon class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-300" /> {#each selected.instructions as instruction, i (i)}
<p class="text-sm leading-relaxed">{warning}</p> <li class="flex gap-3">
</div> <span
{/each} class="grid size-7 shrink-0 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
</div> >
</section> {i + 1}
</div> </span>
<p class="pt-0.5 text-sm leading-relaxed">{instruction}</p>
</li>
{/each}
</ol>
</section>
</div>
</article>
</div>
{/if}
+5 -4
View File
@@ -16,7 +16,7 @@
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left"; import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import ImagePlusIcon from "@lucide/svelte/icons/image-plus"; import ImagePlusIcon from "@lucide/svelte/icons/image-plus";
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle"; import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
import { _ } from "svelte-i18n"; import { _, locale } from "svelte-i18n";
type Draft = FoodSuggestion & { type Draft = FoodSuggestion & {
include: boolean; include: boolean;
@@ -126,8 +126,9 @@
status = "saving"; status = "saving";
try { try {
await addItems( await addItems({
included.map((d) => ({ locale: $locale ?? "en",
items: included.map((d) => ({
name: d.name, name: d.name,
category: d.category, category: d.category,
expiry: new Date( expiry: new Date(
@@ -139,7 +140,7 @@
size: d.iconBytes.byteLength, size: d.iconBytes.byteLength,
}, },
})), })),
); });
await getItems().refresh(); await getItems().refresh();
goto(resolve("/app/list")); goto(resolve("/app/list"));
} catch (error) { } catch (error) {
+38
View File
@@ -0,0 +1,38 @@
import {
WorkflowEntrypoint,
type WorkflowEvent,
type WorkflowStep,
} from "cloudflare:workers";
type Env = {
GUIDE_JOB_URL: string;
GUIDE_JOB_SECRET: string;
};
export class RecommendationWorkflow extends WorkflowEntrypoint<Env> {
async run(_event: WorkflowEvent<unknown>, step: WorkflowStep) {
return step.do(
"generate due recommendations",
{
retries: { limit: 3, delay: "2 minutes", backoff: "exponential" },
timeout: "10 minutes",
},
async () => {
const response = await fetch(this.env.GUIDE_JOB_URL, {
method: "POST",
headers: { authorization: `Bearer ${this.env.GUIDE_JOB_SECRET}` },
});
if (!response.ok) {
throw new Error(`Recommendation endpoint failed (${response.status})`);
}
return response.json<{ processed: number; completed: number }>();
},
);
}
}
export default {
fetch() {
return Response.json({ service: "hotfridge-recommendation-workflow" });
},
};
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hotfridge-recommendations",
"main": "workers/recommendation-workflow.ts",
"compatibility_date": "2026-08-24",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"GUIDE_JOB_URL": "https://hotfridge.pages.dev/api/recommendations/run"
},
"workflows": [
{
"name": "hotfridge-recommendations",
"binding": "RECOMMENDATION_WORKFLOW",
"class_name": "RecommendationWorkflow",
"schedules": ["*/5 * * * *"]
}
]
}