diff --git a/apps/mobile/components/cooking-mode.tsx b/apps/mobile/components/cooking-mode.tsx new file mode 100644 index 0000000..11ae165 --- /dev/null +++ b/apps/mobile/components/cooking-mode.tsx @@ -0,0 +1,173 @@ +import type { CardAct, CardSection } from "@rakazo/contracts"; +import { cardTimerRemaining, formatCardClock } from "@rakazo/core"; +import { useKeepAwake } from "expo-keep-awake"; +import { useEffect, useState } from "react"; +import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from "react-native"; +import { mobileTokens } from "../lib/appearance"; +import { useI18n } from "../lib/i18n"; +import { NativeSymbol } from "./native-symbol"; + +type Steps = Extract; + +/** The current time, ticking every second while `active`. */ +export function useNow(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [active]); + return now; +} + +/** + * A card's steps one at a time in full screen, large enough to read from the stove, with the + * screen kept on. The step and any timer live on the card, so the web and a reload agree. + */ +export function CookingMode({ + title, + steps, + now, + busy, + onAct, + onClose, +}: { + title: string; + steps: Steps; + now: number; + busy: boolean; + onAct: (act: CardAct) => Promise; + onClose: () => void; +}) { + useKeepAwake(); + const tokens = mobileTokens(); + const { t } = useI18n(); + const index = Math.min(steps.current ?? 0, steps.steps.length - 1); + const step = steps.steps[index]; + const last = index === steps.steps.length - 1; + const remaining = cardTimerRemaining(step?.timerEndsAt, now); + const running = remaining !== null && remaining > 0; + const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next }); + + return ( + + + + + {title} + + + + + + + + {t("Step {position} of {total}", { + position: index + 1, + total: steps.steps.length, + })} + + {step?.text} + {step?.timerSeconds ? ( + + void onAct({ + type: "timer", + sectionId: steps.id, + stepId: step.id, + running: !running, + }) + } + style={[ + styles.timer, + { + borderColor: running ? tokens.primary : tokens.border, + backgroundColor: running ? tokens.primary : "transparent", + }, + ]} + > + + + {formatCardClock(remaining ?? step.timerSeconds)} + + + ) : null} + + + go(index - 1)} + style={[styles.nav, { borderColor: tokens.border, opacity: index === 0 ? 0.4 : 1 }]} + > + {t("Back")} + + (last ? onClose() : go(index + 1))} + style={[styles.nav, { borderColor: tokens.primary, backgroundColor: tokens.primary }]} + > + + {last ? t("Done") : t("Next")} + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, paddingHorizontal: 24 }, + top: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + paddingTop: 16, + }, + title: { flex: 1, fontSize: 15, fontWeight: "600" }, + middle: { flex: 1, justifyContent: "center", gap: 28 }, + counter: { fontSize: 15 }, + step: { fontSize: 30, lineHeight: 40, fontWeight: "600" }, + timer: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + gap: 10, + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: 18, + paddingVertical: 12, + }, + clock: { fontSize: 26, fontWeight: "600", fontVariant: ["tabular-nums"] }, + bottom: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingBottom: 16 }, + nav: { + flex: 1, + alignItems: "center", + borderWidth: 1, + borderRadius: 14, + paddingVertical: 14, + }, + navText: { fontSize: 16, fontWeight: "600" }, +}); diff --git a/apps/mobile/components/thread-card.tsx b/apps/mobile/components/thread-card.tsx index d26b8e0..a3ff2ed 100644 --- a/apps/mobile/components/thread-card.tsx +++ b/apps/mobile/components/thread-card.tsx @@ -1,25 +1,44 @@ import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts"; +import { CARD_LIMITS } from "@rakazo/contracts"; +import { + applyCardAct, + cardScale, + cardTimerRemaining, + checklistItemDisplay, + formatCardClock, + formatCardTimerLength, +} from "@rakazo/core"; import { useState } from "react"; import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; import { mobileTokens } from "../lib/appearance"; -import { useI18n } from "../lib/i18n"; +import { dateLocaleForUi, useI18n } from "../lib/i18n"; +import { CookingMode, useNow } from "./cooking-mode"; import { NativeSymbol } from "./native-symbol"; type Section = Extract; /** A bot's interactive card. Taps go to the server, which owns the card's state. */ export function ThreadCard({ - block, + block: stored, onAct, }: { block: CardBlock; onAct?: (act: CardAct) => Promise; }) { const tokens = mobileTokens(); - const { t } = useI18n(); + const { t, locale } = useI18n(); const [pending, setPending] = useState(null); + const [expanded, setExpanded] = useState([]); + const [cooking, setCooking] = useState(false); + // Show the tap in flight on top of the stored card, so the card answers at once. + const preview = pending ? applyCardAct(stored, pending) : null; + const block = preview?.ok ? preview.block : stored; const answered = Boolean(block.answer); const busy = !onAct || pending !== null; + const factor = cardScale(block); + const header = block.sections?.find((part): part is Section<"header"> => part.type === "header"); + const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps"); + const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt))); async function act(next: CardAct) { if (!onAct || pending) return; @@ -36,14 +55,48 @@ export function ThreadCard({ } } - const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) => - pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId - ? pending.checked - : stored; - const selectedNow = (select: Section<"select">, optionId: string) => - pending?.type === "select" && pending.sectionId === select.id && pending.optionId === optionId - ? (pending.selected ?? true) - : (select.selected?.includes(optionId) ?? false); + function visible(id: string, entries: T[]): { shown: T[]; hidden: number } { + if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) { + return { shown: entries, hidden: 0 }; + } + return { + shown: entries.slice(0, CARD_LIMITS.collapsedEntries), + hidden: entries.length - CARD_LIMITS.collapsedEntries, + }; + } + + function showMore(id: string, hidden: number) { + if (!hidden) return null; + return ( + setExpanded((ids) => [...ids, id])} + style={{ paddingVertical: 6 }} + > + + {t("Show {count} more", { count: hidden })} + + + ); + } + + const roundButton = ( + label: string, + symbol: { ios: string; android: "remove" | "add" }, + disabled: boolean, + onPress: () => void, + ) => ( + + + + ); const select = block.sections?.find((part): part is Section<"select"> => part.type === "select"); const nothingChosen = Boolean(select?.multiple && !select.selected?.length); @@ -84,11 +137,71 @@ export function ThreadCard({ ); } + if (part.type === "stepper") { + const label = part.label; + return ( + + {label} + + {roundButton( + t("Fewer {label}", { label }), + { ios: "minus", android: "remove" }, + busy || part.value <= 1, + () => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }), + )} + + {part.value} + + {roundButton( + t("More {label}", { label }), + { ios: "plus", android: "add" }, + busy || part.value >= CARD_LIMITS.stepperMax, + () => void act({ type: "stepper", sectionId: part.id, value: part.value + 1 }), + )} + + + ); + } + if (part.type === "steps") { + const { shown, hidden } = visible(part.id, part.steps); + return ( + + {shown.map((step, position) => { + const remaining = cardTimerRemaining(step.timerEndsAt, now); + return ( + + + {position + 1} + + + {step.text} + {step.timerSeconds ? ( + + {remaining !== null + ? formatCardClock(remaining) + : formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))} + + ) : null} + + + ); + })} + {showMore(part.id, hidden)} + + ); + } if (part.type === "checklist") { + const { shown, hidden } = visible(part.id, part.items); return ( - {part.items.map((item) => { - const checked = checkedNow(part, item.id, Boolean(item.checked)); + {shown.map((item) => { + const checked = Boolean(item.checked); + const display = checklistItemDisplay(item, factor); return ( - {item.label} + {display.amount ? ( + {display.amount} + ) : null} + {display.label} ); })} + {showMore(part.id, hidden)} ); } @@ -133,7 +250,7 @@ export function ThreadCard({ return ( {part.options.map((option) => { - const chosen = selectedNow(part, option.id); + const chosen = part.selected?.includes(option.id) ?? false; return ( ); } - if (answered) return null; + const actions = part.actions.filter((action) => action.kind === "steps" || !answered); + if (!actions.length) return null; return ( - {part.actions.map((action, position) => { + {actions.map((action, position) => { const primary = position === 0; - const disabled = busy || nothingChosen; + const opensSteps = action.kind === "steps"; + const disabled = opensSteps ? !steps : busy || nothingChosen; return ( void act({ type: "action", actionId: action.id })} + onPress={() => + opensSteps + ? setCooking(true) + : void act({ type: "action", actionId: action.id }) + } style={[ styles.option, { @@ -211,6 +334,16 @@ export function ThreadCard({ ); })} + {cooking && steps ? ( + setCooking(false)} + /> + ) : null} ); } @@ -232,4 +365,16 @@ const styles = StyleSheet.create({ checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }, chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 }, + stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + stepperControls: { flexDirection: "row", alignItems: "center", gap: 8 }, + stepperValue: { minWidth: 28, textAlign: "center", fontWeight: "600" }, + round: { + width: 34, + height: 34, + borderRadius: 17, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + stepNumber: { width: 16, textAlign: "right" }, }); diff --git a/apps/mobile/lib/locales/ru.ts b/apps/mobile/lib/locales/ru.ts index 4e19a40..7af207a 100644 --- a/apps/mobile/lib/locales/ru.ts +++ b/apps/mobile/lib/locales/ru.ts @@ -608,4 +608,12 @@ export const RU_MESSAGES: Record = { "Every {day}, {time}": "Еженедельно: {day}, {time}", "Monthly on day {day}, {time}": "Ежемесячно {day}-го числа, {time}", "Next {date}": "Далее: {date}", + Close: "Закрыть", + "Fewer {label}": "Меньше: {label}", + "More {label}": "Больше: {label}", + Next: "Далее", + "Show {count} more": "Показать ещё {count}", + "Start timer": "Запустить таймер", + "Step {position} of {total}": "Шаг {position} из {total}", + "Stop timer": "Остановить таймер", }; diff --git a/apps/mobile/lib/locales/zh.ts b/apps/mobile/lib/locales/zh.ts index 771c5b8..c9477b4 100644 --- a/apps/mobile/lib/locales/zh.ts +++ b/apps/mobile/lib/locales/zh.ts @@ -592,4 +592,12 @@ export const ZH_MESSAGES: Record = { "Every {day}, {time}": "每{day} {time}", "Monthly on day {day}, {time}": "每月 {day} 日 {time}", "Next {date}": "下次:{date}", + Close: "关闭", + "Fewer {label}": "减少{label}", + "More {label}": "增加{label}", + Next: "下一步", + "Show {count} more": "再显示 {count} 项", + "Start timer": "开始计时", + "Step {position} of {total}": "第 {position} 步,共 {total} 步", + "Stop timer": "停止计时", }; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d5bb46b..afdfc51 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -33,6 +33,7 @@ "expo-file-system": "57.0.7", "expo-font": "57.0.4", "expo-image-picker": "57.0.20", + "expo-keep-awake": "57.0.2", "expo-linking": "57.0.11", "expo-localization": "57.0.2", "expo-modules-core": "57.0.20", diff --git a/apps/web/e2e/recipe-card.spec.ts b/apps/web/e2e/recipe-card.spec.ts new file mode 100644 index 0000000..72a72fe --- /dev/null +++ b/apps/web/e2e/recipe-card.spec.ts @@ -0,0 +1,71 @@ +import { expect, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers"; + +async function useAppearance(page: Page, appearance: "light" | "dark") { + const settings = await openUserSettings(page); + await settings.getByTestId(`ui-appearance-${appearance}`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", appearance); + await settings.getByRole("button", { name: "Close user settings" }).click(); + await expect(settings).toBeHidden(); +} + +test("a recipe card scales its ingredients and cooks step by step", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `recipe-card-${stamp}@rakazo.test`, "password12", "Recipe Card"); + await completeOnboarding(page); + + await page.getByPlaceholder(/Message/).fill("Give me a pancake recipe."); + await page.keyboard.press("Enter"); + + const card = page.getByTestId("thread-card"); + await expect(card).toBeVisible({ timeout: 60_000 }); + await expect(card).toContainText("200 g"); + await expect(card).toContainText("1 Egg"); + + await card.getByRole("button", { name: "More Servings" }).click(); + await expect(card.getByTestId("card-stepper-value")).toHaveText("3"); + await expect(card).toContainText("300 g"); + await expect(card).toContainText("1½ Eggs"); + await expect(card).toContainText("450 ml"); + + await useAppearance(page, "light"); + await card.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "recipe-card-light"); + await useAppearance(page, "dark"); + await card.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, "recipe-card-dark"); + + await card.getByRole("button", { name: "Cook" }).click(); + const cooking = page.getByTestId("cooking-mode"); + await expect(cooking).toContainText("Step 1 of 3"); + await cooking.getByRole("button", { name: "Next" }).click(); + await expect(cooking).toContainText("Step 2 of 3"); + await expect(cooking).toContainText("Let the batter rest."); + await cooking.getByRole("button", { name: "Start timer" }).click(); + await expect(cooking.getByRole("button", { name: "Stop timer" })).toContainText(/9:5\d/, { + timeout: 10_000, + }); + await captureScreenshot(page, testInfo, "cooking-mode-dark"); + await closeCookingMode(page); + await useAppearance(page, "light"); + await card.getByRole("button", { name: "Cook" }).click(); + await expect(cooking).toContainText("Step 2 of 3"); + await captureScreenshot(page, testInfo, "cooking-mode-light"); + await closeCookingMode(page); + + // Servings, step and timer are stored on the card, so a reload keeps them. + await page.reload({ waitUntil: "domcontentloaded" }); + const reloaded = page.getByTestId("thread-card"); + await expect(reloaded.getByTestId("card-stepper-value")).toHaveText("3", { timeout: 30_000 }); + await expect(reloaded).toContainText(/9:[45]\d/); + await reloaded.getByRole("button", { name: "Cook" }).click(); + await expect(page.getByTestId("cooking-mode")).toContainText("Step 2 of 3"); + await expect( + page.getByTestId("cooking-mode").getByRole("button", { name: "Stop timer" }), + ).toBeVisible(); +}); + +async function closeCookingMode(page: Page) { + await page.keyboard.press("Escape"); + await expect(page.getByTestId("cooking-mode")).toBeHidden(); +} diff --git a/apps/web/scripts/translations-de.json b/apps/web/scripts/translations-de.json index 79250b1..ece52dc 100644 --- a/apps/web/scripts/translations-de.json +++ b/apps/web/scripts/translations-de.json @@ -630,5 +630,11 @@ "Reminder": "Erinnerung", "Removed": "Entfernt", "Undo": "Rückgängig", - "Weekdays, {time}": "Werktags, {time}" + "Weekdays, {time}": "Werktags, {time}", + "Fewer {label}": "Weniger {label}", + "More {label}": "Mehr {label}", + "Show {hidden} more": "{hidden} weitere zeigen", + "Start timer": "Timer starten", + "Step {position} of {total}": "Schritt {position} von {total}", + "Stop timer": "Timer stoppen" } diff --git a/apps/web/src/components/CookingMode.tsx b/apps/web/src/components/CookingMode.tsx new file mode 100644 index 0000000..f17d668 --- /dev/null +++ b/apps/web/src/components/CookingMode.tsx @@ -0,0 +1,137 @@ +import { useLingui } from "@lingui/react/macro"; +import type { CardAct, CardSection } from "@rakazo/contracts"; +import { cardTimerRemaining, formatCardClock } from "@rakazo/core"; +import { Button, Dialog, DialogContent, DialogTitle } from "@rakazo/ui-web"; +import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; +import { useEffect } from "react"; + +type Steps = Extract; + +type WakeLockSentinelLike = { release: () => Promise }; +type WakeLockNavigator = Navigator & { + wakeLock?: { request: (type: "screen") => Promise }; +}; + +/** Keep the screen on while mounted; the browser drops the lock when the tab hides. */ +function useScreenWakeLock() { + useEffect(() => { + const wakeLock = (navigator as WakeLockNavigator).wakeLock; + if (!wakeLock) return; + let lock: WakeLockSentinelLike | null = null; + let released = false; + const acquire = () => { + if (document.visibilityState !== "visible") return; + wakeLock + .request("screen") + .then((next) => { + if (released) void next.release(); + else lock = next; + }) + .catch(() => undefined); + }; + acquire(); + document.addEventListener("visibilitychange", acquire); + return () => { + released = true; + document.removeEventListener("visibilitychange", acquire); + void lock?.release().catch(() => undefined); + }; + }, []); +} + +/** + * A card's steps one at a time in full screen, large enough to read from the stove. The step and + * any timer live on the card, so another device or a reload picks up where the cook is. + */ +export function CookingMode({ + title, + steps, + now, + busy, + onAct, + onClose, +}: { + title: string; + steps: Steps; + now: number; + busy: boolean; + onAct: (act: CardAct) => Promise; + onClose: () => void; +}) { + const { t } = useLingui(); + useScreenWakeLock(); + const index = Math.min(steps.current ?? 0, steps.steps.length - 1); + const step = steps.steps[index]; + const last = index === steps.steps.length - 1; + const position = index + 1; + const total = steps.steps.length; + const remaining = cardTimerRemaining(step?.timerEndsAt, now); + const running = remaining !== null && remaining > 0; + const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next }); + + return ( + !open && onClose()}> + + {/* A plain line, not the display face the app gives headings. */} + } + className="pe-10 text-ui-body font-medium text-muted-foreground" + > + {title} + +
+
+ {t`Step ${position} of ${total}`} +
+

+ {step?.text} +

+ {step?.timerSeconds ? ( + + ) : null} +
+
+ + {last ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/ThreadCard.tsx b/apps/web/src/components/ThreadCard.tsx index bf75d4b..6e2efe8 100644 --- a/apps/web/src/components/ThreadCard.tsx +++ b/apps/web/src/components/ThreadCard.tsx @@ -1,6 +1,19 @@ +import { useLingui } from "@lingui/react/macro"; import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts"; +import { CARD_LIMITS } from "@rakazo/contracts"; +import { + applyCardAct, + cardScale, + cardTimerRemaining, + checklistItemDisplay, + formatCardClock, + formatCardTimerLength, +} from "@rakazo/core"; import { Button, Checkbox, cn } from "@rakazo/ui-web"; +import { Minus, Plus, Timer } from "lucide-react"; import { useId, useState } from "react"; +import { CookingMode } from "./CookingMode"; +import { useNow } from "./card-timer"; type Section = Extract; @@ -9,7 +22,7 @@ type Section = Extract; * card re-renders from the updated message, so only the tap in flight is held locally. */ export function ThreadCard({ - block, + block: stored, onAct, className, }: { @@ -17,10 +30,21 @@ export function ThreadCard({ onAct?: (act: CardAct) => Promise; className?: string; }) { + const { t, i18n } = useLingui(); const idPrefix = useId(); const [pending, setPending] = useState(null); const [error, setError] = useState(null); + const [expanded, setExpanded] = useState([]); + const [cooking, setCooking] = useState(false); + // Show the tap in flight on top of the stored card, so the card answers at once. + const preview = pending ? applyCardAct(stored, pending) : null; + const block = preview?.ok ? preview.block : stored; const answered = Boolean(block.answer); + const busy = !onAct || pending !== null; + const factor = cardScale(block); + const header = block.sections?.find((part): part is Section<"header"> => part.type === "header"); + const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps"); + const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt))); async function act(next: CardAct) { if (!onAct || pending) return; @@ -35,19 +59,29 @@ export function ThreadCard({ } } - const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) => - pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId - ? pending.checked - : stored; + function visible(id: string, entries: T[]): { shown: T[]; hidden: number } { + if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) { + return { shown: entries, hidden: 0 }; + } + return { + shown: entries.slice(0, CARD_LIMITS.collapsedEntries), + hidden: entries.length - CARD_LIMITS.collapsedEntries, + }; + } - const selectedNow = (select: Section<"select">, optionId: string) => { - const stored = select.selected?.includes(optionId) ?? false; - return pending?.type === "select" && - pending.sectionId === select.id && - pending.optionId === optionId - ? (pending.selected ?? true) - : stored; - }; + function showMore(id: string, hidden: number) { + if (!hidden) return null; + return ( + + ); + } const select = block.sections?.find((part): part is Section<"select"> => part.type === "select"); const nothingChosen = Boolean(select?.multiple && !select.selected?.length); @@ -87,43 +121,120 @@ export function ThreadCard({ ); } - if (part.type === "checklist") { + if (part.type === "stepper") { + const label = part.label; return ( -
    - {part.items.map((item) => { - const checked = checkedNow(part, item.id, Boolean(item.checked)); - return ( -
  • -
+ + {step.text} + {step.timerSeconds ? ( + + + {remaining !== null + ? formatCardClock(remaining) + : formatCardTimerLength(step.timerSeconds, i18n.locale)} + + ) : null} + + + ); + })} + + {showMore(part.id, hidden)} + ); } if (part.type === "select") { @@ -133,7 +244,7 @@ export function ThreadCard({ className={cn("flex gap-1.5", part.multiple ? "flex-wrap" : "flex-col")} > {part.options.map((option) => { - const chosen = selectedNow(part, option.id); + const chosen = part.selected?.includes(option.id) ?? false; return ( - ))} + {visibleActions.map((action, position) => + action.kind === "steps" ? ( + + ) : ( + + ), + )} ); })} {error ?

{error}

: null} + {cooking && steps ? ( + setCooking(false)} + /> + ) : null} ); } diff --git a/apps/web/src/components/card-timer.ts b/apps/web/src/components/card-timer.ts new file mode 100644 index 0000000..5d72b37 --- /dev/null +++ b/apps/web/src/components/card-timer.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +/** The current time, ticking every second while `active`. */ +export function useNow(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + setNow(Date.now()); + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [active]); + return now; +} diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index 80ae231..e1285db 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "Avatar-Studio" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "Kein Stress — wir sind jetzt da." #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "Fehlerbehandlung" msgid "Fast" msgstr "Schnell" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "Weniger {label}" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "Modelle suchen" @@ -3071,6 +3077,10 @@ msgstr "Monatlich am {day}., {time}" msgid "More" msgstr "Mehr" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "Mehr {label}" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "Weitere Computeraktionen" @@ -3189,6 +3199,10 @@ msgstr "Neuer Skill" msgid "New space" msgstr "Neuer Space" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "Weiter" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "Nächstes Mal {date}" @@ -4289,6 +4303,10 @@ msgstr "Geteilte Dokumente" msgid "Sharing the draft…" msgstr "Teile den Entwurf…" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "{hidden} weitere zeigen" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "Starte die Unterhaltung — {activeName} steht oben auf der Bühne." msgid "Start the conversation." msgstr "Starte die Unterhaltung." +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "Timer starten" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "Sprachunterhaltung starten" @@ -4498,6 +4520,10 @@ msgstr "Startet…" msgid "Staying in my specialty…" msgstr "Bleibe in meinem Fachgebiet…" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "Schritt {position} von {total}" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "Schritte" @@ -4517,6 +4543,10 @@ msgstr "Vorlesen stoppen" msgid "Stop teaching" msgstr "Anlernen beenden" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "Timer stoppen" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index a1f4f1c..db9f38f 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "Avatar Studio" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "Don't stress — we're here now." #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "Failure handling" msgid "Fast" msgstr "Fast" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "Fewer {label}" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "Find models" @@ -3071,6 +3077,10 @@ msgstr "Monthly on day {day}, {time}" msgid "More" msgstr "More" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "More {label}" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "More computer actions" @@ -3189,6 +3199,10 @@ msgstr "New skill" msgid "New space" msgstr "New space" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "Next" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "Next {date}" @@ -4289,6 +4303,10 @@ msgstr "Shared documents" msgid "Sharing the draft…" msgstr "Sharing the draft…" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "Show {hidden} more" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "Start the conversation — {activeName} is on stage above." msgid "Start the conversation." msgstr "Start the conversation." +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "Start timer" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "Start voice conversation" @@ -4498,6 +4520,10 @@ msgstr "Starting…" msgid "Staying in my specialty…" msgstr "Staying in my specialty…" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "Step {position} of {total}" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "Steps" @@ -4517,6 +4543,10 @@ msgstr "Stop speaking" msgid "Stop teaching" msgstr "Stop teaching" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "Stop timer" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 8adf09d..ff38f65 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "Estudio de avatares" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "Manejo de fallos" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "Buscar modelos" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "Más acciones de computadora" @@ -3189,6 +3199,10 @@ msgstr "Nueva habilidad" msgid "New space" msgstr "Nuevo space" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "Documentos compartidos" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "Pasos" @@ -4517,6 +4543,10 @@ msgstr "Dejar de hablar" msgid "Stop teaching" msgstr "Detener enseñanza" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 0b578b6..b259b66 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "अवतार स्टूडियो" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "विफलता प्रबंधन" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "मॉडल खोजें" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "" @@ -3189,6 +3199,10 @@ msgstr "नई स्किल" msgid "New space" msgstr "" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "साझा दस्तावेज़" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "चरण" @@ -4517,6 +4543,10 @@ msgstr "बोलना रोकें" msgid "Stop teaching" msgstr "सिखाना रोकें" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 5ba9c66..36f9075 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "아바타 스튜디오" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "실패했을 때 처리" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "모델 찾기" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "컴퓨터 작업 더보기" @@ -3189,6 +3199,10 @@ msgstr "새 스킬" msgid "New space" msgstr "새 스페이스" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "공유 문서" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "실행 단계" @@ -4517,6 +4543,10 @@ msgstr "읽기 중지" msgid "Stop teaching" msgstr "가르치기 종료" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index b305d71..2b98efb 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "Estúdio de avatar" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "Tratamento de falhas" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "Encontrar modelos" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "" @@ -3189,6 +3199,10 @@ msgstr "Nova habilidade" msgid "New space" msgstr "" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "Documentos compartilhados" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "Passos" @@ -4517,6 +4543,10 @@ msgstr "Pare de Falar" msgid "Stop teaching" msgstr "Parar de ensinar" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index f42fe89..362a098 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "Студия аватаров" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "Обработка сбоев" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "Найти модели" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "Ещё" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "Больше действий с компьютером" @@ -3189,6 +3199,10 @@ msgstr "Новый навык" msgid "New space" msgstr "Новое пространство" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "Общие документы" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "Шаги" @@ -4517,6 +4543,10 @@ msgstr "Остановить озвучивание" msgid "Stop teaching" msgstr "Остановить обучение" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index 45a906b..b7c354c 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "Avatar Stüdyosu" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "Hata yönetimi" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "Model bul" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "" @@ -3189,6 +3199,10 @@ msgstr "Yeni beceri" msgid "New space" msgstr "" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "Paylaşılan belgeler" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "Adımlar" @@ -4517,6 +4543,10 @@ msgstr "Seslendirmeyi durdur" msgid "Stop teaching" msgstr "Öğretmeyi durdur" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 7251cb0..2eebc03 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -757,6 +757,7 @@ msgid "Avatar Studio" msgstr "头像工作室" #: src/components/browser/InAppBrowser.tsx +#: src/components/CookingMode.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx #: src/pages/Onboarding.tsx #: src/pages/PluginsOverlay.tsx @@ -2059,6 +2060,7 @@ msgid "Don't stress — we're here now." msgstr "" #: src/components/ai/DelegatedTaskCard.tsx +#: src/components/CookingMode.tsx #: src/components/media/MediaCard.tsx #: src/pages/HeuteBrief.tsx #: src/pages/ProjectSessionTimeline.tsx @@ -2312,6 +2314,10 @@ msgstr "失败处理" msgid "Fast" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Fewer {label}" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx msgid "Find models" msgstr "查找模型" @@ -3071,6 +3077,10 @@ msgstr "" msgid "More" msgstr "" +#: src/components/ThreadCard.tsx +msgid "More {label}" +msgstr "" + #: src/components/ComputerMaintenanceActions.tsx msgid "More computer actions" msgstr "" @@ -3189,6 +3199,10 @@ msgstr "新建技能" msgid "New space" msgstr "新建工作区" +#: src/components/CookingMode.tsx +msgid "Next" +msgstr "" + #: src/lib/routine-line.ts msgid "Next {date}" msgstr "" @@ -4289,6 +4303,10 @@ msgstr "共享文档" msgid "Sharing the draft…" msgstr "" +#: src/components/ThreadCard.tsx +msgid "Show {hidden} more" +msgstr "" + #. placeholder {0}: group.bots.length #: src/pages/Shell.tsx msgid "Show all {0}" @@ -4476,6 +4494,10 @@ msgstr "" msgid "Start the conversation." msgstr "" +#: src/components/CookingMode.tsx +msgid "Start timer" +msgstr "" + #: src/pages/shell/composer.tsx msgid "Start voice conversation" msgstr "" @@ -4498,6 +4520,10 @@ msgstr "" msgid "Staying in my specialty…" msgstr "" +#: src/components/CookingMode.tsx +msgid "Step {position} of {total}" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Steps" msgstr "步骤" @@ -4517,6 +4543,10 @@ msgstr "停止朗读" msgid "Stop teaching" msgstr "停止示范" +#: src/components/CookingMode.tsx +msgid "Stop timer" +msgstr "" + #. js-lingui-explicit-id #: src/lib/browser-notifications.ts msgid "Stopped." diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 6d1c9e8..302e281 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -247,7 +247,7 @@ export const builtinAgentTools: ConnectorTool[] = [ { name: "show_card", description: - "Show the user a card instead of a list in prose: a title, short facts, a checklist they can tick off, options they tap, or up to three buttons. Use it for everyday things worth keeping in view, such as a shopping list, a plan for the day or a choice between options. A tap on an option or button arrives as the user's next message. Keep text short; the card is the answer, so do not repeat it in your reply.", + 'Show the user a card instead of a list in prose: a title, short facts, a servings stepper, a checklist they tick off, numbered steps with timers, options they tap, or up to three buttons. Use it for everyday things worth keeping in view. Templates: a recipe is title + meta ["30 min"] + stepper {label "Servings", value} + checklist of ingredients with amount and unit + steps with timer_minutes + start_label "Cook" + actions ["Add to shopping list", "Every week"]; a shopping list is a checklist; a day plan is steps starting with the time; a decision is facts plus options. A tap on an option or button arrives as the user\'s next message. Keep text short; the card is the answer, so do not repeat it in your reply. At most five parts including the title.', inputSchema: { type: "object", properties: { @@ -271,11 +271,60 @@ export const builtinAgentTools: ConnectorTool[] = [ }, maxItems: 8, }, + stepper: { + type: "object", + description: + "A number the user turns up or down, such as servings. Checklist amounts are written for this value and scale with it.", + properties: { + label: { type: "string", maxLength: 80 }, + value: { type: "integer", minimum: 1, maximum: 99 }, + }, + required: ["label", "value"], + }, checklist: { type: "array", - items: { type: "string", minLength: 1, maxLength: 80 }, + description: + 'Entries to tick off. An ingredient is { label: "Flour", amount: 200, unit: "g" }; a counted one is { label: "Egg", plural: "Eggs", amount: 1 }.', + items: { + anyOf: [ + { type: "string", minLength: 1, maxLength: 80 }, + { + type: "object", + properties: { + label: { type: "string", minLength: 1, maxLength: 80 }, + amount: { type: "number", exclusiveMinimum: 0 }, + unit: { type: "string", maxLength: 20 }, + plural: { type: "string", maxLength: 80 }, + }, + required: ["label"], + }, + ], + }, maxItems: 30, }, + steps: { + type: "array", + items: { + anyOf: [ + { type: "string", minLength: 1, maxLength: 300 }, + { + type: "object", + properties: { + text: { type: "string", minLength: 1, maxLength: 300 }, + timer_minutes: { type: "number", exclusiveMinimum: 0, maximum: 1440 }, + }, + required: ["text"], + }, + ], + }, + maxItems: 20, + }, + start_label: { + type: "string", + maxLength: 80, + description: + 'A button that walks through the steps one at a time in full screen, such as "Cook".', + }, options: { type: "array", items: { type: "string", minLength: 1, maxLength: 80 }, diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 3c33339..60a8580 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -295,6 +295,36 @@ export function inferScript( }, ]; } + if (lower.includes("pancake recipe")) { + return [ + { + assistant: "", + toolCalls: [ + { + name: "show_card", + args: { + title: "Pancakes", + meta: ["25 min"], + stepper: { label: "Servings", value: 2 }, + checklist: [ + { label: "Flour", amount: 200, unit: "g" }, + { label: "Egg", plural: "Eggs", amount: 1 }, + { label: "Milk", amount: 300, unit: "ml" }, + ], + steps: [ + "Whisk flour, egg and milk until smooth.", + { text: "Let the batter rest.", timer_minutes: 10 }, + "Fry thin pancakes in a hot pan.", + ], + start_label: "Cook", + actions: ["Every week"], + }, + }, + ], + complete: true, + }, + ]; + } if (lower.includes("delegate to the bot named")) { const name = namedBot(prompt) ?? "Researcher"; const brief = diff --git a/packages/contracts/src/cards.ts b/packages/contracts/src/cards.ts index 2b12537..b578b11 100644 --- a/packages/contracts/src/cards.ts +++ b/packages/contracts/src/cards.ts @@ -6,8 +6,15 @@ import * as z from "zod"; */ export const CARD_LIMITS = { + sections: 5, facts: 8, checklistItems: 30, + steps: 20, + stepText: 300, + /** Longer lists show this many entries until the user opens the rest. */ + collapsedEntries: 8, + stepperMax: 99, + timerSeconds: 24 * 60 * 60, selectOptions: 6, actions: 3, label: 80, @@ -34,15 +41,63 @@ export const CardFactsSchema = z.object({ .max(CARD_LIMITS.facts), }); +/** "200 g" of something, scaled by the card's stepper; `plural` names more than one ("Eggs"). */ +export const CardQuantitySchema = z.object({ + amount: z.number().positive().max(100_000), + unit: z.string().trim().max(20).optional(), + plural: Label.optional(), +}); +export type CardQuantity = z.infer; + export const CardChecklistSchema = z.object({ type: z.literal("checklist"), id: SectionId, items: z - .array(z.object({ id: SectionId, label: Label, checked: z.boolean().optional() })) + .array( + z.object({ + id: SectionId, + label: Label, + quantity: CardQuantitySchema.optional(), + checked: z.boolean().optional(), + }), + ) .min(1) .max(CARD_LIMITS.checklistItems), }); +/** A number the user turns up or down, such as servings; quantities scale from `base`. */ +export const CardStepperSchema = z.object({ + type: z.literal("stepper"), + id: SectionId, + label: Label, + base: z.number().int().min(1).max(CARD_LIMITS.stepperMax), + value: z.number().int().min(1).max(CARD_LIMITS.stepperMax), +}); + +/** Numbered steps, optionally timed; the card remembers the current step and running timers. */ +export const CardStepsSchema = z.object({ + type: z.literal("steps"), + id: SectionId, + steps: z + .array( + z.object({ + id: SectionId, + text: z.string().trim().min(1).max(CARD_LIMITS.stepText), + timerSeconds: z.number().int().min(1).max(CARD_LIMITS.timerSeconds).optional(), + /** When a started timer runs out; stored so it survives a reload or another device. */ + timerEndsAt: z.string().datetime().optional(), + }), + ) + .min(1) + .max(CARD_LIMITS.steps), + current: z + .number() + .int() + .min(0) + .max(CARD_LIMITS.steps - 1) + .optional(), +}); + export const CardSelectSchema = z.object({ type: z.literal("select"), id: SectionId, @@ -55,11 +110,14 @@ export const CardSelectSchema = z.object({ selected: z.array(SectionId).optional(), }); -/** "reply" sends the label, with any selection, to the bot as the user's answer. */ +/** + * "reply" sends the label, with any selection, to the bot as the user's answer. "steps" opens the + * card's steps one at a time in full screen (cooking mode) and never reaches the bot. + */ export const CardActionSchema = z.object({ id: SectionId, label: Label, - kind: z.literal("reply"), + kind: z.enum(["reply", "steps"]), }); export const CardActionsSchema = z.object({ @@ -70,7 +128,9 @@ export const CardActionsSchema = z.object({ export const CardSectionSchema = z.discriminatedUnion("type", [ CardHeaderSchema, CardFactsSchema, + CardStepperSchema, CardChecklistSchema, + CardStepsSchema, CardSelectSchema, CardActionsSchema, ]); @@ -87,7 +147,7 @@ export const CardBlockSchema = z.object({ kind: z.literal("card"), id: SectionId.optional(), lines: z.array(z.object({ k: z.string(), v: z.string() })).optional(), - sections: z.array(CardSectionSchema).max(5).optional(), + sections: z.array(CardSectionSchema).max(CARD_LIMITS.sections).optional(), answer: CardAnswerSchema.optional(), }); export type CardBlock = z.infer; @@ -108,5 +168,25 @@ export const CardActSchema = z.discriminatedUnion("type", [ selected: z.boolean().optional(), }), z.object({ type: z.literal("action"), actionId: SectionId }), + z.object({ + type: z.literal("stepper"), + sectionId: SectionId, + value: z.number().int().min(1).max(CARD_LIMITS.stepperMax), + }), + z.object({ + type: z.literal("step"), + sectionId: SectionId, + index: z + .number() + .int() + .min(0) + .max(CARD_LIMITS.steps - 1), + }), + z.object({ + type: z.literal("timer"), + sectionId: SectionId, + stepId: SectionId, + running: z.boolean(), + }), ]); export type CardAct = z.infer; diff --git a/packages/core/src/cards.test.ts b/packages/core/src/cards.test.ts index 6271cb6..020ad0b 100644 --- a/packages/core/src/cards.test.ts +++ b/packages/core/src/cards.test.ts @@ -1,6 +1,13 @@ import type { CardBlock } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; -import { applyCardAct, buildCardFromTool, cardHistoryText } from "./cards.js"; +import { + applyCardAct, + buildCardFromTool, + cardHistoryText, + cardScale, + checklistItemDisplay, + formatCardAmount, +} from "./cards.js"; function card(args: Parameters[0]): CardBlock { const built = buildCardFromTool(args, "c1"); @@ -104,3 +111,128 @@ describe("applyCardAct", () => { expect(cardHistoryText({ kind: "card", lines: [{ k: "Mail", v: "sent" }] })).toBe("Mail: sent"); }); }); + +describe("recipe cards", () => { + const recipe = () => + card({ + title: "Pancakes", + stepper: { label: "Servings", value: 2 }, + checklist: [ + { label: "Flour", amount: 200, unit: "g" }, + { label: "Egg", plural: "Eggs", amount: 1 }, + "Salt", + ], + steps: ["Mix everything.", { text: "Rest the batter.", timer_minutes: 10 }], + start_label: "Cook", + actions: ["Every week"], + }); + + it("lays a recipe out as header, servings, ingredients, steps and actions", () => { + const block = recipe(); + expect(block.sections?.map((part) => part.type)).toEqual([ + "header", + "stepper", + "checklist", + "steps", + "actions", + ]); + expect(block.sections?.[3]).toMatchObject({ steps: [{ id: "s1" }, { timerSeconds: 600 }] }); + expect(block.sections?.[4]).toMatchObject({ + actions: [ + { id: "start", kind: "steps" }, + { id: "a1", kind: "reply" }, + ], + }); + }); + + it("scales 200 g to 300 g and one egg to 1½ eggs for three servings", () => { + const scaled = applyCardAct(recipe(), { type: "stepper", sectionId: "stepper", value: 3 }); + if (!scaled.ok) throw new Error(scaled.error); + const factor = cardScale(scaled.block); + const list = scaled.block.sections?.[2]; + if (list?.type !== "checklist") throw new Error("no checklist"); + expect(list.items.map((item) => checklistItemDisplay(item, factor))).toEqual([ + { amount: "300 g", label: "Flour" }, + { amount: "1½", label: "Eggs" }, + { label: "Salt" }, + ]); + expect(cardHistoryText(scaled.block)).toContain("Servings: 3"); + expect(cardHistoryText(scaled.block)).toContain("open: 300 g Flour, 1½ Eggs, Salt"); + }); + + it("writes amounts the way a cook would", () => { + expect(formatCardAmount(1)).toBe("1"); + expect(formatCardAmount(0.5)).toBe("½"); + expect(formatCardAmount(2.34)).toBe("2⅓"); + expect(formatCardAmount(2.9)).toBe("3"); + expect(formatCardAmount(12.6)).toBe("13"); + expect(formatCardAmount(299.99)).toBe("300"); + expect(formatCardAmount(0.05)).toBe("0.1"); + expect( + checklistItemDisplay({ label: "Egg", quantity: { amount: 1, plural: "Eggs" } }, 1), + ).toEqual({ + amount: "1", + label: "Egg", + }); + }); + + it("remembers the step and a timer's end, idempotently", () => { + const now = new Date("2026-10-04T12:00:00Z"); + const moved = applyCardAct(recipe(), { type: "step", sectionId: "steps", index: 1 }); + if (!moved.ok) throw new Error(moved.error); + const started = applyCardAct( + moved.block, + { type: "timer", sectionId: "steps", stepId: "s2", running: true }, + now, + ); + if (!started.ok) throw new Error(started.error); + expect(started.block.sections?.[3]).toMatchObject({ + current: 1, + steps: [{}, { timerEndsAt: "2026-10-04T12:10:00.000Z" }], + }); + const again = applyCardAct( + started.block, + { type: "timer", sectionId: "steps", stepId: "s2", running: true }, + new Date("2026-10-04T12:05:00Z"), + ); + expect(again).toMatchObject({ ok: true, changed: false }); + const stopped = applyCardAct( + started.block, + { + type: "timer", + sectionId: "steps", + stepId: "s2", + running: false, + }, + new Date("2026-10-04T12:05:00Z"), + ); + const stoppedSteps = stopped.ok ? stopped.block.sections?.[3] : undefined; + expect(stoppedSteps?.type === "steps" && stoppedSteps.steps[1]?.timerEndsAt).toBeFalsy(); + expect(stopped).toMatchObject({ ok: true, changed: true }); + expect( + applyCardAct(moved.block, { type: "timer", sectionId: "steps", stepId: "s1", running: true }), + ).toMatchObject({ ok: false }); + expect(applyCardAct(recipe(), { type: "action", actionId: "start" })).toMatchObject({ + ok: false, + }); + }); + + it("refuses more than five parts and a start without steps", () => { + expect( + buildCardFromTool( + { + title: "Too much", + facts: [{ label: "a", value: "b" }], + stepper: { label: "Servings", value: 2 }, + checklist: ["x"], + steps: ["y"], + actions: ["z"], + }, + "c1", + ), + ).toMatchObject({ error: expect.stringContaining("at most 5") }); + expect( + buildCardFromTool({ title: "No steps", checklist: ["x"], start_label: "Cook" }, "c1"), + ).toHaveProperty("error"); + }); +}); diff --git a/packages/core/src/cards.ts b/packages/core/src/cards.ts index 0271e6c..7eb3b42 100644 --- a/packages/core/src/cards.ts +++ b/packages/core/src/cards.ts @@ -1,5 +1,9 @@ -import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts"; -import { CardBlockSchema } from "@rakazo/contracts"; +import type { CardAct, CardBlock, CardQuantity, CardSection } from "@rakazo/contracts"; +import { CARD_LIMITS, CardBlockSchema } from "@rakazo/contracts"; + +type CardChecklistItem = Extract["items"][number]; +type CardStep = Extract["steps"][number]; +type CardAction = Extract["actions"][number]; /** What `show_card` accepts: plain content in a fixed order, never layout. */ export interface ShowCardArgs { @@ -7,7 +11,10 @@ export interface ShowCardArgs { subtitle?: unknown; meta?: unknown; facts?: unknown; + stepper?: unknown; checklist?: unknown; + steps?: unknown; + start_label?: unknown; options?: unknown; multiple?: unknown; actions?: unknown; @@ -18,6 +25,48 @@ const strings = (value: unknown): string[] => const text = (value: unknown) => (typeof value === "string" ? value.trim() : ""); +const field = (value: unknown, key: string): unknown => + value && typeof value === "object" ? (value as Record)[key] : undefined; + +const positive = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; + +function checklistItems(value: unknown): CardChecklistItem[] { + if (!Array.isArray(value)) return []; + const items: CardChecklistItem[] = []; + for (const raw of value) { + const label = typeof raw === "string" ? raw.trim() : text(field(raw, "label")); + if (!label) continue; + const amount = positive(field(raw, "amount")); + const unit = text(field(raw, "unit")); + const plural = text(field(raw, "plural")); + items.push({ + id: `i${items.length + 1}`, + label, + ...(amount + ? { quantity: { amount, ...(unit ? { unit } : {}), ...(plural ? { plural } : {}) } } + : {}), + }); + } + return items; +} + +function stepItems(value: unknown): CardStep[] { + if (!Array.isArray(value)) return []; + const steps: CardStep[] = []; + for (const raw of value) { + const stepText = typeof raw === "string" ? raw.trim() : text(field(raw, "text")); + if (!stepText) continue; + const minutes = positive(field(raw, "timer_minutes")); + steps.push({ + id: `s${steps.length + 1}`, + text: stepText, + ...(minutes ? { timerSeconds: Math.round(minutes * 60) } : {}), + }); + } + return steps; +} + /** Build a validated card from tool arguments; the error is phrased for the model. */ export function buildCardFromTool( args: ShowCardArgs, @@ -27,25 +76,31 @@ export function buildCardFromTool( if (!title) return { error: "title is required." }; const facts = Array.isArray(args.facts) ? args.facts - .map((row) => ({ - label: text((row as { label?: unknown })?.label), - value: text((row as { value?: unknown })?.value), - })) + .map((row) => ({ label: text(field(row, "label")), value: text(field(row, "value")) })) .filter((row) => row.label && row.value) : []; - const checklist = strings(args.checklist); + const stepperLabel = text(field(args.stepper, "label")); + const stepperValue = positive(field(args.stepper, "value")); + const checklist = checklistItems(args.checklist); + const steps = stepItems(args.steps); + const startLabel = text(args.start_label); const options = strings(args.options); const actions = strings(args.actions); const multiple = args.multiple === true; - if (!facts.length && !checklist.length && !options.length && !actions.length) { + if (!facts.length && !checklist.length && !steps.length && !options.length && !actions.length) { return { - error: "Add facts, checklist, options or actions; a card with only a title says nothing.", + error: + "Add facts, checklist, steps, options or actions; a card with only a title says nothing.", }; } if (options.length === 1) return { error: "options needs at least two choices." }; if (multiple && options.length && !actions.length) { return { error: 'A multiple choice needs an action to send it, such as "Send".' }; } + if (args.stepper !== undefined && (!stepperLabel || !stepperValue)) { + return { error: 'stepper needs a label and a whole number value, such as "Servings", 2.' }; + } + if (startLabel && !steps.length) return { error: "start_label needs steps to walk through." }; const subtitle = text(args.subtitle); const meta = strings(args.meta); const sections: CardSection[] = [ @@ -57,13 +112,12 @@ export function buildCardFromTool( }, ]; if (facts.length) sections.push({ type: "facts", rows: facts }); - if (checklist.length) { - sections.push({ - type: "checklist", - id: "checklist", - items: checklist.map((label, index) => ({ id: `i${index + 1}`, label })), - }); + if (stepperLabel && stepperValue) { + const value = Math.round(stepperValue); + sections.push({ type: "stepper", id: "stepper", label: stepperLabel, base: value, value }); } + if (checklist.length) sections.push({ type: "checklist", id: "checklist", items: checklist }); + if (steps.length) sections.push({ type: "steps", id: "steps", steps }); if (options.length) { sections.push({ type: "select", @@ -72,11 +126,15 @@ export function buildCardFromTool( options: options.map((label, index) => ({ id: `o${index + 1}`, label })), }); } - if (actions.length) { - sections.push({ - type: "actions", - actions: actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" })), - }); + const allActions: CardAction[] = [ + ...(startLabel ? [{ id: "start", label: startLabel, kind: "steps" as const }] : []), + ...actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" as const })), + ]; + if (allActions.length) sections.push({ type: "actions", actions: allActions }); + if (sections.length > CARD_LIMITS.sections) { + return { + error: `A card holds at most ${CARD_LIMITS.sections} parts including the title; leave out facts, options or actions.`, + }; } const parsed = CardBlockSchema.safeParse({ kind: "card", id, sections }); if (!parsed.success) { @@ -107,18 +165,69 @@ function replaceSection(block: CardBlock, next: CardSection, index: number): Car return { ...block, sections }; } +function sectionIndex(block: CardBlock, type: CardSection["type"], id: string) { + return (block.sections ?? []).findIndex( + (candidate) => candidate.type === type && "id" in candidate && candidate.id === id, + ); +} + /** - * Apply one tap. Checklists stay usable after the card is answered (a shopping list is still a - * shopping list); a choice or an action answers the card once. + * Apply one tap. Checklists, servings, steps and timers stay usable after the card is answered + * (a shopping list is still a shopping list); a choice or an action answers the card once. + * Every act names the state it wants, so a retried tap changes nothing. */ -export function applyCardAct(block: CardBlock, act: CardAct): CardActResult { +export function applyCardAct( + block: CardBlock, + act: CardAct, + now: Date = new Date(), +): CardActResult { const sections = block.sections ?? []; + if (act.type === "stepper") { + const index = sectionIndex(block, "stepper", act.sectionId); + const stepper = sections[index]; + if (stepper?.type !== "stepper") return { ok: false, error: "No such stepper." }; + if (stepper.value === act.value) return { ok: true, changed: false, block }; + return { + ok: true, + changed: true, + block: replaceSection(block, { ...stepper, value: act.value }, index), + }; + } + if (act.type === "step" || act.type === "timer") { + const index = sectionIndex(block, "steps", act.sectionId); + const list = sections[index]; + if (list?.type !== "steps") return { ok: false, error: "No such steps." }; + if (act.type === "step") { + if (act.index >= list.steps.length) return { ok: false, error: "No such step." }; + if ((list.current ?? 0) === act.index) return { ok: true, changed: false, block }; + return { + ok: true, + changed: true, + block: replaceSection(block, { ...list, current: act.index }, index), + }; + } + const step = list.steps.find((candidate) => candidate.id === act.stepId); + if (!step?.timerSeconds) return { ok: false, error: "This step has no timer." }; + const running = Boolean(step.timerEndsAt && Date.parse(step.timerEndsAt) > now.getTime()); + if (running === act.running) return { ok: true, changed: false, block }; + const steps = list.steps.map((candidate) => { + if (candidate.id !== step.id) return candidate; + const { timerEndsAt: _ended, ...rest } = candidate; + return act.running + ? { + ...rest, + timerEndsAt: new Date(now.getTime() + (step.timerSeconds ?? 0) * 1000).toISOString(), + } + : rest; + }); + return { ok: true, changed: true, block: replaceSection(block, { ...list, steps }, index) }; + } if (act.type === "toggle") { const index = sections.findIndex( (candidate) => candidate.type === "checklist" && candidate.id === act.sectionId, ); const list = sections[index]; - if (!list || list.type !== "checklist") return { ok: false, error: "No such checklist." }; + if (list?.type !== "checklist") return { ok: false, error: "No such checklist." }; if (!list.items.some((item) => item.id === act.itemId)) { return { ok: false, error: "No such item." }; } @@ -135,7 +244,7 @@ export function applyCardAct(block: CardBlock, act: CardAct): CardActResult { (candidate) => candidate.type === "select" && candidate.id === act.sectionId, ); const select = sections[index]; - if (!select || select.type !== "select") return { ok: false, error: "No such choice." }; + if (select?.type !== "select") return { ok: false, error: "No such choice." }; const option = select.options.find((candidate) => candidate.id === act.optionId); if (!option) return { ok: false, error: "No such option." }; if (select.multiple) { @@ -163,6 +272,7 @@ export function applyCardAct(block: CardBlock, act: CardAct): CardActResult { const actions = section(block, "actions"); const action = actions?.actions.find((candidate) => candidate.id === act.actionId); if (!action) return { ok: false, error: "No such action." }; + if (action.kind !== "reply") return { ok: false, error: "This action opens on the device." }; const select = section(block, "select"); const chosen = select?.options .filter((option) => select.selected?.includes(option.id)) @@ -188,9 +298,16 @@ export function cardHistoryText(block: CardBlock): string { parts.push([part.title, part.subtitle, ...(part.meta ?? [])].filter(Boolean).join(" · ")); } else if (part.type === "facts") { parts.push(part.rows.map((row) => `${row.label}: ${row.value}`).join("; ")); + } else if (part.type === "stepper") { + parts.push(`${part.label}: ${part.value}`); + } else if (part.type === "steps") { + parts.push(`step ${(part.current ?? 0) + 1}/${part.steps.length}`); } else if (part.type === "checklist") { + const factor = cardScale(block); const done = part.items.filter((item) => item.checked).length; - const open = part.items.filter((item) => !item.checked).map((item) => item.label); + const open = part.items + .filter((item) => !item.checked) + .map((item) => checklistItemText(item, factor)); parts.push( `checklist ${done}/${part.items.length} done${open.length ? `, open: ${open.join(", ")}` : ""}`, ); @@ -208,3 +325,83 @@ export function cardHistoryText(block: CardBlock): string { if (block.answer) parts.push(`answered: ${block.answer.text}`); return `[card: ${parts.join(" — ")}]`; } + +/** How much the card's quantities scale: the stepper's value over the amount they were written for. */ +export function cardScale(block: CardBlock): number { + const stepper = block.sections?.find((part) => part.type === "stepper"); + return stepper?.type === "stepper" ? stepper.value / stepper.base : 1; +} + +const FRACTIONS: Array<[number, string]> = [ + [0, ""], + [1 / 4, "¼"], + [1 / 3, "⅓"], + [1 / 2, "½"], + [2 / 3, "⅔"], + [3 / 4, "¾"], + [1, ""], +]; + +/** + * A scaled amount as a cook would write it: kitchen fractions below ten ("1½"), whole numbers + * above, and steps of five from a hundred on ("300 g", never "299.99 g"). + */ +export function formatCardAmount(amount: number): string { + if (amount >= 100) return String(Math.round(amount / 5) * 5); + if (amount >= 10) return String(Math.round(amount)); + let whole = Math.floor(amount); + const rest = amount - whole; + const [value, glyph] = FRACTIONS.reduce((best, candidate) => + Math.abs(candidate[0] - rest) < Math.abs(best[0] - rest) ? candidate : best, + ); + if (value === 1) whole += 1; + if (whole === 0) return glyph || formatSmallAmount(amount); + return `${whole}${glyph}`; +} + +/** Below an eighth, kitchen fractions would round a pinch away; keep one decimal instead. */ +function formatSmallAmount(amount: number): string { + return String(Math.max(0.1, Math.round(amount * 10) / 10)); +} + +/** A checklist entry's amount and name at the card's current scale. */ +export function checklistItemDisplay( + item: { label: string; quantity?: CardQuantity }, + factor: number, +): { amount?: string; label: string } { + if (!item.quantity) return { label: item.label }; + const formatted = formatCardAmount(item.quantity.amount * factor); + const many = !item.quantity.unit && formatted !== "1" && Number.parseFloat(formatted) >= 1; + return { + amount: [formatted, item.quantity.unit].filter(Boolean).join(" "), + label: many && item.quantity.plural ? item.quantity.plural : item.label, + }; +} + +function checklistItemText(item: { label: string; quantity?: CardQuantity }, factor: number) { + const shown = checklistItemDisplay(item, factor); + return shown.amount ? `${shown.amount} ${shown.label}` : shown.label; +} + +/** Seconds left on a started step timer, or null when it is not running. */ +export function cardTimerRemaining(timerEndsAt: string | undefined, now: number): number | null { + if (!timerEndsAt) return null; + return Math.max(0, Math.ceil((Date.parse(timerEndsAt) - now) / 1000)); +} + +/** A timer as a clock: "9:58" or "1:05:00". */ +export function formatCardClock(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = String(seconds % 60).padStart(2, "0"); + return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; +} + +/** A timer's length in the user's language, such as "10 min". */ +export function formatCardTimerLength(seconds: number, locale: string): string { + const unit = (value: number, name: "second" | "minute" | "hour") => + new Intl.NumberFormat(locale, { style: "unit", unit: name }).format(value); + if (seconds < 60) return unit(seconds, "second"); + const minutes = Math.round(seconds / 60); + return minutes % 60 === 0 ? unit(minutes / 60, "hour") : unit(minutes, "minute"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5625ec..416f8d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: expo-image-picker: specifier: 57.0.20 version: 57.0.20(expo@57.0.26) + expo-keep-awake: + specifier: 57.0.2 + version: 57.0.2(expo@57.0.26)(react@19.2.3) expo-linking: specifier: 57.0.11 version: 57.0.11(expo@57.0.26)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)