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
run: |
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 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
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
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": {
"dev": "vite dev",
"build": "vite build",
"deploy:workflow": "wrangler deploy --config wrangler.workflow.jsonc",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"lint": "oxlint .",
+2
View File
@@ -21,6 +21,8 @@ declare global {
interface Platform {
env: {
DB: D1Database;
GEMINI_API_KEY?: string;
GUIDE_JOB_SECRET?: string;
};
}
}
+28 -9
View File
@@ -1,14 +1,33 @@
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) => ({
tItems: {
icon: r.one.tImages({
from: r.tItems.iconId,
to: r.tImages.id,
}),
},
}));
export const relations = defineRelations(
{ tItems, tImages, tRecommendationBatches, tRecommendations },
(r) => ({
tItems: {
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 };
+46
View File
@@ -3,6 +3,10 @@ import { sqliteTable } from "drizzle-orm/sqlite-core/table";
export type ItemId = number & { __brand: "item_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", {
id: integer().$type<ItemId>().primaryKey({ autoIncrement: true }),
@@ -22,5 +26,47 @@ export const tImages = sqliteTable("images", {
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 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 { tImages, tItems, type ItemId } from "$lib/db/schema";
import {
tImages,
tItems,
type ItemId,
} from "$lib/db/schema";
import { getDb } from "$lib/server/db";
import {
getRecommendationRateLimitKey,
scheduleRecommendationJob,
} from "$lib/server/recommendations";
import { eq } from "drizzle-orm";
export const getItems = query(() => {
@@ -22,14 +30,20 @@ export const getItem = query("unchecked", (id: ItemId) => {
export const addItems = command(
"unchecked",
async (
items: {
name: string;
category: string;
expiry: Date;
icon: { mime: string; data: Uint8Array; size: number };
}[],
{
items,
locale,
}: {
items: {
name: string;
category: string;
expiry: Date;
icon: { mime: string; data: Uint8Array; size: number };
}[];
locale: string;
},
): Promise<ItemId[]> => {
const { locals } = getRequestEvent();
const { locals, request } = getRequestEvent();
const db = getDb();
const ids: ItemId[] = [];
@@ -47,12 +61,18 @@ export const addItems = command(
ids.push(inserted.id);
}
await scheduleRecommendationJob(db, locals.sessionId, {
locale,
delay: 5 * 60 * 1000,
rateLimitKey: await getRecommendationRateLimitKey(request),
});
return ids;
},
);
export const removeItem = command("unchecked", async (id: ItemId) => {
const { locals } = getRequestEvent();
const { locals, request } = getRequestEvent();
const db = getDb();
const item = await db.query.tItems.findFirst({
where: { id: { eq: id }, sessionId: { eq: locals.sessionId } },
@@ -65,4 +85,8 @@ export const removeItem = command("unchecked", async (id: ItemId) => {
} else {
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...",
"cancel": "Cancel",
"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": {
"food": "Food",
+27 -1
View File
@@ -34,7 +34,33 @@
"saving": "กำลังบันทึก...",
"cancel": "ยกเลิก",
"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": {
"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 { relations } from "$lib/db/relations";
import type { D1Database } from "@cloudflare/workers-types";
import { drizzle } from "drizzle-orm/d1";
export function createDb(database: D1Database) {
return drizzle(database, { relations, logger: true });
}
export function getDb() {
const { platform } = getRequestEvent();
if (!platform) {
@@ -9,5 +14,5 @@ export function getDb() {
"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">
import BookImageIcon from "@lucide/svelte/icons/book-image";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import Image from "$lib/components/image.svelte";
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 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 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",
note: "Wrap with paper before chilling.",
time: "3-5 days",
accent: "bg-emerald-500/12 text-emerald-700 dark:text-emerald-300"
id: "near_expiry_food",
label: "guide.tier.near_expiry_food",
note: "guide.tier_note.near_expiry_food",
},
{
title: "Cooked rice",
note: "Seal fast while still slightly warm.",
time: "1-2 days",
accent: "bg-amber-500/12 text-amber-700 dark:text-amber-300"
id: "near_expiry_ingredients",
label: "guide.tier.near_expiry_ingredients",
note: "guide.tier_note.near_expiry_ingredients",
},
{
title: "Fresh meat",
note: "Keep on the coldest shelf, bottom tray.",
time: "1-2 days",
accent: "bg-rose-500/12 text-rose-700 dark:text-rose-300"
id: "discovery",
label: "guide.tier.discovery",
note: "guide.tier_note.discovery",
},
{
title: "Sauces",
note: "Door shelf is fine once opened.",
time: "1 month",
accent: "bg-sky-500/12 text-sky-700 dark:text-sky-300"
id: "fridge",
label: "guide.tier.fridge",
note: "guide.tier_note.fridge",
},
];
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;
}
];
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"
if (mounted && current && !current.pending && current.menus.length === 0) {
generationError = true;
}
];
}
const warnings = [
"Milk beside the door expires faster.",
"Hot food raises the whole fridge temp.",
"Uncovered leftovers dry out and smell spreads.",
"Overpacking blocks cold air circulation."
];
async function regenerate(force: boolean) {
generating = true;
generationError = false;
try {
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>
<div class="flex flex-col gap-4 p-3">
<section class="rounded-3xl border bg-card p-4 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div class="space-y-2">
<div class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
<BookImageIcon class="size-3.5" />
Fridge guide concept
<div class="flex min-h-full flex-col bg-background">
<header class="border-b bg-card px-4 py-5">
<div class="mx-auto flex max-w-4xl items-start justify-between gap-4">
<div class="space-y-1">
<div class="flex items-center gap-2 text-primary">
<ChefHatIcon class="size-5" />
<span class="text-xs font-semibold uppercase tracking-[0.2em]">
{$_("guide.eyebrow")}
</span>
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight">Store smarter, waste less</h1>
<p class="max-w-[28rem] text-sm text-muted-foreground">
Quick visual cheatsheet for where food goes, how long it lasts,
and what usually goes wrong.
</p>
<h1 class="text-2xl font-semibold tracking-tight">{$_("guide.title")}</h1>
<p class="max-w-xl text-sm text-muted-foreground">
{$_("guide.description")}
</p>
</div>
<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 class="grid min-w-24 place-items-center rounded-2xl bg-primary p-3 text-primary-foreground">
<SnowflakeIcon class="size-7" />
{:else if generationError || recommendations.error}
<div class="rounded-2xl border border-destructive/30 bg-destructive/5 p-5">
<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>
</section>
{:else}
{#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="flex items-center justify-between px-1">
<h2 class="text-sm font-semibold">Quick guides</h2>
<span class="text-xs text-muted-foreground">2-col concept</span>
</div>
<div class="columns-1 gap-3 min-[520px]:columns-2">
{#each menus as menu (menu.id)}
<button
type="button"
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">
{#each quickGuides as guide (guide.title)}
{#if selected}
<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
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">
<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>
<XIcon class="size-5" />
</button>
{/each}
</div>
</section>
</div>
<section class="rounded-3xl border bg-card p-4">
<div class="mb-3 flex items-center gap-2">
<SparklesIcon class="size-4 text-primary" />
<h2 class="text-sm font-semibold">Fridge zones</h2>
</div>
<div class="grid grid-cols-2 gap-2">
{#each zones as zone (zone.title)}
<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 class="space-y-7 p-5 sm:p-7">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<Badge>{$_(`guide.tier.${selected.tier}`)}</Badge>
<Badge variant="outline" class="gap-1">
<Clock3Icon class="size-3" />
{$_("guide.minutes", { values: { count: selected.cookMinutes } })}
</Badge>
</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>
{/each}
</div>
</section>
<section class="rounded-3xl border border-amber-500/30 bg-amber-500/8 p-4">
<div class="mb-3 flex items-center gap-2 text-amber-700 dark:text-amber-300">
<TriangleAlertIcon class="size-4" />
<h2 class="text-sm font-semibold">Common mistakes</h2>
</div>
<section class="space-y-3">
<h3 class="text-lg font-semibold">{$_("guide.ingredients")}</h3>
<div class="grid gap-2 sm:grid-cols-2">
{#each selected.ingredients as ingredient, i (i)}
<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">
{#each warnings as warning (warning)}
<div class="flex items-start gap-2 rounded-2xl bg-background/70 px-3 py-2">
<Clock3Icon class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-300" />
<p class="text-sm leading-relaxed">{warning}</p>
</div>
{/each}
</div>
</section>
</div>
<section class="space-y-3">
<h3 class="text-lg font-semibold">{$_("guide.instructions")}</h3>
<ol class="space-y-3">
{#each selected.instructions as instruction, i (i)}
<li class="flex gap-3">
<span
class="grid size-7 shrink-0 place-items-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
>
{i + 1}
</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 ImagePlusIcon from "@lucide/svelte/icons/image-plus";
import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle";
import { _ } from "svelte-i18n";
import { _, locale } from "svelte-i18n";
type Draft = FoodSuggestion & {
include: boolean;
@@ -126,8 +126,9 @@
status = "saving";
try {
await addItems(
included.map((d) => ({
await addItems({
locale: $locale ?? "en",
items: included.map((d) => ({
name: d.name,
category: d.category,
expiry: new Date(
@@ -139,7 +140,7 @@
size: d.iconBytes.byteLength,
},
})),
);
});
await getItems().refresh();
goto(resolve("/app/list"));
} 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 * * * *"]
}
]
}