Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ jobs:
tools/tests/backend_proxy_logout_fallback.test.mjs
tools/tests/backend_warmup_rate_limit.test.mjs
tools/tests/calorieapp_embed_readiness.test.mjs
tools/tests/food_logging_ui.test.mjs
tools/tests/food_search_deadline.test.mjs
tools/tests/identity_locales.test.mjs
tools/tests/xaman_logout_request.test.mjs
tools/tests/xaman_login_start_retry.test.mjs
Expand Down
30 changes: 30 additions & 0 deletions docs/FOOD_SEARCH_DEADLINES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Food search deadline correction

The mobile screenshots show one failed search for Magnum followed by results
after a second click. They do not contain an HTTP status, trace or provider log.

The app already waits for backend readiness before searching. In
`backend/services/open_food_facts.py`, the existing primary request has a
10-second timeout and its eligible fallback has a 15-second timeout, with
bounded adapter queueing. Before this change, the frontend proxy stopped at
18 seconds and the browser request helper at 20. A valid slower backend search
could therefore be cut off even though backend readiness had succeeded.

Only `/search-food` now uses a 45-second proxy timeout and a 50-second browser
timeout. The client gives the proxy time to return a controlled 504. Search
progress explains the possible delay; 429, 504 and other service errors have
specific user-facing messages. Identity, logout, ordinary request and account
import deadlines are unchanged. Existing provider concurrency, attempt limits,
queue limits, rate governor and upstream Retry-After forwarding are unchanged.
No automatic search retry is added.

`tools/tests/food_search_deadline.test.mjs` runs the real transpiled browser
helper and proxy together under a virtual clock. It checks one successful
26-second response, a bounded 45-second timeout, an unchanged 18-second ordinary
request timeout, and a forwarded 429/Retry-After without an extra provider call.
Existing food logging and logout behavior tests run beside it in CI.

This demonstrates a concrete deadline mismatch. It does not prove that the
captured failure followed that route or guarantee availability of Open Food
Facts. One live search after inactivity remains the acceptance check after
deployment; the WordPress plugin ZIP alone cannot deliver this app change.
28 changes: 15 additions & 13 deletions frontend/app/api/backend/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isTrustedPrivateExportRequest } from "@/lib/privateExportRequest";
export const dynamic = "force-dynamic";

const DEFAULT_UPSTREAM_TIMEOUT_MS = 18_000;
const FOOD_SEARCH_UPSTREAM_TIMEOUT_MS = 45_000;
const COLD_START_UPSTREAM_TIMEOUT_MS = 70_000;
const ACCOUNT_IMPORT_UPSTREAM_TIMEOUT_MS = 60_000;
const LOGOUT_REVOCATION_TIMEOUT_MS = 8_000;
Expand Down Expand Up @@ -182,20 +183,21 @@ async function proxyRequest(request: NextRequest, context: RouteContext) {
}

const controller = new AbortController();
// A sleeping Render backend can need well over the ordinary request timeout
// before its health endpoint answers. Keep only this readiness probe alive
// long enough to wake it; normal application requests retain the tighter
// timeout after readiness has been established.
// Readiness and identity routes retain their accepted deadlines. Search must
// also fit the existing primary/fallback provider attempts and queue time;
// otherwise the proxy returns 504 while the backend is still finding food.
const upstreamTimeoutMs =
path === ACCOUNT_IMPORT_PATH
? ACCOUNT_IMPORT_UPSTREAM_TIMEOUT_MS
: [
"health",
"api/identity/login/start",
"api/identity/callback",
].includes(path)
? COLD_START_UPSTREAM_TIMEOUT_MS
: DEFAULT_UPSTREAM_TIMEOUT_MS;
path === "search-food"
? FOOD_SEARCH_UPSTREAM_TIMEOUT_MS
: path === ACCOUNT_IMPORT_PATH
? ACCOUNT_IMPORT_UPSTREAM_TIMEOUT_MS
: [
"health",
"api/identity/login/start",
"api/identity/callback",
].includes(path)
? COLD_START_UPSTREAM_TIMEOUT_MS
: DEFAULT_UPSTREAM_TIMEOUT_MS;
const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);

try {
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ body {
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
color: var(--ink);
background-color: var(--brand-bg);
background-image: url('/background.png');
background-image: url('/calorieapp-background.png');
background-attachment: scroll;
Comment thread
xrpbanks marked this conversation as resolved.
background-position: center;
background-repeat: repeat;
Expand All @@ -35,3 +35,4 @@ body {
background-attachment: fixed;
}
}

58 changes: 52 additions & 6 deletions frontend/components/FoodCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,44 @@

import { FoodSearchItem } from "@/components/foodTypes";
import Image from "next/image";
import { useState } from "react";
import { ReactNode, useEffect, useId, useRef, useState } from "react";

type FoodCardProps = {
item: FoodSearchItem;
isLogging: boolean;
isDisabled?: boolean;
onLog: () => void;
formatNumber: (value: number) => string;
children?: ReactNode;
feedback?: { message: string; isError: boolean } | null;
};

export function FoodCard({ item, isLogging, onLog, formatNumber }: FoodCardProps) {
export function FoodCard({ item, isLogging, isDisabled = false, onLog, formatNumber, children, feedback }: FoodCardProps) {
const [imageFailed, setImageFailed] = useState(false);
const portionId = useId();
const portionRef = useRef<HTMLDivElement>(null);
const logButtonRef = useRef<HTMLButtonElement>(null);
const wasExpandedRef = useRef(false);
const isExpanded = Boolean(children);
const showImage = Boolean(item.image_url) && !imageFailed;

useEffect(() => {
if (isExpanded && !wasExpandedRef.current) {
portionRef.current?.focus({ preventScroll: true });
portionRef.current?.scrollIntoView({ block: "nearest", behavior: "auto" });
} else if (!isExpanded && wasExpandedRef.current) {
if (isDisabled || isLogging) return;
// Restore keyboard focus after the portion controls are removed, while
// leaving focus alone if the user has moved to another product or search.
if (document.activeElement === document.body) {
logButtonRef.current?.focus({ preventScroll: true });
}
}
wasExpandedRef.current = isExpanded;
}, [isExpanded, isDisabled, isLogging]);

return (
<li className="rounded-xl border border-brand-secondary/15 bg-white p-4 sm:p-5 shadow-sm hover:shadow-md transition duration-200">
<li className={`rounded-xl border bg-white p-4 sm:p-5 shadow-sm transition duration-200 ${isExpanded ? "border-brand-primary ring-2 ring-brand-primary/15" : "border-brand-secondary/15 hover:shadow-md"}`}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="h-24 w-full shrink-0 overflow-hidden rounded-lg border border-brand-secondary/15 bg-brand-bg sm:h-24 sm:w-24">
{showImage ? (
Expand Down Expand Up @@ -77,15 +100,38 @@ export function FoodCard({ item, isLogging, onLog, formatNumber }: FoodCardProps
</div>
</div>
<button
ref={logButtonRef}
type="button"
className="mt-4 rounded-full bg-brand-primary px-6 py-2 text-xs font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
className="mt-4 min-h-11 rounded-full bg-brand-primary px-6 py-2 text-xs font-semibold text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-secondary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
onClick={onLog}
disabled={isLogging}
disabled={isLogging || isDisabled || isExpanded}
aria-busy={isLogging}
aria-expanded={isExpanded}
aria-controls={isExpanded ? portionId : undefined}
aria-label={`Log ${item.product_name}`}
>
{isLogging ? "Logging..." : "Log Food"}
{isLogging ? "Logging..." : isExpanded ? "Choose your portion below" : "Log Food"}
</button>
{isExpanded ? (
<div
id={portionId}
ref={portionRef}
tabIndex={-1}
role="region"
aria-label={`Choose a portion for ${item.product_name}`}
className="scroll-mt-4 outline-none"
>
{children}
</div>
) : null}
{feedback ? (
<p
role={feedback.isError ? "alert" : "status"}
className={`mt-3 text-sm font-semibold ${feedback.isError ? "text-red-600" : "text-brand-primary"}`}
>
{feedback.message}
</p>
) : null}
</li>
);
}
Loading