SPB Git

spb/admin-ka

Public
383.3 KB · 118 lines
1{"type":"user_prompt","text":"Peux tu afficher en premier les plat ou resto récemment afficher","ts":1787439716440}2{"type":"status","state":"running"}3{"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/resto-ka","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","ToolSearch","WebFetch","WebSearch","Write"],"mcp_servers":[],"model":"claude-fable-5","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api","clear","compact","context","cost","heapdump","init","review","security-review","insights","team-onboarding"],"apiKeySource":"ANTHROPIC_API_KEY","claude_code_version":"2.1.117","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api"],"plugins":[],"uuid":"a8d512db-7990-4904-aac2-a796aab9c788","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-resto-ka/memory/"},"fast_mode_state":"off"}}4{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c7f5eab0-f506-4174-ba22-013d71a729f7","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}5{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcugehzXUXdQYrC9KrF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStgUKpwEIERgCKkAQFXmwMZCebnz4BhHlYOLC+MQC5h0eg4NcyRcszT4HS9zFZ7l2SMisFodGaIjaWb0bIU35/F71zzr9/MmpCCCjMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHr1KjUBrABAhIMhbuO27qXQFJlGff7Ggw8X0A+QOBN9+YP2TMiMKrj7f9MunRiNHdQWhIv2GfuQvBD91xhpXdQIPUsYWjyOwZ2CjwV5UPSbn3IeHc2SCq7A0TqtxcTWAkRjG0AqYqyjOgehG/OnYGWYpiYfnZaHen2K5q6RKeE41HncITaO5Fl1blDWV/2TLjieIFhlBt7XkF50o+hYb+2Rk7RqYzwBlKwderMIUKhk2f+yawfG2WeNcljNDHk6pFnunvRP0CUOZukhOfMYqQizM4g3SLgX+wQcebRS9YB5kk2HDXZhfy4a5QOBlusLCbDYJTC7ItxKhfcLEPJ1BkWzz8SL9LuT3R1fDWch3akcyMzFiuAqi3lUe4S1ToiQ7ykq3ecbmfpa/iuH//axENGFnnXSb3xowzbIBZFmdHwYL3bY7yLgTchW9xdJ7pcLDLa9WNzGPCwqXph+CwXPO7BmJAF5i4IBMo1T/HpN5w8PEFWKHpteCz/ywjGjkYsNNzqLzFVSiHwdnAAQMXtcSs+MtLpmAoiYZQSLVZeB54sPZwnA0FJsZn7rcjc/GldMJ7ZaALuZXYMqwboTi2kZvCX0FwO5TjQzianUDTqkNER4I/u5tktdc8xtJ3aNmUyP16hWk4qow9STKRf6JZrWSJ/rbxRExWmsl5/F0R+G9nlypdmagb6My0tUTAICl1RsW+4e9HCGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44611,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44611,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"39b5fd1b-09aa-4739-8330-a89ff96f3442"}}6{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcugehzXUXdQYrC9KrF","type":"message","role":"assistant","content":[{"type":"text","text":"Je vais explorer la structure du projet pour comprendre comment la liste des restos/plats est affichée avant d'ajouter le tri « récemment consultés en premier »."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44611,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44611,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"467b7a05-e14e-42f6-80d3-7f73a05739c9"}}7{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcugehzXUXdQYrC9KrF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015ds9ZoUk69Vp5u3bLyrG4z","name":"Bash","input":{"command":"ls /Users/simon-pierreboucher/apps/resto-ka && ls /Users/simon-pierreboucher/apps/resto-ka/frontend 2>/dev/null","description":"List project structure"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44611,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44611,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"eec470dc-e4d4-4d89-8d0d-3f31d3ac5c09"}}8{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015ds9ZoUk69Vp5u3bLyrG4z","type":"tool_result","content":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\nrequirements.txt\nrestoka\nrun.py\nscripts\ntests\ndist\nindex.html\nnode_modules\npackage-lock.json\npackage.json\npublic\nsrc\ntsconfig.json\ntsconfig.tsbuildinfo\nvite.config.ts","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"bdde8de4-984f-4ded-b200-b071cc4ee778","timestamp":"2026-08-22T23:02:05.516Z","tool_use_result":{"stdout":"CLAUDE.md\nREADME.md\ndata\ndocs\nfrontend\nrequirements.txt\nrestoka\nrun.py\nscripts\ntests\ndist\nindex.html\nnode_modules\npackage-lock.json\npackage.json\npublic\nsrc\ntsconfig.json\ntsconfig.tsbuildinfo\nvite.config.ts","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}9{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3808a481-f309-46a9-ad2e-24f622c57c30","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}10{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcvLMqwzAifYrGTM1rn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012rbyfXhzW89wrEhqnSH46b","name":"Bash","input":{"command":"ls -R /Users/simon-pierreboucher/apps/resto-ka/frontend/src | head -60","description":"List frontend source files"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":442,"cache_read_input_tokens":44611,"cache_creation":{"ephemeral_5m_input_tokens":442,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1de79325-6e05-42af-b032-c6d43e1e47dd"}}11{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012rbyfXhzW89wrEhqnSH46b","type":"tool_result","content":"App.tsx\naccount.tsx\napi.ts\ncomponents\nka\nmain.tsx\npages\nstyles.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components:\nIcons.tsx\nLogo.tsx\nPager.tsx\nRestoCard.tsx\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages:\nContact.tsx\nFavoris.tsx\nHome.tsx\nResto.tsx\nSources.tsx\nStats.tsx","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0a7b435b-d39f-45cf-9610-5c5309250a97","timestamp":"2026-08-22T23:02:10.400Z","tool_use_result":{"stdout":"App.tsx\naccount.tsx\napi.ts\ncomponents\nka\nmain.tsx\npages\nstyles.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components:\nIcons.tsx\nLogo.tsx\nPager.tsx\nRestoCard.tsx\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n\n/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages:\nContact.tsx\nFavoris.tsx\nHome.tsx\nResto.tsx\nSources.tsx\nStats.tsx","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}12{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"46bb084b-36de-489f-8138-01636b8b4009","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}13{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcvh8GL7ippH6Qub8ye","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MwaqF3SejXWE5hEMee3ZB2","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":428,"cache_read_input_tokens":45053,"cache_creation":{"ephemeral_5m_input_tokens":428,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"653b8878-00f2-4990-84bf-d35ad20bab6d"}}14{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MwaqF3SejXWE5hEMee3ZB2","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File:   pages/Home.tsx\n4\t// Desc:   Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n5\t//         tous les menus), chips cuisines, barre de filtres (région, ville,\n6\t//         type, diète, prix), grilles de résultats + pagination numérotée.\n7\t//         Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n8\t//         Lou·Ka, design éditorial sharp Groupe KA).\n9\t// ==============================================================================\n10\timport { useEffect, useMemo, useState } from \"react\";\n11\timport { Link, useSearchParams } from \"react-router-dom\";\n12\timport {\n13\t  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n14\t  fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n15\t} from \"../api\";\n16\timport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\n17\timport Pager from \"../components/Pager\";\n18\timport RestoCard from \"../components/RestoCard\";\n19\t\n20\tconst PAGE_SIZE = 24;\n21\tconst DISH_PAGE_SIZE = 20;\n22\t\n23\tconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n24\t                     \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\n25\ttype FilterKey = typeof FILTER_KEYS[number];\n26\t\n27\tfunction DishRow({ d }: { d: Dish }) {\n28\t  return (\n29\t    <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n30\t      {d.image ? (\n31\t        <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n32\t      ) : (\n33\t        <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n34\t      )}\n35\t      <div className=\"dish-main\">\n36\t        <div className=\"dish-line\">\n37\t          <span className=\"dish-name\">{d.item}</span>\n38\t          <span className=\"mi-dots\" aria-hidden=\"true\" />\n39\t          <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n40\t        </div>\n41\t        {d.description && <p className=\"dish-desc\">{d.description}</p>}\n42\t        <div className=\"dish-meta\">\n43\t          <b>{d.brand}</b>\n44\t          <span className=\"sep\" />\n45\t          <span>{d.section}</span>\n46\t          <span className=\"sep\" />\n47\t          {d.locations > 1\n48\t            ? <span>{d.locations} emplacements</span>\n49\t            : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n50\t          <span className={`ctx-pill ctx-${d.price_context}`}>\n51\t            {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n52\t          </span>\n53\t        </div>\n54\t      </div>\n55\t    </Link>\n56\t  );\n57\t}\n58\t\n59\texport default function Home() {\n60\t  const [params, setParams] = useSearchParams();\n61\t  const [facets, setFacets] = useState<Facets | null>(null);\n62\t  const [restos, setRestos] = useState<Restaurant[]>([]);\n63\t  const [dishes, setDishes] = useState<Dish[]>([]);\n64\t  const [total, setTotal] = useState(0);\n65\t  const [loading, setLoading] = useState(true);\n66\t  const [error, setError] = useState(false);\n67\t  const [page, setPage] = useState(0);\n68\t  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n69\t  const [sheetOpen, setSheetOpen] = useState(false);\n70\t\n71\t  // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n72\t  // Escape (même pattern que le menu hamburger d'App.tsx)\n73\t  useEffect(() => {\n74\t    document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n75\t    if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n76\t    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n77\t    window.addEventListener(\"keydown\", onKey);\n78\t    return () => {\n79\t      document.body.style.overflow = \"\";\n80\t      window.removeEventListener(\"keydown\", onKey);\n81\t    };\n82\t  }, [sheetOpen]);\n83\t\n84\t  const filters = useMemo(() => {\n85\t    const f: Partial<Record<FilterKey, string>> = {};\n86\t    for (const k of FILTER_KEYS) {\n87\t      const v = params.get(k);\n88\t      if (v) f[k] = v;\n89\t    }\n90\t    return f;\n91\t  }, [params]);\n92\t\n93\t  const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n94\t  const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n95\t\n96\t  const setFilter = (key: FilterKey, value: string) => {\n97\t    const next = new URLSearchParams(params);\n98\t    if (value) next.set(key, value);\n99\t    else next.delete(key);\n100\t    setParams(next, { replace: true });\n101\t    setPage(0);\n102\t  };\n103\t\n104\t  useEffect(() => {\n105\t    fetchStats()\n106\t      .then((s) => setStatChips([\n107\t        { label: \"restaurants\", value: s.restaurants },\n108\t        { label: \"avec menu complet\", value: s.with_menu },\n109\t        { label: \"plats avec prix\", value: s.items },\n110\t        { label: \"régions couvertes\", value: s.regions },\n111\t      ]))\n112\t      .catch(() => {});\n113\t  }, []);\n114\t\n115\t  useEffect(() => {\n116\t    fetchFacets(filters.region).then(setFacets).catch(() => {});\n117\t  }, [filters.region]);\n118\t\n119\t  useEffect(() => {\n120\t    setLoading(true);\n121\t    setError(false);\n122\t    if (mode === \"plats\") {\n123\t      const q = (filters.q ?? \"\").trim();\n124\t      if (q.length < 2) {\n125\t        setDishes([]); setTotal(0); setLoading(false);\n126\t        return;\n127\t      }\n128\t      fetchDishes({\n129\t        q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n130\t        limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n131\t      })\n132\t        .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n133\t        .catch(() => setError(true))\n134\t        .finally(() => setLoading(false));\n135\t    } else {\n136\t      const { mode: _m, ...rest } = filters;\n137\t      fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n138\t        .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n139\t        .catch(() => setError(true))\n140\t        .finally(() => setLoading(false));\n141\t    }\n142\t  }, [filters, page, mode]);\n143\t\n144\t  const activePills: { key: FilterKey; label: string }[] = [];\n145\t  if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n146\t  if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n147\t  if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n148\t  if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n149\t  if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n150\t  if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n151\t  if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n152\t  if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n153\t  if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n154\t\n155\t  const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n156\t\n157\t  const filterbar = (\n158\t    <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n159\t      <div className=\"sheet-head\">\n160\t        Filtres\n161\t        <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n162\t      </div>\n163\t      <div className=\"f-primary\">\n164\t        <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n165\t          <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n166\t            aria-selected={mode === \"restos\"}\n167\t            onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n168\t          <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n169\t            aria-selected={mode === \"plats\"}\n170\t            onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n171\t        </div>\n172\t        <label className=\"f-search\">\n173\t          <IcoSearch size={16} />\n174\t          <input\n175\t            type=\"search\"\n176\t            placeholder={mode === \"plats\"\n177\t              ? \"Poutine, tartare, pad thaï, sushi…\"\n178\t              : \"Resto, chaîne, ville…\"}\n179\t            value={filters.q ?? \"\"}\n180\t            onChange={(e) => setFilter(\"q\", e.target.value)}\n181\t            aria-label=\"Recherche\"\n182\t          />\n183\t          {filters.q && (\n184\t            <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n185\t          )}\n186\t        </label>\n187\t        <label className=\"f-ctl\">\n188\t          <span>Région</span>\n189\t          <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n190\t            <option value=\"\">Toutes</option>\n191\t            {(facets?.regions ?? []).map((r) => (\n192\t              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n193\t            ))}\n194\t          </select>\n195\t        </label>\n196\t        <label className=\"f-ctl\">\n197\t          <span>Ville</span>\n198\t          <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n199\t            <option value=\"\">Toutes</option>\n200\t            {(facets?.cities ?? []).map((c) => (\n201\t              <option key={c} value={c}>{c}</option>\n202\t            ))}\n203\t          </select>\n204\t        </label>\n205\t        {mode === \"restos\" && (\n206\t          <>\n207\t            <label className=\"f-ctl\">\n208\t              <span>Type</span>\n209\t              <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n210\t                <option value=\"\">Tous</option>\n211\t                {(facets?.establishment_types ?? []).map((t) => (\n212\t                  <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n213\t                ))}\n214\t              </select>\n215\t            </label>\n216\t            <label className=\"f-ctl\">\n217\t              <span>Diète</span>\n218\t              <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n219\t                <option value=\"\">Toutes</option>\n220\t                {(facets?.diets ?? []).map((d) => (\n221\t                  <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n222\t                ))}\n223\t              </select>\n224\t            </label>\n225\t            <label className=\"f-ctl\">\n226\t              <span>Prix</span>\n227\t              <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n228\t                <option value=\"\">Tous</option>\n229\t                {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n230\t                  <option key={p} value={p}>{p}</option>\n231\t                ))}\n232\t              </select>\n233\t            </label>\n234\t            <label className=\"f-ctl\">\n235\t              <span>Menu</span>\n236\t              <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n237\t                <option value=\"\">Tous</option>\n238\t                <option value=\"1\">Avec menu complet</option>\n239\t                <option value=\"0\">Fiche seulement</option>\n240\t              </select>\n241\t            </label>\n242\t            <label className=\"f-ctl\">\n243\t              <span>Tri</span>\n244\t              <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n245\t                <option value=\"menu\">Menus d'abord</option>\n246\t                <option value=\"name\">Nom</option>\n247\t                <option value=\"price\">Fourchette de prix</option>\n248\t                <option value=\"recent\">Récents</option>\n249\t              </select>\n250\t            </label>\n251\t          </>\n252\t        )}\n253\t      </div>\n254\t      <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n255\t        Voir les {total} résultats\n256\t      </button>\n257\t    </div>\n258\t  );\n259\t\n260\t  return (\n261\t    <div className=\"container\">\n262\t      <section className=\"hero\">\n263\t        <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n264\t        <h1>\n265\t          Tous les restos. Tous les menus.<br />\n266\t          <span className=\"hl\">Tous les prix.</span>\n267\t        </h1>\n268\t        <p className=\"lede\">\n269\t          Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n270\t          et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n271\t          le contexte du prix (salle, emporter, livraison) est toujours affiché.\n272\t        </p>\n273\t        <div className=\"stat-row\">\n274\t          <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n275\t          {statChips.map((s) => (\n276\t            <div className=\"stat-chip\" key={s.label}>\n277\t              <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n278\t            </div>\n279\t          ))}\n280\t        </div>\n281\t        <div className=\"benefits\">\n282\t          <div className=\"benefit\">\n283\t            <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n284\t            <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n285\t          </div>\n286\t          <div className=\"benefit\">\n287\t            <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n288\t            <span className=\"benefit-label\">17 régions du Québec</span>\n289\t          </div>\n290\t          <div className=\"benefit\">\n291\t            <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n292\t            <span className=\"benefit-label\">Recherche par plat</span>\n293\t          </div>\n294\t          <div className=\"benefit\">\n295\t            <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n296\t            <span className=\"benefit-label\">Filtres diète & cuisine</span>\n297\t          </div>\n298\t        </div>\n299\t      </section>\n300\t\n301\t      {/* chips cuisines (défilement horizontal) */}\n302\t      <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n303\t        <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n304\t          onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n305\t        {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n306\t          <button\n307\t            key={c.cuisine}\n308\t            className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n309\t            onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n310\t          >\n311\t            {cuisineLabel(c.cuisine)} · {c.n}\n312\t          </button>\n313\t        ))}\n314\t      </div>\n315\t\n316\t      {filterbar}\n317\t      {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n318\t      <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n319\t        <IcoSearch size={16} /> Filtres\n320\t      </button>\n321\t\n322\t      {activePills.length > 0 && (\n323\t        <div className=\"pills\">\n324\t          {activePills.map((p) => (\n325\t            <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n326\t              {p.label} <span className=\"pill-x\">✕</span>\n327\t            </button>\n328\t          ))}\n329\t          <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n330\t        </div>\n331\t      )}\n332\t\n333\t      <div className=\"results-head\">\n334\t        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n335\t        <span>\n336\t          {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n337\t            ? \"tapez un plat (min. 2 lettres)\"\n338\t            : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n339\t        </span>\n340\t      </div>\n341\t\n342\t      {error && (\n343\t        <div className=\"notice\">\n344\t          <h2>Impossible de charger les résultats</h2>\n345\t          <p>Réessayez dans un instant.</p>\n346\t        </div>\n347\t      )}\n348\t\n349\t      {loading ? (\n350\t        <div className=\"grid\">\n351\t          {Array.from({ length: 8 }).map((_, i) => (\n352\t            <div className=\"skel\" key={i}>\n353\t              <div className=\"sk-img\" />\n354\t              <div className=\"sk-line\" />\n355\t              <div className=\"sk-line short\" />\n356\t            </div>\n357\t          ))}\n358\t        </div>\n359\t      ) : mode === \"plats\" ? (\n360\t        (filters.q ?? \"\").trim().length < 2 ? (\n361\t          <div className=\"notice\">\n362\t            <h2>Cherchez n'importe quel plat</h2>\n363\t            <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n364\t              du Québec sont indexés, avec leurs prix réels.</p>\n365\t          </div>\n366\t        ) : dishes.length === 0 ? (\n367\t          <div className=\"notice\">\n368\t            <h2>Aucun plat trouvé</h2>\n369\t            <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n370\t          </div>\n371\t        ) : (\n372\t          <>\n373\t            <div className=\"dish-list\">\n374\t              {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n375\t            </div>\n376\t            <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n377\t          </>\n378\t        )\n379\t      ) : restos.length === 0 && !error ? (\n380\t        <div className=\"notice\">\n381\t          <h2>Aucun resto ne correspond</h2>\n382\t          <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n383\t        </div>\n384\t      ) : (\n385\t        <>\n386\t          <div className=\"grid\">\n387\t            {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n388\t          </div>\n389\t          <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n390\t        </>\n391\t      )}\n392\t    </div>\n393\t  );\n394\t}\n395\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"28946169-7d5e-4297-8c2a-e8c75f447556","timestamp":"2026-08-22T23:02:15.365Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Home.tsx\n// Desc:   Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n//         tous les menus), chips cuisines, barre de filtres (région, ville,\n//         type, diète, prix), grilles de résultats + pagination numérotée.\n//         Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n//         Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n                     \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n  return (\n    <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n      {d.image ? (\n        <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n      ) : (\n        <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n      )}\n      <div className=\"dish-main\">\n        <div className=\"dish-line\">\n          <span className=\"dish-name\">{d.item}</span>\n          <span className=\"mi-dots\" aria-hidden=\"true\" />\n          <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n        </div>\n        {d.description && <p className=\"dish-desc\">{d.description}</p>}\n        <div className=\"dish-meta\">\n          <b>{d.brand}</b>\n          <span className=\"sep\" />\n          <span>{d.section}</span>\n          <span className=\"sep\" />\n          {d.locations > 1\n            ? <span>{d.locations} emplacements</span>\n            : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n          <span className={`ctx-pill ctx-${d.price_context}`}>\n            {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n          </span>\n        </div>\n      </div>\n    </Link>\n  );\n}\n\nexport default function Home() {\n  const [params, setParams] = useSearchParams();\n  const [facets, setFacets] = useState<Facets | null>(null);\n  const [restos, setRestos] = useState<Restaurant[]>([]);\n  const [dishes, setDishes] = useState<Dish[]>([]);\n  const [total, setTotal] = useState(0);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(false);\n  const [page, setPage] = useState(0);\n  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);\n\n  // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n  // Escape (même pattern que le menu hamburger d'App.tsx)\n  useEffect(() => {\n    document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n    if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n    window.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.body.style.overflow = \"\";\n      window.removeEventListener(\"keydown\", onKey);\n    };\n  }, [sheetOpen]);\n\n  const filters = useMemo(() => {\n    const f: Partial<Record<FilterKey, string>> = {};\n    for (const k of FILTER_KEYS) {\n      const v = params.get(k);\n      if (v) f[k] = v;\n    }\n    return f;\n  }, [params]);\n\n  const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n  const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n  const setFilter = (key: FilterKey, value: string) => {\n    const next = new URLSearchParams(params);\n    if (value) next.set(key, value);\n    else next.delete(key);\n    setParams(next, { replace: true });\n    setPage(0);\n  };\n\n  useEffect(() => {\n    fetchStats()\n      .then((s) => setStatChips([\n        { label: \"restaurants\", value: s.restaurants },\n        { label: \"avec menu complet\", value: s.with_menu },\n        { label: \"plats avec prix\", value: s.items },\n        { label: \"régions couvertes\", value: s.regions },\n      ]))\n      .catch(() => {});\n  }, []);\n\n  useEffect(() => {\n    fetchFacets(filters.region).then(setFacets).catch(() => {});\n  }, [filters.region]);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(false);\n    if (mode === \"plats\") {\n      const q = (filters.q ?? \"\").trim();\n      if (q.length < 2) {\n        setDishes([]); setTotal(0); setLoading(false);\n        return;\n      }\n      fetchDishes({\n        q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n        limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n      })\n        .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    } else {\n      const { mode: _m, ...rest } = filters;\n      fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n        .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    }\n  }, [filters, page, mode]);\n\n  const activePills: { key: FilterKey; label: string }[] = [];\n  if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n  if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n  if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n  if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n  if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n  if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n  if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n  if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n  if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n  const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n  const filterbar = (\n    <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n      <div className=\"sheet-head\">\n        Filtres\n        <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n      </div>\n      <div className=\"f-primary\">\n        <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n          <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"restos\"}\n            onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n          <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"plats\"}\n            onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n        </div>\n        <label className=\"f-search\">\n          <IcoSearch size={16} />\n          <input\n            type=\"search\"\n            placeholder={mode === \"plats\"\n              ? \"Poutine, tartare, pad thaï, sushi…\"\n              : \"Resto, chaîne, ville…\"}\n            value={filters.q ?? \"\"}\n            onChange={(e) => setFilter(\"q\", e.target.value)}\n            aria-label=\"Recherche\"\n          />\n          {filters.q && (\n            <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n          )}\n        </label>\n        <label className=\"f-ctl\">\n          <span>Région</span>\n          <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n            <option value=\"\">Toutes</option>\n            {(facets?.regions ?? []).map((r) => (\n              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n            ))}\n          </select>\n        </label>\n        <label className=\"f-ctl\">\n          <span>Ville</span>\n          <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n            <option value=\"\">Toutes</option>\n            {(facets?.cities ?? []).map((c) => (\n              <option key={c} value={c}>{c}</option>\n            ))}\n          </select>\n        </label>\n        {mode === \"restos\" && (\n          <>\n            <label className=\"f-ctl\">\n              <span>Type</span>\n              <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {(facets?.establishment_types ?? []).map((t) => (\n                  <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Diète</span>\n              <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n                <option value=\"\">Toutes</option>\n                {(facets?.diets ?? []).map((d) => (\n                  <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Prix</span>\n              <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n                  <option key={p} value={p}>{p}</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Menu</span>\n              <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                <option value=\"1\">Avec menu complet</option>\n                <option value=\"0\">Fiche seulement</option>\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Tri</span>\n              <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n                <option value=\"menu\">Menus d'abord</option>\n                <option value=\"name\">Nom</option>\n                <option value=\"price\">Fourchette de prix</option>\n                <option value=\"recent\">Récents</option>\n              </select>\n            </label>\n          </>\n        )}\n      </div>\n      <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n        Voir les {total} résultats\n      </button>\n    </div>\n  );\n\n  return (\n    <div className=\"container\">\n      <section className=\"hero\">\n        <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n        <h1>\n          Tous les restos. Tous les menus.<br />\n          <span className=\"hl\">Tous les prix.</span>\n        </h1>\n        <p className=\"lede\">\n          Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n          et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n          le contexte du prix (salle, emporter, livraison) est toujours affiché.\n        </p>\n        <div className=\"stat-row\">\n          <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n          {statChips.map((s) => (\n            <div className=\"stat-chip\" key={s.label}>\n              <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n            </div>\n          ))}\n        </div>\n        <div className=\"benefits\">\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n            <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n            <span className=\"benefit-label\">17 régions du Québec</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n            <span className=\"benefit-label\">Recherche par plat</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n            <span className=\"benefit-label\">Filtres diète & cuisine</span>\n          </div>\n        </div>\n      </section>\n\n      {/* chips cuisines (défilement horizontal) */}\n      <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n        <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n          onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n        {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n          <button\n            key={c.cuisine}\n            className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n            onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n          >\n            {cuisineLabel(c.cuisine)} · {c.n}\n          </button>\n        ))}\n      </div>\n\n      {filterbar}\n      {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n      <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n        <IcoSearch size={16} /> Filtres\n      </button>\n\n      {activePills.length > 0 && (\n        <div className=\"pills\">\n          {activePills.map((p) => (\n            <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n              {p.label} <span className=\"pill-x\">✕</span>\n            </button>\n          ))}\n          <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n        </div>\n      )}\n\n      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n        <span>\n          {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n            ? \"tapez un plat (min. 2 lettres)\"\n            : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n        </span>\n      </div>\n\n      {error && (\n        <div className=\"notice\">\n          <h2>Impossible de charger les résultats</h2>\n          <p>Réessayez dans un instant.</p>\n        </div>\n      )}\n\n      {loading ? (\n        <div className=\"grid\">\n          {Array.from({ length: 8 }).map((_, i) => (\n            <div className=\"skel\" key={i}>\n              <div className=\"sk-img\" />\n              <div className=\"sk-line\" />\n              <div className=\"sk-line short\" />\n            </div>\n          ))}\n        </div>\n      ) : mode === \"plats\" ? (\n        (filters.q ?? \"\").trim().length < 2 ? (\n          <div className=\"notice\">\n            <h2>Cherchez n'importe quel plat</h2>\n            <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n              du Québec sont indexés, avec leurs prix réels.</p>\n          </div>\n        ) : dishes.length === 0 ? (\n          <div className=\"notice\">\n            <h2>Aucun plat trouvé</h2>\n            <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n          </div>\n        ) : (\n          <>\n            <div className=\"dish-list\">\n              {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n            </div>\n            <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n          </>\n        )\n      ) : restos.length === 0 && !error ? (\n        <div className=\"notice\">\n          <h2>Aucun resto ne correspond</h2>\n          <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n        </div>\n      ) : (\n        <>\n          <div className=\"grid\">\n            {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n          </div>\n          <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n        </>\n      )}\n    </div>\n  );\n}\n","numLines":395,"startLine":1,"totalLines":395}}}}15{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b80206db-0f99-4682-9078-fa4ca393ab86","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}16{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9AoKpwEIERgCKkAzlXdDau0huz9E3jibHT/9swBm3RHONi2Q+piiL87mRyVZUBS339/Adqljx+Hm1KuLAN7LNulgPTBy8Ekp1EMVMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGD1ajUBrABAhIMYwriawTI1U3vrXu7GgxzRISE3hwiBjl5GQ8iMPeMvm1knwUGCHAyFFLQJYVBkPhpkajGNuUZ6Rcl+o6fZSRmyflAwCxNOJ25UKW4Qir5CGvYxgTfCj6sQlgjDHTqzjmGV3tCJFRJOazmeAikh1ZIHLE+gE0uC/LZTxIUzKuxi9i3RZ6jJthVCt0Lj6GfQw7nbANAFEnLnNtZ1Lb3nsqppVsmR8x4u47dYz6MVv8iK2lQxEMnbOfRJ4KWk8/iC34VXDZrCBQVx8vQjIYsNlGnF9x/flrRlpEzO8yqjxMryRl93lygFiIlUgVITUiarfdR5RY71r3kB0knPS14ln6fTbBD9J7H4oWYd/T7AYMTZMfrFkWQxueeS5IYip5a4gogxlMn0x2fwEflX0rtILJ2lRoTJa6ufUkQ6gndxXVlky3y3sjzxjCQdGQD1CbPc8PpsiVR/ClIfNWU8p4ICuuIsk1kqkR6m8E9Qa0nmBudUqtqNsAbJeyHrHXHK6yRdDFp7lLADxaldOyktBKr+uAysUQl0HD4hf7J9aU1qUxeHFkXrNRCm4gjax5g6HDatlQC1ubIuLsaNNcf+LpLes7E+sALP9MVdCCA5zES0bNydkcnZ/0manC7ARJraooYtm4+7OCnC0BvzH57ke4/O+7WhsUhBG+ZxAI8n1y/urX4zAGRTATuE1xGf7b5H9ni64U8qCPgFIUYM2BbwyLlAgaVUnRHzrmHx7NoARDWZ2YPsTbrhgAvrxtkSPJBOwk3BQj8ari1XhAcZ9rVbESF2FsIMGOEXry8WObxiqi6Pj0bXjHiuwmuVNjzr1BJ61vu91NU1Z8Sx6/mLe6L49wot6sp6CtFVBS9JQhqvOgcRoXy8pVf9Q5kWU2tIIY3Au4Gj2by8P8d/H+DW6pbRIBOTw1SZUUYYYLfger6mwgi9lyOsIB7qBsc/jDORFjCmV9zSWpoVzKWDQizZHWyoIQsLSe1FGhhjWv5O025/H9EvsnopG4L2iN+7bMZLvq01TNYtY+27C9OXGAwQY3rQx/YKDWnwrHEcAQNoC1oC60tSMnwj8is1jMU+dn7T1ZtFvIu1q7IhIr55sh172o33oGPIdcJfguVTmzcwB4MvXTBy6sZhBd3dGsWyjLAuJYL8qlkIe0LVvhvPEToYktmpZURiGPqNbzOpqy3pHiViLq+2gyx6auELtZj5QC7n7XCv/WiKKSZbBqNJNzvgsyQwbijg0y4ifCpYjHbixyrr7h4wa+P+V/y8lkwt/mRqBtEB11498zMXkggCbrQBuCBDFadqdGx3PkmezpCzN2hci/XBhu1UxiW1zv3zDl+VGNPtuX9WLVtZRQifstM5oUvgG8kCiHA70jX6QHIYfEPD+Nvl/VVjrrH9kO68EBmZPUOYV3MtdYLiAqFZigk8GNhlNnJAbLHc0EFcSt7/r4eELpjr1hZTv4gwuf2HlgG8cdScBQTsQgTyj5F0OA1IM9jP1TKAzCQUBfZFhEiJ2UOsemd162gOBw4PiMCLj0FAHvt8QQ6qejAZEttVb5t11jG7zmwU9kqhKfF5bss1w/RkXqh2Dy9vKDiUxUD1hTabkfQ/IIGfv4gki/D1DEMcg1WC4/DeKVWOqD4XZGIbQ4IGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ee826a95-895f-41b7-bd70-f932c75d3b99"}}17{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"text","text":"La demande : afficher en premier les restos/plats **récemment consultés**. Je regarde comment la page Favoris et la fiche resto fonctionnent pour réutiliser le même patron (localStorage)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9017c54e-b32a-447d-8579-cd8e66421036"}}18{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0113rPBxtatomb9F8WCukZQ7","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Favoris.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3642358b-b8f0-4a78-be65-fb81a9015e18"}}19{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0113rPBxtatomb9F8WCukZQ7","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File:   pages/Favoris.tsx\n4\t// Desc:   Restos favoris du membre connecté (« Se connecter avec KA »).\n5\t// ==============================================================================\n6\timport { useEffect, useState } from \"react\";\n7\timport { useAccount } from \"../account\";\n8\timport { Restaurant, fetchFavoris } from \"../api\";\n9\timport RestoCard from \"../components/RestoCard\";\n10\t\n11\texport default function FavorisPage() {\n12\t  const { me, favs } = useAccount();\n13\t  const [restos, setRestos] = useState<Restaurant[]>([]);\n14\t  const [loading, setLoading] = useState(true);\n15\t\n16\t  useEffect(() => {\n17\t    if (!me) { setLoading(false); return; }\n18\t    fetchFavoris()\n19\t      .then((f) => setRestos(f.restaurants))\n20\t      .catch(() => {})\n21\t      .finally(() => setLoading(false));\n22\t  }, [me, favs.size]);\n23\t\n24\t  if (!me) {\n25\t    return (\n26\t      <div className=\"notice container\">\n27\t        <h2>Connectez-vous pour vos favoris</h2>\n28\t        <p>\n29\t          <a className=\"login-btn\" href=\"/api/auth/ka/login\" style={{ display: \"inline-flex\" }}>\n30\t            <span className=\"ka-badge\" aria-hidden=\"true\">KA</span>\n31\t            Se connecter avec KA\n32\t          </a>\n33\t        </p>\n34\t        <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p>\n35\t      </div>\n36\t    );\n37\t  }\n38\t\n39\t  return (\n40\t    <div className=\"container sources\">\n41\t      <span className=\"kicker\">Mon compte — {me.ka_id}</span>\n42\t      <h1>Mes restos favoris</h1>\n43\t      <p className=\"sub\">Les restos que vous avez épinglés, sur tous vos appareils.</p>\n44\t      {loading ? (\n45\t        <p>Chargement…</p>\n46\t      ) : restos.length === 0 ? (\n47\t        <div className=\"notice\">\n48\t          <h2>Aucun favori pour l'instant</h2>\n49\t          <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p>\n50\t        </div>\n51\t      ) : (\n52\t        <div className=\"grid\">\n53\t          {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n54\t        </div>\n55\t      )}\n56\t    </div>\n57\t  );\n58\t}\n59\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e99ae954-6e7e-45b7-9036-85f19fbe2edc","timestamp":"2026-08-22T23:02:28.507Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Favoris.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Favoris.tsx\n// Desc:   Restos favoris du membre connecté (« Se connecter avec KA »).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { useAccount } from \"../account\";\nimport { Restaurant, fetchFavoris } from \"../api\";\nimport RestoCard from \"../components/RestoCard\";\n\nexport default function FavorisPage() {\n  const { me, favs } = useAccount();\n  const [restos, setRestos] = useState<Restaurant[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    if (!me) { setLoading(false); return; }\n    fetchFavoris()\n      .then((f) => setRestos(f.restaurants))\n      .catch(() => {})\n      .finally(() => setLoading(false));\n  }, [me, favs.size]);\n\n  if (!me) {\n    return (\n      <div className=\"notice container\">\n        <h2>Connectez-vous pour vos favoris</h2>\n        <p>\n          <a className=\"login-btn\" href=\"/api/auth/ka/login\" style={{ display: \"inline-flex\" }}>\n            <span className=\"ka-badge\" aria-hidden=\"true\">KA</span>\n            Se connecter avec KA\n          </a>\n        </p>\n        <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"container sources\">\n      <span className=\"kicker\">Mon compte — {me.ka_id}</span>\n      <h1>Mes restos favoris</h1>\n      <p className=\"sub\">Les restos que vous avez épinglés, sur tous vos appareils.</p>\n      {loading ? (\n        <p>Chargement…</p>\n      ) : restos.length === 0 ? (\n        <div className=\"notice\">\n          <h2>Aucun favori pour l'instant</h2>\n          <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p>\n        </div>\n      ) : (\n        <div className=\"grid\">\n          {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n        </div>\n      )}\n    </div>\n  );\n}\n","numLines":59,"startLine":1,"totalLines":59}}}}20{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcw3YLgoT48UhzxUVeW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0196WrkD2RWj6j1e8tbPmAHb","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7243,"cache_read_input_tokens":45481,"cache_creation":{"ephemeral_5m_input_tokens":7243,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"27a19826-58bf-466d-90c8-00174903e838"}}21{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0196WrkD2RWj6j1e8tbPmAHb","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File:   api.ts\n4\t// Desc:   Types + client API robuste (timeout, erreurs typées) + libellés FR\n5\t//         (cuisines, contextes de prix, régions). Même patron que Lou·Ka.\n6\t// ==============================================================================\n7\t\n8\texport interface MenuOptionChoice { name: string; price_delta: number }\n9\texport interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }\n10\t\n11\texport interface MenuItem {\n12\t  id?: string;\n13\t  name: string;\n14\t  description?: string;\n15\t  price: number | null;\n16\t  currency?: string;\n17\t  tags?: string[];\n18\t  options?: MenuOption[];\n19\t  image?: string;\n20\t}\n21\t\n22\texport interface MenuSection { name: string; items: MenuItem[]; image?: string }\n23\t\n24\texport interface Menu {\n25\t  price_context: \"dine-in\" | \"takeout\" | \"delivery\";\n26\t  price_source: string;\n27\t  currency: string;\n28\t  captured_at: string;\n29\t  item_count: number;\n30\t  sections: MenuSection[];\n31\t}\n32\t\n33\texport interface MenuSummary {\n34\t  image?: string | null;\n35\t  price_context: string;\n36\t  price_source: string;\n37\t  captured_at: string;\n38\t  item_count: number;\n39\t  price_min: number | null;\n40\t  price_median: number | null;\n41\t}\n42\t\n43\texport interface Restaurant {\n44\t  uid: string;\n45\t  source: string;\n46\t  external_id: string;\n47\t  name: string;\n48\t  chain: string | null;\n49\t  cuisines: string[];\n50\t  establishment_type: string;\n51\t  price_range: string;\n52\t  address: string;\n53\t  city: string;\n54\t  region: string;\n55\t  postal_code: string;\n56\t  lat: number | null;\n57\t  lng: number | null;\n58\t  phone: string;\n59\t  website: string;\n60\t  url: string;\n61\t  hours: Record<string, string>;\n62\t  services: string[];\n63\t  dietary_options: string[];\n64\t  languages: string[];\n65\t  images: string[];\n66\t  status: string;\n67\t  menu_summary?: MenuSummary | null;\n68\t  menus?: Menu[];\n69\t  recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];\n70\t  dup_sources?: string[];\n71\t  first_seen?: number;\n72\t  last_seen: number;\n73\t  updated_at: number;\n74\t  active: number;\n75\t}\n76\t\n77\texport interface Facets {\n78\t  regions: { region: string; n: number }[];\n79\t  all_regions: string[];\n80\t  cities: string[];\n81\t  cuisines: { cuisine: string; n: number }[];\n82\t  diets: { diet: string; n: number }[];\n83\t  establishment_types: { t: string; n: number }[];\n84\t  chains: string[];\n85\t  sources: { source: string; n: number }[];\n86\t}\n87\t\n88\texport interface Source {\n89\t  id: string;\n90\t  name: string;\n91\t  url: string;\n92\t  platform?: string;\n93\t  tier?: number;\n94\t  price_context?: string;\n95\t  extraction?: string;\n96\t  cadence?: string;\n97\t  status: string;\n98\t  active_restaurants: number;\n99\t  menu_items: number;\n100\t  last_sync: number | null;\n101\t  integrations?: { label: string; status: string }[];\n102\t}\n103\t\n104\texport interface Stats {\n105\t  restaurants: number;\n106\t  with_menu: number;\n107\t  chains: number;\n108\t  regions: number;\n109\t  sources: number;\n110\t  menus: number;\n111\t  items: number;\n112\t  by_region: { region: string; n: number }[];\n113\t  by_context: { price_context: string; n: number }[];\n114\t  recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];\n115\t}\n116\t\n117\t// --- libellés français --------------------------------------------------------\n118\t\n119\texport const CUISINE_LABELS: Record<string, string> = {\n120\t  \"quebecois\": \"Québécois\", \"francais\": \"Français\", \"italien\": \"Italien\",\n121\t  \"pizza\": \"Pizza\", \"burgers\": \"Burgers\", \"poulet\": \"Poulet\",\n122\t  \"bbq-grillades\": \"BBQ & grillades\", \"fruits-de-mer\": \"Fruits de mer\",\n123\t  \"sushi-japonais\": \"Sushi & japonais\", \"chinois\": \"Chinois\", \"thai\": \"Thaï\",\n124\t  \"vietnamien\": \"Vietnamien\", \"coreen\": \"Coréen\", \"indien\": \"Indien\",\n125\t  \"libanais-moyen-orient\": \"Libanais & Moyen-Orient\", \"mexicain\": \"Mexicain\",\n126\t  \"grec\": \"Grec\", \"mediterraneen\": \"Méditerranéen\",\n127\t  \"dejeuner-brunch\": \"Déjeuner & brunch\", \"cafe-dessert\": \"Café & dessert\",\n128\t  \"vegetarien-vegan\": \"Végé & vegan\", \"fast-food\": \"Fast-food\", \"autre\": \"Autre\",\n129\t};\n130\t\n131\texport const CONTEXT_LABELS: Record<string, string> = {\n132\t  \"dine-in\": \"Prix en salle\",\n133\t  \"takeout\": \"Prix pour emporter\",\n134\t  \"delivery\": \"Prix livraison (majoré 25-30 %)\",\n135\t};\n136\t\n137\texport const CONTEXT_SHORT: Record<string, string> = {\n138\t  \"dine-in\": \"Salle\", \"takeout\": \"Emporter\", \"delivery\": \"Livraison\",\n139\t};\n140\t\n141\texport const TYPE_LABELS: Record<string, string> = {\n142\t  \"restaurant\": \"Restaurant\", \"fast-food\": \"Fast-food\", \"cafe\": \"Café\",\n143\t  \"bar\": \"Bar & pub\", \"food-truck\": \"Food truck\", \"traiteur\": \"Traiteur\",\n144\t  \"boulangerie-patisserie\": \"Boulangerie-pâtisserie\",\n145\t  \"casse-croute\": \"Casse-croûte\", \"microbrasserie\": \"Microbrasserie\",\n146\t  \"hotel\": \"Resto d'hôtel\", \"ghost-kitchen\": \"Cuisine fantôme\",\n147\t};\n148\t\n149\texport const DIET_LABELS: Record<string, string> = {\n150\t  \"vegan\": \"Vegan\", \"vegetarien\": \"Végétarien\", \"sans-gluten\": \"Sans gluten\",\n151\t  \"halal\": \"Halal\", \"casher\": \"Casher\", \"sans-noix\": \"Sans noix\", \"epice\": \"Épicé\",\n152\t};\n153\t\n154\texport const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;\n155\texport const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;\n156\texport const dietLabel = (d: string) => DIET_LABELS[d] ?? d;\n157\t\n158\tconst SOURCE_NAMES: Record<string, string> = {\n159\t  ueat: \"UEAT (commande en ligne)\",\n160\t  osm: \"OpenStreetMap\",\n161\t};\n162\texport function registerSourceNames(sources: Source[]) {\n163\t  for (const s of sources) SOURCE_NAMES[s.id] = s.name;\n164\t}\n165\texport const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;\n166\t\n167\t/** 16.5 -> « 16,50 $ » */\n168\texport const fmtPrice = (p: number | null | undefined): string =>\n169\t  p == null ? \"—\" : `${p.toFixed(2).replace(\".\", \",\")} $`;\n170\t\n171\texport const fmtDate = (iso: string | null | undefined): string => {\n172\t  if (!iso) return \"—\";\n173\t  const d = new Date(iso);\n174\t  return isNaN(d.getTime()) ? iso : d.toLocaleDateString(\"fr-CA\");\n175\t};\n176\t\n177\texport const fmtTs = (ts: number | null | undefined): string =>\n178\t  ts ? new Date(ts * 1000).toLocaleString(\"fr-CA\") : \"—\";\n179\t\n180\t// --- client -------------------------------------------------------------------\n181\t\n182\tasync function get<T>(path: string): Promise<T> {\n183\t  const ctl = new AbortController();\n184\t  const timer = setTimeout(() => ctl.abort(), 20000);\n185\t  try {\n186\t    const resp = await fetch(path, { signal: ctl.signal });\n187\t    if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);\n188\t    return (await resp.json()) as T;\n189\t  } finally {\n190\t    clearTimeout(timer);\n191\t  }\n192\t}\n193\t\n194\texport interface Dish {\n195\t  item: string;\n196\t  description: string;\n197\t  price: number | null;\n198\t  currency: string;\n199\t  image?: string | null;\n200\t  tags: string[];\n201\t  section: string;\n202\t  price_context: string;\n203\t  price_source: string;\n204\t  captured_at: string;\n205\t  brand: string;\n206\t  restaurant: string;\n207\t  uid: string;\n208\t  city: string;\n209\t  region: string;\n210\t  locations: number;\n211\t}\n212\t\n213\texport interface DishQuery {\n214\t  q: string; region?: string; city?: string; cuisine?: string;\n215\t  price_max?: number; limit?: number; offset?: number;\n216\t}\n217\t\n218\texport function fetchDishes(query: DishQuery) {\n219\t  const params = new URLSearchParams();\n220\t  for (const [k, v] of Object.entries(query))\n221\t    if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n222\t  return get<{ total: number; count: number; dishes: Dish[] }>(\n223\t    `/api/dishes?${params.toString()}`);\n224\t}\n225\t\n226\texport interface RestaurantQuery {\n227\t  region?: string; city?: string; cuisine?: string; establishment_type?: string;\n228\t  diet?: string; service?: string; price_range?: string; chain?: string;\n229\t  source?: string; q?: string; has_menu?: string; sort?: string;\n230\t  limit?: number; offset?: number;\n231\t}\n232\t\n233\texport function fetchRestaurants(query: RestaurantQuery = {}) {\n234\t  const params = new URLSearchParams();\n235\t  for (const [k, v] of Object.entries(query))\n236\t    if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n237\t  return get<{ total: number; count: number; restaurants: Restaurant[] }>(\n238\t    `/api/restaurants?${params.toString()}`);\n239\t}\n240\t\n241\texport const fetchRestaurant = (uid: string) =>\n242\t  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n243\t\n244\texport const fetchFacets = (region?: string) =>\n245\t  get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);\n246\t\n247\texport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n248\t\n249\texport const fetchStats = () => get<Stats>(\"/api/stats\");\n250\t\n251\t// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------\n252\t\n253\texport interface Me {\n254\t  uid: number;\n255\t  ka_id: string;\n256\t  email: string;\n257\t  name: string;\n258\t  picture: string;\n259\t  bio?: string;\n260\t  city?: string;\n261\t  public_url?: string;\n262\t  profile_source: string;\n263\t}\n264\t\n265\texport const fetchAuthConfig = () => get<{ ka: boolean }>(\"/api/auth/config\");\n266\t\n267\texport async function fetchMe(): Promise<Me | null> {\n268\t  try {\n269\t    return await get<Me>(\"/api/me\");\n270\t  } catch {\n271\t    return null;\n272\t  }\n273\t}\n274\t\n275\texport async function logout(): Promise<void> {\n276\t  await fetch(\"/api/auth/logout\", { method: \"POST\" });\n277\t}\n278\t\n279\texport const fetchFavoris = () =>\n280\t  get<{ count: number; restaurants: Restaurant[]; uids: string[] }>(\"/api/favoris\");\n281\t\n282\texport async function toggleFavori(uid: string): Promise<boolean> {\n283\t  const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: \"POST\" });\n284\t  if (!resp.ok) throw new Error(`API ${resp.status}`);\n285\t  return (await resp.json()).favori as boolean;\n286\t}\n287\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"04bed392-c6d4-45ef-ac49-7717d435fda1","timestamp":"2026-08-22T23:02:28.577Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   api.ts\n// Desc:   Types + client API robuste (timeout, erreurs typées) + libellés FR\n//         (cuisines, contextes de prix, régions). Même patron que Lou·Ka.\n// ==============================================================================\n\nexport interface MenuOptionChoice { name: string; price_delta: number }\nexport interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }\n\nexport interface MenuItem {\n  id?: string;\n  name: string;\n  description?: string;\n  price: number | null;\n  currency?: string;\n  tags?: string[];\n  options?: MenuOption[];\n  image?: string;\n}\n\nexport interface MenuSection { name: string; items: MenuItem[]; image?: string }\n\nexport interface Menu {\n  price_context: \"dine-in\" | \"takeout\" | \"delivery\";\n  price_source: string;\n  currency: string;\n  captured_at: string;\n  item_count: number;\n  sections: MenuSection[];\n}\n\nexport interface MenuSummary {\n  image?: string | null;\n  price_context: string;\n  price_source: string;\n  captured_at: string;\n  item_count: number;\n  price_min: number | null;\n  price_median: number | null;\n}\n\nexport interface Restaurant {\n  uid: string;\n  source: string;\n  external_id: string;\n  name: string;\n  chain: string | null;\n  cuisines: string[];\n  establishment_type: string;\n  price_range: string;\n  address: string;\n  city: string;\n  region: string;\n  postal_code: string;\n  lat: number | null;\n  lng: number | null;\n  phone: string;\n  website: string;\n  url: string;\n  hours: Record<string, string>;\n  services: string[];\n  dietary_options: string[];\n  languages: string[];\n  images: string[];\n  status: string;\n  menu_summary?: MenuSummary | null;\n  menus?: Menu[];\n  recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];\n  dup_sources?: string[];\n  first_seen?: number;\n  last_seen: number;\n  updated_at: number;\n  active: number;\n}\n\nexport interface Facets {\n  regions: { region: string; n: number }[];\n  all_regions: string[];\n  cities: string[];\n  cuisines: { cuisine: string; n: number }[];\n  diets: { diet: string; n: number }[];\n  establishment_types: { t: string; n: number }[];\n  chains: string[];\n  sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n  id: string;\n  name: string;\n  url: string;\n  platform?: string;\n  tier?: number;\n  price_context?: string;\n  extraction?: string;\n  cadence?: string;\n  status: string;\n  active_restaurants: number;\n  menu_items: number;\n  last_sync: number | null;\n  integrations?: { label: string; status: string }[];\n}\n\nexport interface Stats {\n  restaurants: number;\n  with_menu: number;\n  chains: number;\n  regions: number;\n  sources: number;\n  menus: number;\n  items: number;\n  by_region: { region: string; n: number }[];\n  by_context: { price_context: string; n: number }[];\n  recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];\n}\n\n// --- libellés français --------------------------------------------------------\n\nexport const CUISINE_LABELS: Record<string, string> = {\n  \"quebecois\": \"Québécois\", \"francais\": \"Français\", \"italien\": \"Italien\",\n  \"pizza\": \"Pizza\", \"burgers\": \"Burgers\", \"poulet\": \"Poulet\",\n  \"bbq-grillades\": \"BBQ & grillades\", \"fruits-de-mer\": \"Fruits de mer\",\n  \"sushi-japonais\": \"Sushi & japonais\", \"chinois\": \"Chinois\", \"thai\": \"Thaï\",\n  \"vietnamien\": \"Vietnamien\", \"coreen\": \"Coréen\", \"indien\": \"Indien\",\n  \"libanais-moyen-orient\": \"Libanais & Moyen-Orient\", \"mexicain\": \"Mexicain\",\n  \"grec\": \"Grec\", \"mediterraneen\": \"Méditerranéen\",\n  \"dejeuner-brunch\": \"Déjeuner & brunch\", \"cafe-dessert\": \"Café & dessert\",\n  \"vegetarien-vegan\": \"Végé & vegan\", \"fast-food\": \"Fast-food\", \"autre\": \"Autre\",\n};\n\nexport const CONTEXT_LABELS: Record<string, string> = {\n  \"dine-in\": \"Prix en salle\",\n  \"takeout\": \"Prix pour emporter\",\n  \"delivery\": \"Prix livraison (majoré 25-30 %)\",\n};\n\nexport const CONTEXT_SHORT: Record<string, string> = {\n  \"dine-in\": \"Salle\", \"takeout\": \"Emporter\", \"delivery\": \"Livraison\",\n};\n\nexport const TYPE_LABELS: Record<string, string> = {\n  \"restaurant\": \"Restaurant\", \"fast-food\": \"Fast-food\", \"cafe\": \"Café\",\n  \"bar\": \"Bar & pub\", \"food-truck\": \"Food truck\", \"traiteur\": \"Traiteur\",\n  \"boulangerie-patisserie\": \"Boulangerie-pâtisserie\",\n  \"casse-croute\": \"Casse-croûte\", \"microbrasserie\": \"Microbrasserie\",\n  \"hotel\": \"Resto d'hôtel\", \"ghost-kitchen\": \"Cuisine fantôme\",\n};\n\nexport const DIET_LABELS: Record<string, string> = {\n  \"vegan\": \"Vegan\", \"vegetarien\": \"Végétarien\", \"sans-gluten\": \"Sans gluten\",\n  \"halal\": \"Halal\", \"casher\": \"Casher\", \"sans-noix\": \"Sans noix\", \"epice\": \"Épicé\",\n};\n\nexport const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;\nexport const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;\nexport const dietLabel = (d: string) => DIET_LABELS[d] ?? d;\n\nconst SOURCE_NAMES: Record<string, string> = {\n  ueat: \"UEAT (commande en ligne)\",\n  osm: \"OpenStreetMap\",\n};\nexport function registerSourceNames(sources: Source[]) {\n  for (const s of sources) SOURCE_NAMES[s.id] = s.name;\n}\nexport const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;\n\n/** 16.5 -> « 16,50 $ » */\nexport const fmtPrice = (p: number | null | undefined): string =>\n  p == null ? \"—\" : `${p.toFixed(2).replace(\".\", \",\")} $`;\n\nexport const fmtDate = (iso: string | null | undefined): string => {\n  if (!iso) return \"—\";\n  const d = new Date(iso);\n  return isNaN(d.getTime()) ? iso : d.toLocaleDateString(\"fr-CA\");\n};\n\nexport const fmtTs = (ts: number | null | undefined): string =>\n  ts ? new Date(ts * 1000).toLocaleString(\"fr-CA\") : \"—\";\n\n// --- client -------------------------------------------------------------------\n\nasync function get<T>(path: string): Promise<T> {\n  const ctl = new AbortController();\n  const timer = setTimeout(() => ctl.abort(), 20000);\n  try {\n    const resp = await fetch(path, { signal: ctl.signal });\n    if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);\n    return (await resp.json()) as T;\n  } finally {\n    clearTimeout(timer);\n  }\n}\n\nexport interface Dish {\n  item: string;\n  description: string;\n  price: number | null;\n  currency: string;\n  image?: string | null;\n  tags: string[];\n  section: string;\n  price_context: string;\n  price_source: string;\n  captured_at: string;\n  brand: string;\n  restaurant: string;\n  uid: string;\n  city: string;\n  region: string;\n  locations: number;\n}\n\nexport interface DishQuery {\n  q: string; region?: string; city?: string; cuisine?: string;\n  price_max?: number; limit?: number; offset?: number;\n}\n\nexport function fetchDishes(query: DishQuery) {\n  const params = new URLSearchParams();\n  for (const [k, v] of Object.entries(query))\n    if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n  return get<{ total: number; count: number; dishes: Dish[] }>(\n    `/api/dishes?${params.toString()}`);\n}\n\nexport interface RestaurantQuery {\n  region?: string; city?: string; cuisine?: string; establishment_type?: string;\n  diet?: string; service?: string; price_range?: string; chain?: string;\n  source?: string; q?: string; has_menu?: string; sort?: string;\n  limit?: number; offset?: number;\n}\n\nexport function fetchRestaurants(query: RestaurantQuery = {}) {\n  const params = new URLSearchParams();\n  for (const [k, v] of Object.entries(query))\n    if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n  return get<{ total: number; count: number; restaurants: Restaurant[] }>(\n    `/api/restaurants?${params.toString()}`);\n}\n\nexport const fetchRestaurant = (uid: string) =>\n  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\nexport const fetchFacets = (region?: string) =>\n  get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get<Stats>(\"/api/stats\");\n\n// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------\n\nexport interface Me {\n  uid: number;\n  ka_id: string;\n  email: string;\n  name: string;\n  picture: string;\n  bio?: string;\n  city?: string;\n  public_url?: string;\n  profile_source: string;\n}\n\nexport const fetchAuthConfig = () => get<{ ka: boolean }>(\"/api/auth/config\");\n\nexport async function fetchMe(): Promise<Me | null> {\n  try {\n    return await get<Me>(\"/api/me\");\n  } catch {\n    return null;\n  }\n}\n\nexport async function logout(): Promise<void> {\n  await fetch(\"/api/auth/logout\", { method: \"POST\" });\n}\n\nexport const fetchFavoris = () =>\n  get<{ count: number; restaurants: Restaurant[]; uids: string[] }>(\"/api/favoris\");\n\nexport async function toggleFavori(uid: string): Promise<boolean> {\n  const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: \"POST\" });\n  if (!resp.ok) throw new Error(`API ${resp.status}`);\n  return (await resp.json()).favori as boolean;\n}\n","numLines":287,"startLine":1,"totalLines":287}}}}22{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d5a5f967-3dc7-4d64-b3e2-341095e4d6fe","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}23{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIShwcKpwEIERgCKkDlPPs2Iw84p7VAl3lsuf+QDPJ077fRIx9WMbG3e19VFKBtMgvWCrEvmaTbMlTbgCWWNco4RiIIDUpHTATbSY/tMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGN1ajUBrABAhIMh6l44+3FLFUAzt6TGgz+AaP13y+Xhl/fa98iMMxhD4t3z3YawADnjNzokpKZMByuqEkNgaeAq7ypfiu8/AeRXkijPqth41IeO+JMDiqMBbsbD/zwz6Ksk5dFcQ6NCE6g3gVGgPwJmEf254at07B5AA7TksW6xTIH3TN81ODYL5CZdzzOetBXjhDT3PZWb9UxjYSTgBAN/vZGK+MaJAiEzwJYwdeTCkmM8CGMdSu9Ykd3EXPQipCCDnMoTtA6rNUG4r3H14UFsNtwJArDwCC7z2ONjip3SxBphsaEL2CQZJd1vY1hde5B1Ry/sB1jfISea6HehOzG0sL+Yj8WgbYLTMyvkUs4kyBK92vALGUGh2iwWofxH/0o/4MmIQ+7ZAQNT0chWi2fDix6597IYWfWKodvUleBWapUHTZLvO8SaLzcRicutUzi8aFxTfAXB6uE11bs96pES/G0uWXz/2Wdfw/f6Sow3AFm9c5Sq1uE8Z7VSmKqvL64VBnNn0XOlyPd29awZMTr2IWtN6N58SNYNg8dkiiGGe0xeEqx25aCZVOjJsaJC+dfWiBtKdhgumZVu8DGIENSWkqF6bHCZ2vSBmjVWh97/0c3qm15Ha1yb4ywjoLB9+jajuWu4Wb6Vk8N4xNg7H4Y/wNIFt6oKivyKYUOoVkAgztp7MOCoC2+v8HQn75rqoGQlz5Yq+DnDmoVP8bw/Rq+IcxirOd3heWe/cnaLRkmqCDdgy0S1apxTtGNphelnw+u4saST2u33xRW4SOpZHflW3vCsXBY3HOrh+uNb6hkWeZkbthYnuUPO/9oKMzeXGt8IYB4mZjYgc+7yW/HVFoygEk6S7lMrkTEjXnqy29eg7D0TJNfPxJn74OJJWjX4PXTVpL9Dsb9Uf6E8TQYywrfPx9KPAHLYKNwQHxU2do3FvW+yNjnS6cAuWxCs/1KTlwPJFtxm927pOdzTbSnfvW+GunpU80YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9c1608c5-8f69-4c48-91e8-b9451a9465b1"}}24{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjAQKqAEIERgCKkAKb3pdSrxUGpKRN9hYBDA1fuQaAbj06Pq3xcxBRpUgWiH+jfJnZg/i1eW0q5bPSgj4d/EgVz5xNEodTtSycscmMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQi06A1NuRRJo41amXnK2lOYgBAagBjdWo1AawAQISDF8ZYuuW9jSuulUGBRoMqrVGFaOQzrfWSbz5IjAyiamw6MRDN7vfDZQKDboCeFmOnXT52nDAgmSGT8YG14HHTIkrL7iuBMrCg5iXYYYqkAK8XHQmQuVG4T18yVAiqW7fuoI/8PPGOGv2l2ohctJgdAktfZ4pQIjoZrmApWtGUx8pxga+6/+KEYwzfQHAGVxgzahGHKCa7tl6Huqq4YwA01+jYdpv5JCxhlrErY85TGmvrIO+xZAe5zGQ0WxvzJt6y9GPLqBfe609+9Iow52aF3I8f98CIN+LhhCkFC70jg/zKIDDnOg4PJ4xDT3YpXXoE89DHilGt99ppvwEP+6TZuQ4u8Yt6CqPLsYYXJ+0yVjJtGx6CV++IQzC87vOvOzNyGrXh0QAATFsTMnmEbHLBBVqro/ae/jqYjSvt3tzEczC2tRIV03ay89U5hIATpOlbB8og/GZVCvSC/ZZxV5FkRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"892551cf-0ac6-4b97-9e10-e740d6820f15"}}25{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RybvL1vESbCEsKuD2fGrsJ","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","limit":80},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ef3ab84b-4018-4821-8f0f-fa45fd1cdbab"}}26{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RybvL1vESbCEsKuD2fGrsJ","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File:   pages/Resto.tsx\n4\t// Desc:   Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n5\t//         + menu complet par sections avec prix, options et badge de CONTEXTE\n6\t//         de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n7\t//         est inutilisable ; delivery affiché clairement étiqueté).\n8\t// ==============================================================================\n9\timport { useEffect, useState } from \"react\";\n10\timport { Link, useParams } from \"react-router-dom\";\n11\timport {\n12\t  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n13\t  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n14\t} from \"../api\";\n15\timport { IcoCompass } from \"../components/Icons\";\n16\t\n17\tfunction MenuBlock({ menu }: { menu: Menu }) {\n18\t  const [openSections, setOpenSections] = useState<Set<number>>(\n19\t    new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n20\t  const toggle = (i: number) => {\n21\t    const next = new Set(openSections);\n22\t    if (next.has(i)) next.delete(i);\n23\t    else next.add(i);\n24\t    setOpenSections(next);\n25\t  };\n26\t  return (\n27\t    <section className=\"menu-block\">\n28\t      <div className={`ctx-banner ctx-${menu.price_context}`}>\n29\t        <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n30\t        <span>\n31\t          source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n32\t          {\" · \"}{menu.item_count} plats\n33\t        </span>\n34\t      </div>\n35\t      {menu.sections.map((sec, i) => (\n36\t        <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n37\t          <button className=\"menu-section-head\" onClick={() => toggle(i)}\n38\t            aria-expanded={openSections.has(i)}>\n39\t            {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n40\t            <h3>{sec.name}</h3>\n41\t            <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n42\t          </button>\n43\t          {openSections.has(i) && (\n44\t            <ul className=\"menu-items\">\n45\t              {sec.items.map((it, j) => (\n46\t                <li className=\"menu-item\" key={`${it.name}-${j}`}>\n47\t                  <div className=\"mi-row\">\n48\t                    <div className=\"mi-main\">\n49\t                      <div className=\"mi-line\">\n50\t                        <span className=\"mi-name\">\n51\t                          {it.name}\n52\t                          {(it.tags ?? []).map((t) => (\n53\t                            <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n54\t                          ))}\n55\t                        </span>\n56\t                        <span className=\"mi-dots\" aria-hidden=\"true\" />\n57\t                        <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n58\t                      </div>\n59\t                      {it.description && <p className=\"mi-desc\">{it.description}</p>}\n60\t                    </div>\n61\t                    {it.image && (\n62\t                      <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n63\t                    )}\n64\t                  </div>\n65\t                  {(it.options ?? []).length > 0 && (\n66\t                    <div className=\"mi-optgroups\">\n67\t                      {(it.options ?? []).map((op, k) => (\n68\t                        <div className=\"og\" key={`${op.group}-${k}`}>\n69\t                          <div className=\"og-head\">\n70\t                            <span className=\"og-name\">{op.group}</span>\n71\t                            <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n72\t                              {op.required ? \"Choix requis\" : \"Optionnel\"}\n73\t                            </span>\n74\t                          </div>\n75\t                          <div className=\"og-choices\">\n76\t                            {op.choices.map((c, m) => (\n77\t                              <span className=\"og-choice\" key={`${c.name}-${m}`}>\n78\t                                {c.name}\n79\t                                {c.price_delta !== 0 && (\n80\t                                  <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d98795a8-dd3f-48be-b745-7dce732d6392","timestamp":"2026-08-22T23:02:38.620Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Resto.tsx\n// Desc:   Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n//         + menu complet par sections avec prix, options et badge de CONTEXTE\n//         de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n//         est inutilisable ; delivery affiché clairement étiqueté).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoCompass } from \"../components/Icons\";\n\nfunction MenuBlock({ menu }: { menu: Menu }) {\n  const [openSections, setOpenSections] = useState<Set<number>>(\n    new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n  const toggle = (i: number) => {\n    const next = new Set(openSections);\n    if (next.has(i)) next.delete(i);\n    else next.add(i);\n    setOpenSections(next);\n  };\n  return (\n    <section className=\"menu-block\">\n      <div className={`ctx-banner ctx-${menu.price_context}`}>\n        <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n        <span>\n          source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n          {\" · \"}{menu.item_count} plats\n        </span>\n      </div>\n      {menu.sections.map((sec, i) => (\n        <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n          <button className=\"menu-section-head\" onClick={() => toggle(i)}\n            aria-expanded={openSections.has(i)}>\n            {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n            <h3>{sec.name}</h3>\n            <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n          </button>\n          {openSections.has(i) && (\n            <ul className=\"menu-items\">\n              {sec.items.map((it, j) => (\n                <li className=\"menu-item\" key={`${it.name}-${j}`}>\n                  <div className=\"mi-row\">\n                    <div className=\"mi-main\">\n                      <div className=\"mi-line\">\n                        <span className=\"mi-name\">\n                          {it.name}\n                          {(it.tags ?? []).map((t) => (\n                            <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n                          ))}\n                        </span>\n                        <span className=\"mi-dots\" aria-hidden=\"true\" />\n                        <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n                      </div>\n                      {it.description && <p className=\"mi-desc\">{it.description}</p>}\n                    </div>\n                    {it.image && (\n                      <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n                    )}\n                  </div>\n                  {(it.options ?? []).length > 0 && (\n                    <div className=\"mi-optgroups\">\n                      {(it.options ?? []).map((op, k) => (\n                        <div className=\"og\" key={`${op.group}-${k}`}>\n                          <div className=\"og-head\">\n                            <span className=\"og-name\">{op.group}</span>\n                            <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n                              {op.required ? \"Choix requis\" : \"Optionnel\"}\n                            </span>\n                          </div>\n                          <div className=\"og-choices\">\n                            {op.choices.map((c, m) => (\n                              <span className=\"og-choice\" key={`${c.name}-${m}`}>\n                                {c.name}\n                                {c.price_delta !== 0 && (\n                                  <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>","numLines":80,"startLine":1,"totalLines":242}}}}27{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcx24pKMcGzHtqaMwBP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011kaTNC7SHuY9ZssT8Vv6r3","name":"Grep","input":{"pattern":"uids|/api/restaurants","path":"/Users/simon-pierreboucher/apps/resto-ka/restoka","output_mode":"content","-n":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5986,"cache_read_input_tokens":52724,"cache_creation":{"ephemeral_5m_input_tokens":5986,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3034a17f-18fe-4af9-8959-5651bb46157a"}}28{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011kaTNC7SHuY9ZssT8Vv6r3","type":"tool_result","content":"restoka/permits.py:119:    seen_uids: set[str] = set()\nrestoka/permits.py:165:        if hit is None or hit in seen_uids:\nrestoka/permits.py:167:        seen_uids.add(hit)\nrestoka/auth.py:270:            \"uids\": [r[\"uid\"] for r in out]}\nrestoka/dedup.py:107:    for uids in groups.values():\nrestoka/dedup.py:108:        uids = sorted(uids)\nrestoka/dedup.py:109:        for other in uids[1:]:\nrestoka/dedup.py:110:            union(uids[0], other)\nrestoka/web.py:68:def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\nrestoka/web.py:70:    if not uids:\nrestoka/web.py:72:    qmarks = \",\".join(\"?\" * len(uids))\nrestoka/web.py:76:            f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\nrestoka/web.py:107:@app.get(\"/api/restaurants\")\nrestoka/web.py:251:@app.get(\"/api/restaurants/{uid:path}/prices\")\nrestoka/web.py:262:@app.get(\"/api/restaurants/{uid:path}/inspections\")\nrestoka/web.py:284:@app.get(\"/api/restaurants/{uid:path}\")\nrestoka/db.py:325:    seen_uids = set()\nrestoka/db.py:345:        seen_uids.add(resto.uid)\nrestoka/db.py:410:            if r[\"uid\"] in seen_uids:\nrestoka/stats.py:564:        seen_uids: set[str] = set()\nrestoka/stats.py:572:            if r[\"uid\"] in seen_uids:\nrestoka/stats.py:574:            seen_uids.add(r[\"uid\"])"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1f7d9705-d154-4c5e-bf1d-147764e9f107","timestamp":"2026-08-22T23:02:39.313Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"restoka/permits.py:119:    seen_uids: set[str] = set()\nrestoka/permits.py:165:        if hit is None or hit in seen_uids:\nrestoka/permits.py:167:        seen_uids.add(hit)\nrestoka/auth.py:270:            \"uids\": [r[\"uid\"] for r in out]}\nrestoka/dedup.py:107:    for uids in groups.values():\nrestoka/dedup.py:108:        uids = sorted(uids)\nrestoka/dedup.py:109:        for other in uids[1:]:\nrestoka/dedup.py:110:            union(uids[0], other)\nrestoka/web.py:68:def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\nrestoka/web.py:70:    if not uids:\nrestoka/web.py:72:    qmarks = \",\".join(\"?\" * len(uids))\nrestoka/web.py:76:            f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\nrestoka/web.py:107:@app.get(\"/api/restaurants\")\nrestoka/web.py:251:@app.get(\"/api/restaurants/{uid:path}/prices\")\nrestoka/web.py:262:@app.get(\"/api/restaurants/{uid:path}/inspections\")\nrestoka/web.py:284:@app.get(\"/api/restaurants/{uid:path}\")\nrestoka/db.py:325:    seen_uids = set()\nrestoka/db.py:345:        seen_uids.add(resto.uid)\nrestoka/db.py:410:            if r[\"uid\"] in seen_uids:\nrestoka/stats.py:564:        seen_uids: set[str] = set()\nrestoka/stats.py:572:            if r[\"uid\"] in seen_uids:\nrestoka/stats.py:574:            seen_uids.add(r[\"uid\"])","numLines":22}}}29{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5917c3dc-583f-462d-91c1-896aed0a42d3","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}30{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcxooQhvAma6Kh7zww6","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjwMKpwEIERgCKkDKVQTwY07b1i65UETHTn/nG8zwl8CBaxvAsXls96A8CpjtYZefdPkNKDtwEb6GM2hOH4oL+tvFBh1dgF+8aFUfMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGT1ajUBrABAhIM9fjm/FA3sO60o6Y7GgwhPe+fKBsFECdFp2siMPp7cIzmVrJ01szqRs9dunfU+lKwBE9e1FkFDR6Mpw8tnAzKV2L6LBuzLTfkgVeHUCqUAYE9cP+rI3SkvsTX4Vj4SBqLNIbSnZCVpF3J+VgPljQen4470SP00P1YrqgdIiGryyEU+sQbgMFlVER/gjV0cgFbjeYUPZFK1dq3lCKjfcE8BnOAK/Ttqumv/BoyZ55995r26FgTuoVh/U/7t6RHOmjajD7SEwWkAc8nvJnA8Km0v8Uq6ULKqFT2bmYvfsQ933ONeU0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2861,"cache_read_input_tokens":58710,"cache_creation":{"ephemeral_5m_input_tokens":2861,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3e23d32d-47f5-401a-bab8-4896f7f849b0"}}31{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcxooQhvAma6Kh7zww6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G83XTyDDAF797VKvgvbgAv","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","offset":100,"limit":110},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2861,"cache_read_input_tokens":58710,"cache_creation":{"ephemeral_5m_input_tokens":2861,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"219cbc96-4f88-4ba4-b27e-665c7f70c20a"}}32{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G83XTyDDAF797VKvgvbgAv","type":"tool_result","content":"100\t\n101\t\n102\t@app.get(\"/healthz\")\n103\tdef healthz():\n104\t    return {\"status\": \"ok\"}\n105\t\n106\t\n107\t@app.get(\"/api/restaurants\")\n108\tdef list_restaurants(\n109\t    region: str | None = None,\n110\t    city: str | None = None,\n111\t    cuisine: str | None = None,\n112\t    establishment_type: str | None = None,\n113\t    diet: str | None = None,             # vegan, sans-gluten…\n114\t    service: str | None = None,          # takeout, delivery, salle\n115\t    price_range: str | None = None,      # $ à $$$$\n116\t    chain: str | None = None,\n117\t    source: str | None = None,\n118\t    q: str | None = None,\n119\t    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n120\t    active: int = 1,\n121\t    sort: str = \"menu\",                  # menu | name | price | recent\n122\t    limit: int = Query(60, le=500),\n123\t    offset: int = 0,\n124\t):\n125\t    con = db.connect()\n126\t    sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\"   # doublons masqués\n127\t    args: list = []\n128\t    if active in (0, 1):\n129\t        sql += \" AND active=?\"; args.append(active)\n130\t    if region:\n131\t        sql += \" AND region=?\"; args.append(region)\n132\t    if city:\n133\t        sql += \" AND city=?\"; args.append(city)\n134\t    if cuisine:\n135\t        sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n136\t    if establishment_type:\n137\t        sql += \" AND establishment_type=?\"; args.append(establishment_type)\n138\t    if diet:\n139\t        sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n140\t    if service:\n141\t        sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n142\t    if price_range:\n143\t        sql += \" AND price_range=?\"; args.append(price_range)\n144\t    if chain:\n145\t        sql += \" AND chain=?\"; args.append(chain)\n146\t    if source:\n147\t        sql += \" AND source=?\"; args.append(source)\n148\t    if q:\n149\t        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n150\t        args += [f\"%{q}%\"] * 4\n151\t    if has_menu == 1:\n152\t        sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n153\t    elif has_menu == 0:\n154\t        sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n155\t    total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n156\t    if sort == \"recent\":\n157\t        sql += \" ORDER BY updated_at DESC\"\n158\t    elif sort == \"price\":\n159\t        sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n160\t    elif sort == \"name\":\n161\t        sql += \" ORDER BY name COLLATE NOCASE ASC\"\n162\t    else:   # défaut « menu » : les restos avec menu complet d'abord\n163\t        sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n164\t                \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n165\t    sql += \" LIMIT ? OFFSET ?\"\n166\t    args += [limit, offset]\n167\t    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n168\t    menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n169\t    for r in rows:\n170\t        r[\"menu_summary\"] = menus.get(r[\"uid\"])\n171\t    con.close()\n172\t    return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n173\t\n174\t\n175\t@app.get(\"/api/dishes\")\n176\tdef search_dishes(\n177\t    q: str = Query(..., min_length=2),\n178\t    region: str | None = None,\n179\t    city: str | None = None,\n180\t    cuisine: str | None = None,\n181\t    price_max: float | None = None,\n182\t    limit: int = Query(30, le=100),\n183\t    offset: int = 0,\n184\t):\n185\t    \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n186\t\n187\t    Les succursales d'une même chaîne partagent le même menu : les résultats\n188\t    sont regroupés par (marque, plat, prix, contexte) avec le nombre\n189\t    d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n190\t    \"\"\"\n191\t    con = db.connect()\n192\t    needle = q.strip().lower()\n193\t    sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n194\t           \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n195\t           \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n196\t           \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n197\t    args: list = [f\"%{needle}%\"]\n198\t    if region:\n199\t        sql += \" AND r.region=?\"; args.append(region)\n200\t    if city:\n201\t        sql += \" AND r.city=?\"; args.append(city)\n202\t    if cuisine:\n203\t        sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n204\t\n205\t    groups: dict[tuple, dict] = {}\n206\t    for row in con.execute(sql, args):\n207\t        try:\n208\t            sections = json.loads(row[\"sections\"] or \"[]\")\n209\t        except ValueError:"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"6a4f8dbd-1d7e-4da4-9c84-fad0737850a6","timestamp":"2026-08-22T23:02:44.640Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","content":"\n\n@app.get(\"/healthz\")\ndef healthz():\n    return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/restaurants\")\ndef list_restaurants(\n    region: str | None = None,\n    city: str | None = None,\n    cuisine: str | None = None,\n    establishment_type: str | None = None,\n    diet: str | None = None,             # vegan, sans-gluten…\n    service: str | None = None,          # takeout, delivery, salle\n    price_range: str | None = None,      # $ à $$$$\n    chain: str | None = None,\n    source: str | None = None,\n    q: str | None = None,\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,\n    sort: str = \"menu\",                  # menu | name | price | recent\n    limit: int = Query(60, le=500),\n    offset: int = 0,\n):\n    con = db.connect()\n    sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\"   # doublons masqués\n    args: list = []\n    if active in (0, 1):\n        sql += \" AND active=?\"; args.append(active)\n    if region:\n        sql += \" AND region=?\"; args.append(region)\n    if city:\n        sql += \" AND city=?\"; args.append(city)\n    if cuisine:\n        sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n    if establishment_type:\n        sql += \" AND establishment_type=?\"; args.append(establishment_type)\n    if diet:\n        sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n    if service:\n        sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n    if price_range:\n        sql += \" AND price_range=?\"; args.append(price_range)\n    if chain:\n        sql += \" AND chain=?\"; args.append(chain)\n    if source:\n        sql += \" AND source=?\"; args.append(source)\n    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4\n    if has_menu == 1:\n        sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n    elif has_menu == 0:\n        sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n    total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n    if sort == \"recent\":\n        sql += \" ORDER BY updated_at DESC\"\n    elif sort == \"price\":\n        sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n    elif sort == \"name\":\n        sql += \" ORDER BY name COLLATE NOCASE ASC\"\n    else:   # défaut « menu » : les restos avec menu complet d'abord\n        sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n                \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n    sql += \" LIMIT ? OFFSET ?\"\n    args += [limit, offset]\n    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n    menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n    for r in rows:\n        r[\"menu_summary\"] = menus.get(r[\"uid\"])\n    con.close()\n    return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n\n\n@app.get(\"/api/dishes\")\ndef search_dishes(\n    q: str = Query(..., min_length=2),\n    region: str | None = None,\n    city: str | None = None,\n    cuisine: str | None = None,\n    price_max: float | None = None,\n    limit: int = Query(30, le=100),\n    offset: int = 0,\n):\n    \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n\n    Les succursales d'une même chaîne partagent le même menu : les résultats\n    sont regroupés par (marque, plat, prix, contexte) avec le nombre\n    d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n    \"\"\"\n    con = db.connect()\n    needle = q.strip().lower()\n    sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n           \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n           \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n           \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n    args: list = [f\"%{needle}%\"]\n    if region:\n        sql += \" AND r.region=?\"; args.append(region)\n    if city:\n        sql += \" AND r.city=?\"; args.append(city)\n    if cuisine:\n        sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n\n    groups: dict[tuple, dict] = {}\n    for row in con.execute(sql, args):\n        try:\n            sections = json.loads(row[\"sections\"] or \"[]\")\n        except ValueError:","numLines":110,"startLine":100,"totalLines":517}}}}33{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"53b0d38e-1cb2-413b-b10c-1b7b2752e06d","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}34{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4xQKpwEIERgCKkCWfnmbJnUiQUMLM0Z6CmvJKqpUx6aJtrEVhgGUzCvolz5LOQkTt9HPLCMA8iRj7RSzA4+itTbW0IM51XtvtCTcMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGo1ajUBrABAhIM1aKrocwZBejRA/WtGgwL8zDz3PL/4c0cGHEiMFvon3gW8ZrgHF6jhuAowMcIQHGPeFOHoaXKYOCjxBHptr1OuPrRxfBhzp2Q4W36tSroEgoRAPn0bRVpTYh2EICVGcNhD9ANgAJ4b8iWG6HBwY72fN7iP6Pcfs0rccKGx2C8fRfSUDR7nUSOyyL98LH9CyA/iix6i0PbRJL7zb92Dzl0VUVb2yzyLEVQLPaXxRfoo0euN0wzL4h7pdOOaSPEcOIVgummByWx7WpDC7927UUcTvFLDk0sb426O1vs0h32gpBTjuJUu8+Y4B795/lrtxlWg32glPzgMhES6acir/m6cxc6gG+dhVD9RtA7M+FtUTP5EPZr2VCBb059+9DyN+wAYSn3fSD/2JXhHtFc3jMCRcPnGQ2RAamBTwMYuDYCZ2n1snoSZVZoGCQyEYImQNApvceoKtXXvFwJfF7MOqcFoq089AgruRPhgM/l42QgtYRkAZhVPu9IHTrwnr2QaXXx4bdmNCbtX8XqY6I1KsqIWLdUcZIv1x9J72y/tcdQWU/s2y6FJSvOfx+3SZyPFYljROhTwps0faJoyKsph8vU22t8UP8UJqbPmrqRPW1A5glLWT7lKwHn8evjMKb0KSdUT4vytal/9wQwIR4uAciqVggCDefvX+laMdNy2xEAp9WFJcv1z8L0pmrjUHkTfpvXUTU/MbgV6zvUbUpm6ltkOfkez/f+yBxatT9ZpuQjU5Elu004v0YdgG1MRNvC3srivDyUoh9WRQLlmWQqnDEwQWAZARYulrmeWjeVK9MWUdn25xSEIoJKnlCpuuO/i0GtvjiCjtnA9naRpwDkKZetMYnf2EY/KlACAl1kx21Jh9ycyJ7OM0rl1DLjlvaYtGoVdzag0Y2TZxzvQV8Ns8fEVoIqcWVKv2DP87Gg/trZNJQRamnJT/1xBr86IZGO+rRlDbQ1YnY9Le5b1cCwu4Uh4T9qm7Qx7eK5eflqyaQa1lglMZhtLs9MTfJVa7Mr4QywMP8wzhQ5LlBLjj9CGonGg1PFlB1SEsyYtGjvPeFE1vLbTbQwX+ED3hayRR54AHYergvewB7UGxY1z6xg7qypoC15nUp/DovWC9OwC5h+uU871xTsMV9jkT2V+lScbiy4mj/WdfFeRIJg5r2008lvFkMY7bkPqrozaXFWd1q28HjndWdDan881q8T2L2RUHml1dt24kwjcULW+ULUKmAnSY1F3aFEhmX0x+WaWFCjZsdZbnvgCA+cIehGcw13Aa3lYh7pWgdl8/Kauaw/0SqRv8ADaLINyq7q6gf4/tt6DjRi6bWah2ESRg58nHCPcIaG3iRgL7mRShXm4EAtzgLoF20VpeMvqSqChHPn7op8AZF/9xaGNcXpkgVaF7cvQesmGdaApPzpB44R8GYRDX3Rj2rqovx6javhfXIJgCNbOgz5IG/t8mxdptk2rvZn0O9T9mu/e1enrAMVdEtqVSE3ceLuuzKMeSHX8EadFvtTJKxjUksNymw7kmAlFQ5fhCE3aXLPAtcg2byjshOYqnHhuJfHdA3eDDpwrGbU7ZsgxeNJHhUnaAn4IVCpblal63v9rm+B/5DdZ+MogO/Y3oL8uYFKf7q8D/9TPE+HCZGqsjvvYRBZjq//h/jxk1T/TyXfdzGcBOIH1mtCAuXvjcuSXANnpbitxDIqtt56gYjJ/kRit+GNh/UrUcYdaocL3S6EKlqx+zKHzex2Mt/687tk5RVdNl00YNvJYPNp+52g49SDofG4ALmhIhB8rRcydKzLe7rukY5Dpiwmnan0VFFTV0FJ3remJoqfHAFdvWEzN39B8Dw1KsedJdA3W5D5ztZh/Vsq4U0dpIWvTULq+TAL9jiKj+v43LeNsjPllhMXM3QUdixAIwbY1wK7yTc1Glmmwk9/gdKCi5sWCX7q8SYMi7OXUGK51/M2fA8RZoPm68ucon23VTwjsHHDACqBFHmEwfLSb86HwKu6Wf7mcHGbEieJAT9pYS2kMhHGj/lLjPFdYweMQqVnFEXxU8BQLTEf6KgRqzabHZ7TmOS/ZL1BYZ9jjJOsSFwenMT6Cx0ITihz/p9gyA1+8IDrl0uPEOph/1TNj1AFgN4K3plkWQMYq4fkYFHtrzg2LFn3u4a0f7670BbYpIj7PL/Ku0mG7yX+ms4+jpTN44zIET4VtEI+t7s24Ij3XOlAEsw+GrG38FdaBqEmD89rClvs7bHyJ2ZOxhYWldXaF1CfJ4H2BYq71jnjKjG8vZyGiL1aRGhCpccjq+AObpnsDRS5Iv2nqEzLhRtB7fsQhKZXjCUAES1kvJLG1/5/q3QTpzGO4h8E8o123zs0CDlNBbKZrwzHViyMbHGqwnx6CoAgsMZMZ+pXMbILy8ibrIk8trWnBjB8opqBzGir64BdFLcD0lUhlzkVvDnm9tHLJTfIPhlBRaFEqwOqfCu0vxX99YYNsRzc6W4omA3YBLb1Fdf0TEt10gd7YcNEFNfDV3iR1qNMB3F+WmPWnDEKJxnuYT0gLYIV4Xgw8kg/+ccJu/LDZ+sykLHZFScAu7o7RcLVs3Y02rL0sV8/KKhhZxrv0WPcrV2VkwOzNPPjDuE8kwzZVWoVf5jlRZGz9OKv0ND2j2QkX62XOmPdkNh3Cdmi9H5Zi1hksxYdvjRvrN+XELTCeXpxuz8AjqqrlT/MAg7JuJ0UO2dSHDV1cVIVCJ1NzOAqPJu1ZVL4tWlveKJ7m1Zf1cMV/NKPoiHd2rfzrnggyCpi/WERDktuyopw83fVjhmoHsbs/ErfMjwrvCUuX5/tmSDm2j+OUVbaWVdSnv30EkmV9uuhBP65462vEfjXY+jji5C+jxABVuCFH+qTMAkG5pyX4uLk2C26IOkvDjdx1KfwGBZOPZCL2Z1zY3iiEM1HTmCzX4FTwujpNyoEZfxHRBtq+vbP/1ChN/HaKYYXqiLKIlE+8wBA97gXuujprYL5rxhIkYfJIwUCbmdP/zQpMZ9vsFJBqGOccwWYrM080adIVUlib8yZCtW0u1B4OkC/HvQU5v1O+jnFjL3G1FdycE0eNpM6lkkUTamgUN+d+uF2zcpK+hm7rPVknaxb1w1YXYXbQvYJuyl1q93dCilEGn1FDL69nh4s+0Q15ZjmKUqRDsHA2MticjMF+Y9e7tH4ZFaPQegLk6SDYPBKZ0u5YWazOoGYrxJCILIgalAh2ylg5LTpKH+E62dxKJo6hQC1Oqsf6P1v0QqMLHfvGUspjlU51rqLpnHiB9JHdV/qvKx3QKYtL+C1hQGzyi/YCX0GxTW2zvihJJMBb4LbGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"2ecd571f-2542-445a-a403-1780ffdd2d96"}}35{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISwQQKqAEIERgCKkCMdXJYr7sjvJbfnYQnWKKIclj6cWVRj8AuJAbFfN2VaH5APXmNzFWqAT3HgIsDStjXlP+0YzovQ3GI8WxKVwVjMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQi06A1NuRRJo41amXnK2lOYgBAagBqNWo1AawAQISDGUiZNacajsOUq2RhxoM1VhA/71DJPl1kKC+IjAS58a3sW27L4u0UvrkhMoc1nOR8fo0QQOgzWpeP9hykPFl0gATxT5k5gef7xZZVVsqxQIWyV4e0FbfTOJ6Wnt2gBW1e2zu9S//sFo95pputcO2jStsJmOdiARRNHGiJfKiDqOV5Yhv8AEW3t9agw4opCaZevTi+3jhgi7MRCDqps7lj1AFjiXxgxrKUTdMfuZXhMHLns0SeywI0Q8h1i/VGuxhue1y1y5gXoa+6OLm8o4o6qNLYUqwuIU85mxG2gqasYULmB7e8GKFZFsCi1u4Y26uempRK5Osk6UtQlStAkDcLAP34+npLYgfpcC7WFPNpvMVO+YuZEat6ge1WHDiPjahwoEotMcas4II/1dA/sEMlyothR+AdsLpJAde4b2oldgxFZu8zDDSJv1KtypFrxFFH+WurKcGAtLvgqy/dZXSFZFqDk5wzpqFF4qSfFJYh7DCMYfMFZUHhnvB0IzLmANydGdiDzpa+1XTvbWfhv8kEny3uW7SGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"a8b83c85-7d76-4653-b131-3c825edbbd93"}}36{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LrQTPp29dsDE3cawcNghWL","name":"Grep","input":{"pattern":"^\\.grid|\\.results-head","path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","output_mode":"content","-n":true,"-A":6},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"69e5f5fa-b239-4a61-bad9-26445275ed4b"}}37{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LrQTPp29dsDE3cawcNghWL","type":"tool_result","content":"217:.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n218:.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n219:.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n220:.grid {\n221-  display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n222-  gap: 22px; padding-bottom: 30px;\n223-}\n224-@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n225-\n226-.card {\n--\n650:  .results-head { margin-top: 20px; }\n651-  .chips { margin-right: -16px; padding-right: 16px; }\n652-  .notice { padding: 48px 16px; }\n653-  .menu-items { padding: 0 14px 12px; }\n654-  .menu-section-head { padding: 13px 14px; }\n655-  .mi-thumb { width: 56px; height: 56px; }\n656-}"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"51dfc704-9389-4996-b146-c0d7c5e371ee","timestamp":"2026-08-22T23:03:05.477Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"217:.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n218:.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n219:.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n220:.grid {\n221-  display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n222-  gap: 22px; padding-bottom: 30px;\n223-}\n224-@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n225-\n226-.card {\n--\n650:  .results-head { margin-top: 20px; }\n651-  .chips { margin-right: -16px; padding-right: 16px; }\n652-  .notice { padding: 48px 16px; }\n653-  .menu-items { padding: 0 14px 12px; }\n654-  .menu-section-head { padding: 13px 14px; }\n655-  .mi-thumb { width: 56px; height: 56px; }\n656-}","numLines":18}}}38{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJcyCcrD5v29sZeAs4c5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013mtW5zRiywigpnfa7MvyCb","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components/RestoCard.tsx"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2381,"cache_read_input_tokens":61571,"cache_creation":{"ephemeral_5m_input_tokens":2381,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"63869124-4f74-4565-96c0-1208a73fb302"}}39{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013mtW5zRiywigpnfa7MvyCb","type":"tool_result","content":"1\t// ==============================================================================\n2\t// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t// File:   components/RestoCard.tsx\n4\t// Desc:   Carte de restaurant (grille de résultats) — même patron que la\n5\t//         ListingCard de Lou·Ka : prix en tête, badge de contexte de prix\n6\t//         TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.\n7\t// ==============================================================================\n8\timport { Link } from \"react-router-dom\";\n9\timport { useAccount } from \"../account\";\n10\timport {\n11\t  CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,\n12\t} from \"../api\";\n13\timport { IcoPlate } from \"./Icons\";\n14\t\n15\texport default function RestoCard({ r }: { r: Restaurant }) {\n16\t  const ms = r.menu_summary;\n17\t  const img = ms?.image || (r.images ?? [])[1] || null;   // photo de plat d'abord\n18\t  const logo = (r.images ?? [])[0] || null;\n19\t  const { me, favs, toggleFav } = useAccount();\n20\t  const fav = favs.has(r.uid);\n21\t  return (\n22\t    <Link to={`/resto/${encodeURIComponent(r.uid)}`} className=\"card\">\n23\t      <div className=\"card-img\">\n24\t        {img ? (\n25\t          <img src={img} alt={r.name} loading=\"lazy\" />\n26\t        ) : logo ? (\n27\t          <div className=\"noimg\"><img className=\"card-logo\" src={logo} alt={r.name} loading=\"lazy\" /></div>\n28\t        ) : (\n29\t          <div className=\"noimg\"><IcoPlate size={38} strokeWidth={1.6} /></div>\n30\t        )}\n31\t        {r.price_range && <span className=\"badge type\">{r.price_range}</span>}\n32\t        {ms && (\n33\t          <span className={`badge right ctx ctx-${ms.price_context}`}>\n34\t            {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}\n35\t          </span>\n36\t        )}\n37\t        {me && (\n38\t          <button\n39\t            className={`fav-btn ${fav ? \"on\" : \"\"}`}\n40\t            aria-label={fav ? \"Retirer des favoris\" : \"Ajouter aux favoris\"}\n41\t            aria-pressed={fav}\n42\t            onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }}\n43\t          >\n44\t            {fav ? \"♥\" : \"♡\"}\n45\t          </button>\n46\t        )}\n47\t      </div>\n48\t      <div className=\"card-body\">\n49\t        <div className=\"card-price\">\n50\t          {ms?.price_median != null ? (\n51\t            <>\n52\t              {fmtPrice(ms.price_median)} <small>plat médian</small>\n53\t            </>\n54\t          ) : (\n55\t            <span className=\"no-menu\">Menu à venir</span>\n56\t          )}\n57\t        </div>\n58\t        <div className=\"card-title\">{r.name}</div>\n59\t        <div className=\"card-meta\">\n60\t          <span>{typeLabel(r.establishment_type)}</span>\n61\t          {r.cuisines.slice(0, 2).map((c) => (\n62\t            <span key={c}>\n63\t              <span className=\"sep\" /> {cuisineLabel(c)}\n64\t            </span>\n65\t          ))}\n66\t        </div>\n67\t        <div className=\"card-meta\">\n68\t          {r.city && <span>{r.city}</span>}\n69\t          {r.city && r.region && <span className=\"sep\" />}\n70\t          {r.region && <span>{r.region}</span>}\n71\t        </div>\n72\t        <div className=\"card-foot\">\n73\t          <span className=\"source-tag\">{sourceName(r.source)}</span>\n74\t          {ms && <span className=\"avail\">{ms.item_count} plats au menu</span>}\n75\t        </div>\n76\t      </div>\n77\t    </Link>\n78\t  );\n79\t}\n80\t"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d3b860dd-fe78-4c0f-9e01-2dff76b83c91","timestamp":"2026-08-22T23:03:05.763Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/components/RestoCard.tsx","content":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   components/RestoCard.tsx\n// Desc:   Carte de restaurant (grille de résultats) — même patron que la\n//         ListingCard de Lou·Ka : prix en tête, badge de contexte de prix\n//         TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte.\n// ==============================================================================\nimport { Link } from \"react-router-dom\";\nimport { useAccount } from \"../account\";\nimport {\n  CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoPlate } from \"./Icons\";\n\nexport default function RestoCard({ r }: { r: Restaurant }) {\n  const ms = r.menu_summary;\n  const img = ms?.image || (r.images ?? [])[1] || null;   // photo de plat d'abord\n  const logo = (r.images ?? [])[0] || null;\n  const { me, favs, toggleFav } = useAccount();\n  const fav = favs.has(r.uid);\n  return (\n    <Link to={`/resto/${encodeURIComponent(r.uid)}`} className=\"card\">\n      <div className=\"card-img\">\n        {img ? (\n          <img src={img} alt={r.name} loading=\"lazy\" />\n        ) : logo ? (\n          <div className=\"noimg\"><img className=\"card-logo\" src={logo} alt={r.name} loading=\"lazy\" /></div>\n        ) : (\n          <div className=\"noimg\"><IcoPlate size={38} strokeWidth={1.6} /></div>\n        )}\n        {r.price_range && <span className=\"badge type\">{r.price_range}</span>}\n        {ms && (\n          <span className={`badge right ctx ctx-${ms.price_context}`}>\n            {CONTEXT_SHORT[ms.price_context] ?? ms.price_context}\n          </span>\n        )}\n        {me && (\n          <button\n            className={`fav-btn ${fav ? \"on\" : \"\"}`}\n            aria-label={fav ? \"Retirer des favoris\" : \"Ajouter aux favoris\"}\n            aria-pressed={fav}\n            onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }}\n          >\n            {fav ? \"♥\" : \"♡\"}\n          </button>\n        )}\n      </div>\n      <div className=\"card-body\">\n        <div className=\"card-price\">\n          {ms?.price_median != null ? (\n            <>\n              {fmtPrice(ms.price_median)} <small>plat médian</small>\n            </>\n          ) : (\n            <span className=\"no-menu\">Menu à venir</span>\n          )}\n        </div>\n        <div className=\"card-title\">{r.name}</div>\n        <div className=\"card-meta\">\n          <span>{typeLabel(r.establishment_type)}</span>\n          {r.cuisines.slice(0, 2).map((c) => (\n            <span key={c}>\n              <span className=\"sep\" /> {cuisineLabel(c)}\n            </span>\n          ))}\n        </div>\n        <div className=\"card-meta\">\n          {r.city && <span>{r.city}</span>}\n          {r.city && r.region && <span className=\"sep\" />}\n          {r.region && <span>{r.region}</span>}\n        </div>\n        <div className=\"card-foot\">\n          <span className=\"source-tag\">{sourceName(r.source)}</span>\n          {ms && <span className=\"avail\">{ms.item_count} plats au menu</span>}\n        </div>\n      </div>\n    </Link>\n  );\n}\n","numLines":80,"startLine":1,"totalLines":80}}}}40{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7fdafa64-84dd-46a5-ad58-d4e650780618","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}41{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJczmzKhJhaYCodDfMLu","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3yQKpwEIERgCKkB/Lb3sugs6YuwTkwFJdd2eM2cneW+vXwpT2sJEbcyr8QmSNLKdtVzXlBmyiXD+1oNVdjqWWyP4r8PN4RkqLdlGMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHE1ajUBrABAhIMZ4MVcIQXTXVXc/yfGgzLJxr9aodfkDHUfq0iMBm8NIPwcKrviXe/FTLXi4IgSe5ywNIDPSQKWhhQqFFqeiizE1Q+8JTELorPO4Bj5yrkIilFBtag2GONZG/sMNouhekwWjD47QdhKIsn8I0aeq4ei3BIXe/5BS/Scy2Tg5P4oYPKgDxVWK5DTkPkkX3b9thYVLhSeGhu1ELl6j/68SArQ6aOsSWWYkWEy/i/Ohn+7AXgPW/vL8TEr9d0JLt4vcpxuYrjQQ9hQiIOphHxcM5Qiz6g34pp/0Wk+TESW5dkFT8G9JVF3HsTxg7e0EZwokafHlfTeL5kVe2u7jDQQnSTNcAgiw/f0Hd40pbwU4n7Qnmu5Xlh8qBPXF4qA2qOnHlFf6n11QN1L+qLf38m4+/UFwUP6KvOotIo+qGo9jP5cnnkuVpvLwEowgc6s1GegA8YKWCd+ePvP9NFEh9qeJX35Ym+zsQd0/1Bh8NvY4mV1lpL2iVdDqLK5P+j1azWgwVQVXNep5SyBykn5PnxCJH4KvWGpD1DEyfXMTabbG5hD1nIM7+KBa49cgme5bnMu30mxz9u6r/DWn9aQt7ru/KSfkM3UCTkaydW6PnU8FRJZuuXCUtgErLUCAY2ltZ+K4wKI4ARUifyIAuOSf58iVFuZJDPKI+L69PYsLOhhuMpVUAjpUxNKs/2EjMqe8bkgz6qb3Sa5tgDekR2vFOUj/e0HfJ2Z0CBu2W2FAfrrirHZY8EMh87ws+wXVRwZw9jA+JkpSjJSzWU7cWrJ2QJiYuNgespZAxpK9wBHdWtX6Whwvf7xLknJEjwMoGn042ImB1R2zNI0MZgdUoaqcal91RBjbRKHwIc/Cdj2I6QmO1lgX1dVjRpEDKSJ/tOAeKxH5VKBg2KlVWkW8F1ElaoBRFKWao6sYqTOOqMkChNFNZxA/PyHEr7EV1hY5mk00KkPekf+fD7wNNfJRCsKwMGm8WlMmeg4LoGt+vI8o1o0+egJzb7jalv+9aACJKR+LPagoIinr8RetmbuASnRLJEGU0P68IwWQYLLEKAcCi4NTBe/2xrslbjmk04cj/ono93daZUGRSReeVkAkO9e4sDreyhUxvKHfyrRWmOhSs3JIk4f0p6/7+MravhxgmENNKKFpFytJTZMqzIpjg+KuwRU3Zvt7vwjiL8jcoKVy2iWY1tKM+JlnxeOmtVcpZPvjRHjfKqAAZ8EpSJFOMgKoTbhDRrAUpqhnx9Qt8ofa4UvQI8tbJLJjjhRiSYvxmJkRlJW+RlC4N/nGu1Q73GiWlOUu8wHXY5fPpuUqcJFpXzYkeets0bsZbcYTa0KQLuHn7AGCUMp995YXujRAe4dbOJ9QCV//ScobJeUqboFdsZfBIbsnw0jItfGadNMTUa6gJN+9/Ihj7fA8bzKjIEgY4aEthNGMCSnk0Z+7nYy0k8S3TA6VFtJPlaA6QaCxSyb8UuPvlD8D0V/nzgHdaE7tkxTU+FoqgJSXLv2i7kZevU/1zCFQkMbbE8MWpurrHAY4OlXO8bj4jX8VNcYrTGcFbPewTS3GtrZsSkXiTTG0pMf8JMMHjwrs10yhedNKp+BnaHM2fjSokE9jq4epo86yoGpi1hbtFnHSYHn9S3G94GIs3NqRpSJxu7QP1lZWb5l/kDj1FwijoWZCYLLvR9PAQgrymrTMomw1lYoJmmA8T1c0pxSgV1Ll4vrY3PUIQITua8qEeAntU6S+J41HfzbsV6uxAwNa0Nn5sC4EdAx5UVVL9VBbXNJtmIXOc31wl3xJYUD1wsRO9XuL1YzOXv8RGEgGKml6UBDL7hQQxRUD/+BPGr3VLubAUSezEQ+uXg6fdetROIZfQcnKw07EQUR2LUsrEgLRLoGk/iaPxNBHBcDWdCqWu8K1ghTds1VcCl6GvrECROJ8bnq99KMieY6nPDTLWVmexxbDaLzVDJRTW6rBtDkI2Y/UUNRJMWRTQJUwoIpC0BWM3cik9UKauL1vtCTSkGPAuf+1FSH5bmILeDB739FMwDMVhnehUoRtuttRhQH0kVwcuQ8k7czmpV2QKBG5a9m5iLnqa3Rqtz60haCo/S3XxoC6+YXC8qUtRYIV887D5k5Ag+Ohnezr5LLGBND+HNwiGTM3kx+/VSMloc2lTUtwpRq9s+eLquYg9N60RlpiX/Q225ffU6LrprZ7BBEwfG3sVMk7a5iGdFtKiEYxQmerI+KjncG5EHvRGE3yuoBgwnb49Rff5tr66UXMT3if9PFqi32sVjUQfXrYnQZEDAIVVrpYzg2Pks3mlp8odyR81jZ8mrhtYi3kfNWEQ5mpxKFIoVAB5vcUUsBOKkneyIJQY5QvJWN1OqAvaY262BpVWhEHDA/sJk0MTGuKphLy/z5vXmdJcypxsTHPfk2RZJqSc1POVDQN6UpbhRDLbP/Ag8r8eDzG/QqyTnZ38j90u2SHELGDVDRTvW3Vv7ceo0MQi3FIAve3IvAnrS0w9BSFycJbGYWfrAamWW+prtrvv75G2pOj1wS0xgCaal0IALa8AaoZjsqroaTJ9MUX4LsmFzKNLSyVI53TAziPeI24jsltinktsa9RzX8b/+rDTRgNpShN6KssTv5Y7IV+pW4Y+NgyiSWu63OtnXQHlxX8iCSNDMTkJXKHhd1DwoctrxEd4d0QLAAeMY2NoXAS9E8mMJnOr2QIjKHmnqyXDnefqojJ5KQ2pZvjm8Of3hiNVFnlL3JiFdCrF2r6+0qTcmcpSGC0aNsp72XpThhYp42qNL9QaAVI5MUITFFp6v35nNbEYIz8PKERfy/0rIulftZbDVgEWD2NJwx28niup8iz6zV51gZvfjKqc+GsAdMHjiAEiMoph+aZOKUFFrgVylypFFBmWHzyCmt2Bxe3ukDtpE64j+/U5vfOTHbJGmX4nmrzEmiXBrSct7KP2Bvh15F0Q2kz/gzwr1673lqMzqA0WXkEkbiksh6qqSgK6HDdlzc40k2WoAf185SCzy/iti7o6DurpXxNltLfFvFrmD9+7o44eAu/np/m2f5BHNwE44ir2mTsFnWlE65IZBCehQ2xuyn5HVw3//ZrS2gf2w6WeGUCr04EcgtNGnR5l2H9w9xIJyZOVAzmi5ZQYVGUAQn1Q/RDJEteoRQq66EEY8Y1gOVZFhjVzWWYBo7BM6Sy35fJCFvsw5kjHwHQarrEGv7oe6UyCZrw/v6SnXT/JaewCuh2VcAExdG/JjVcnq9PiKA8jqQU4yw3/955xi61VAX/fUJSOBSov0jctGRryxjSR/6wp7R8dPiqKd/rghZDoTM7W/JU93TXSNxi9pZRDMBgnneXB7/KsVmQxX7+SmQ244q59I2OyKJxCQ3DzZ2npbgS2mIjrp4oG2DCYqUa0hXHamhKBBRp+TUr1raZv4VAOUbSumXW1aK9qTfTemJ25Qw6GBILfXv81WSCIGb8ba9ut6dR4lAS0PPaaod4lKHzfSlUPFKmwV7Wb60P7GZKxce2hBFDl7f15vffa6+DVd25uUN3Y0lab1TQbAIO0I0mEPIlbWXrZ4pGlblv73gH5TIDZ4qJOdr8umPpOsdXQBLaFodPTR7pVcUN95gZ1WfWY5blGNnvtcgsZLcKWDPfaYnc/rCle9S36xxoBw1IR9a29d4kGKz8aYCkuPtjn5rB3QM5pXCxSG2cnJ8Ez+0J6/KOtMkswocrDgdDSR9ydXQAnUgvQwkAeNlHZGQWXFYMItPo/uWJ7cjKVyvbo155VphnsJ04wpORv3V5KVBVQMIV9JD7Xb5hbvVFjyEsVq/bhoaCylpwwcjmbXOihXbiZZh5uHfe1GtT3PlTkOj7U2n4N/pwmbkN0Ee4SPgZVMTblFoP3HP9adeV6ItfnQ/nTBDlVSNAfr++UrhrRLNUQqmKDAJCJCd+5o5urt5+32rKlzWVULRgv6MR4QU1vKrKTLhdDUCoWeoWU2/wtxq5GkWFF1QYArjRUvRZ+rhk5E4tipmjzO+YOdQBDCoKqZUGxDIxjC+ObaYp9IHbQd2kWYgQO2EGyJgVO8kQoC9y7OC9KnQ0KaqTyFjWc4sOCVfuF0HwwFWwa9Hkqr1TWa0usMw4dXf2KG0jH/pHw3gST7jA5DT35Y+78o4f+VtxDQTRsBeAxxr24LrJrqNTGX9iwmYtcpDOKYKZnyaImen06OVCIjmkW6Jg+72JdtptsompU4k1bNGt8dEoKvtl6qCB+jSnXPLnQPlyVuGIMtF4UTbfZ+RLUJfHBhrT+zN98COguhlaHb5jDkCDu54zAi8EJLHiIklGyafRK7n8VXByQWSvIRj4aA8BSLiYSwLi2bHTrvMgmZt2OvfBlIMnLiPDvNJM3GOQcTNC27yyolASDtxdyc+oKVSC1KyR84gPUQA5QZkGS4V8aWdy2JiJ18xL/OvHEe2IVq3K2yHPvlVKosx03S2eHJhKjzze/k3NYa115OHqquNLsQv/vCd9WJtq8+rtp0OlikLyPFINl27CeIP4kjLYr57W1EcjyhzOhLmuwlsM2om2ZsLS/N29ZElPBvVNTg55uJMbYGshp+LPYCUozIvuLYdIu1bhQS9Y28u/ExZw6xQuqwURGWh2y8UCCZEOTztDFUQUIS1BGWrQCZz3VwlSTUGBaq6mwsfqS72ypGbO2WD9Iant1qajIHk4zChRu1Rets9RxAGeVJsRA9yiPEFLLHkYZV69UnjQNGx2+jBrZiqlYs0yCDzsFzaAHDQ+pfyXRF7y8Z8PXAqEljKivh3vmWbPhccCI2FhvdPzHaWYEod4t/9P5tBs6gN5i6v7rdYwZaEV0nE2dqak3NDNzWciDRIhQ3qq+e1oRQodMEixCNQmgKfGP/EFD1DNC4tVHe4M24bM497ey0SRE5QjlLVJwE2KR1pXrX7Gij85PU3shZ19AvmZgOzwcC2GHmmuIj24Cz0Ok52HRhIWlGo5eCk27obB7RBPVoSjnkHtczKO0j7a+KuCiLn4eLen465CfLUmORJADaDiQvuRVqdvAZblDA7e3r6oreylpuFwDuQLatZptG6AVHfQ2Jna2/L6sna1kmj7xeKsHgLW+T0X826qg9xP/R7jJgGxjejvG4D5foILybqk+aazDVe0ShyUC6a/kL2WdEkdAeef8ja+ituXumryBNI+dMykZ58xItruUn8YstFdMjD8UAHEo02noILlwUJzKH+xE14w/sF98Pjtvo4J8UMR0pi1A1z1zFCbT1vUx9Vcz38YoTFUdYe7ZgO6xmkscZSsT1xOW4gDHcYJ0ZjTuFuNF9FHqBxImnpotFI4n5nrlKoq2sUG72W4FVtAAwzZCWgLdGZ+d9jzf14FkudSx33vYtyH60skaHtqfkivs8ocBNK7WQKYkREUxPx5DGiX5fjA/Rn8LF7ggMjyT1ESH54l8PCG0ez7a/4beNa+1cGoNGTWUW35ueNa//8hl7byjmQsmrR5JawouigHBclipHsUGeoDR/HPJuly0MsTLHyNfBdQAUFV+CiiCSYc7MWyBgPzrGUJbGR9Rrk7tYcWlxD07Q7SRBdyPg5bqoVcUiT9afmMPtrHYTLF6R6NpVkhyjE99EdT79cJvxZMbDw0du9vQoxZveb7Svwbl9h5Wyjl6gpKQ4L/qiHxCImsUu+WFsqmlam3YLdG9HGS942ztc6D3eRl4+qzn2/YUuSu831ZL4SWBMJy++bMTCvmVODMwju+r0cZmlyjugegRK9j+QGtj7YmcDcsLvdImskWv1yV81y22Cv5QwXqjTlV2aEQ988bpJFt1O1kU9CYJuQWXS79pA6TkV9kFCxX34J9aBbj0Ee0UmO563GJkNsNHvD6ZScmk6Umcer8hNFH4lvR0fpMuQwP5wpLePTFA3kAfA1mWjJJIke4iJYOUyV+JwKQDJ2r/SpgURDRghLUQ92DhRowDIaJ6sBQCaRogzfaJzU5faa06qNAOhF38cj+yAEar9yLhS38NazfiP4ll0BgcHSXxv2JfZ4tKO+qSVUM+EKt/A1IQCxAfqu+bGzPYJY6kG3AqV03x6Gz+GQ44oYxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3189,"cache_read_input_tokens":63952,"cache_creation":{"ephemeral_5m_input_tokens":3189,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"392a6ebe-b2c4-4ee9-8fd5-ec0507d6c41d"}}42{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJczmzKhJhaYCodDfMLu","type":"message","role":"assistant","content":[{"type":"text","text":"Je passe à l'implémentation. D'abord la partie fiche resto (traçage des consultations) — je lis le useEffect de chargement."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3189,"cache_read_input_tokens":63952,"cache_creation":{"ephemeral_5m_input_tokens":3189,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d95c1ab3-b056-4536-a292-a4a87b0f3e43"}}43{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJczmzKhJhaYCodDfMLu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EpaCWRZdaFxCTftPfLwruf","name":"Grep","input":{"pattern":"useEffect|fetchRestaurant\\(","path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","output_mode":"content","-n":true,"-A":10},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3189,"cache_read_input_tokens":63952,"cache_creation":{"ephemeral_5m_input_tokens":3189,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"99b6a564-ff50-4dff-9612-07c3815755da"}}44{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EpaCWRZdaFxCTftPfLwruf","type":"tool_result","content":"9:import { useEffect, useState } from \"react\";\n10-import { Link, useParams } from \"react-router-dom\";\n11-import {\n12-  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n13-  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n14-} from \"../api\";\n15-import { IcoCompass } from \"../components/Icons\";\n16-\n17-function MenuBlock({ menu }: { menu: Menu }) {\n18-  const [openSections, setOpenSections] = useState<Set<number>>(\n19-    new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n--\n108:  useEffect(() => {\n109-    if (!uid) return;\n110-    setResto(null);\n111-    setNotFound(false);\n112:    fetchRestaurant(uid)\n113-      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n114-      .catch(() => setNotFound(true));\n115-    window.scrollTo({ top: 0 });\n116-  }, [uid]);\n117-\n118-  if (notFound) {\n119-    return (\n120-      <div className=\"notice container\">\n121-        <div className=\"big\"><IcoCompass size={40} /></div>\n122-        <h2>Restaurant introuvable</h2>"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"bb1334ac-4b6c-4227-ac8c-74026d0b7d3a","timestamp":"2026-08-22T23:03:34.450Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"9:import { useEffect, useState } from \"react\";\n10-import { Link, useParams } from \"react-router-dom\";\n11-import {\n12-  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n13-  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n14-} from \"../api\";\n15-import { IcoCompass } from \"../components/Icons\";\n16-\n17-function MenuBlock({ menu }: { menu: Menu }) {\n18-  const [openSections, setOpenSections] = useState<Set<number>>(\n19-    new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n--\n108:  useEffect(() => {\n109-    if (!uid) return;\n110-    setResto(null);\n111-    setNotFound(false);\n112:    fetchRestaurant(uid)\n113-      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n114-      .catch(() => setNotFound(true));\n115-    window.scrollTo({ top: 0 });\n116-  }, [uid]);\n117-\n118-  if (notFound) {\n119-    return (\n120-      <div className=\"notice container\">\n121-        <div className=\"big\"><IcoCompass size={40} /></div>\n122-        <h2>Restaurant introuvable</h2>","numLines":27}}}45{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d8ebb08d-e5da-4769-8a52-f12b83ba8cda","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}46{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzgMKpwEIERgCKkB6Xqkf+T+LuZHEy+lj44VN5z0Uw4hknKsJCeXQj2fNXIMtNksXEYdDuNO82e3mc9uH1WtruZfQcoLRc+5ahEd5Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHM1ajUBrABAhIM9xEE2yWAiK9JbexoGgzp1OmAxPaHfu1P7xwiMNvy5mxN57Tw8/CgXwX6KLraHoCeyoj1atRP8t4RO39VLC5xroiVb9tOP+GiZNUpLirTAZ46HDRZDtw3FLqfHmnQ2tUViC4RIHpKon82uHtD0W8dkVt0E7d0uFP8g8nANfsk1KPFEI1jYMwkzNpTWh2LkPf+phzVhENAfKsJVLUa2PR+rLazT1o7xf9pYzCOxOTZppyUjGxtOfmcJxRF+nj2uY+YU0jWOKu5+BnYUxnBUITAZOjX0a5S9Q16NQS8XI7MrUaOoBWhoyVnSYXK9SYxFb2mR6M+uGw3fQ2Su7939eT+pBkBjcxjE7MSOuNu2HvGwVLFxqQkTYasMug8Br+s374Kxn0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"83434518-3952-46d8-a8d2-b57c03652eb1"}}47{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"text","text":"Tout est clair. J'applique les modifications : API backend (`uids`), helpers localStorage dans api.ts, traçage dans Resto.tsx, section « Récemment consultés » dans Home.tsx + CSS."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"24d06d6f-4337-4788-97d4-49b0eabc08ab"}}48{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EgLrTHft5oVwZjtRZvCxvd","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","old_string":"    q: str | None = None,\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,","new_string":"    q: str | None = None,\n    uids: str | None = None,             # liste d'uids séparés par des virgules\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"bb9ab7ff-654c-4ec7-ad54-e538e1968b6c"}}49{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EgLrTHft5oVwZjtRZvCxvd","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/restoka/web.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"385ba3c6-8710-4bc2-9f2e-34e310a36834","timestamp":"2026-08-22T23:03:43.104Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","oldString":"    q: str | None = None,\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,","newString":"    q: str | None = None,\n    uids: str | None = None,             # liste d'uids séparés par des virgules\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File:   restoka/web.py\n# Desc:   API FastAPI (JSON) + service du frontend React (frontend/dist).\n#         Recherche des restaurants (région, ville, cuisine, diète, prix, texte),\n#         fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >\n#         delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport statistics\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\n\nfrom . import db, ingest\nfrom .normalize import PRICE_CONTEXTS\nfrom .regions import REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\n# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)\n_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}\n\napp = FastAPI(title=\"Resto-Ka API\", version=\"1.0\",\n              description=\"Agrégateur des restaurants du Québec — menus & prix\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n                   allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n_sync_lock = threading.Lock()\n\n# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py\nfrom . import auth  # noqa: E402  (import tardif : évite le cycle web<->auth)\napp.include_router(auth.router)\n\n# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py\n# inclus AVANT le fallback SPA pour que ces routes aient priorité\nfrom . import seo  # noqa: E402\napp.include_router(seo.router)\n\n\ndef _row_to_dict(row) -> dict:\n    d = dict(row)\n    for col in (\"cuisines\", \"services\", \"dietary_options\", \"languages\", \"images\"):\n        d[col] = json.loads(d.get(col) or \"[]\")\n    d[\"hours\"] = json.loads(d.get(\"hours\") or \"{}\")\n    try:  # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details\n        d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n    except (ValueError, TypeError):\n        d[\"details\"] = {}\n    if d.get(\"dup_sources\"):\n        try:\n            d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n        except (ValueError, TypeError):\n            d[\"dup_sources\"] = []\n    return d\n\n\ndef _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\n    \"\"\"Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes.\"\"\"\n    if not uids:\n        return {}\n    qmarks = \",\".join(\"?\" * len(uids))\n    best: dict[str, dict] = {}\n    for r in con.execute(\n            f\"SELECT uid, price_context, price_source, captured_at, item_count,\"\n            f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\n        cur = best.get(r[\"uid\"])\n        if cur and _CTX_ORDER.get(cur[\"price_context\"], 9) <= \\\n                _CTX_ORDER.get(r[\"price_context\"], 9):\n            continue\n        sections = json.loads(r[\"sections\"] or \"[]\")\n        prices = [it.get(\"price\")\n                  for sec in sections\n                  for it in sec.get(\"items\") or []\n                  if isinstance(it.get(\"price\"), (int, float)) and it[\"price\"] > 0]\n        image = next((it[\"image\"] for sec in sections\n                      for it in sec.get(\"items\") or [] if it.get(\"image\")),\n                     next((sec[\"image\"] for sec in sections\n                           if sec.get(\"image\")), None))\n        best[r[\"uid\"]] = {\n            \"image\": image,\n            \"price_context\": r[\"price_context\"],\n            \"price_source\": r[\"price_source\"],\n            \"captured_at\": r[\"captured_at\"],\n            \"item_count\": r[\"item_count\"],\n            \"price_min\": min(prices) if prices else None,\n            \"price_median\": round(statistics.median(prices), 2) if prices else None,\n        }\n    return best\n\n\n@app.get(\"/healthz\")\ndef healthz():\n    return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/restaurants\")\ndef list_restaurants(\n    region: str | None = None,\n    city: str | None = None,\n    cuisine: str | None = None,\n    establishment_type: str | None = None,\n    diet: str | None = None,             # vegan, sans-gluten…\n    service: str | None = None,          # takeout, delivery, salle\n    price_range: str | None = None,      # $ à $$$$\n    chain: str | None = None,\n    source: str | None = None,\n    q: str | None = None,\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,\n    sort: str = \"menu\",                  # menu | name | price | recent\n    limit: int = Query(60, le=500),\n    offset: int = 0,\n):\n    con = db.connect()\n    sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\"   # doublons masqués\n    args: list = []\n    if active in (0, 1):\n        sql += \" AND active=?\"; args.append(active)\n    if region:\n        sql += \" AND region=?\"; args.append(region)\n    if city:\n        sql += \" AND city=?\"; args.append(city)\n    if cuisine:\n        sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n    if establishment_type:\n        sql += \" AND establishment_type=?\"; args.append(establishment_type)\n    if diet:\n        sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n    if service:\n        sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n    if price_range:\n        sql += \" AND price_range=?\"; args.append(price_range)\n    if chain:\n        sql += \" AND chain=?\"; args.append(chain)\n    if source:\n        sql += \" AND source=?\"; args.append(source)\n    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4\n    if has_menu == 1:\n        sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n    elif has_menu == 0:\n        sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n    total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n    if sort == \"recent\":\n        sql += \" ORDER BY updated_at DESC\"\n    elif sort == \"price\":\n        sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n    elif sort == \"name\":\n        sql += \" ORDER BY name COLLATE NOCASE ASC\"\n    else:   # défaut « menu » : les restos avec menu complet d'abord\n        sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n                \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n    sql += \" LIMIT ? OFFSET ?\"\n    args += [limit, offset]\n    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n    menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n    for r in rows:\n        r[\"menu_summary\"] = menus.get(r[\"uid\"])\n    con.close()\n    return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n\n\n@app.get(\"/api/dishes\")\ndef search_dishes(\n    q: str = Query(..., min_length=2),\n    region: str | None = None,\n    city: str | None = None,\n    cuisine: str | None = None,\n    price_max: float | None = None,\n    limit: int = Query(30, le=100),\n    offset: int = 0,\n):\n    \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n\n    Les succursales d'une même chaîne partagent le même menu : les résultats\n    sont regroupés par (marque, plat, prix, contexte) avec le nombre\n    d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n    \"\"\"\n    con = db.connect()\n    needle = q.strip().lower()\n    sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n           \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n           \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n           \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n    args: list = [f\"%{needle}%\"]\n    if region:\n        sql += \" AND r.region=?\"; args.append(region)\n    if city:\n        sql += \" AND r.city=?\"; args.append(city)\n    if cuisine:\n        sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n\n    groups: dict[tuple, dict] = {}\n    for row in con.execute(sql, args):\n        try:\n            sections = json.loads(row[\"sections\"] or \"[]\")\n        except ValueError:\n            continue\n        brand = row[\"chain\"] or row[\"rname\"]\n        for sec in sections:\n            for it in sec.get(\"items\") or []:\n                hay = f\"{it.get('name', '')} {it.get('description', '')}\".lower()\n                if needle not in hay:\n                    continue\n                price = it.get(\"price\")\n                if price_max is not None and (price is None or price > price_max):\n                    continue\n                key = (brand, it.get(\"name\"), price, row[\"price_context\"])\n                g = groups.get(key)\n                if g is None:\n                    groups[key] = {\n                        \"item\": it.get(\"name\"),\n                        \"description\": it.get(\"description\") or \"\",\n                        \"price\": price,\n                        \"currency\": it.get(\"currency\", \"CAD\"),\n                        \"image\": it.get(\"image\"),\n                        \"tags\": it.get(\"tags\") or [],\n                        \"section\": sec.get(\"name\"),\n                        \"price_context\": row[\"price_context\"],\n                        \"price_source\": row[\"price_source\"],\n                        \"captured_at\": row[\"captured_at\"],\n                        \"brand\": brand,\n                        \"restaurant\": row[\"rname\"],\n                        \"uid\": row[\"uid\"],\n                        \"city\": row[\"city\"],\n                        \"region\": row[\"region\"],\n                        \"locations\": 1,\n                    }\n                else:\n                    g[\"locations\"] += 1\n    con.close()\n    results = sorted(groups.values(),\n                     key=lambda g: (g[\"price\"] is None, g[\"price\"] or 0))\n    total = len(results)\n    return {\"total\": total, \"count\": min(limit, max(0, total - offset)),\n            \"dishes\": results[offset:offset + limit]}\n\n\n@app.get(\"/api/restaurants/{uid:path}/prices\")\ndef price_history(uid: str):\n    \"\"\"Historique des prix par item (série temporelle, détection des hausses).\"\"\"\n    con = db.connect()\n    rows = [dict(r) for r in con.execute(\n        \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n        \" WHERE uid=? ORDER BY ts DESC LIMIT 500\", (uid,))]\n    con.close()\n    return {\"uid\": uid, \"prices\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}/inspections\")\ndef restaurant_inspections(uid: str):\n    \"\"\"Condamnations MAPAQ croisées avec ce restaurant (inspections\n    alimentaires — Données Québec, licence CC-BY 4.0). Croisement\n    conservateur : voir restoka/inspections.py.\"\"\"\n    con = db.connect()\n    if con.execute(\"SELECT 1 FROM restaurants WHERE uid=?\",\n                   (uid,)).fetchone() is None:\n        con.close()\n        raise HTTPException(404, \"Restaurant introuvable\")\n    rows = [dict(r) for r in con.execute(\n        \"SELECT etablissement, exploitant, description, adresse,\"\n        \" date_infraction, date_jugement, montant_amende, motif, matched_by\"\n        \" FROM inspections WHERE uid=? ORDER BY date_jugement DESC\", (uid,))]\n    con.close()\n    return {\"uid\": uid, \"total\": len(rows),\n            \"total_amendes\": round(sum(r[\"montant_amende\"] or 0 for r in rows), 2),\n            \"source\": \"MAPAQ — Condamnations des établissements alimentaires \"\n                      \"(Données Québec, CC-BY 4.0)\",\n            \"inspections\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}\")\ndef get_restaurant(uid: str):\n    con = db.connect()\n    row = con.execute(\"SELECT * FROM restaurants WHERE uid=?\", (uid,)).fetchone()\n    if row is None:\n        con.close()\n        raise HTTPException(404, \"Restaurant introuvable\")\n    d = _row_to_dict(row)\n    # tous les contextes de prix disponibles, meilleur en premier — un menu\n    # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)\n    menus = []\n    for m in con.execute(\n            \"SELECT price_context, price_source, currency, captured_at,\"\n            \" item_count, sections FROM menus WHERE uid=?\", (uid,)):\n        menus.append({\n            \"price_context\": m[\"price_context\"],\n            \"price_source\": m[\"price_source\"],\n            \"currency\": m[\"currency\"],\n            \"captured_at\": m[\"captured_at\"],\n            \"item_count\": m[\"item_count\"],\n            \"sections\": json.loads(m[\"sections\"] or \"[]\"),\n        })\n    menus.sort(key=lambda m: _CTX_ORDER.get(m[\"price_context\"], 9))\n    d[\"menus\"] = menus\n    # items dont le prix a changé récemment (crédibilité, §12.2)\n    d[\"recent_price_changes\"] = [dict(r) for r in con.execute(\n        \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n        \" WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)\"\n        \" GROUP BY item_key HAVING COUNT(*) > 1\"\n        \" ORDER BY ts DESC LIMIT 20\", (uid, uid))]\n    con.close()\n    return d\n\n\n@app.get(\"/api/facets\")\ndef facets(region: str | None = None):\n    \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n    con = db.connect()\n    city_sql = (\"SELECT DISTINCT city FROM restaurants\"\n                \" WHERE active=1 AND dup_of IS NULL AND city<>''\")\n    city_args: list = []\n    if region:\n        city_sql += \" AND region=?\"\n        city_args.append(region)\n    cuisines: dict[str, int] = {}\n    diets: dict[str, int] = {}\n    for r in con.execute(\"SELECT cuisines, dietary_options FROM restaurants\"\n                         \" WHERE active=1 AND dup_of IS NULL\"):\n        for c in json.loads(r[\"cuisines\"] or \"[]\"):\n            cuisines[c] = cuisines.get(c, 0) + 1\n        for dd in json.loads(r[\"dietary_options\"] or \"[]\"):\n            diets[dd] = diets.get(dd, 0) + 1\n    out = {\n        \"regions\": [dict(r) for r in con.execute(\n            \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n            \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")],\n        \"all_regions\": REGIONS,\n        \"cities\": [r[\"city\"] for r in con.execute(\n            city_sql + \" ORDER BY city\", city_args)],\n        \"cuisines\": [{\"cuisine\": c, \"n\": n} for c, n in\n                     sorted(cuisines.items(), key=lambda kv: -kv[1])],\n        \"diets\": [{\"diet\": d, \"n\": n} for d, n in\n                  sorted(diets.items(), key=lambda kv: -kv[1])],\n        \"establishment_types\": [dict(r) for r in con.execute(\n            \"SELECT establishment_type t, COUNT(*) n FROM restaurants\"\n            \" WHERE active=1 AND dup_of IS NULL AND establishment_type<>''\"\n            \" GROUP BY establishment_type ORDER BY n DESC\")],\n        \"chains\": [r[\"chain\"] for r in con.execute(\n            \"SELECT DISTINCT chain FROM restaurants WHERE active=1\"\n            \" AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain\")],\n        \"sources\": [dict(r) for r in con.execute(\n            \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1\"\n            \" AND dup_of IS NULL GROUP BY source ORDER BY n DESC\")],\n    }\n    con.close()\n    return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n    registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n    con = db.connect()\n    counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n        \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source\")}\n    items = {r[\"source\"]: r[\"n\"] for r in con.execute(\n        \"SELECT r.source, SUM(m.item_count) n FROM menus m\"\n        \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source\")}\n    last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n        \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n    con.close()\n    for s in registry:\n        s[\"active_restaurants\"] = counts.get(s[\"id\"], 0)\n        s[\"menu_items\"] = items.get(s[\"id\"], 0)\n        s[\"last_sync\"] = last.get(s[\"id\"])\n        # ne pas exposer les clés d'intégration dans l'API publique\n        if \"integrations\" in s:\n            s[\"integrations\"] = [{\"label\": i.get(\"label\"),\n                                  \"status\": i.get(\"status\", \"actif\")}\n                                 for i in s[\"integrations\"]]\n    return {\"sources\": registry}\n\n\n# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------\n_SITE = {\n    \"wordmark\": \"Resto·Ka\",\n    \"accent\": \"#f08c00\",            # safran — on-accent encre #141814\n    \"domain\": \"www.resto-ka.com\",\n    \"tagline\": \"Chaque resto, chaque plat, chaque prix.\",\n}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(\n    period: str = \"30j\",\n    from_: str | None = Query(None, alias=\"from\"),\n    to: str | None = Query(None),\n):\n    \"\"\"Tableau de bord analytique (contrat commun Groupe KA, cache 5 min).\"\"\"\n    from . import stats as kastats\n    if period not in kastats.PERIOD_LABELS and not (from_ and to):\n        raise HTTPException(400, \"période inconnue\")\n    return kastats.dashboard(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(\n    period: str = \"30j\",\n    from_: str | None = Query(None, alias=\"from\"),\n    to: str | None = Query(None),\n    mode: str = \"complet\",\n):\n    \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :\n    complet | synthese | tendances | repartitions | donnees.\n    Un mode inconnu retombe sur complet (rétrocompatible v1).\"\"\"\n    from . import kapdf\n    from . import stats as kastats\n    if mode not in kapdf.REPORT_MODES:\n        mode = \"complet\"\n    if period not in kastats.PERIOD_LABELS and not (from_ and to):\n        raise HTTPException(400, \"période inconnue\")\n    dash = kastats.dashboard(period, from_, to)\n    pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()\n    fname = kapdf.filename(\"resto-ka\", period, mode)\n    return Response(content=pdf, media_type=\"application/pdf\",\n                    headers={\"Content-Disposition\":\n                             f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats\")\ndef stats():\n    con = db.connect()\n    head = dict(con.execute(\n        \"\"\"SELECT COUNT(*) restaurants,\n                  SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))\n                      with_menu,\n                  COUNT(DISTINCT chain) chains,\n                  COUNT(DISTINCT region) regions,\n                  COUNT(DISTINCT source) sources\n           FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n    m = dict(con.execute(\n        \"\"\"SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items\n           FROM menus m JOIN restaurants r ON r.uid=m.uid\n           WHERE r.active=1 AND r.dup_of IS NULL\"\"\").fetchone())\n    by_region = [dict(r) for r in con.execute(\n        \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n        \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")]\n    by_context = [dict(r) for r in con.execute(\n        \"SELECT m.price_context, COUNT(*) n FROM menus m\"\n        \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1\"\n        \" GROUP BY m.price_context\")]\n    log = [dict(r) for r in con.execute(\n        \"SELECT source, ts, found, added, updated, removed, ok, message\"\n        \" FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n    insp = dict(con.execute(\n        \"\"\"SELECT COUNT(*) inspections,\n                  SUM(uid IS NOT NULL) inspections_matched,\n                  COUNT(DISTINCT uid) restaurants_with_inspections\n           FROM inspections\"\"\").fetchone())\n    enr = dict(con.execute(\n        \"\"\"SELECT SUM(details LIKE '%\"yelp\":%') with_yelp_rating,\n                  SUM(details LIKE '%\"ubereats\":%') with_ubereats,\n                  SUM(details LIKE '%\"permis_alcool\"%') with_alcohol_permit\n           FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n    con.close()\n    try:  # nb de sources au registre (data/sources.json), actives ou en attente\n        registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n        registered = len(registry)\n    except (ValueError, OSError, KeyError):\n        registered = None\n    return {**head, **m, **insp, **enr, \"sources_registry\": registered,\n            \"by_region\": by_region, \"by_context\": by_context,\n            \"recent_syncs\": log}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n    \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n    def _job():\n        with _sync_lock:\n            ingest.run([source] if source else None)\n            ingest.enrich()\n    background.add_task(_job)\n    return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Frontend React (build Vite) --------------------------------------------\nif FRONTEND_DIST.exists():\n    app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"),\n              name=\"assets\")\n\n    @app.middleware(\"http\")\n    async def _cache_headers(request, call_next):\n        \"\"\"Bundles hachés immuables ; index.html toujours revalidé.\"\"\"\n        resp = await call_next(request)\n        path = request.url.path\n        if path.startswith(\"/assets/\"):\n            resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n        elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n            resp.headers[\"Cache-Control\"] = \"no-cache\"\n        return resp\n\n    _CLIENT_PREFIXES = (\"resto/\", \"sources\", \"stats\", \"favoris\", \"contact\")\n\n    @app.get(\"/{full_path:path}\")\n    def spa(full_path: str):\n        target = FRONTEND_DIST / full_path\n        if full_path and target.is_file():\n            return FileResponse(target)\n        known = (full_path == \"\" or full_path in _CLIENT_PREFIXES\n                 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n        return FileResponse(FRONTEND_DIST / \"index.html\",\n                            status_code=200 if known else 404,\n                            headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":116,"oldLines":6,"newStart":116,"newLines":7,"lines":["     chain: str | None = None,","     source: str | None = None,","     q: str | None = None,","+    uids: str | None = None,             # liste d'uids séparés par des virgules","     has_menu: int | None = None,         # 1 = seulement les restos avec menu","     active: int = 1,","     sort: str = \"menu\",                  # menu | name | price | recent"]}],"userModified":false,"replaceAll":false}}}50{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd2tLcgKPEEmycxno9C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MXGmRsUFRZNqkockvPaG7X","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","old_string":"    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4","new_string":"    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4\n    if uids:\n        lst = [u.strip() for u in uids.split(\",\") if u.strip()][:24]\n        if lst:\n            sql += f\" AND uid IN ({','.join('?' * len(lst))})\"\n            args += lst"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2606,"cache_read_input_tokens":67141,"cache_creation":{"ephemeral_5m_input_tokens":2606,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"eedf531d-7770-41f9-bc83-7cd6820e9e22"}}51{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MXGmRsUFRZNqkockvPaG7X","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/restoka/web.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"c3f9826e-515f-4e92-acfd-3aaa0969de64","timestamp":"2026-08-22T23:03:45.514Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/restoka/web.py","oldString":"    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4","newString":"    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4\n    if uids:\n        lst = [u.strip() for u in uids.split(\",\") if u.strip()][:24]\n        if lst:\n            sql += f\" AND uid IN ({','.join('?' * len(lst))})\"\n            args += lst","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File:   restoka/web.py\n# Desc:   API FastAPI (JSON) + service du frontend React (frontend/dist).\n#         Recherche des restaurants (région, ville, cuisine, diète, prix, texte),\n#         fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >\n#         delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport statistics\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import BackgroundTasks, FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom fastapi.staticfiles import StaticFiles\n\nfrom . import db, ingest\nfrom .normalize import PRICE_CONTEXTS\nfrom .regions import REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n\n# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)\n_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}\n\napp = FastAPI(title=\"Resto-Ka API\", version=\"1.0\",\n              description=\"Agrégateur des restaurants du Québec — menus & prix\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n                   allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n_sync_lock = threading.Lock()\n\n# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py\nfrom . import auth  # noqa: E402  (import tardif : évite le cycle web<->auth)\napp.include_router(auth.router)\n\n# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py\n# inclus AVANT le fallback SPA pour que ces routes aient priorité\nfrom . import seo  # noqa: E402\napp.include_router(seo.router)\n\n\ndef _row_to_dict(row) -> dict:\n    d = dict(row)\n    for col in (\"cuisines\", \"services\", \"dietary_options\", \"languages\", \"images\"):\n        d[col] = json.loads(d.get(col) or \"[]\")\n    d[\"hours\"] = json.loads(d.get(\"hours\") or \"{}\")\n    try:  # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details\n        d[\"details\"] = json.loads(d.get(\"details\") or \"{}\")\n    except (ValueError, TypeError):\n        d[\"details\"] = {}\n    if d.get(\"dup_sources\"):\n        try:\n            d[\"dup_sources\"] = json.loads(d[\"dup_sources\"])\n        except (ValueError, TypeError):\n            d[\"dup_sources\"] = []\n    return d\n\n\ndef _menu_summaries(con, uids: list[str]) -> dict[str, dict]:\n    \"\"\"Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes.\"\"\"\n    if not uids:\n        return {}\n    qmarks = \",\".join(\"?\" * len(uids))\n    best: dict[str, dict] = {}\n    for r in con.execute(\n            f\"SELECT uid, price_context, price_source, captured_at, item_count,\"\n            f\" sections FROM menus WHERE uid IN ({qmarks})\", uids):\n        cur = best.get(r[\"uid\"])\n        if cur and _CTX_ORDER.get(cur[\"price_context\"], 9) <= \\\n                _CTX_ORDER.get(r[\"price_context\"], 9):\n            continue\n        sections = json.loads(r[\"sections\"] or \"[]\")\n        prices = [it.get(\"price\")\n                  for sec in sections\n                  for it in sec.get(\"items\") or []\n                  if isinstance(it.get(\"price\"), (int, float)) and it[\"price\"] > 0]\n        image = next((it[\"image\"] for sec in sections\n                      for it in sec.get(\"items\") or [] if it.get(\"image\")),\n                     next((sec[\"image\"] for sec in sections\n                           if sec.get(\"image\")), None))\n        best[r[\"uid\"]] = {\n            \"image\": image,\n            \"price_context\": r[\"price_context\"],\n            \"price_source\": r[\"price_source\"],\n            \"captured_at\": r[\"captured_at\"],\n            \"item_count\": r[\"item_count\"],\n            \"price_min\": min(prices) if prices else None,\n            \"price_median\": round(statistics.median(prices), 2) if prices else None,\n        }\n    return best\n\n\n@app.get(\"/healthz\")\ndef healthz():\n    return {\"status\": \"ok\"}\n\n\n@app.get(\"/api/restaurants\")\ndef list_restaurants(\n    region: str | None = None,\n    city: str | None = None,\n    cuisine: str | None = None,\n    establishment_type: str | None = None,\n    diet: str | None = None,             # vegan, sans-gluten…\n    service: str | None = None,          # takeout, delivery, salle\n    price_range: str | None = None,      # $ à $$$$\n    chain: str | None = None,\n    source: str | None = None,\n    q: str | None = None,\n    uids: str | None = None,             # liste d'uids séparés par des virgules\n    has_menu: int | None = None,         # 1 = seulement les restos avec menu\n    active: int = 1,\n    sort: str = \"menu\",                  # menu | name | price | recent\n    limit: int = Query(60, le=500),\n    offset: int = 0,\n):\n    con = db.connect()\n    sql = \"SELECT * FROM restaurants WHERE dup_of IS NULL\"   # doublons masqués\n    args: list = []\n    if active in (0, 1):\n        sql += \" AND active=?\"; args.append(active)\n    if region:\n        sql += \" AND region=?\"; args.append(region)\n    if city:\n        sql += \" AND city=?\"; args.append(city)\n    if cuisine:\n        sql += \" AND cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n    if establishment_type:\n        sql += \" AND establishment_type=?\"; args.append(establishment_type)\n    if diet:\n        sql += \" AND dietary_options LIKE ?\"; args.append(f'%\"{diet}\"%')\n    if service:\n        sql += \" AND services LIKE ?\"; args.append(f'%\"{service}\"%')\n    if price_range:\n        sql += \" AND price_range=?\"; args.append(price_range)\n    if chain:\n        sql += \" AND chain=?\"; args.append(chain)\n    if source:\n        sql += \" AND source=?\"; args.append(source)\n    if q:\n        sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"\n        args += [f\"%{q}%\"] * 4\n    if has_menu == 1:\n        sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n    elif has_menu == 0:\n        sql += \" AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"\n    total = con.execute(f\"SELECT COUNT(*) c FROM ({sql})\", args).fetchone()[\"c\"]\n    if sort == \"recent\":\n        sql += \" ORDER BY updated_at DESC\"\n    elif sort == \"price\":\n        sql += \" ORDER BY price_range='' , price_range ASC, name ASC\"\n    elif sort == \"name\":\n        sql += \" ORDER BY name COLLATE NOCASE ASC\"\n    else:   # défaut « menu » : les restos avec menu complet d'abord\n        sql += (\" ORDER BY NOT EXISTS (SELECT 1 FROM menus m\"\n                \" WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC\")\n    sql += \" LIMIT ? OFFSET ?\"\n    args += [limit, offset]\n    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]\n    menus = _menu_summaries(con, [r[\"uid\"] for r in rows])\n    for r in rows:\n        r[\"menu_summary\"] = menus.get(r[\"uid\"])\n    con.close()\n    return {\"total\": total, \"count\": len(rows), \"restaurants\": rows}\n\n\n@app.get(\"/api/dishes\")\ndef search_dishes(\n    q: str = Query(..., min_length=2),\n    region: str | None = None,\n    city: str | None = None,\n    cuisine: str | None = None,\n    price_max: float | None = None,\n    limit: int = Query(30, le=100),\n    offset: int = 0,\n):\n    \"\"\"Recherche PAR PLAT dans tous les menus (nom + description).\n\n    Les succursales d'une même chaîne partagent le même menu : les résultats\n    sont regroupés par (marque, plat, prix, contexte) avec le nombre\n    d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.\n    \"\"\"\n    con = db.connect()\n    needle = q.strip().lower()\n    sql = (\"SELECT m.uid, m.price_context, m.price_source, m.captured_at,\"\n           \" m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines\"\n           \" FROM menus m JOIN restaurants r ON r.uid = m.uid\"\n           \" WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?\")\n    args: list = [f\"%{needle}%\"]\n    if region:\n        sql += \" AND r.region=?\"; args.append(region)\n    if city:\n        sql += \" AND r.city=?\"; args.append(city)\n    if cuisine:\n        sql += \" AND r.cuisines LIKE ?\"; args.append(f'%\"{cuisine}\"%')\n\n    groups: dict[tuple, dict] = {}\n    for row in con.execute(sql, args):\n        try:\n            sections = json.loads(row[\"sections\"] or \"[]\")\n        except ValueError:\n            continue\n        brand = row[\"chain\"] or row[\"rname\"]\n        for sec in sections:\n            for it in sec.get(\"items\") or []:\n                hay = f\"{it.get('name', '')} {it.get('description', '')}\".lower()\n                if needle not in hay:\n                    continue\n                price = it.get(\"price\")\n                if price_max is not None and (price is None or price > price_max):\n                    continue\n                key = (brand, it.get(\"name\"), price, row[\"price_context\"])\n                g = groups.get(key)\n                if g is None:\n                    groups[key] = {\n                        \"item\": it.get(\"name\"),\n                        \"description\": it.get(\"description\") or \"\",\n                        \"price\": price,\n                        \"currency\": it.get(\"currency\", \"CAD\"),\n                        \"image\": it.get(\"image\"),\n                        \"tags\": it.get(\"tags\") or [],\n                        \"section\": sec.get(\"name\"),\n                        \"price_context\": row[\"price_context\"],\n                        \"price_source\": row[\"price_source\"],\n                        \"captured_at\": row[\"captured_at\"],\n                        \"brand\": brand,\n                        \"restaurant\": row[\"rname\"],\n                        \"uid\": row[\"uid\"],\n                        \"city\": row[\"city\"],\n                        \"region\": row[\"region\"],\n                        \"locations\": 1,\n                    }\n                else:\n                    g[\"locations\"] += 1\n    con.close()\n    results = sorted(groups.values(),\n                     key=lambda g: (g[\"price\"] is None, g[\"price\"] or 0))\n    total = len(results)\n    return {\"total\": total, \"count\": min(limit, max(0, total - offset)),\n            \"dishes\": results[offset:offset + limit]}\n\n\n@app.get(\"/api/restaurants/{uid:path}/prices\")\ndef price_history(uid: str):\n    \"\"\"Historique des prix par item (série temporelle, détection des hausses).\"\"\"\n    con = db.connect()\n    rows = [dict(r) for r in con.execute(\n        \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n        \" WHERE uid=? ORDER BY ts DESC LIMIT 500\", (uid,))]\n    con.close()\n    return {\"uid\": uid, \"prices\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}/inspections\")\ndef restaurant_inspections(uid: str):\n    \"\"\"Condamnations MAPAQ croisées avec ce restaurant (inspections\n    alimentaires — Données Québec, licence CC-BY 4.0). Croisement\n    conservateur : voir restoka/inspections.py.\"\"\"\n    con = db.connect()\n    if con.execute(\"SELECT 1 FROM restaurants WHERE uid=?\",\n                   (uid,)).fetchone() is None:\n        con.close()\n        raise HTTPException(404, \"Restaurant introuvable\")\n    rows = [dict(r) for r in con.execute(\n        \"SELECT etablissement, exploitant, description, adresse,\"\n        \" date_infraction, date_jugement, montant_amende, motif, matched_by\"\n        \" FROM inspections WHERE uid=? ORDER BY date_jugement DESC\", (uid,))]\n    con.close()\n    return {\"uid\": uid, \"total\": len(rows),\n            \"total_amendes\": round(sum(r[\"montant_amende\"] or 0 for r in rows), 2),\n            \"source\": \"MAPAQ — Condamnations des établissements alimentaires \"\n                      \"(Données Québec, CC-BY 4.0)\",\n            \"inspections\": rows}\n\n\n@app.get(\"/api/restaurants/{uid:path}\")\ndef get_restaurant(uid: str):\n    con = db.connect()\n    row = con.execute(\"SELECT * FROM restaurants WHERE uid=?\", (uid,)).fetchone()\n    if row is None:\n        con.close()\n        raise HTTPException(404, \"Restaurant introuvable\")\n    d = _row_to_dict(row)\n    # tous les contextes de prix disponibles, meilleur en premier — un menu\n    # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)\n    menus = []\n    for m in con.execute(\n            \"SELECT price_context, price_source, currency, captured_at,\"\n            \" item_count, sections FROM menus WHERE uid=?\", (uid,)):\n        menus.append({\n            \"price_context\": m[\"price_context\"],\n            \"price_source\": m[\"price_source\"],\n            \"currency\": m[\"currency\"],\n            \"captured_at\": m[\"captured_at\"],\n            \"item_count\": m[\"item_count\"],\n            \"sections\": json.loads(m[\"sections\"] or \"[]\"),\n        })\n    menus.sort(key=lambda m: _CTX_ORDER.get(m[\"price_context\"], 9))\n    d[\"menus\"] = menus\n    # items dont le prix a changé récemment (crédibilité, §12.2)\n    d[\"recent_price_changes\"] = [dict(r) for r in con.execute(\n        \"SELECT price_context, item_key, ts, price FROM item_price_log\"\n        \" WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)\"\n        \" GROUP BY item_key HAVING COUNT(*) > 1\"\n        \" ORDER BY ts DESC LIMIT 20\", (uid, uid))]\n    con.close()\n    return d\n\n\n@app.get(\"/api/facets\")\ndef facets(region: str | None = None):\n    \"\"\"Valeurs distinctes pour construire les filtres du frontend.\"\"\"\n    con = db.connect()\n    city_sql = (\"SELECT DISTINCT city FROM restaurants\"\n                \" WHERE active=1 AND dup_of IS NULL AND city<>''\")\n    city_args: list = []\n    if region:\n        city_sql += \" AND region=?\"\n        city_args.append(region)\n    cuisines: dict[str, int] = {}\n    diets: dict[str, int] = {}\n    for r in con.execute(\"SELECT cuisines, dietary_options FROM restaurants\"\n                         \" WHERE active=1 AND dup_of IS NULL\"):\n        for c in json.loads(r[\"cuisines\"] or \"[]\"):\n            cuisines[c] = cuisines.get(c, 0) + 1\n        for dd in json.loads(r[\"dietary_options\"] or \"[]\"):\n            diets[dd] = diets.get(dd, 0) + 1\n    out = {\n        \"regions\": [dict(r) for r in con.execute(\n            \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n            \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")],\n        \"all_regions\": REGIONS,\n        \"cities\": [r[\"city\"] for r in con.execute(\n            city_sql + \" ORDER BY city\", city_args)],\n        \"cuisines\": [{\"cuisine\": c, \"n\": n} for c, n in\n                     sorted(cuisines.items(), key=lambda kv: -kv[1])],\n        \"diets\": [{\"diet\": d, \"n\": n} for d, n in\n                  sorted(diets.items(), key=lambda kv: -kv[1])],\n        \"establishment_types\": [dict(r) for r in con.execute(\n            \"SELECT establishment_type t, COUNT(*) n FROM restaurants\"\n            \" WHERE active=1 AND dup_of IS NULL AND establishment_type<>''\"\n            \" GROUP BY establishment_type ORDER BY n DESC\")],\n        \"chains\": [r[\"chain\"] for r in con.execute(\n            \"SELECT DISTINCT chain FROM restaurants WHERE active=1\"\n            \" AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain\")],\n        \"sources\": [dict(r) for r in con.execute(\n            \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1\"\n            \" AND dup_of IS NULL GROUP BY source ORDER BY n DESC\")],\n    }\n    con.close()\n    return out\n\n\n@app.get(\"/api/sources\")\ndef sources():\n    registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n    con = db.connect()\n    counts = {r[\"source\"]: r[\"n\"] for r in con.execute(\n        \"SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source\")}\n    items = {r[\"source\"]: r[\"n\"] for r in con.execute(\n        \"SELECT r.source, SUM(m.item_count) n FROM menus m\"\n        \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source\")}\n    last = {r[\"source\"]: r[\"ts\"] for r in con.execute(\n        \"SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source\")}\n    con.close()\n    for s in registry:\n        s[\"active_restaurants\"] = counts.get(s[\"id\"], 0)\n        s[\"menu_items\"] = items.get(s[\"id\"], 0)\n        s[\"last_sync\"] = last.get(s[\"id\"])\n        # ne pas exposer les clés d'intégration dans l'API publique\n        if \"integrations\" in s:\n            s[\"integrations\"] = [{\"label\": i.get(\"label\"),\n                                  \"status\": i.get(\"status\", \"actif\")}\n                                 for i in s[\"integrations\"]]\n    return {\"sources\": registry}\n\n\n# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------\n_SITE = {\n    \"wordmark\": \"Resto·Ka\",\n    \"accent\": \"#f08c00\",            # safran — on-accent encre #141814\n    \"domain\": \"www.resto-ka.com\",\n    \"tagline\": \"Chaque resto, chaque plat, chaque prix.\",\n}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(\n    period: str = \"30j\",\n    from_: str | None = Query(None, alias=\"from\"),\n    to: str | None = Query(None),\n):\n    \"\"\"Tableau de bord analytique (contrat commun Groupe KA, cache 5 min).\"\"\"\n    from . import stats as kastats\n    if period not in kastats.PERIOD_LABELS and not (from_ and to):\n        raise HTTPException(400, \"période inconnue\")\n    return kastats.dashboard(period, from_, to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(\n    period: str = \"30j\",\n    from_: str | None = Query(None, alias=\"from\"),\n    to: str | None = Query(None),\n    mode: str = \"complet\",\n):\n    \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :\n    complet | synthese | tendances | repartitions | donnees.\n    Un mode inconnu retombe sur complet (rétrocompatible v1).\"\"\"\n    from . import kapdf\n    from . import stats as kastats\n    if mode not in kapdf.REPORT_MODES:\n        mode = \"complet\"\n    if period not in kastats.PERIOD_LABELS and not (from_ and to):\n        raise HTTPException(400, \"période inconnue\")\n    dash = kastats.dashboard(period, from_, to)\n    pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()\n    fname = kapdf.filename(\"resto-ka\", period, mode)\n    return Response(content=pdf, media_type=\"application/pdf\",\n                    headers={\"Content-Disposition\":\n                             f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/stats\")\ndef stats():\n    con = db.connect()\n    head = dict(con.execute(\n        \"\"\"SELECT COUNT(*) restaurants,\n                  SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))\n                      with_menu,\n                  COUNT(DISTINCT chain) chains,\n                  COUNT(DISTINCT region) regions,\n                  COUNT(DISTINCT source) sources\n           FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n    m = dict(con.execute(\n        \"\"\"SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items\n           FROM menus m JOIN restaurants r ON r.uid=m.uid\n           WHERE r.active=1 AND r.dup_of IS NULL\"\"\").fetchone())\n    by_region = [dict(r) for r in con.execute(\n        \"SELECT region, COUNT(*) n FROM restaurants WHERE active=1\"\n        \" AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC\")]\n    by_context = [dict(r) for r in con.execute(\n        \"SELECT m.price_context, COUNT(*) n FROM menus m\"\n        \" JOIN restaurants r ON r.uid=m.uid WHERE r.active=1\"\n        \" GROUP BY m.price_context\")]\n    log = [dict(r) for r in con.execute(\n        \"SELECT source, ts, found, added, updated, removed, ok, message\"\n        \" FROM sync_log ORDER BY ts DESC LIMIT 20\")]\n    insp = dict(con.execute(\n        \"\"\"SELECT COUNT(*) inspections,\n                  SUM(uid IS NOT NULL) inspections_matched,\n                  COUNT(DISTINCT uid) restaurants_with_inspections\n           FROM inspections\"\"\").fetchone())\n    enr = dict(con.execute(\n        \"\"\"SELECT SUM(details LIKE '%\"yelp\":%') with_yelp_rating,\n                  SUM(details LIKE '%\"ubereats\":%') with_ubereats,\n                  SUM(details LIKE '%\"permis_alcool\"%') with_alcohol_permit\n           FROM restaurants WHERE active=1 AND dup_of IS NULL\"\"\").fetchone())\n    con.close()\n    try:  # nb de sources au registre (data/sources.json), actives ou en attente\n        registry = json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))[\"sources\"]\n        registered = len(registry)\n    except (ValueError, OSError, KeyError):\n        registered = None\n    return {**head, **m, **insp, **enr, \"sources_registry\": registered,\n            \"by_region\": by_region, \"by_context\": by_context,\n            \"recent_syncs\": log}\n\n\n@app.post(\"/api/sync\")\ndef trigger_sync(background: BackgroundTasks, source: str | None = None):\n    \"\"\"Déclenche une synchronisation (équivalent d'un webhook entrant).\"\"\"\n    def _job():\n        with _sync_lock:\n            ingest.run([source] if source else None)\n            ingest.enrich()\n    background.add_task(_job)\n    return {\"status\": \"démarré\", \"source\": source or \"toutes\"}\n\n\n# --- Frontend React (build Vite) --------------------------------------------\nif FRONTEND_DIST.exists():\n    app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIST / \"assets\"),\n              name=\"assets\")\n\n    @app.middleware(\"http\")\n    async def _cache_headers(request, call_next):\n        \"\"\"Bundles hachés immuables ; index.html toujours revalidé.\"\"\"\n        resp = await call_next(request)\n        path = request.url.path\n        if path.startswith(\"/assets/\"):\n            resp.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n        elif \"text/html\" in (resp.headers.get(\"content-type\") or \"\"):\n            resp.headers[\"Cache-Control\"] = \"no-cache\"\n        return resp\n\n    _CLIENT_PREFIXES = (\"resto/\", \"sources\", \"stats\", \"favoris\", \"contact\")\n\n    @app.get(\"/{full_path:path}\")\n    def spa(full_path: str):\n        target = FRONTEND_DIST / full_path\n        if full_path and target.is_file():\n            return FileResponse(target)\n        known = (full_path == \"\" or full_path in _CLIENT_PREFIXES\n                 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))\n        return FileResponse(FRONTEND_DIST / \"index.html\",\n                            status_code=200 if known else 404,\n                            headers={\"Cache-Control\": \"no-cache\"})\n","structuredPatch":[{"oldStart":149,"oldLines":6,"newStart":149,"newLines":11,"lines":["     if q:","         sql += \" AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)\"","         args += [f\"%{q}%\"] * 4","+    if uids:","+        lst = [u.strip() for u in uids.split(\",\") if u.strip()][:24]","+        if lst:","+            sql += f\" AND uid IN ({','.join('?' * len(lst))})\"","+            args += lst","     if has_menu == 1:","         sql += \" AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)\"","     elif has_menu == 0:"]}],"userModified":false,"replaceAll":false}}}52{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9de48342-e427-42ba-abda-96a846e65f11","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}53{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd3h4jSuLXSdhKnj9dF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WXoYCRVCHqgvtqBxcAqyNq","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts","old_string":"export const fetchRestaurant = (uid: string) =>\n  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);","new_string":"export const fetchRestaurant = (uid: string) =>\n  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\n// --- récemment consultés (localStorage, par appareil) ---------------------------\n\nconst RECENT_KEY = \"restoka:recent\";\nconst RECENT_MAX = 12;\n\nexport function getRecentUids(): string[] {\n  try {\n    const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? \"[]\");\n    return Array.isArray(raw) ? raw.filter((u) => typeof u === \"string\") : [];\n  } catch {\n    return [];\n  }\n}\n\nexport function pushRecentUid(uid: string) {\n  try {\n    const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);\n    localStorage.setItem(RECENT_KEY, JSON.stringify(next));\n  } catch { /* stockage indisponible (navigation privée) */ }\n}\n\n/** Fiches des restos consultés, remises dans l'ordre de consultation. */\nexport async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {\n  if (uids.length === 0) return [];\n  const params = new URLSearchParams({ uids: uids.join(\",\"), limit: String(uids.length) });\n  const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);\n  const by = new Map(r.restaurants.map((x) => [x.uid, x]));\n  return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":922,"cache_read_input_tokens":69747,"cache_creation":{"ephemeral_5m_input_tokens":922,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"8b716c47-531e-4f86-8ba2-06da5c7ca58c"}}54{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WXoYCRVCHqgvtqBxcAqyNq","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"320f5873-fbdf-4574-9b51-e3c55a90b165","timestamp":"2026-08-22T23:03:56.385Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/api.ts","oldString":"export const fetchRestaurant = (uid: string) =>\n  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);","newString":"export const fetchRestaurant = (uid: string) =>\n  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\n// --- récemment consultés (localStorage, par appareil) ---------------------------\n\nconst RECENT_KEY = \"restoka:recent\";\nconst RECENT_MAX = 12;\n\nexport function getRecentUids(): string[] {\n  try {\n    const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? \"[]\");\n    return Array.isArray(raw) ? raw.filter((u) => typeof u === \"string\") : [];\n  } catch {\n    return [];\n  }\n}\n\nexport function pushRecentUid(uid: string) {\n  try {\n    const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);\n    localStorage.setItem(RECENT_KEY, JSON.stringify(next));\n  } catch { /* stockage indisponible (navigation privée) */ }\n}\n\n/** Fiches des restos consultés, remises dans l'ordre de consultation. */\nexport async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {\n  if (uids.length === 0) return [];\n  const params = new URLSearchParams({ uids: uids.join(\",\"), limit: String(uids.length) });\n  const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);\n  const by = new Map(r.restaurants.map((x) => [x.uid, x]));\n  return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);\n}","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   api.ts\n// Desc:   Types + client API robuste (timeout, erreurs typées) + libellés FR\n//         (cuisines, contextes de prix, régions). Même patron que Lou·Ka.\n// ==============================================================================\n\nexport interface MenuOptionChoice { name: string; price_delta: number }\nexport interface MenuOption { group: string; required: boolean; choices: MenuOptionChoice[] }\n\nexport interface MenuItem {\n  id?: string;\n  name: string;\n  description?: string;\n  price: number | null;\n  currency?: string;\n  tags?: string[];\n  options?: MenuOption[];\n  image?: string;\n}\n\nexport interface MenuSection { name: string; items: MenuItem[]; image?: string }\n\nexport interface Menu {\n  price_context: \"dine-in\" | \"takeout\" | \"delivery\";\n  price_source: string;\n  currency: string;\n  captured_at: string;\n  item_count: number;\n  sections: MenuSection[];\n}\n\nexport interface MenuSummary {\n  image?: string | null;\n  price_context: string;\n  price_source: string;\n  captured_at: string;\n  item_count: number;\n  price_min: number | null;\n  price_median: number | null;\n}\n\nexport interface Restaurant {\n  uid: string;\n  source: string;\n  external_id: string;\n  name: string;\n  chain: string | null;\n  cuisines: string[];\n  establishment_type: string;\n  price_range: string;\n  address: string;\n  city: string;\n  region: string;\n  postal_code: string;\n  lat: number | null;\n  lng: number | null;\n  phone: string;\n  website: string;\n  url: string;\n  hours: Record<string, string>;\n  services: string[];\n  dietary_options: string[];\n  languages: string[];\n  images: string[];\n  status: string;\n  menu_summary?: MenuSummary | null;\n  menus?: Menu[];\n  recent_price_changes?: { price_context: string; item_key: string; ts: number; price: number | null }[];\n  dup_sources?: string[];\n  first_seen?: number;\n  last_seen: number;\n  updated_at: number;\n  active: number;\n}\n\nexport interface Facets {\n  regions: { region: string; n: number }[];\n  all_regions: string[];\n  cities: string[];\n  cuisines: { cuisine: string; n: number }[];\n  diets: { diet: string; n: number }[];\n  establishment_types: { t: string; n: number }[];\n  chains: string[];\n  sources: { source: string; n: number }[];\n}\n\nexport interface Source {\n  id: string;\n  name: string;\n  url: string;\n  platform?: string;\n  tier?: number;\n  price_context?: string;\n  extraction?: string;\n  cadence?: string;\n  status: string;\n  active_restaurants: number;\n  menu_items: number;\n  last_sync: number | null;\n  integrations?: { label: string; status: string }[];\n}\n\nexport interface Stats {\n  restaurants: number;\n  with_menu: number;\n  chains: number;\n  regions: number;\n  sources: number;\n  menus: number;\n  items: number;\n  by_region: { region: string; n: number }[];\n  by_context: { price_context: string; n: number }[];\n  recent_syncs: { source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string }[];\n}\n\n// --- libellés français --------------------------------------------------------\n\nexport const CUISINE_LABELS: Record<string, string> = {\n  \"quebecois\": \"Québécois\", \"francais\": \"Français\", \"italien\": \"Italien\",\n  \"pizza\": \"Pizza\", \"burgers\": \"Burgers\", \"poulet\": \"Poulet\",\n  \"bbq-grillades\": \"BBQ & grillades\", \"fruits-de-mer\": \"Fruits de mer\",\n  \"sushi-japonais\": \"Sushi & japonais\", \"chinois\": \"Chinois\", \"thai\": \"Thaï\",\n  \"vietnamien\": \"Vietnamien\", \"coreen\": \"Coréen\", \"indien\": \"Indien\",\n  \"libanais-moyen-orient\": \"Libanais & Moyen-Orient\", \"mexicain\": \"Mexicain\",\n  \"grec\": \"Grec\", \"mediterraneen\": \"Méditerranéen\",\n  \"dejeuner-brunch\": \"Déjeuner & brunch\", \"cafe-dessert\": \"Café & dessert\",\n  \"vegetarien-vegan\": \"Végé & vegan\", \"fast-food\": \"Fast-food\", \"autre\": \"Autre\",\n};\n\nexport const CONTEXT_LABELS: Record<string, string> = {\n  \"dine-in\": \"Prix en salle\",\n  \"takeout\": \"Prix pour emporter\",\n  \"delivery\": \"Prix livraison (majoré 25-30 %)\",\n};\n\nexport const CONTEXT_SHORT: Record<string, string> = {\n  \"dine-in\": \"Salle\", \"takeout\": \"Emporter\", \"delivery\": \"Livraison\",\n};\n\nexport const TYPE_LABELS: Record<string, string> = {\n  \"restaurant\": \"Restaurant\", \"fast-food\": \"Fast-food\", \"cafe\": \"Café\",\n  \"bar\": \"Bar & pub\", \"food-truck\": \"Food truck\", \"traiteur\": \"Traiteur\",\n  \"boulangerie-patisserie\": \"Boulangerie-pâtisserie\",\n  \"casse-croute\": \"Casse-croûte\", \"microbrasserie\": \"Microbrasserie\",\n  \"hotel\": \"Resto d'hôtel\", \"ghost-kitchen\": \"Cuisine fantôme\",\n};\n\nexport const DIET_LABELS: Record<string, string> = {\n  \"vegan\": \"Vegan\", \"vegetarien\": \"Végétarien\", \"sans-gluten\": \"Sans gluten\",\n  \"halal\": \"Halal\", \"casher\": \"Casher\", \"sans-noix\": \"Sans noix\", \"epice\": \"Épicé\",\n};\n\nexport const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;\nexport const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;\nexport const dietLabel = (d: string) => DIET_LABELS[d] ?? d;\n\nconst SOURCE_NAMES: Record<string, string> = {\n  ueat: \"UEAT (commande en ligne)\",\n  osm: \"OpenStreetMap\",\n};\nexport function registerSourceNames(sources: Source[]) {\n  for (const s of sources) SOURCE_NAMES[s.id] = s.name;\n}\nexport const sourceName = (id: string) => SOURCE_NAMES[id] ?? id;\n\n/** 16.5 -> « 16,50 $ » */\nexport const fmtPrice = (p: number | null | undefined): string =>\n  p == null ? \"—\" : `${p.toFixed(2).replace(\".\", \",\")} $`;\n\nexport const fmtDate = (iso: string | null | undefined): string => {\n  if (!iso) return \"—\";\n  const d = new Date(iso);\n  return isNaN(d.getTime()) ? iso : d.toLocaleDateString(\"fr-CA\");\n};\n\nexport const fmtTs = (ts: number | null | undefined): string =>\n  ts ? new Date(ts * 1000).toLocaleString(\"fr-CA\") : \"—\";\n\n// --- client -------------------------------------------------------------------\n\nasync function get<T>(path: string): Promise<T> {\n  const ctl = new AbortController();\n  const timer = setTimeout(() => ctl.abort(), 20000);\n  try {\n    const resp = await fetch(path, { signal: ctl.signal });\n    if (!resp.ok) throw new Error(`API ${resp.status} — ${path}`);\n    return (await resp.json()) as T;\n  } finally {\n    clearTimeout(timer);\n  }\n}\n\nexport interface Dish {\n  item: string;\n  description: string;\n  price: number | null;\n  currency: string;\n  image?: string | null;\n  tags: string[];\n  section: string;\n  price_context: string;\n  price_source: string;\n  captured_at: string;\n  brand: string;\n  restaurant: string;\n  uid: string;\n  city: string;\n  region: string;\n  locations: number;\n}\n\nexport interface DishQuery {\n  q: string; region?: string; city?: string; cuisine?: string;\n  price_max?: number; limit?: number; offset?: number;\n}\n\nexport function fetchDishes(query: DishQuery) {\n  const params = new URLSearchParams();\n  for (const [k, v] of Object.entries(query))\n    if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n  return get<{ total: number; count: number; dishes: Dish[] }>(\n    `/api/dishes?${params.toString()}`);\n}\n\nexport interface RestaurantQuery {\n  region?: string; city?: string; cuisine?: string; establishment_type?: string;\n  diet?: string; service?: string; price_range?: string; chain?: string;\n  source?: string; q?: string; has_menu?: string; sort?: string;\n  limit?: number; offset?: number;\n}\n\nexport function fetchRestaurants(query: RestaurantQuery = {}) {\n  const params = new URLSearchParams();\n  for (const [k, v] of Object.entries(query))\n    if (v !== undefined && v !== \"\" && v !== null) params.set(k, String(v));\n  return get<{ total: number; count: number; restaurants: Restaurant[] }>(\n    `/api/restaurants?${params.toString()}`);\n}\n\nexport const fetchRestaurant = (uid: string) =>\n  get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);\n\nexport const fetchFacets = (region?: string) =>\n  get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);\n\nexport const fetchSources = () => get<{ sources: Source[] }>(\"/api/sources\");\n\nexport const fetchStats = () => get<Stats>(\"/api/stats\");\n\n// --- comptes (« Se connecter avec KA » — hub Groupe KA) ------------------------\n\nexport interface Me {\n  uid: number;\n  ka_id: string;\n  email: string;\n  name: string;\n  picture: string;\n  bio?: string;\n  city?: string;\n  public_url?: string;\n  profile_source: string;\n}\n\nexport const fetchAuthConfig = () => get<{ ka: boolean }>(\"/api/auth/config\");\n\nexport async function fetchMe(): Promise<Me | null> {\n  try {\n    return await get<Me>(\"/api/me\");\n  } catch {\n    return null;\n  }\n}\n\nexport async function logout(): Promise<void> {\n  await fetch(\"/api/auth/logout\", { method: \"POST\" });\n}\n\nexport const fetchFavoris = () =>\n  get<{ count: number; restaurants: Restaurant[]; uids: string[] }>(\"/api/favoris\");\n\nexport async function toggleFavori(uid: string): Promise<boolean> {\n  const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: \"POST\" });\n  if (!resp.ok) throw new Error(`API ${resp.status}`);\n  return (await resp.json()).favori as boolean;\n}\n","structuredPatch":[{"oldStart":241,"oldLines":6,"newStart":241,"newLines":36,"lines":[" export const fetchRestaurant = (uid: string) =>","   get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`);"," ","+// --- récemment consultés (localStorage, par appareil) ---------------------------","+","+const RECENT_KEY = \"restoka:recent\";","+const RECENT_MAX = 12;","+","+export function getRecentUids(): string[] {","+  try {","+    const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? \"[]\");","+    return Array.isArray(raw) ? raw.filter((u) => typeof u === \"string\") : [];","+  } catch {","+    return [];","+  }","+}","+","+export function pushRecentUid(uid: string) {","+  try {","+    const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX);","+    localStorage.setItem(RECENT_KEY, JSON.stringify(next));","+  } catch { /* stockage indisponible (navigation privée) */ }","+}","+","+/** Fiches des restos consultés, remises dans l'ordre de consultation. */","+export async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> {","+  if (uids.length === 0) return [];","+  const params = new URLSearchParams({ uids: uids.join(\",\"), limit: String(uids.length) });","+  const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`);","+  const by = new Map(r.restaurants.map((x) => [x.uid, x]));","+  return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null);","+}","+"," export const fetchFacets = (region?: string) =>","   get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : \"\"}`);"," "]}],"userModified":false,"replaceAll":false}}}55{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd3h4jSuLXSdhKnj9dF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VwsG2nLSQFpTye9wnkAo5Z","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","old_string":"  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";","new_string":"  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,\n} from \"../api\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":922,"cache_read_input_tokens":69747,"cache_creation":{"ephemeral_5m_input_tokens":922,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"105cc677-311d-40c9-bf84-1d519a5757c6"}}56{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VwsG2nLSQFpTye9wnkAo5Z","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"2be50eb9-808c-45f2-9db0-84deb2de1048","timestamp":"2026-08-22T23:03:58.472Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","oldString":"  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";","newString":"  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,\n} from \"../api\";","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Resto.tsx\n// Desc:   Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n//         + menu complet par sections avec prix, options et badge de CONTEXTE\n//         de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n//         est inutilisable ; delivery affiché clairement étiqueté).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoCompass } from \"../components/Icons\";\n\nfunction MenuBlock({ menu }: { menu: Menu }) {\n  const [openSections, setOpenSections] = useState<Set<number>>(\n    new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n  const toggle = (i: number) => {\n    const next = new Set(openSections);\n    if (next.has(i)) next.delete(i);\n    else next.add(i);\n    setOpenSections(next);\n  };\n  return (\n    <section className=\"menu-block\">\n      <div className={`ctx-banner ctx-${menu.price_context}`}>\n        <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n        <span>\n          source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n          {\" · \"}{menu.item_count} plats\n        </span>\n      </div>\n      {menu.sections.map((sec, i) => (\n        <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n          <button className=\"menu-section-head\" onClick={() => toggle(i)}\n            aria-expanded={openSections.has(i)}>\n            {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n            <h3>{sec.name}</h3>\n            <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n          </button>\n          {openSections.has(i) && (\n            <ul className=\"menu-items\">\n              {sec.items.map((it, j) => (\n                <li className=\"menu-item\" key={`${it.name}-${j}`}>\n                  <div className=\"mi-row\">\n                    <div className=\"mi-main\">\n                      <div className=\"mi-line\">\n                        <span className=\"mi-name\">\n                          {it.name}\n                          {(it.tags ?? []).map((t) => (\n                            <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n                          ))}\n                        </span>\n                        <span className=\"mi-dots\" aria-hidden=\"true\" />\n                        <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n                      </div>\n                      {it.description && <p className=\"mi-desc\">{it.description}</p>}\n                    </div>\n                    {it.image && (\n                      <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n                    )}\n                  </div>\n                  {(it.options ?? []).length > 0 && (\n                    <div className=\"mi-optgroups\">\n                      {(it.options ?? []).map((op, k) => (\n                        <div className=\"og\" key={`${op.group}-${k}`}>\n                          <div className=\"og-head\">\n                            <span className=\"og-name\">{op.group}</span>\n                            <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n                              {op.required ? \"Choix requis\" : \"Optionnel\"}\n                            </span>\n                          </div>\n                          <div className=\"og-choices\">\n                            {op.choices.map((c, m) => (\n                              <span className=\"og-choice\" key={`${c.name}-${m}`}>\n                                {c.name}\n                                {c.price_delta !== 0 && (\n                                  <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>\n                                    {c.price_delta > 0 ? \"+\" : \"−\"}\n                                    {Math.abs(c.price_delta).toFixed(2).replace(\".\", \",\")} $\n                                  </b>\n                                )}\n                              </span>\n                            ))}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      ))}\n    </section>\n  );\n}\n\nexport default function RestoPage() {\n  const { uid } = useParams<{ uid: string }>();\n  const [resto, setResto] = useState<Restaurant | null>(null);\n  const [notFound, setNotFound] = useState(false);\n  const [ctx, setCtx] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!uid) return;\n    setResto(null);\n    setNotFound(false);\n    fetchRestaurant(uid)\n      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n      .catch(() => setNotFound(true));\n    window.scrollTo({ top: 0 });\n  }, [uid]);\n\n  if (notFound) {\n    return (\n      <div className=\"notice container\">\n        <div className=\"big\"><IcoCompass size={40} /></div>\n        <h2>Restaurant introuvable</h2>\n        <p><Link to=\"/\" className=\"link-btn\">Retour à la recherche</Link></p>\n      </div>\n    );\n  }\n  if (!resto) {\n    return (\n      <div className=\"container detail\">\n        <div className=\"skel\"><div className=\"sk-img\" /><div className=\"sk-line\" /><div className=\"sk-line short\" /></div>\n      </div>\n    );\n  }\n\n  const menus = resto.menus ?? [];\n  const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null;\n\n  return (\n    <div className=\"container detail\">\n      <nav className=\"crumbs\" aria-label=\"Fil d'Ariane\">\n        <Link to=\"/\">Restos</Link> ›\n        {resto.region && (\n          <>\n            <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> ›\n          </>\n        )}\n        <span>{resto.name}</span>\n      </nav>\n\n      <div className=\"detail-grid\">\n        {/* Panneau resto (nom, prix, adresse) EN PREMIER dans le DOM : c'est le\n            héro de la fiche — l'ordre du DOM est l'ordre visuel sur mobile\n            (standard Groupe Ka « Ordre des sections — pages détail »).\n            Sur desktop, le CSS le place explicitement en colonne 2. */}\n        <aside className=\"panel\">\n          <div className=\"panel-head\">\n            {(resto.images ?? [])[0] && (\n              <img className=\"panel-logo\" src={resto.images[0]} alt=\"\" loading=\"lazy\" />\n            )}\n            {resto.price_range && <div className=\"price\">{resto.price_range}</div>}\n          </div>\n          <h1>{resto.name}</h1>\n          <div className=\"loc\">\n            {resto.address}{resto.address && resto.city ? \", \" : \"\"}{resto.city}\n            {resto.postal_code ? ` (${resto.postal_code})` : \"\"}\n          </div>\n          <div className=\"kv\">\n            <div className=\"cell\">\n              <div className=\"k\">Région</div>\n              <div className=\"v\">{resto.region || \"—\"}</div>\n            </div>\n            <div className=\"cell\">\n              <div className=\"k\">Type</div>\n              <div className=\"v\">{typeLabel(resto.establishment_type)}</div>\n            </div>\n            {resto.chain && (\n              <div className=\"cell\">\n                <div className=\"k\">Chaîne</div>\n                <div className=\"v\">{resto.chain}</div>\n              </div>\n            )}\n            <div className=\"cell\">\n              <div className=\"k\">Statut</div>\n              <div className=\"v\">{resto.status === \"open\" ? \"Ouvert\" : \"Fermé temporairement\"}</div>\n            </div>\n          </div>\n          {resto.cuisines.length > 0 && (\n            <>\n              <div className=\"klabel\">Cuisines</div>\n              <div className=\"amenity-row\">\n                {resto.cuisines.map((c) => (\n                  <span className=\"amenity\" key={c}>{cuisineLabel(c)}</span>\n                ))}\n              </div>\n            </>\n          )}\n          {resto.dietary_options.length > 0 && (\n            <>\n              <div className=\"klabel\">Diètes offertes</div>\n              <div className=\"amenity-row\">\n                {resto.dietary_options.map((d) => (\n                  <span className=\"amenity\" key={d}>{dietLabel(d)}</span>\n                ))}\n              </div>\n            </>\n          )}\n          {(resto.website || resto.url) && (\n            <a className=\"cta\" href={resto.url || resto.website}\n              target=\"_blank\" rel=\"noopener noreferrer\">\n              Commander / voir chez le resto ↗\n            </a>\n          )}\n          <div className=\"fine\">\n            Source : {sourceName(resto.source)} — les prix appartiennent au\n            resto et peuvent changer sans préavis.\n          </div>\n        </aside>\n\n        <div className=\"detail-main\">\n          {menus.length > 1 && (\n            <div className=\"view-toggle ctx-toggle\" role=\"tablist\" aria-label=\"Contexte de prix\">\n              {menus.map((m) => (\n                <button key={m.price_context}\n                  className={m.price_context === menu?.price_context ? \"on\" : \"\"}\n                  onClick={() => setCtx(m.price_context)}>\n                  {CONTEXT_SHORT[m.price_context] ?? m.price_context}\n                </button>\n              ))}\n            </div>\n          )}\n          {menu ? <MenuBlock menu={menu} /> : (\n            <div className=\"notice\">\n              <h2>Menu à venir</h2>\n              <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n","structuredPatch":[{"oldStart":10,"oldLines":7,"newStart":10,"newLines":7,"lines":[" import { Link, useParams } from \"react-router-dom\";"," import {","   CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,","-  fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel,","+  fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,"," } from \"../api\";"," import { IcoCompass } from \"../components/Icons\";"," "]}],"userModified":false,"replaceAll":false}}}57{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd3h4jSuLXSdhKnj9dF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Mzzo8dxfqnZ3mAWcgGKusY","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","old_string":"    fetchRestaurant(uid)\n      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n      .catch(() => setNotFound(true));","new_string":"    fetchRestaurant(uid)\n      .then((r) => {\n        setResto(r);\n        setCtx(r.menus?.[0]?.price_context ?? null);\n        pushRecentUid(r.uid);\n      })\n      .catch(() => setNotFound(true));"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":922,"cache_read_input_tokens":69747,"cache_creation":{"ephemeral_5m_input_tokens":922,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"889b767c-5357-4ce8-9605-2d7667595fc3"}}58{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Mzzo8dxfqnZ3mAWcgGKusY","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"7464e903-23bb-4cee-9f25-e0301c05749a","timestamp":"2026-08-22T23:04:00.891Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Resto.tsx","oldString":"    fetchRestaurant(uid)\n      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n      .catch(() => setNotFound(true));","newString":"    fetchRestaurant(uid)\n      .then((r) => {\n        setResto(r);\n        setCtx(r.menus?.[0]?.price_context ?? null);\n        pushRecentUid(r.uid);\n      })\n      .catch(() => setNotFound(true));","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Resto.tsx\n// Desc:   Fiche restaurant — panneau synthèse collant (comme la fiche Lou·Ka)\n//         + menu complet par sections avec prix, options et badge de CONTEXTE\n//         de prix toujours visible (CLAUDE.md §6.2 : un prix sans contexte\n//         est inutilisable ; delivery affiché clairement étiqueté).\n// ==============================================================================\nimport { useEffect, useState } from \"react\";\nimport { Link, useParams } from \"react-router-dom\";\nimport {\n  CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel,\n  fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel,\n} from \"../api\";\nimport { IcoCompass } from \"../components/Icons\";\n\nfunction MenuBlock({ menu }: { menu: Menu }) {\n  const [openSections, setOpenSections] = useState<Set<number>>(\n    new Set(menu.sections.map((_, i) => i).slice(0, 4)));\n  const toggle = (i: number) => {\n    const next = new Set(openSections);\n    if (next.has(i)) next.delete(i);\n    else next.add(i);\n    setOpenSections(next);\n  };\n  return (\n    <section className=\"menu-block\">\n      <div className={`ctx-banner ctx-${menu.price_context}`}>\n        <b>{CONTEXT_LABELS[menu.price_context] ?? menu.price_context}</b>\n        <span>\n          source {sourceName(menu.price_source)} · capté le {fmtDate(menu.captured_at)}\n          {\" · \"}{menu.item_count} plats\n        </span>\n      </div>\n      {menu.sections.map((sec, i) => (\n        <div className=\"menu-section\" key={`${sec.name}-${i}`}>\n          <button className=\"menu-section-head\" onClick={() => toggle(i)}\n            aria-expanded={openSections.has(i)}>\n            {sec.image && <img className=\"ms-img\" src={sec.image} alt=\"\" loading=\"lazy\" />}\n            <h3>{sec.name}</h3>\n            <span className=\"ms-meta\">{sec.items.length} · {openSections.has(i) ? \"−\" : \"+\"}</span>\n          </button>\n          {openSections.has(i) && (\n            <ul className=\"menu-items\">\n              {sec.items.map((it, j) => (\n                <li className=\"menu-item\" key={`${it.name}-${j}`}>\n                  <div className=\"mi-row\">\n                    <div className=\"mi-main\">\n                      <div className=\"mi-line\">\n                        <span className=\"mi-name\">\n                          {it.name}\n                          {(it.tags ?? []).map((t) => (\n                            <em className={`mi-tag mi-tag-${t}`} key={t}>{dietLabel(t)}</em>\n                          ))}\n                        </span>\n                        <span className=\"mi-dots\" aria-hidden=\"true\" />\n                        <span className=\"mi-price\">{fmtPrice(it.price)}</span>\n                      </div>\n                      {it.description && <p className=\"mi-desc\">{it.description}</p>}\n                    </div>\n                    {it.image && (\n                      <img className=\"mi-thumb\" src={it.image} alt={it.name} loading=\"lazy\" />\n                    )}\n                  </div>\n                  {(it.options ?? []).length > 0 && (\n                    <div className=\"mi-optgroups\">\n                      {(it.options ?? []).map((op, k) => (\n                        <div className=\"og\" key={`${op.group}-${k}`}>\n                          <div className=\"og-head\">\n                            <span className=\"og-name\">{op.group}</span>\n                            <span className={`og-req ${op.required ? \"on\" : \"\"}`}>\n                              {op.required ? \"Choix requis\" : \"Optionnel\"}\n                            </span>\n                          </div>\n                          <div className=\"og-choices\">\n                            {op.choices.map((c, m) => (\n                              <span className=\"og-choice\" key={`${c.name}-${m}`}>\n                                {c.name}\n                                {c.price_delta !== 0 && (\n                                  <b className={c.price_delta > 0 ? \"plus\" : \"moins\"}>\n                                    {c.price_delta > 0 ? \"+\" : \"−\"}\n                                    {Math.abs(c.price_delta).toFixed(2).replace(\".\", \",\")} $\n                                  </b>\n                                )}\n                              </span>\n                            ))}\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      ))}\n    </section>\n  );\n}\n\nexport default function RestoPage() {\n  const { uid } = useParams<{ uid: string }>();\n  const [resto, setResto] = useState<Restaurant | null>(null);\n  const [notFound, setNotFound] = useState(false);\n  const [ctx, setCtx] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!uid) return;\n    setResto(null);\n    setNotFound(false);\n    fetchRestaurant(uid)\n      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })\n      .catch(() => setNotFound(true));\n    window.scrollTo({ top: 0 });\n  }, [uid]);\n\n  if (notFound) {\n    return (\n      <div className=\"notice container\">\n        <div className=\"big\"><IcoCompass size={40} /></div>\n        <h2>Restaurant introuvable</h2>\n        <p><Link to=\"/\" className=\"link-btn\">Retour à la recherche</Link></p>\n      </div>\n    );\n  }\n  if (!resto) {\n    return (\n      <div className=\"container detail\">\n        <div className=\"skel\"><div className=\"sk-img\" /><div className=\"sk-line\" /><div className=\"sk-line short\" /></div>\n      </div>\n    );\n  }\n\n  const menus = resto.menus ?? [];\n  const menu = menus.find((m) => m.price_context === ctx) ?? menus[0] ?? null;\n\n  return (\n    <div className=\"container detail\">\n      <nav className=\"crumbs\" aria-label=\"Fil d'Ariane\">\n        <Link to=\"/\">Restos</Link> ›\n        {resto.region && (\n          <>\n            <Link to={`/?region=${encodeURIComponent(resto.region)}`}>{resto.region}</Link> ›\n          </>\n        )}\n        <span>{resto.name}</span>\n      </nav>\n\n      <div className=\"detail-grid\">\n        {/* Panneau resto (nom, prix, adresse) EN PREMIER dans le DOM : c'est le\n            héro de la fiche — l'ordre du DOM est l'ordre visuel sur mobile\n            (standard Groupe Ka « Ordre des sections — pages détail »).\n            Sur desktop, le CSS le place explicitement en colonne 2. */}\n        <aside className=\"panel\">\n          <div className=\"panel-head\">\n            {(resto.images ?? [])[0] && (\n              <img className=\"panel-logo\" src={resto.images[0]} alt=\"\" loading=\"lazy\" />\n            )}\n            {resto.price_range && <div className=\"price\">{resto.price_range}</div>}\n          </div>\n          <h1>{resto.name}</h1>\n          <div className=\"loc\">\n            {resto.address}{resto.address && resto.city ? \", \" : \"\"}{resto.city}\n            {resto.postal_code ? ` (${resto.postal_code})` : \"\"}\n          </div>\n          <div className=\"kv\">\n            <div className=\"cell\">\n              <div className=\"k\">Région</div>\n              <div className=\"v\">{resto.region || \"—\"}</div>\n            </div>\n            <div className=\"cell\">\n              <div className=\"k\">Type</div>\n              <div className=\"v\">{typeLabel(resto.establishment_type)}</div>\n            </div>\n            {resto.chain && (\n              <div className=\"cell\">\n                <div className=\"k\">Chaîne</div>\n                <div className=\"v\">{resto.chain}</div>\n              </div>\n            )}\n            <div className=\"cell\">\n              <div className=\"k\">Statut</div>\n              <div className=\"v\">{resto.status === \"open\" ? \"Ouvert\" : \"Fermé temporairement\"}</div>\n            </div>\n          </div>\n          {resto.cuisines.length > 0 && (\n            <>\n              <div className=\"klabel\">Cuisines</div>\n              <div className=\"amenity-row\">\n                {resto.cuisines.map((c) => (\n                  <span className=\"amenity\" key={c}>{cuisineLabel(c)}</span>\n                ))}\n              </div>\n            </>\n          )}\n          {resto.dietary_options.length > 0 && (\n            <>\n              <div className=\"klabel\">Diètes offertes</div>\n              <div className=\"amenity-row\">\n                {resto.dietary_options.map((d) => (\n                  <span className=\"amenity\" key={d}>{dietLabel(d)}</span>\n                ))}\n              </div>\n            </>\n          )}\n          {(resto.website || resto.url) && (\n            <a className=\"cta\" href={resto.url || resto.website}\n              target=\"_blank\" rel=\"noopener noreferrer\">\n              Commander / voir chez le resto ↗\n            </a>\n          )}\n          <div className=\"fine\">\n            Source : {sourceName(resto.source)} — les prix appartiennent au\n            resto et peuvent changer sans préavis.\n          </div>\n        </aside>\n\n        <div className=\"detail-main\">\n          {menus.length > 1 && (\n            <div className=\"view-toggle ctx-toggle\" role=\"tablist\" aria-label=\"Contexte de prix\">\n              {menus.map((m) => (\n                <button key={m.price_context}\n                  className={m.price_context === menu?.price_context ? \"on\" : \"\"}\n                  onClick={() => setCtx(m.price_context)}>\n                  {CONTEXT_SHORT[m.price_context] ?? m.price_context}\n                </button>\n              ))}\n            </div>\n          )}\n          {menu ? <MenuBlock menu={menu} /> : (\n            <div className=\"notice\">\n              <h2>Menu à venir</h2>\n              <p>Ce resto est répertorié ; son menu sera capté à la prochaine synchronisation.</p>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n","structuredPatch":[{"oldStart":110,"oldLines":7,"newStart":110,"newLines":11,"lines":["     setResto(null);","     setNotFound(false);","     fetchRestaurant(uid)","-      .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); })","+      .then((r) => {","+        setResto(r);","+        setCtx(r.menus?.[0]?.price_context ?? null);","+        pushRecentUid(r.uid);","+      })","       .catch(() => setNotFound(true));","     window.scrollTo({ top: 0 });","   }, [uid]);"]}],"userModified":false,"replaceAll":false}}}59{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7db04391-b405-4550-a22b-93bdad10e917","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}60{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant la section sur l'accueil et le CSS."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"f4902605-fde2-4707-a01c-c5ded0021732"}}61{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PNpfRZ4pT2i4pqwjCozSTF","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","old_string":"import {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";","new_string":"import {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n  fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3fd23029-56a5-4396-8564-c5eefcbd36e0"}}62{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PNpfRZ4pT2i4pqwjCozSTF","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"2d5294d1-8182-43f0-97d7-b2bdeb82abc5","timestamp":"2026-08-22T23:04:08.096Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","oldString":"import {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";","newString":"import {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n  fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Home.tsx\n// Desc:   Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n//         tous les menus), chips cuisines, barre de filtres (région, ville,\n//         type, diète, prix), grilles de résultats + pagination numérotée.\n//         Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n//         Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n                     \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n  return (\n    <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n      {d.image ? (\n        <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n      ) : (\n        <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n      )}\n      <div className=\"dish-main\">\n        <div className=\"dish-line\">\n          <span className=\"dish-name\">{d.item}</span>\n          <span className=\"mi-dots\" aria-hidden=\"true\" />\n          <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n        </div>\n        {d.description && <p className=\"dish-desc\">{d.description}</p>}\n        <div className=\"dish-meta\">\n          <b>{d.brand}</b>\n          <span className=\"sep\" />\n          <span>{d.section}</span>\n          <span className=\"sep\" />\n          {d.locations > 1\n            ? <span>{d.locations} emplacements</span>\n            : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n          <span className={`ctx-pill ctx-${d.price_context}`}>\n            {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n          </span>\n        </div>\n      </div>\n    </Link>\n  );\n}\n\nexport default function Home() {\n  const [params, setParams] = useSearchParams();\n  const [facets, setFacets] = useState<Facets | null>(null);\n  const [restos, setRestos] = useState<Restaurant[]>([]);\n  const [dishes, setDishes] = useState<Dish[]>([]);\n  const [total, setTotal] = useState(0);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(false);\n  const [page, setPage] = useState(0);\n  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);\n\n  // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n  // Escape (même pattern que le menu hamburger d'App.tsx)\n  useEffect(() => {\n    document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n    if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n    window.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.body.style.overflow = \"\";\n      window.removeEventListener(\"keydown\", onKey);\n    };\n  }, [sheetOpen]);\n\n  const filters = useMemo(() => {\n    const f: Partial<Record<FilterKey, string>> = {};\n    for (const k of FILTER_KEYS) {\n      const v = params.get(k);\n      if (v) f[k] = v;\n    }\n    return f;\n  }, [params]);\n\n  const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n  const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n  const setFilter = (key: FilterKey, value: string) => {\n    const next = new URLSearchParams(params);\n    if (value) next.set(key, value);\n    else next.delete(key);\n    setParams(next, { replace: true });\n    setPage(0);\n  };\n\n  useEffect(() => {\n    fetchStats()\n      .then((s) => setStatChips([\n        { label: \"restaurants\", value: s.restaurants },\n        { label: \"avec menu complet\", value: s.with_menu },\n        { label: \"plats avec prix\", value: s.items },\n        { label: \"régions couvertes\", value: s.regions },\n      ]))\n      .catch(() => {});\n  }, []);\n\n  useEffect(() => {\n    fetchFacets(filters.region).then(setFacets).catch(() => {});\n  }, [filters.region]);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(false);\n    if (mode === \"plats\") {\n      const q = (filters.q ?? \"\").trim();\n      if (q.length < 2) {\n        setDishes([]); setTotal(0); setLoading(false);\n        return;\n      }\n      fetchDishes({\n        q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n        limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n      })\n        .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    } else {\n      const { mode: _m, ...rest } = filters;\n      fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n        .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    }\n  }, [filters, page, mode]);\n\n  const activePills: { key: FilterKey; label: string }[] = [];\n  if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n  if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n  if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n  if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n  if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n  if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n  if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n  if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n  if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n  const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n  const filterbar = (\n    <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n      <div className=\"sheet-head\">\n        Filtres\n        <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n      </div>\n      <div className=\"f-primary\">\n        <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n          <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"restos\"}\n            onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n          <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"plats\"}\n            onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n        </div>\n        <label className=\"f-search\">\n          <IcoSearch size={16} />\n          <input\n            type=\"search\"\n            placeholder={mode === \"plats\"\n              ? \"Poutine, tartare, pad thaï, sushi…\"\n              : \"Resto, chaîne, ville…\"}\n            value={filters.q ?? \"\"}\n            onChange={(e) => setFilter(\"q\", e.target.value)}\n            aria-label=\"Recherche\"\n          />\n          {filters.q && (\n            <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n          )}\n        </label>\n        <label className=\"f-ctl\">\n          <span>Région</span>\n          <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n            <option value=\"\">Toutes</option>\n            {(facets?.regions ?? []).map((r) => (\n              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n            ))}\n          </select>\n        </label>\n        <label className=\"f-ctl\">\n          <span>Ville</span>\n          <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n            <option value=\"\">Toutes</option>\n            {(facets?.cities ?? []).map((c) => (\n              <option key={c} value={c}>{c}</option>\n            ))}\n          </select>\n        </label>\n        {mode === \"restos\" && (\n          <>\n            <label className=\"f-ctl\">\n              <span>Type</span>\n              <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {(facets?.establishment_types ?? []).map((t) => (\n                  <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Diète</span>\n              <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n                <option value=\"\">Toutes</option>\n                {(facets?.diets ?? []).map((d) => (\n                  <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Prix</span>\n              <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n                  <option key={p} value={p}>{p}</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Menu</span>\n              <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                <option value=\"1\">Avec menu complet</option>\n                <option value=\"0\">Fiche seulement</option>\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Tri</span>\n              <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n                <option value=\"menu\">Menus d'abord</option>\n                <option value=\"name\">Nom</option>\n                <option value=\"price\">Fourchette de prix</option>\n                <option value=\"recent\">Récents</option>\n              </select>\n            </label>\n          </>\n        )}\n      </div>\n      <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n        Voir les {total} résultats\n      </button>\n    </div>\n  );\n\n  return (\n    <div className=\"container\">\n      <section className=\"hero\">\n        <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n        <h1>\n          Tous les restos. Tous les menus.<br />\n          <span className=\"hl\">Tous les prix.</span>\n        </h1>\n        <p className=\"lede\">\n          Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n          et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n          le contexte du prix (salle, emporter, livraison) est toujours affiché.\n        </p>\n        <div className=\"stat-row\">\n          <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n          {statChips.map((s) => (\n            <div className=\"stat-chip\" key={s.label}>\n              <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n            </div>\n          ))}\n        </div>\n        <div className=\"benefits\">\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n            <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n            <span className=\"benefit-label\">17 régions du Québec</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n            <span className=\"benefit-label\">Recherche par plat</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n            <span className=\"benefit-label\">Filtres diète & cuisine</span>\n          </div>\n        </div>\n      </section>\n\n      {/* chips cuisines (défilement horizontal) */}\n      <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n        <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n          onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n        {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n          <button\n            key={c.cuisine}\n            className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n            onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n          >\n            {cuisineLabel(c.cuisine)} · {c.n}\n          </button>\n        ))}\n      </div>\n\n      {filterbar}\n      {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n      <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n        <IcoSearch size={16} /> Filtres\n      </button>\n\n      {activePills.length > 0 && (\n        <div className=\"pills\">\n          {activePills.map((p) => (\n            <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n              {p.label} <span className=\"pill-x\">✕</span>\n            </button>\n          ))}\n          <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n        </div>\n      )}\n\n      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n        <span>\n          {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n            ? \"tapez un plat (min. 2 lettres)\"\n            : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n        </span>\n      </div>\n\n      {error && (\n        <div className=\"notice\">\n          <h2>Impossible de charger les résultats</h2>\n          <p>Réessayez dans un instant.</p>\n        </div>\n      )}\n\n      {loading ? (\n        <div className=\"grid\">\n          {Array.from({ length: 8 }).map((_, i) => (\n            <div className=\"skel\" key={i}>\n              <div className=\"sk-img\" />\n              <div className=\"sk-line\" />\n              <div className=\"sk-line short\" />\n            </div>\n          ))}\n        </div>\n      ) : mode === \"plats\" ? (\n        (filters.q ?? \"\").trim().length < 2 ? (\n          <div className=\"notice\">\n            <h2>Cherchez n'importe quel plat</h2>\n            <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n              du Québec sont indexés, avec leurs prix réels.</p>\n          </div>\n        ) : dishes.length === 0 ? (\n          <div className=\"notice\">\n            <h2>Aucun plat trouvé</h2>\n            <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n          </div>\n        ) : (\n          <>\n            <div className=\"dish-list\">\n              {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n            </div>\n            <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n          </>\n        )\n      ) : restos.length === 0 && !error ? (\n        <div className=\"notice\">\n          <h2>Aucun resto ne correspond</h2>\n          <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n        </div>\n      ) : (\n        <>\n          <div className=\"grid\">\n            {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n          </div>\n          <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n        </>\n      )}\n    </div>\n  );\n}\n","structuredPatch":[{"oldStart":11,"oldLines":7,"newStart":11,"newLines":8,"lines":[" import { Link, useSearchParams } from \"react-router-dom\";"," import {","   CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,","-  fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel,","+  fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,","+  fetchStats, fmtPrice, getRecentUids, typeLabel,"," } from \"../api\";"," import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";"," import Pager from \"../components/Pager\";"]}],"userModified":false,"replaceAll":false}}}63{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NMvPt7FXW6GJrKzLXi76H2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","old_string":"  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);","new_string":"  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);\n  const [recent, setRecent] = useState<Restaurant[]>([]);\n\n  useEffect(() => {\n    fetchRecentRestaurants(getRecentUids().slice(0, 8))\n      .then(setRecent)\n      .catch(() => {});\n  }, []);"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"4f376696-a6f6-49d1-8742-f8ca12f67d70"}}64{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NMvPt7FXW6GJrKzLXi76H2","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"31b62d8c-16db-4185-aa70-ce72826fad1d","timestamp":"2026-08-22T23:04:10.914Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","oldString":"  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);","newString":"  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);\n  const [recent, setRecent] = useState<Restaurant[]>([]);\n\n  useEffect(() => {\n    fetchRecentRestaurants(getRecentUids().slice(0, 8))\n      .then(setRecent)\n      .catch(() => {});\n  }, []);","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Home.tsx\n// Desc:   Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n//         tous les menus), chips cuisines, barre de filtres (région, ville,\n//         type, diète, prix), grilles de résultats + pagination numérotée.\n//         Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n//         Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n  fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n                     \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n  return (\n    <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n      {d.image ? (\n        <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n      ) : (\n        <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n      )}\n      <div className=\"dish-main\">\n        <div className=\"dish-line\">\n          <span className=\"dish-name\">{d.item}</span>\n          <span className=\"mi-dots\" aria-hidden=\"true\" />\n          <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n        </div>\n        {d.description && <p className=\"dish-desc\">{d.description}</p>}\n        <div className=\"dish-meta\">\n          <b>{d.brand}</b>\n          <span className=\"sep\" />\n          <span>{d.section}</span>\n          <span className=\"sep\" />\n          {d.locations > 1\n            ? <span>{d.locations} emplacements</span>\n            : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n          <span className={`ctx-pill ctx-${d.price_context}`}>\n            {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n          </span>\n        </div>\n      </div>\n    </Link>\n  );\n}\n\nexport default function Home() {\n  const [params, setParams] = useSearchParams();\n  const [facets, setFacets] = useState<Facets | null>(null);\n  const [restos, setRestos] = useState<Restaurant[]>([]);\n  const [dishes, setDishes] = useState<Dish[]>([]);\n  const [total, setTotal] = useState(0);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(false);\n  const [page, setPage] = useState(0);\n  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);\n\n  // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n  // Escape (même pattern que le menu hamburger d'App.tsx)\n  useEffect(() => {\n    document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n    if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n    window.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.body.style.overflow = \"\";\n      window.removeEventListener(\"keydown\", onKey);\n    };\n  }, [sheetOpen]);\n\n  const filters = useMemo(() => {\n    const f: Partial<Record<FilterKey, string>> = {};\n    for (const k of FILTER_KEYS) {\n      const v = params.get(k);\n      if (v) f[k] = v;\n    }\n    return f;\n  }, [params]);\n\n  const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n  const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n  const setFilter = (key: FilterKey, value: string) => {\n    const next = new URLSearchParams(params);\n    if (value) next.set(key, value);\n    else next.delete(key);\n    setParams(next, { replace: true });\n    setPage(0);\n  };\n\n  useEffect(() => {\n    fetchStats()\n      .then((s) => setStatChips([\n        { label: \"restaurants\", value: s.restaurants },\n        { label: \"avec menu complet\", value: s.with_menu },\n        { label: \"plats avec prix\", value: s.items },\n        { label: \"régions couvertes\", value: s.regions },\n      ]))\n      .catch(() => {});\n  }, []);\n\n  useEffect(() => {\n    fetchFacets(filters.region).then(setFacets).catch(() => {});\n  }, [filters.region]);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(false);\n    if (mode === \"plats\") {\n      const q = (filters.q ?? \"\").trim();\n      if (q.length < 2) {\n        setDishes([]); setTotal(0); setLoading(false);\n        return;\n      }\n      fetchDishes({\n        q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n        limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n      })\n        .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    } else {\n      const { mode: _m, ...rest } = filters;\n      fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n        .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    }\n  }, [filters, page, mode]);\n\n  const activePills: { key: FilterKey; label: string }[] = [];\n  if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n  if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n  if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n  if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n  if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n  if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n  if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n  if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n  if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n  const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n  const filterbar = (\n    <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n      <div className=\"sheet-head\">\n        Filtres\n        <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n      </div>\n      <div className=\"f-primary\">\n        <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n          <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"restos\"}\n            onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n          <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"plats\"}\n            onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n        </div>\n        <label className=\"f-search\">\n          <IcoSearch size={16} />\n          <input\n            type=\"search\"\n            placeholder={mode === \"plats\"\n              ? \"Poutine, tartare, pad thaï, sushi…\"\n              : \"Resto, chaîne, ville…\"}\n            value={filters.q ?? \"\"}\n            onChange={(e) => setFilter(\"q\", e.target.value)}\n            aria-label=\"Recherche\"\n          />\n          {filters.q && (\n            <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n          )}\n        </label>\n        <label className=\"f-ctl\">\n          <span>Région</span>\n          <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n            <option value=\"\">Toutes</option>\n            {(facets?.regions ?? []).map((r) => (\n              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n            ))}\n          </select>\n        </label>\n        <label className=\"f-ctl\">\n          <span>Ville</span>\n          <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n            <option value=\"\">Toutes</option>\n            {(facets?.cities ?? []).map((c) => (\n              <option key={c} value={c}>{c}</option>\n            ))}\n          </select>\n        </label>\n        {mode === \"restos\" && (\n          <>\n            <label className=\"f-ctl\">\n              <span>Type</span>\n              <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {(facets?.establishment_types ?? []).map((t) => (\n                  <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Diète</span>\n              <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n                <option value=\"\">Toutes</option>\n                {(facets?.diets ?? []).map((d) => (\n                  <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Prix</span>\n              <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n                  <option key={p} value={p}>{p}</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Menu</span>\n              <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                <option value=\"1\">Avec menu complet</option>\n                <option value=\"0\">Fiche seulement</option>\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Tri</span>\n              <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n                <option value=\"menu\">Menus d'abord</option>\n                <option value=\"name\">Nom</option>\n                <option value=\"price\">Fourchette de prix</option>\n                <option value=\"recent\">Récents</option>\n              </select>\n            </label>\n          </>\n        )}\n      </div>\n      <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n        Voir les {total} résultats\n      </button>\n    </div>\n  );\n\n  return (\n    <div className=\"container\">\n      <section className=\"hero\">\n        <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n        <h1>\n          Tous les restos. Tous les menus.<br />\n          <span className=\"hl\">Tous les prix.</span>\n        </h1>\n        <p className=\"lede\">\n          Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n          et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n          le contexte du prix (salle, emporter, livraison) est toujours affiché.\n        </p>\n        <div className=\"stat-row\">\n          <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n          {statChips.map((s) => (\n            <div className=\"stat-chip\" key={s.label}>\n              <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n            </div>\n          ))}\n        </div>\n        <div className=\"benefits\">\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n            <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n            <span className=\"benefit-label\">17 régions du Québec</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n            <span className=\"benefit-label\">Recherche par plat</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n            <span className=\"benefit-label\">Filtres diète & cuisine</span>\n          </div>\n        </div>\n      </section>\n\n      {/* chips cuisines (défilement horizontal) */}\n      <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n        <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n          onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n        {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n          <button\n            key={c.cuisine}\n            className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n            onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n          >\n            {cuisineLabel(c.cuisine)} · {c.n}\n          </button>\n        ))}\n      </div>\n\n      {filterbar}\n      {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n      <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n        <IcoSearch size={16} /> Filtres\n      </button>\n\n      {activePills.length > 0 && (\n        <div className=\"pills\">\n          {activePills.map((p) => (\n            <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n              {p.label} <span className=\"pill-x\">✕</span>\n            </button>\n          ))}\n          <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n        </div>\n      )}\n\n      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n        <span>\n          {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n            ? \"tapez un plat (min. 2 lettres)\"\n            : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n        </span>\n      </div>\n\n      {error && (\n        <div className=\"notice\">\n          <h2>Impossible de charger les résultats</h2>\n          <p>Réessayez dans un instant.</p>\n        </div>\n      )}\n\n      {loading ? (\n        <div className=\"grid\">\n          {Array.from({ length: 8 }).map((_, i) => (\n            <div className=\"skel\" key={i}>\n              <div className=\"sk-img\" />\n              <div className=\"sk-line\" />\n              <div className=\"sk-line short\" />\n            </div>\n          ))}\n        </div>\n      ) : mode === \"plats\" ? (\n        (filters.q ?? \"\").trim().length < 2 ? (\n          <div className=\"notice\">\n            <h2>Cherchez n'importe quel plat</h2>\n            <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n              du Québec sont indexés, avec leurs prix réels.</p>\n          </div>\n        ) : dishes.length === 0 ? (\n          <div className=\"notice\">\n            <h2>Aucun plat trouvé</h2>\n            <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n          </div>\n        ) : (\n          <>\n            <div className=\"dish-list\">\n              {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n            </div>\n            <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n          </>\n        )\n      ) : restos.length === 0 && !error ? (\n        <div className=\"notice\">\n          <h2>Aucun resto ne correspond</h2>\n          <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n        </div>\n      ) : (\n        <>\n          <div className=\"grid\">\n            {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n          </div>\n          <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n        </>\n      )}\n    </div>\n  );\n}\n","structuredPatch":[{"oldStart":68,"oldLines":7,"newStart":68,"newLines":14,"lines":["   const [page, setPage] = useState(0);","   const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);","   const [sheetOpen, setSheetOpen] = useState(false);","+  const [recent, setRecent] = useState<Restaurant[]>([]);"," ","+  useEffect(() => {","+    fetchRecentRestaurants(getRecentUids().slice(0, 8))","+      .then(setRecent)","+      .catch(() => {});","+  }, []);","+","   // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à","   // Escape (même pattern que le menu hamburger d'App.tsx)","   useEffect(() => {"]}],"userModified":false,"replaceAll":false}}}65{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd4prpbK9sAW7h9p7Mk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HoCBdzdnYdagH9LLdvjgW1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","old_string":"      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","new_string":"      {page === 0 && recent.length > 0 && (\n        <section aria-label=\"Récemment consultés\">\n          <div className=\"results-head\">\n            <h2>Récemment consultés</h2>\n            <span>vos dernières fiches</span>\n          </div>\n          <div className=\"recent-row\">\n            {recent.map((r) => <RestoCard key={r.uid} r={r} />)}\n          </div>\n        </section>\n      )}\n\n      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1806,"cache_read_input_tokens":70669,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"8eb82533-e1a7-4fcd-a255-c2482ed9de7c"}}66{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HoCBdzdnYdagH9LLdvjgW1","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"894bbbc6-888f-42e2-bca4-ec0b89f9ee0a","timestamp":"2026-08-22T23:04:13.785Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/pages/Home.tsx","oldString":"      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","newString":"      {page === 0 && recent.length > 0 && (\n        <section aria-label=\"Récemment consultés\">\n          <div className=\"results-head\">\n            <h2>Récemment consultés</h2>\n            <span>vos dernières fiches</span>\n          </div>\n          <div className=\"recent-row\">\n            {recent.map((r) => <RestoCard key={r.uid} r={r} />)}\n          </div>\n        </section>\n      )}\n\n      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","originalFile":"// ==============================================================================\n// Author: Simon-Pierre Boucher <contact@spboucher.ai>\n// File:   pages/Home.tsx\n// Desc:   Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans\n//         tous les menus), chips cuisines, barre de filtres (région, ville,\n//         type, diète, prix), grilles de résultats + pagination numérotée.\n//         Filtres synchronisés à l'URL, feuille mobile, skeletons (patron\n//         Lou·Ka, design éditorial sharp Groupe KA).\n// ==============================================================================\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Link, useSearchParams } from \"react-router-dom\";\nimport {\n  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,\n  fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,\n  fetchStats, fmtPrice, getRecentUids, typeLabel,\n} from \"../api\";\nimport { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from \"../components/Icons\";\nimport Pager from \"../components/Pager\";\nimport RestoCard from \"../components/RestoCard\";\n\nconst PAGE_SIZE = 24;\nconst DISH_PAGE_SIZE = 20;\n\nconst FILTER_KEYS = [\"q\", \"region\", \"city\", \"cuisine\", \"establishment_type\",\n                     \"diet\", \"price_range\", \"sort\", \"mode\", \"has_menu\"] as const;\ntype FilterKey = typeof FILTER_KEYS[number];\n\nfunction DishRow({ d }: { d: Dish }) {\n  return (\n    <Link to={`/resto/${encodeURIComponent(d.uid)}`} className=\"dish-row\">\n      {d.image ? (\n        <img className=\"dish-img\" src={d.image} alt={d.item} loading=\"lazy\" />\n      ) : (\n        <span className=\"dish-img dish-noimg\"><IcoPlate size={22} strokeWidth={1.8} /></span>\n      )}\n      <div className=\"dish-main\">\n        <div className=\"dish-line\">\n          <span className=\"dish-name\">{d.item}</span>\n          <span className=\"mi-dots\" aria-hidden=\"true\" />\n          <span className=\"dish-price\">{fmtPrice(d.price)}</span>\n        </div>\n        {d.description && <p className=\"dish-desc\">{d.description}</p>}\n        <div className=\"dish-meta\">\n          <b>{d.brand}</b>\n          <span className=\"sep\" />\n          <span>{d.section}</span>\n          <span className=\"sep\" />\n          {d.locations > 1\n            ? <span>{d.locations} emplacements</span>\n            : <span>{d.city}{d.region ? ` · ${d.region}` : \"\"}</span>}\n          <span className={`ctx-pill ctx-${d.price_context}`}>\n            {CONTEXT_SHORT[d.price_context] ?? d.price_context}\n          </span>\n        </div>\n      </div>\n    </Link>\n  );\n}\n\nexport default function Home() {\n  const [params, setParams] = useSearchParams();\n  const [facets, setFacets] = useState<Facets | null>(null);\n  const [restos, setRestos] = useState<Restaurant[]>([]);\n  const [dishes, setDishes] = useState<Dish[]>([]);\n  const [total, setTotal] = useState(0);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(false);\n  const [page, setPage] = useState(0);\n  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);\n  const [sheetOpen, setSheetOpen] = useState(false);\n  const [recent, setRecent] = useState<Restaurant[]>([]);\n\n  useEffect(() => {\n    fetchRecentRestaurants(getRecentUids().slice(0, 8))\n      .then(setRecent)\n      .catch(() => {});\n  }, []);\n\n  // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à\n  // Escape (même pattern que le menu hamburger d'App.tsx)\n  useEffect(() => {\n    document.body.style.overflow = sheetOpen ? \"hidden\" : \"\";\n    if (!sheetOpen) return () => { document.body.style.overflow = \"\"; };\n    const onKey = (e: KeyboardEvent) => { if (e.key === \"Escape\") setSheetOpen(false); };\n    window.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.body.style.overflow = \"\";\n      window.removeEventListener(\"keydown\", onKey);\n    };\n  }, [sheetOpen]);\n\n  const filters = useMemo(() => {\n    const f: Partial<Record<FilterKey, string>> = {};\n    for (const k of FILTER_KEYS) {\n      const v = params.get(k);\n      if (v) f[k] = v;\n    }\n    return f;\n  }, [params]);\n\n  const mode = filters.mode === \"plats\" ? \"plats\" : \"restos\";\n  const pageSize = mode === \"plats\" ? DISH_PAGE_SIZE : PAGE_SIZE;\n\n  const setFilter = (key: FilterKey, value: string) => {\n    const next = new URLSearchParams(params);\n    if (value) next.set(key, value);\n    else next.delete(key);\n    setParams(next, { replace: true });\n    setPage(0);\n  };\n\n  useEffect(() => {\n    fetchStats()\n      .then((s) => setStatChips([\n        { label: \"restaurants\", value: s.restaurants },\n        { label: \"avec menu complet\", value: s.with_menu },\n        { label: \"plats avec prix\", value: s.items },\n        { label: \"régions couvertes\", value: s.regions },\n      ]))\n      .catch(() => {});\n  }, []);\n\n  useEffect(() => {\n    fetchFacets(filters.region).then(setFacets).catch(() => {});\n  }, [filters.region]);\n\n  useEffect(() => {\n    setLoading(true);\n    setError(false);\n    if (mode === \"plats\") {\n      const q = (filters.q ?? \"\").trim();\n      if (q.length < 2) {\n        setDishes([]); setTotal(0); setLoading(false);\n        return;\n      }\n      fetchDishes({\n        q, region: filters.region, city: filters.city, cuisine: filters.cuisine,\n        limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,\n      })\n        .then((r) => { setDishes(r.dishes); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    } else {\n      const { mode: _m, ...rest } = filters;\n      fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })\n        .then((r) => { setRestos(r.restaurants); setTotal(r.total); })\n        .catch(() => setError(true))\n        .finally(() => setLoading(false));\n    }\n  }, [filters, page, mode]);\n\n  const activePills: { key: FilterKey; label: string }[] = [];\n  if (filters.region) activePills.push({ key: \"region\", label: filters.region });\n  if (filters.city) activePills.push({ key: \"city\", label: filters.city });\n  if (filters.cuisine) activePills.push({ key: \"cuisine\", label: cuisineLabel(filters.cuisine) });\n  if (filters.establishment_type) activePills.push({ key: \"establishment_type\", label: typeLabel(filters.establishment_type) });\n  if (filters.diet) activePills.push({ key: \"diet\", label: dietLabel(filters.diet) });\n  if (filters.price_range) activePills.push({ key: \"price_range\", label: filters.price_range });\n  if (filters.has_menu === \"1\") activePills.push({ key: \"has_menu\", label: \"Avec menu complet\" });\n  if (filters.has_menu === \"0\") activePills.push({ key: \"has_menu\", label: \"Fiche seulement\" });\n  if (filters.q) activePills.push({ key: \"q\", label: `« ${filters.q} »` });\n\n  const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };\n\n  const filterbar = (\n    <div className={`filterbar ${sheetOpen ? \"open\" : \"\"}`}>\n      <div className=\"sheet-head\">\n        Filtres\n        <button className=\"sheet-close\" onClick={() => setSheetOpen(false)} aria-label=\"Fermer\">✕</button>\n      </div>\n      <div className=\"f-primary\">\n        <div className=\"mode-toggle\" role=\"tablist\" aria-label=\"Chercher par\">\n          <button className={mode === \"restos\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"restos\"}\n            onClick={() => setFilter(\"mode\", \"\")}>Restos</button>\n          <button className={mode === \"plats\" ? \"on\" : \"\"} role=\"tab\"\n            aria-selected={mode === \"plats\"}\n            onClick={() => setFilter(\"mode\", \"plats\")}>Plats</button>\n        </div>\n        <label className=\"f-search\">\n          <IcoSearch size={16} />\n          <input\n            type=\"search\"\n            placeholder={mode === \"plats\"\n              ? \"Poutine, tartare, pad thaï, sushi…\"\n              : \"Resto, chaîne, ville…\"}\n            value={filters.q ?? \"\"}\n            onChange={(e) => setFilter(\"q\", e.target.value)}\n            aria-label=\"Recherche\"\n          />\n          {filters.q && (\n            <button className=\"f-clear\" onClick={() => setFilter(\"q\", \"\")} aria-label=\"Effacer\">✕</button>\n          )}\n        </label>\n        <label className=\"f-ctl\">\n          <span>Région</span>\n          <select value={filters.region ?? \"\"} onChange={(e) => { setFilter(\"region\", e.target.value); setFilter(\"city\", \"\"); }}>\n            <option value=\"\">Toutes</option>\n            {(facets?.regions ?? []).map((r) => (\n              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>\n            ))}\n          </select>\n        </label>\n        <label className=\"f-ctl\">\n          <span>Ville</span>\n          <select value={filters.city ?? \"\"} onChange={(e) => setFilter(\"city\", e.target.value)}>\n            <option value=\"\">Toutes</option>\n            {(facets?.cities ?? []).map((c) => (\n              <option key={c} value={c}>{c}</option>\n            ))}\n          </select>\n        </label>\n        {mode === \"restos\" && (\n          <>\n            <label className=\"f-ctl\">\n              <span>Type</span>\n              <select value={filters.establishment_type ?? \"\"} onChange={(e) => setFilter(\"establishment_type\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {(facets?.establishment_types ?? []).map((t) => (\n                  <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Diète</span>\n              <select value={filters.diet ?? \"\"} onChange={(e) => setFilter(\"diet\", e.target.value)}>\n                <option value=\"\">Toutes</option>\n                {(facets?.diets ?? []).map((d) => (\n                  <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Prix</span>\n              <select value={filters.price_range ?? \"\"} onChange={(e) => setFilter(\"price_range\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                {[\"$\", \"$$\", \"$$$\", \"$$$$\"].map((p) => (\n                  <option key={p} value={p}>{p}</option>\n                ))}\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Menu</span>\n              <select value={filters.has_menu ?? \"\"} onChange={(e) => setFilter(\"has_menu\", e.target.value)}>\n                <option value=\"\">Tous</option>\n                <option value=\"1\">Avec menu complet</option>\n                <option value=\"0\">Fiche seulement</option>\n              </select>\n            </label>\n            <label className=\"f-ctl\">\n              <span>Tri</span>\n              <select value={filters.sort ?? \"menu\"} onChange={(e) => setFilter(\"sort\", e.target.value)}>\n                <option value=\"menu\">Menus d'abord</option>\n                <option value=\"name\">Nom</option>\n                <option value=\"price\">Fourchette de prix</option>\n                <option value=\"recent\">Récents</option>\n              </select>\n            </label>\n          </>\n        )}\n      </div>\n      <button className=\"btn btn-primary sheet-apply\" onClick={() => setSheetOpen(false)}>\n        Voir les {total} résultats\n      </button>\n    </div>\n  );\n\n  return (\n    <div className=\"container\">\n      <section className=\"hero\">\n        <span className=\"kicker\">Agrégateur — 17 régions du Québec</span>\n        <h1>\n          Tous les restos. Tous les menus.<br />\n          <span className=\"hl\">Tous les prix.</span>\n        </h1>\n        <p className=\"lede\">\n          Resto-Ka rassemble les restaurants du Québec avec leurs menus complets\n          et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,\n          le contexte du prix (salle, emporter, livraison) est toujours affiché.\n        </p>\n        <div className=\"stat-row\">\n          <div className=\"stat-chip\"><span className=\"pulse\" />En direct</div>\n          {statChips.map((s) => (\n            <div className=\"stat-chip\" key={s.label}>\n              <b>{s.value.toLocaleString(\"fr-CA\")}</b> {s.label}\n            </div>\n          ))}\n        </div>\n        <div className=\"benefits\">\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoTag size={19} /></span>\n            <span className=\"benefit-label\">Prix réels, jamais sans contexte</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoPin size={19} /></span>\n            <span className=\"benefit-label\">17 régions du Québec</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico\"><IcoBag size={19} /></span>\n            <span className=\"benefit-label\">Recherche par plat</span>\n          </div>\n          <div className=\"benefit\">\n            <span className=\"benefit-ico blue\"><IcoLeaf size={19} /></span>\n            <span className=\"benefit-label\">Filtres diète & cuisine</span>\n          </div>\n        </div>\n      </section>\n\n      {/* chips cuisines (défilement horizontal) */}\n      <div className=\"chips\" role=\"tablist\" aria-label=\"Cuisines\">\n        <button className={`chip ${!filters.cuisine ? \"on\" : \"\"}`}\n          onClick={() => setFilter(\"cuisine\", \"\")}>Toutes les cuisines</button>\n        {(facets?.cuisines ?? []).slice(0, 14).map((c) => (\n          <button\n            key={c.cuisine}\n            className={`chip ${filters.cuisine === c.cuisine ? \"on\" : \"\"}`}\n            onClick={() => setFilter(\"cuisine\", filters.cuisine === c.cuisine ? \"\" : c.cuisine)}\n          >\n            {cuisineLabel(c.cuisine)} · {c.n}\n          </button>\n        ))}\n      </div>\n\n      {filterbar}\n      {sheetOpen && <div className=\"sheet-backdrop\" onClick={() => setSheetOpen(false)} aria-hidden=\"true\" />}\n      <button className=\"fab\" onClick={() => setSheetOpen(true)}>\n        <IcoSearch size={16} /> Filtres\n      </button>\n\n      {activePills.length > 0 && (\n        <div className=\"pills\">\n          {activePills.map((p) => (\n            <button key={p.key} className=\"pill\" onClick={() => setFilter(p.key, \"\")}>\n              {p.label} <span className=\"pill-x\">✕</span>\n            </button>\n          ))}\n          <button className=\"pill pill-clear\" onClick={clearAll}>Tout effacer</button>\n        </div>\n      )}\n\n      <div className=\"results-head\">\n        <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>\n        <span>\n          {mode === \"plats\" && (filters.q ?? \"\").trim().length < 2\n            ? \"tapez un plat (min. 2 lettres)\"\n            : `${total.toLocaleString(\"fr-CA\")} résultat${total > 1 ? \"s\" : \"\"}`}\n        </span>\n      </div>\n\n      {error && (\n        <div className=\"notice\">\n          <h2>Impossible de charger les résultats</h2>\n          <p>Réessayez dans un instant.</p>\n        </div>\n      )}\n\n      {loading ? (\n        <div className=\"grid\">\n          {Array.from({ length: 8 }).map((_, i) => (\n            <div className=\"skel\" key={i}>\n              <div className=\"sk-img\" />\n              <div className=\"sk-line\" />\n              <div className=\"sk-line short\" />\n            </div>\n          ))}\n        </div>\n      ) : mode === \"plats\" ? (\n        (filters.q ?? \"\").trim().length < 2 ? (\n          <div className=\"notice\">\n            <h2>Cherchez n'importe quel plat</h2>\n            <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus\n              du Québec sont indexés, avec leurs prix réels.</p>\n          </div>\n        ) : dishes.length === 0 ? (\n          <div className=\"notice\">\n            <h2>Aucun plat trouvé</h2>\n            <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>\n          </div>\n        ) : (\n          <>\n            <div className=\"dish-list\">\n              {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}\n            </div>\n            <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n          </>\n        )\n      ) : restos.length === 0 && !error ? (\n        <div className=\"notice\">\n          <h2>Aucun resto ne correspond</h2>\n          <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>\n        </div>\n      ) : (\n        <>\n          <div className=\"grid\">\n            {restos.map((r) => <RestoCard key={r.uid} r={r} />)}\n          </div>\n          <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />\n        </>\n      )}\n    </div>\n  );\n}\n","structuredPatch":[{"oldStart":338,"oldLines":6,"newStart":338,"newLines":18,"lines":["         </div>","       )}"," ","+      {page === 0 && recent.length > 0 && (","+        <section aria-label=\"Récemment consultés\">","+          <div className=\"results-head\">","+            <h2>Récemment consultés</h2>","+            <span>vos dernières fiches</span>","+          </div>","+          <div className=\"recent-row\">","+            {recent.map((r) => <RestoCard key={r.uid} r={r} />)}","+          </div>","+        </section>","+      )}","+","       <div className=\"results-head\">","         <h2>{mode === \"plats\" ? \"Plats\" : \"Restaurants\"}</h2>","         <span>"]}],"userModified":false,"replaceAll":false}}}67{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f23ca9f1-02e2-4a2a-a0fb-ac8032a35ca0","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}68{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd5moo5CYQen1aciGnM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XnZvWSpzTkCGVac5zfT5vS","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","offset":217,"limit":12},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1345,"cache_read_input_tokens":72475,"cache_creation":{"ephemeral_5m_input_tokens":1345,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"6636843b-68a0-4d91-b831-9e9dc1daacb8"}}69{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XnZvWSpzTkCGVac5zfT5vS","type":"tool_result","content":"217\t.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n218\t.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n219\t.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n220\t.grid {\n221\t  display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n222\t  gap: 22px; padding-bottom: 30px;\n223\t}\n224\t@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n225\t\n226\t.card {\n227\t  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n228\t  overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0f609dea-cbd2-4cf2-ac90-c3b165c23aeb","timestamp":"2026-08-22T23:04:18.239Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","content":".results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n.grid {\n  display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n  gap: 22px; padding-bottom: 30px;\n}\n@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n.card {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);","numLines":12,"startLine":217,"totalLines":907}}}}70{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"059cee3c-e4cc-43a0-942d-644f2de514ee","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}71{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd67E6oAG5NogZz4TaR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PcK2PJqbnJxmw3au1NuGHh","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","old_string":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }","new_string":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n/* récemment consultés — bande horizontale de cartes, défilement latéral */\n.recent-row {\n  display: grid; grid-auto-flow: column; grid-auto-columns: 290px;\n  gap: 18px; overflow-x: auto; padding-bottom: 12px;\n  scroll-snap-type: x proximity;\n}\n.recent-row > .card { scroll-snap-align: start; }\n@media (max-width: 640px) {\n  .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; }\n}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":498,"cache_read_input_tokens":73820,"cache_creation":{"ephemeral_5m_input_tokens":498,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0bf8752e-00ee-4a0c-8bae-383b8d5b5d4b"}}72{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PcK2PJqbnJxmw3au1NuGHh","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"7104059a-9770-4720-ab5c-f83d68ea3c75","timestamp":"2026-08-22T23:04:25.516Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/resto-ka/frontend/src/styles.css","oldString":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }","newString":"@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n/* récemment consultés — bande horizontale de cartes, défilement latéral */\n.recent-row {\n  display: grid; grid-auto-flow: column; grid-auto-columns: 290px;\n  gap: 18px; overflow-x: auto; padding-bottom: 12px;\n  scroll-snap-type: x proximity;\n}\n.recent-row > .card { scroll-snap-align: start; }\n@media (max-width: 640px) {\n  .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; }\n}","originalFile":"/* -----------------------------------------------------------------------------\n  Author: Simon-Pierre Boucher <contact@spboucher.ai>\n  File:   styles.css\n  Desc:   Identité visuelle Resto·Ka — système de design « éditorial sharp »\n          du GROUPE KA. Le socle (palette fixe, typo, grain, boutons, cartes…)\n          vient de ka-ui (src/ka/tokens.css, importé AVANT ce fichier) ; ici on\n          ne définit que l'ACCENT SAFRAN de Resto-Ka + les classes métier.\n    · Accent Resto-Ka : safran #f08c00 (soft #fdeed7 · deep #b96a00)\n    · Baseline : « Chaque resto, chaque plat, chaque prix. »\n    · 100 % adaptatif mobile (safe-areas iOS)\n----------------------------------------------------------------------------- */\n:root {\n  /* ACCENT du site — seule surcharge de tokens.css (ka-ui) */\n  --accent: #f08c00;\n  --accent-soft: #fdeed7;\n  --accent-deep: #b96a00;\n  --on-accent: #141814;\n\n  /* alias rétro-compatibles (le code historique utilise --lime) */\n  --lime: var(--accent);\n  --lime-soft: var(--accent-soft);\n}\n\nh1, h2, h3, h4 { font-weight: 700; margin: 0; }\na { text-decoration: none; }\nbutton { font-family: inherit; }\nimg { display: block; }\n\n.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }\n@media (max-width: 640px) { .container { padding: 0 16px; } }\n\n/* ================= Header ================= */\n.header {\n  position: sticky; top: 0; z-index: var(--z-header, 500);\n  background: var(--paper);\n  border-bottom: 1.5px solid var(--ink);\n}\n:root { --header-h: 64px; }  /* hauteur du header (ancre du menu compte fixed) */\n.header-inner { display: flex; align-items: center; gap: 20px; height: var(--header-h); }\n.brand {\n  font-family: var(--font-display); font-weight: 700; font-size: 24px;\n  letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1;\n  color: var(--ink);\n}\n.brand .logo-ico { margin-right: 11px; flex: none; }\n/* « Ka » en pastille lime inclinée — même geste que la carte membre Groupe KA */\n.brand .ka {\n  background: var(--accent); color: var(--ink);\n  padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px;\n  display: inline-block; transform: rotate(-2deg);\n}\n.brand-tag {\n  font-family: var(--font-mono); font-size: 10px; font-weight: 500;\n  text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);\n  margin-left: 14px;\n}\n@media (max-width: 900px) { .brand-tag { display: none; } }\n.nav { margin-left: auto; display: flex; gap: 6px; }\n.nav a {\n  padding: 8px 16px; border: 1.5px solid transparent; border-radius: var(--r-ctl);\n  font-family: var(--font-display); font-weight: 700; font-size: 14px;\n  color: var(--ink); transition: all 0.13s ease; min-height: 38px;\n  display: inline-flex; align-items: center;\n}\n.nav a:hover { background: var(--accent); border-color: var(--ink); }\n.nav a.active { background: var(--ink); color: var(--accent); border-color: var(--ink); }\n\n/* ================= Ticker ================= */\n.ticker {\n  background: var(--ink); color: rgba(245, 243, 238, 0.85); overflow: hidden;\n  font-family: var(--font-mono); font-size: 11px; font-weight: 500;\n  letter-spacing: 0.08em; text-transform: uppercase; padding: 7px 0;\n  white-space: nowrap; border-bottom: 1.5px solid var(--ink);\n}\n.ticker-track { display: inline-flex; gap: 0; animation: ticker 40s linear infinite; will-change: transform; }\n.ticker span { padding: 0 26px; position: relative; }\n.ticker span::after { content: \"◆\"; position: absolute; right: -6px; color: var(--accent); opacity: 0.9; font-size: 8px; top: 3px; }\n@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }\n@media (prefers-reduced-motion: reduce) {\n  .ticker-track { animation: none; }\n  * { transition-duration: 0.01ms !important; }\n}\n\n/* ================= Hero ================= */\n.hero { padding: 58px 0 22px; }\n@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }\n.hero h1 {\n  font-size: clamp(36px, 5.4vw, 58px); font-weight: 700; line-height: 1.04;\n  letter-spacing: -0.04em; max-width: 920px; margin-top: 16px; color: var(--ink);\n}\n.hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; }\n.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }\n.stat-chip {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n  padding: 7px 14px; font-size: 12.5px; font-weight: 500;\n  color: var(--ink-2); display: flex; gap: 8px; align-items: center;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n}\n.stat-chip b { color: var(--ink); font-weight: 700; font-family: var(--font-display); }\n.stat-chip .pulse {\n  width: 8px; height: 8px; border-radius: 50%; background: var(--green);\n  box-shadow: 0 0 0 4px var(--accent-soft); animation: pulse 2.4s ease infinite;\n}\n@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(28, 92, 65, 0.18); } }\n\n/* ================= Rangée d'avantages ================= */\n.benefits {\n  display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n  gap: 14px; margin-top: 30px;\n}\n.benefit { display: flex; align-items: center; gap: 12px; }\n.benefit-ico {\n  width: 42px; height: 42px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n  flex: none; display: flex; align-items: center; justify-content: center;\n  background: var(--accent); color: var(--ink); box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);\n}\n.benefit-ico.blue { background: var(--surface); color: var(--green); }\n.benefit-label {\n  font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink);\n}\n@media (max-width: 640px) { .benefits { grid-template-columns: 1fr 1fr; gap: 12px; } }\n\n/* ================= Filter bar ================= */\n.filterbar {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  box-shadow: var(--shadow-off-soft); padding: 14px; margin: 20px 0 6px;\n  display: flex; flex-direction: column; gap: 0; min-width: 0;\n}\n.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }\n.f-search {\n  flex: 1 1 220px; min-width: 0; max-width: 440px; align-self: center;\n  display: flex; align-items: center; gap: 8px;\n  border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface-2);\n  padding: 0 13px; min-height: 46px; color: var(--ink-2);\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);\n  transition: box-shadow 0.15s ease, background 0.15s ease;\n}\n.f-search:focus-within {\n  background: var(--white, #fff); outline: 2px solid var(--green); outline-offset: 1px;\n  box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25);\n}\n.f-search input {\n  border: none; background: none; outline: none; flex: 1; min-width: 0;\n  font-size: 14px; color: var(--ink); font-family: inherit;\n}\n.f-clear {\n  border: 1px solid var(--line); background: var(--paper); color: var(--ink-2);\n  border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer;\n  flex: none; display: grid; place-items: center;\n}\n.f-ctl {\n  flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center;\n  gap: 2px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface);\n  padding: 7px 14px 6px; min-height: 52px; cursor: pointer;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.08);\n  transition: box-shadow 0.15s ease;\n}\n.f-ctl:focus-within { outline: 2px solid var(--green); outline-offset: 1px; box-shadow: 3px 3px 0 rgba(28, 92, 65, 0.25); }\n.f-ctl > span {\n  font-family: var(--font-mono); font-size: 9px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);\n}\n.f-ctl select {\n  border: none; background: transparent; outline: none; font-family: var(--font-display);\n  font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer;\n  appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 170px;\n  text-overflow: ellipsis;\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E\");\n  background-repeat: no-repeat; background-position: right center;\n}\n\n/* pastilles de filtres actifs */\n.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; }\n.pill {\n  display: inline-flex; align-items: center; gap: 7px;\n  border: 1.5px solid var(--ink); background: var(--accent); color: var(--ink);\n  border-radius: var(--r-ctl); padding: 5px 12px; font-size: 12.5px; font-weight: 700;\n  font-family: var(--font-display); cursor: pointer;\n  box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2); transition: all 0.12s ease;\n}\n.pill:hover { background: var(--ink); color: var(--accent); }\n.pill-x { font-size: 10px; opacity: 0.65; }\n.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }\n.pill-clear:hover { background: var(--danger); color: #fff; }\n.link-btn {\n  background: none; border: none; padding: 0; color: var(--green); font: inherit;\n  text-decoration: underline; cursor: pointer;\n}\n.btn {\n  display: inline-flex; align-items: center; justify-content: center; gap: 8px;\n  border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 10px 20px;\n  font-family: var(--font-display); font-weight: 700; font-size: 14px;\n  cursor: pointer; min-height: 44px;\n  transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.1s ease;\n}\n.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }\n.btn-primary { background: var(--ink); color: var(--accent); box-shadow: var(--shadow-off-mid); }\n.btn-primary:hover { background: var(--green-deep); }\n.btn-ghost { background: transparent; color: var(--ink); }\n.btn-ghost:hover { background: var(--accent); }\n.btn:disabled { opacity: 0.4; cursor: default; }\n\n/* ================= Chips ================= */\n.chips { display: flex; gap: 8px; margin: 18px 0 4px; overflow-x: auto; padding: 4px 4px 8px 0; scrollbar-width: none; }\n.chips::-webkit-scrollbar { display: none; }\n.chip {\n  border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink);\n  border-radius: var(--r-ctl); padding: 8px 16px; font-size: 13px; font-weight: 700;\n  cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); transition: all 0.13s ease;\n}\n.chip:hover { background: var(--accent-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.15); }\n.chip.on { background: var(--ink); color: var(--accent); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.3); }\n\n/* ================= Results ================= */\n.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; }\n.results-head h2 { font-size: 23px; letter-spacing: -0.03em; color: var(--ink); }\n.results-head span { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; }\n.grid {\n  display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));\n  gap: 22px; padding-bottom: 30px;\n}\n@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }\n\n.card {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);\n  transition: transform 0.18s ease, box-shadow 0.18s ease;\n}\n.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }\n.card:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }\n.card-img { position: relative; aspect-ratio: 16/9; background: var(--surface-2); overflow: hidden; border-bottom: 1.5px solid var(--ink); }\n.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }\n.card:hover .card-img img { transform: scale(1.04); }\n.card-img .noimg {\n  display: flex; align-items: center; justify-content: center; height: 100%;\n  color: var(--ink-3);\n  background:\n    radial-gradient(circle at 82% 20%, var(--accent-soft) 0, transparent 46%),\n    radial-gradient(circle at 12% 85%, var(--amber-soft) 0, transparent 42%),\n    var(--surface-2);\n}\n.card-logo { max-width: 46%; max-height: 68%; object-fit: contain; }\n.card:hover .card-logo { transform: none; }\n.badge {\n  position: absolute; top: 10px; left: 10px; border: 1.5px solid var(--ink);\n  border-radius: var(--r-ctl); padding: 3px 10px;\n  font-family: var(--font-mono); font-size: 10px; font-weight: 700; letter-spacing: 0.05em;\n  background: rgba(255, 255, 255, 0.95); color: var(--ink);\n  box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.25);\n}\n.badge.type { background: var(--ink); color: var(--accent); }\n.badge.right { left: auto; right: 10px; }\n/* badge de contexte de prix — TOUJOURS visible (un prix sans contexte est inutilisable) */\n.badge.ctx-takeout, .ctx-pill.ctx-takeout { background: var(--accent); color: var(--ink); }\n.badge.ctx-dine-in, .ctx-pill.ctx-dine-in { background: var(--green); color: #fff; }\n.badge.ctx-delivery, .ctx-pill.ctx-delivery { background: var(--amber); color: var(--ink); }\n.card-body { padding: 15px 17px; display: flex; flex-direction: column; gap: 6px; flex: 1; }\n.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.03em; color: var(--ink); }\n.card-price small { font-family: var(--font-mono); font-weight: 500; color: var(--ink-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }\n.card-price .no-menu { font-size: 14px; font-weight: 600; color: var(--ink-3); }\n.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); }\n.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }\n.card-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; }\n.card-foot { margin-top: auto; padding-top: 11px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }\n.source-tag {\n  font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.06em; color: var(--green);\n  background: var(--accent-soft); border: 1px solid rgba(28, 92, 65, 0.35);\n  border-radius: var(--r-ctl); padding: 3px 9px;\n  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60%;\n}\n.avail { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); font-weight: 500; text-align: right; }\n\n/* ================= Bascule Restos / Plats ================= */\n.mode-toggle {\n  display: inline-flex; align-self: center; border: 1.5px solid var(--ink);\n  border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); flex: none;\n}\n.mode-toggle button {\n  border: 0; background: transparent; padding: 12px 20px; cursor: pointer;\n  font-family: var(--font-display); font-size: 14px; font-weight: 700; color: var(--ink-2);\n  min-height: 46px;\n}\n.mode-toggle button + button { border-left: 1.5px solid var(--ink); }\n.mode-toggle button.on { background: var(--ink); color: var(--accent); }\n\n/* ================= Résultats par plat ================= */\n.dish-list {\n  display: flex; flex-direction: column; gap: 12px; padding-bottom: 10px;\n}\n.dish-row {\n  display: flex; gap: 14px; align-items: flex-start;\n  background: var(--surface); border: 1.5px solid var(--ink);\n  border-radius: var(--r-card); padding: 14px 16px;\n  box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.08);\n  transition: transform 0.15s ease, box-shadow 0.15s ease;\n}\n.dish-row:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }\n.dish-img {\n  width: 62px; height: 62px; flex: none; object-fit: cover;\n  border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2);\n}\n.dish-noimg { display: flex; align-items: center; justify-content: center; color: var(--ink-3); }\n.dish-main { flex: 1; min-width: 0; }\n.dish-line { display: flex; align-items: baseline; gap: 10px; }\n.dish-name { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); }\n.dish-price { font-family: var(--font-display); font-weight: 700; font-size: 16.5px; color: var(--ink); white-space: nowrap; }\n.dish-desc {\n  margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 620px;\n  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\n}\n.dish-meta {\n  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\n  margin-top: 7px; font-size: 12px; color: var(--ink-2);\n}\n.dish-meta b { color: var(--ink); }\n.dish-meta .sep { width: 4px; height: 4px; background: var(--green); border-radius: 50%; display: inline-block; }\n.dish-meta .ctx-pill {\n  border: 1px solid var(--ink); border-radius: 4px; padding: 2px 8px;\n  font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.05em;\n}\n\n/* ================= Pager (numéroté) ================= */\n.pager {\n  display: flex; align-items: center; justify-content: center; gap: 6px;\n  flex-wrap: wrap; padding: 18px 0 60px;\n}\n.pg-btn {\n  min-width: 40px; min-height: 40px; padding: 0 12px;\n  border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n  background: var(--surface); color: var(--ink); cursor: pointer;\n  font-family: var(--font-display); font-weight: 700; font-size: 13.5px;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);\n  transition: all 0.12s ease;\n}\n.pg-btn:hover:not(:disabled) { background: var(--accent-soft); }\n.pg-btn.on { background: var(--ink); color: var(--accent); }\n.pg-btn:disabled { opacity: 0.35; cursor: default; box-shadow: none; }\n.pg-btn:active:not(:disabled) { transform: translate(2px, 2px); box-shadow: none; }\n.pg-ellipsis { color: var(--ink-3); font-family: var(--font-mono); padding: 0 4px; }\n\n/* ================= Skeletons ================= */\n@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }\n.skel { border-radius: var(--r-card); border: 1.5px solid var(--line-strong); overflow: hidden; background: var(--surface); }\n.skel .sk-img, .skel .sk-line {\n  background: linear-gradient(90deg, #eceae4 25%, #f6f4ef 50%, #eceae4 75%);\n  background-size: 800px 100%; animation: shimmer 1.4s infinite linear;\n}\n.skel .sk-img { aspect-ratio: 16/9; }\n.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }\n.skel .sk-line.short { width: 45%; }\n\n/* ================= Empty / error ================= */\n.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }\n.notice .big { font-size: 44px; margin-bottom: 10px; color: var(--green); }\n.notice h2 { color: var(--ink); }\n\n/* ================= Fiche resto ================= */\n.detail { padding: 30px 0 90px; }\n.crumbs {\n  font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;\n  letter-spacing: 0.08em; text-transform: uppercase;\n  color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;\n}\n.crumbs a { border-bottom: 1.5px solid transparent; }\n.crumbs a:hover { color: var(--green); border-color: var(--green); }\n/* Ordre DOM = ordre visuel (standard Groupe Ka « Ordre des sections — pages\n   détail ») : le panneau héro (nom/prix/adresse) est 1er dans le DOM ; sur\n   desktop il est placé en colonne 2 via grid-column — pas de `order` ni de\n   `column-reverse` pour réordonner entre breakpoints. */\n.detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }\n@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }\n.detail-grid > * { min-width: 0; }\n@media (min-width: 901px) {\n  .detail-grid > .panel { grid-column: 2; grid-row: 1; }\n  .detail-grid > .detail-main { grid-column: 1; grid-row: 1; }\n}\n\n.panel {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  box-shadow: var(--shadow-off-soft); padding: 26px; position: sticky; top: 120px;\n}\n@media (max-width: 900px) { .panel { position: static; } }\n.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }\n.panel-logo { max-width: 110px; max-height: 54px; object-fit: contain; border-radius: var(--r-ctl); }\n.panel .price {\n  font-family: var(--font-display); font-size: 30px; font-weight: 700;\n  letter-spacing: -0.03em; color: var(--ink);\n  background: var(--accent); border-radius: 8px; padding: 0 10px 2px;\n  display: inline-block; transform: rotate(-1deg);\n}\n.panel h1 { font-size: 23px; margin: 10px 0 2px; }\n.panel .loc { color: var(--ink-2); font-size: 14px; }\n.kv { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }\n@media (max-width: 380px) { .kv { grid-template-columns: 1fr; } }\n.kv .cell { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-ctl); padding: 10px 14px; }\n.kv .cell .k { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); font-weight: 700; }\n.kv .cell .v { font-weight: 700; font-size: 14.5px; margin-top: 2px; font-family: var(--font-display); color: var(--ink); }\n.klabel { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 700; margin-bottom: 8px; }\n.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 20px; }\n.amenity {\n  background: var(--accent-soft); color: var(--green); font-size: 12px; font-weight: 600;\n  border: 1px solid rgba(28, 92, 65, 0.35); border-radius: var(--r-ctl); padding: 4px 11px;\n}\n.cta {\n  display: block; text-align: center; background: var(--ink); color: var(--accent);\n  font-weight: 700; font-family: var(--font-display); border: 1.5px solid var(--ink);\n  border-radius: var(--r-ctl); padding: 14px; min-height: 48px;\n  box-shadow: var(--shadow-off-mid); transition: all 0.14s ease;\n}\n.cta:hover { background: var(--green-deep); }\n.cta:active { transform: translate(2px, 2px); box-shadow: none; }\n.panel .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; }\n\n/* --- menu (sections, items, options) --------------------------------------- */\n.view-toggle {\n  display: inline-flex; border: 1.5px solid var(--ink);\n  border-radius: var(--r-ctl); overflow: hidden; background: var(--surface);\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n}\n.view-toggle button {\n  border: 0; background: transparent; padding: 8px 16px; cursor: pointer;\n  font-family: var(--font-display); font-size: 12.5px; font-weight: 700; color: var(--ink-2);\n}\n.view-toggle button + button { border-left: 1.5px solid var(--ink); }\n.view-toggle button.on { background: var(--ink); color: var(--accent); }\n.ctx-toggle { margin-bottom: 14px; }\n\n.menu-block { display: flex; flex-direction: column; gap: 16px; }\n.ctx-banner {\n  border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 12px 18px;\n  display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;\n  font-size: 12.5px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);\n}\n.ctx-banner b { font-family: var(--font-display); font-size: 14px; }\n.ctx-banner span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); }\n.ctx-banner.ctx-takeout { background: var(--accent-soft); }\n.ctx-banner.ctx-takeout b { color: var(--green); }\n.ctx-banner.ctx-dine-in { background: var(--surface); }\n.ctx-banner.ctx-dine-in b { color: var(--green-deep); }\n.ctx-banner.ctx-delivery { background: var(--amber-soft); }\n.ctx-banner.ctx-delivery b { color: #9a6414; }\n\n.menu-section {\n  background: var(--surface); border: 1.5px solid var(--ink);\n  border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden;\n}\n.menu-section-head {\n  width: 100%; display: flex; align-items: center; gap: 12px;\n  border: none; background: none; cursor: pointer;\n  padding: 14px 18px; text-align: left;\n}\n.menu-section-head h3 { font-size: 17px; color: var(--ink); letter-spacing: -0.02em; flex: 1; }\n.menu-section-head:hover { background: var(--accent-soft); }\n.ms-img {\n  width: 36px; height: 36px; object-fit: cover; border-radius: var(--r-ctl);\n  border: 1.5px solid var(--ink); flex: none;\n}\n.ms-meta { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; font-weight: 600; white-space: nowrap; }\n.menu-items { list-style: none; margin: 0; padding: 0 18px 14px; }\n.menu-item { padding: 12px 0; border-top: 1px solid var(--line); }\n.mi-row { display: flex; gap: 14px; align-items: flex-start; }\n.mi-main { flex: 1; min-width: 0; }\n.mi-line { display: flex; align-items: baseline; gap: 10px; }\n.mi-name { font-weight: 600; font-size: 14.5px; color: var(--ink); }\n.mi-dots { flex: 1; border-bottom: 2px dotted var(--line-strong); opacity: 0.35; transform: translateY(-3px); min-width: 20px; }\n.mi-price { font-family: var(--font-display); font-weight: 700; font-size: 15.5px; color: var(--ink); white-space: nowrap; }\n.mi-desc { margin: 3px 0 0; color: var(--ink-3); font-size: 12.5px; max-width: 560px; }\n.mi-thumb {\n  width: 68px; height: 68px; flex: none; object-fit: cover;\n  border-radius: var(--r-ctl); border: 1.5px solid var(--ink); background: var(--surface-2);\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n}\n.mi-tag {\n  font-style: normal; font-family: var(--font-mono); font-size: 9px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.06em; border-radius: 4px;\n  padding: 2px 7px; margin-left: 7px;\n  background: var(--accent-soft); color: var(--green); border: 1px solid rgba(28, 92, 65, 0.3);\n  vertical-align: 2px;\n}\n.mi-tag-epice { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.5); }\n.mi-tag-rupture { background: var(--surface-2); color: var(--ink-3); border-color: var(--line); }\n\n/* groupes d'options : blocs structurés, choix en pastilles avec ± prix */\n.mi-optgroups {\n  margin-top: 10px; display: flex; flex-direction: column; gap: 8px;\n  background: var(--surface-2); border: 1px solid var(--line);\n  border-radius: var(--r-ctl); padding: 10px 12px;\n}\n.og-head { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }\n.og-name {\n  font-family: var(--font-mono); font-weight: 700; color: var(--ink);\n  font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;\n}\n.og-req {\n  font-family: var(--font-mono); font-size: 9px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.05em; border-radius: 4px; padding: 2px 7px;\n  background: var(--surface); color: var(--ink-3); border: 1px solid var(--line);\n}\n.og-req.on { background: var(--accent); color: var(--ink); border-color: var(--ink); }\n.og-choices { display: flex; flex-wrap: wrap; gap: 6px; }\n.og-choice {\n  display: inline-flex; align-items: center; gap: 6px;\n  background: var(--surface); border: 1px solid var(--line-strong);\n  border-radius: var(--r-ctl); padding: 4px 10px;\n  font-size: 12px; color: var(--ink-2);\n}\n.og-choice b { font-family: var(--font-mono); font-weight: 700; font-size: 11px; }\n.og-choice b.plus { color: #9a6414; }\n.og-choice b.moins { color: var(--green); }\n\n/* ================= Sources page ================= */\n.sources { padding: 44px 0 90px; }\n.sources h1 { font-size: clamp(28px, 4vw, 42px); margin: 12px 0 6px; color: var(--ink); }\n.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 700px; }\n.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }\n.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 720px; }\n.src-table th {\n  text-align: left; font-family: var(--font-mono); font-size: 10px;\n  text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-2);\n  padding: 13px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2);\n}\n.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }\n.src-table tr:last-child td { border-bottom: none; }\n.src-table tr:hover td { background: var(--accent-soft); }\n.src-table a { color: var(--green); font-weight: 600; border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); }\n.src-integrations { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; max-width: 380px; }\n.src-table .pill, .pill.ok, .pill.todo {\n  display: inline-block; border-radius: 4px; padding: 3px 9px;\n  font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--ink);\n  box-shadow: none; cursor: default; transform: none;\n}\n.pill.ok { background: var(--accent); color: var(--ink); }\n.pill.todo { background: var(--amber-soft); color: #9a6414; border-color: rgba(232, 163, 61, 0.6); }\n.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; color: var(--ink); }\n\n/* ================= Page Statistiques ================= */\n.stats-page { padding: 44px 0 90px; }\n.stats-title { font-size: clamp(30px, 4.6vw, 48px); margin: 12px 0 6px; color: var(--ink); }\n.stats-page .sub { color: var(--ink-2); max-width: 640px; margin-bottom: 30px; }\n.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }\n.tile {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  padding: 18px 20px; box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);\n}\n.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.05; color: var(--ink); }\n.tile-k { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }\n.hero-tile { background: var(--ink); color: var(--paper); border-color: var(--ink); box-shadow: 6px 6px 0 rgba(20, 24, 20, 0.25); }\n.hero-tile .tile-v { color: var(--accent); }\n.hero-tile .tile-k { color: rgba(245, 243, 238, 0.6); }\n\n.viz-card {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px;\n}\n.viz-card h2 { font-size: 20px; letter-spacing: -0.02em; color: var(--ink); }\n.viz-sub { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }\n.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }\n@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }\n\n.hbars { display: flex; flex-direction: column; gap: 7px; }\n.hbar-row {\n  display: grid; grid-template-columns: minmax(96px, 190px) 1fr auto;\n  gap: 12px; align-items: center; min-height: 26px; cursor: default;\n}\n.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.hbar-label a { border-bottom: 1.5px solid rgba(28, 92, 65, 0.4); }\n.hbar-label a:hover { color: var(--green); }\n.hbar-track { background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 4px; height: 18px; overflow: hidden; }\n.hbar-fill {\n  display: block; height: 100%; background: var(--green); border-radius: 0;\n  min-width: 2px; transition: background 0.12s ease;\n}\n.hbar-row:hover .hbar-fill { background: var(--accent); }\n.hbar-value { font-family: var(--font-mono); font-size: 11px; font-weight: 700; white-space: nowrap; color: var(--ink); }\n@media (max-width: 640px) {\n  .hbar-row { grid-template-columns: minmax(80px, 120px) 1fr auto; gap: 8px; }\n  .viz-card { padding: 20px 16px 14px; }\n}\n.stats-foot { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.02em; margin-top: 6px; }\n\n/* ================= Mobile : feuille de filtres + FAB ================= */\n.sheet-head { display: none; }\n.sheet-apply { display: none; }\n.sheet-backdrop { display: none; }\n.fab { display: none; }\n\n@media (max-width: 640px) {\n  :root { --header-h: 56px; }\n  .brand { font-size: 20px; }\n  .nav a { padding: 8px 13px; font-size: 13.5px; }\n  .ticker { font-size: 10px; padding: 6px 0; }\n  .hero h1 { font-size: clamp(30px, 8.6vw, 42px); }\n  .hero p.lede { font-size: 15px; }\n  .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }\n  .stat-row::-webkit-scrollbar { display: none; }\n  .stat-chip { flex: 0 0 auto; white-space: nowrap; }\n\n  /* la barre de filtres devient une feuille coulissante (bottom sheet) */\n  .filterbar { display: none; }\n  .filterbar.open .f-primary { flex-direction: column; }\n  .filterbar.open .f-search { align-self: stretch; max-width: none; min-height: 46px; }\n  .filterbar.open .f-search input { font-size: 16px; }  /* anti-zoom iOS */\n  .filterbar.open .f-ctl select { max-width: none; width: 100%; }\n  .filterbar.open {\n    display: flex; flex-direction: column; gap: 12px;\n    position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);\n    margin: 0; border-radius: 16px 16px 0 0; border: none; border-top: 2px solid var(--ink);\n    max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;\n    padding: 16px 18px calc(18px + env(safe-area-inset-bottom));\n    box-shadow: 0 -16px 48px rgba(20, 24, 20, 0.28);\n    animation: sheet-up 0.22s ease;\n  }\n  @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }\n  .filterbar.open .sheet-head {\n    display: flex; justify-content: space-between; align-items: center;\n    font-family: var(--font-display); font-weight: 700; font-size: 17px;\n    letter-spacing: -0.01em; color: var(--ink);\n    position: sticky; top: -16px; background: var(--surface); padding: 6px 0 8px;\n    border-bottom: 1px solid var(--line); margin-bottom: 2px; z-index: 1;\n  }\n  .sheet-close {\n    border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl);\n    width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; color: var(--ink);\n  }\n  .filterbar.open .sheet-apply { display: flex; width: 100%; }\n  .sheet-backdrop {\n    display: block; position: fixed; inset: 0; z-index: var(--z-overlay, 800);\n    background: rgba(20, 24, 20, 0.45); backdrop-filter: blur(2px);\n  }\n  .fab {\n    display: flex; align-items: center; gap: 6px;\n    position: fixed; left: 50%; transform: translateX(-50%);\n    bottom: calc(74px + env(safe-area-inset-bottom)); z-index: 80;\n    background: var(--ink); color: var(--accent); border: 1.5px solid var(--ink);\n    border-radius: var(--r-ctl); padding: 13px 24px; font-family: var(--font-display);\n    font-weight: 700; font-size: 15px; cursor: pointer;\n    box-shadow: 5px 5px 0 rgba(20, 24, 20, 0.3);\n  }\n  .fab:active { transform: translateX(-50%) translate(2px, 2px); box-shadow: none; }\n\n  .detail { padding-top: 20px; }\n  .panel { padding: 20px; }\n  .panel .price { font-size: 26px; }\n  .results-head { margin-top: 20px; }\n  .chips { margin-right: -16px; padding-right: 16px; }\n  .notice { padding: 48px 16px; }\n  .menu-items { padding: 0 14px 12px; }\n  .menu-section-head { padding: 13px 14px; }\n  .mi-thumb { width: 56px; height: 56px; }\n}\n\n/* ================= Barre de navigation inférieure (mobile) ================= */\n.tabbar { display: none; }\n@media (max-width: 760px) {\n  .tabbar {\n    display: grid;\n    position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-bottombar, 600);\n    background: rgba(245, 243, 238, 0.94);\n    backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);\n    border-top: 1.5px solid var(--ink);\n    padding: 6px 4px calc(6px + env(safe-area-inset-bottom));\n  }\n  .tabbar-3 { grid-template-columns: repeat(3, 1fr); }\n  .tabbar a {\n    display: flex; flex-direction: column; align-items: center; gap: 3px;\n    padding: 5px 2px; min-height: 48px; justify-content: center;\n    font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--ink-3);\n    border-radius: var(--r-ctl); transition: color 0.12s ease;\n  }\n  .tabbar a.active { color: var(--green); }\n  .tabbar a svg { display: block; }\n  body { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }\n  .ka-footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); }\n}\n\n/* Anti-zoom iOS : les champs doivent faire >= 16px\n   (filet global : tokens.css force aussi 16 px au doigt) */\n@media (max-width: 900px) {\n  .f-search input, .f-ctl select { font-size: 16px; }\n}\n\n/* Cible tactile ≥44 px du logo (138×30 rendus) : zone étendue invisible */\n@media (pointer: coarse) {\n  .brand { position: relative; }\n  .brand::after { content: \"\"; position: absolute; inset: -8px; }\n}\n\n/* ================= Footer (KaFooter — ka-ui) ================= */\n/* Le footer commun vient de tokens.css (.ka-footer) + KaFooter.tsx. On garde\n   ici seulement la rangée de nav locale posée au-dessus. */\n.prefoot { border-top: 2px solid var(--ink); padding: 22px 0; margin-top: 40px; background: var(--surface-2); }\n.ka-footer { margin-top: 0; }\n.prefoot-inner { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; }\n.prefoot-nav { display: flex; flex-wrap: wrap; gap: 4px 8px; }\n.prefoot-nav a {\n  display: inline-flex; align-items: center; min-height: var(--touch);\n  padding: 8px 12px; border-radius: var(--r-ctl);\n  font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);\n}\n.prefoot-nav a:hover { background: var(--accent); color: var(--on-accent); }\n.prefoot-osm {\n  margin-left: auto; font-family: var(--font-mono); font-size: 10.5px;\n  letter-spacing: 0.03em; color: var(--ink-3);\n}\n.prefoot-osm a { color: var(--green); border-bottom: 1px solid rgba(28, 92, 65, 0.4); }\n@media (max-width: 640px) { .prefoot-osm { margin-left: 0; } }\n\n/* --- Compte membre (« Se connecter avec KA ») ------------------------------- */\n.login-btn {\n  display: inline-flex; align-items: center; gap: 8px;\n  border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n  background: var(--surface); color: var(--ink);\n  padding: 8px 14px; font-family: var(--font-display);\n  font-weight: 700; font-size: 13.5px; min-height: 38px;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12);\n  transition: background 0.13s ease, transform 0.1s ease, box-shadow 0.1s ease;\n}\n.login-btn:hover { background: var(--accent-soft); }\n.login-btn:active { transform: translate(2px, 2px); box-shadow: none; }\n.ka-badge {\n  display: inline-flex; align-items: center; justify-content: center;\n  background: var(--accent); color: var(--ink); border: 1px solid var(--ink);\n  padding: 1px 6px 2px; border-radius: 4px;\n  font-family: var(--font-display); font-weight: 700; font-size: 11px;\n  transform: rotate(-2deg);\n}\n.account { position: relative; }\n.account-btn {\n  width: 40px; height: 40px; border-radius: 50%; overflow: hidden;\n  border: 1.5px solid var(--ink); background: var(--accent);\n  cursor: pointer; padding: 0;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);\n}\n.account-btn img { width: 100%; height: 100%; object-fit: cover; }\n.account-initial {\n  display: flex; align-items: center; justify-content: center;\n  width: 100%; height: 100%;\n  font-family: var(--font-display); font-weight: 700; font-size: 17px; color: var(--ink);\n}\n/* Backdrop + menu rendus à la racine de <body> via createPortal (un fixed\n   descendant du header blurré se re-parentait, et le stacking context z-50 du\n   header plaquait le menu sous la tabbar z-85 / le fab z-80). Échelle commune\n   tokens.css : voile = --z-overlay (800), menu = --z-modal (900). */\n.account-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay); }\n.account-menu {\n  position: fixed;\n  right: max(12px, env(safe-area-inset-right));\n  top: calc(var(--header-h, 66px) + 10px);\n  z-index: var(--z-modal);\n  min-width: 230px; background: var(--surface);\n  border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  box-shadow: var(--shadow-off); padding: 8px; display: flex; flex-direction: column;\n}\n.account-id { padding: 8px 10px 10px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }\n.account-id b { font-family: var(--font-display); font-size: 14.5px; }\n.account-id span { font-size: 12px; color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; }\n.account-kaid {\n  font-family: var(--font-mono) !important; font-size: 11px !important;\n  color: var(--green) !important; letter-spacing: 0.06em;\n}\n.account-link, .account-menu button {\n  text-align: left; border: none; background: none; cursor: pointer;\n  padding: 10px; border-radius: var(--r-ctl);\n  font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);\n}\n.account-link:hover, .account-menu button:hover { background: var(--accent-soft); }\n\n/* cœur favori sur les cartes */\n.fav-btn {\n  position: absolute; bottom: 10px; right: 10px;\n  width: 34px; height: 34px; border-radius: 50%;\n  border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.95);\n  color: var(--ink-2); font-size: 16px; line-height: 1; cursor: pointer;\n  box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.2);\n  transition: transform 0.12s ease, background 0.12s ease;\n}\n.fav-btn:hover { transform: scale(1.08); }\n.fav-btn.on { background: var(--accent); color: var(--ink); }\n\n/* --- Menu déroulant mobile ------------------------------------------------- */\n.menu-btn {\n  display: none; position: relative; z-index: 60;\n  width: 44px; height: 44px; margin-left: auto;\n  border: 1.5px solid var(--ink); border-radius: var(--r-ctl);\n  background: var(--surface); cursor: pointer; padding: 0;\n  flex-direction: column; align-items: center; justify-content: center; gap: 5px;\n  box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);\n  transition: box-shadow 0.15s ease, transform 0.15s ease;\n}\n.menu-btn:active { transform: translate(2px, 2px); box-shadow: none; }\n.menu-btn span {\n  display: block; width: 18px; height: 2px; background: var(--ink);\n  border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease;\n}\n.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }\n.menu-btn.open span:nth-child(2) { opacity: 0; }\n.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }\n\n.mobile-menu {\n  display: none; position: absolute; left: 0; right: 0; top: 100%;\n  background: var(--paper); border-bottom: 2px solid var(--ink);\n  box-shadow: 0 18px 30px rgba(20, 24, 20, 0.14);\n  padding: 8px 16px calc(16px + env(safe-area-inset-bottom));\n  max-height: 0; overflow: hidden; opacity: 0;\n  transition: max-height 0.3s ease, opacity 0.22s ease;\n}\n.mobile-menu { z-index: var(--z-modal, 900); }\n.mobile-menu.open { max-height: 480px; opacity: 1; }\n/* Le drawer vit DANS le header : quand il est ouvert, le header monte au niveau\n   modal pour peindre au-dessus du .mm-backdrop (frère du header, overlay 800). */\n.header:has(.mobile-menu.open) { z-index: var(--z-modal, 900); }\n.mm-link {\n  display: flex; align-items: center; gap: 12px;\n  padding: 15px 10px; min-height: 52px;\n  font-family: var(--font-display); font-weight: 700; font-size: 19px;\n  letter-spacing: -0.02em; border-bottom: 1px solid var(--line);\n  color: var(--ink);\n  opacity: 0; transform: translateY(-8px);\n  transition: opacity 0.25s ease, transform 0.25s ease;\n}\n.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }\n.mm-link.active { color: var(--green); }\n.mm-link.active .mm-arrow { opacity: 1; color: var(--green); }\n.mm-ico { width: 26px; text-align: center; font-size: 17px; }\n.mm-arrow { margin-left: auto; opacity: 0.25; color: var(--green); transition: opacity 0.15s ease; }\n.mm-link:active { background: var(--accent-soft); border-radius: var(--r-ctl); }\n.mm-foot {\n  padding: 12px 10px 4px;\n  font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.02em;\n}\n.mm-backdrop {\n  position: fixed; inset: 0; z-index: var(--z-overlay, 800);\n  background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px);\n}\n@media (max-width: 760px) {\n  .menu-btn { display: flex; }\n  .nav { display: none; }\n  .mobile-menu { display: block; }\n}\n\n/* ================= Badge « Un service Groupe KA » (ka-ui) ================= */\n.hdr-badge { display: none; }\n@media (min-width: 900px) { .hdr-badge { display: inline-flex; } }\n.mm-badge { padding: 14px 10px 2px; }\n\n/* ================= Page Contact ================= */\n.contact-page { padding: 44px 0 90px; }\n.contact-page h1 { font-size: clamp(30px, 4.6vw, 48px); margin: 12px 0 6px; color: var(--ink); }\n.contact-page .sub { color: var(--ink-2); max-width: 640px; margin: 0 0 30px; }\n.contact-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; margin-bottom: 26px; }\n.contact-card {\n  background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);\n  box-shadow: var(--shadow-off-soft); padding: 20px;\n  display: flex; flex-direction: column; gap: 6px;\n}\n.contact-card .role {\n  font-family: var(--font-mono); font-size: 10px; font-weight: 700;\n  text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);\n}\n.contact-card a.mail {\n  display: inline-flex; align-items: center; min-height: var(--touch);\n  font-family: var(--font-display); font-weight: 700; font-size: 16.5px;\n  letter-spacing: -0.01em; color: var(--ink); word-break: break-all;\n}\n.contact-card a.mail:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }\n.contact-note {\n  border: 1.5px solid var(--ink); border-left: 6px solid var(--accent);\n  border-radius: var(--r-card); background: var(--accent-soft);\n  padding: 16px 18px; max-width: 760px; margin-bottom: 26px; font-size: 14px;\n  box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.1);\n}\n.contact-hub { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 38px; }\n.contact-hub .hint { font-size: 12.5px; color: var(--ink-2); max-width: 460px; }\n.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.contact-sites a {\n  display: inline-flex; align-items: center; gap: 7px; min-height: var(--touch);\n  border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface);\n  padding: 8px 14px; font-family: var(--font-display); font-weight: 700; font-size: 13.5px;\n  color: var(--ink); box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1);\n  transition: all 0.13s ease;\n}\n.contact-sites a:hover { background: var(--accent); color: var(--on-accent); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); }\n.contact-sites .dot { width: 9px; height: 9px; border-radius: 50%; border: 1px solid rgba(20, 24, 20, 0.35); flex: none; }\n\n/* ================= Stats — tableau de bord Groupe KA (SPEC ka-ui/stats) ==== */\n.stats-page { display: grid; gap: 26px; }\n.stats-page .sub { margin-bottom: 0; }\n.stats-head { display: flex; flex-wrap: wrap; gap: 18px; justify-content: space-between; align-items: flex-end; }\n.stats-actions { display: grid; gap: 10px; justify-items: start; }\n.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); gap: 14px; }\n.period-bar { padding: 12px 14px; }\n.stats-grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); gap: 18px; align-items: start; }\n.records-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 12px; margin-top: 12px; }\n.stats-foot { margin-top: 12px; }\n@media (max-width: 767px) {\n  .stats-page { gap: 20px; }\n  .kpi-grid { grid-template-columns: 1fr 1fr; }\n  .stats-head { align-items: flex-start; flex-direction: column; }\n}\n@media (max-width: 420px) { .kpi-grid { grid-template-columns: 1fr; } }\n","structuredPatch":[{"oldStart":223,"oldLines":6,"newStart":223,"newLines":17,"lines":[" }"," @media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } }"," ","+/* récemment consultés — bande horizontale de cartes, défilement latéral */","+.recent-row {","+  display: grid; grid-auto-flow: column; grid-auto-columns: 290px;","+  gap: 18px; overflow-x: auto; padding-bottom: 12px;","+  scroll-snap-type: x proximity;","+}","+.recent-row > .card { scroll-snap-align: start; }","+@media (max-width: 640px) {","+  .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; }","+}","+"," .card {","   background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card);","   overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft);"]}],"userModified":false,"replaceAll":false}}}73{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a6aec4a8-1e81-4e00-b932-103fc7a6ca59","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}74{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8QIKpwEIERgCKkAkN00v9bOz6ka4EcxVmxwShmSWsV4nTJlbr6fXwYuKeP0BB8HuSV+FLvM6eZ6Bq77SfOgtwhssEM8qg/aP1QG/Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAH+1ajUBrABAhIMrt5CKsEf+7f2fqIjGgxCNGhCfu+9oEdiRQ0iMEnlLwWsHz0F7UlQuBiuMEcH2mguI6S8LKJRV+yfY6Ogy7jjZ4moK4TX35JU6FlB/ip3796us5tmm17UqXKgPaIbX1aVF3pmlaNI9GiPKtTHge8sOIV2HElLScKaEyidIUJgIyhA62hTLeCwnxnq7OJn9Vtm+I02TRaejbk8V+nSVlFSzHnOm9XGrvIUFLNL8kcH3Tstu0mmIuzH54bfGiEDpFadpxgld4YYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"d20f8ce9-ad57-449d-bbe7-2d0afbc7041c"}}75{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"text","text":"Je build le frontend et vérifie le nom PM2 de l'app."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"081e187e-c8d4-4311-a8c7-7947e50a0950"}}76{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01N7PAWYwyLYvNmZDVYCER3x","name":"Bash","input":{"command":"cd /Users/simon-pierreboucher/apps/resto-ka/frontend && npm run build 2>&1 | tail -15","description":"Build frontend Vite","timeout":180000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"5f3d336b-e31b-4e0c-be9e-f41d035f35b0"}}77{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd6eAgyJQygUjbQJRgU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WzE3AgjjtfL82VirT9Su3u","name":"Bash","input":{"command":"pm2 ls | head -20","description":"List PM2 apps"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":485,"cache_read_input_tokens":74318,"cache_creation":{"ephemeral_5m_input_tokens":485,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"b32a152b-910f-4ec9-9da6-2937d7fd1c83"}}78{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01N7PAWYwyLYvNmZDVYCER3x","type":"tool_result","content":"> resto-ka-frontend@1.0.0 build\n> tsc -b && vite build\n\nvite v5.4.21 building for production...\ntransforming...\n✓ 51 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html                   2.16 kB │ gzip:  0.89 kB\ndist/assets/index-CjaZ2C7m.css   47.50 kB │ gzip:  9.49 kB\ndist/assets/index-CDRAbc8P.js   239.01 kB │ gzip: 74.71 kB\n✓ built in 419ms","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"be6be758-3ec0-49f4-9da5-7bbdf5cda395","timestamp":"2026-08-22T23:04:33.554Z","tool_use_result":{"stdout":"> resto-ka-frontend@1.0.0 build\n> tsc -b && vite build\n\nvite v5.4.21 building for production...\ntransforming...\n✓ 51 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html                   2.16 kB │ gzip:  0.89 kB\ndist/assets/index-CjaZ2C7m.css   47.50 kB │ gzip:  9.49 kB\ndist/assets/index-CDRAbc8P.js   239.01 kB │ gzip: 74.71 kB\n✓ built in 419ms","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}79{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WzE3AgjjtfL82VirT9Su3u","type":"tool_result","content":"┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name                     │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6  │ airiskindex-ngrok        │ default     │ N/A     │ fork    │ 13980    │ 17D    │ 0    │ online    │ 0%       │ 171.9mb  │ simon-p… │ disabled │\n│ 5  │ airiskindex-web          │ default     │ N/A     │ fork    │ 65729    │ 17D    │ 13   │ online    │ 0%       │ 185.7mb  │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas            │ default     │ N/A     │ fork    │ 33650    │ 10D    │ 14   │ online    │ 0%       │ 74.3mb   │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok      │ default     │ N/A     │ fork    │ 61023    │ 10D    │ 0    │ online    │ 0%       │ 73.1mb   │ simon-p… │ disabled │\n│ 77 │ apika-api                │ default     │ N/A     │ fork    │ 13966    │ 3D     │ 21   │ online    │ 0%       │ 702.3mb  │ simon-p… │ disabled │\n│ 79 │ apika-ngrok              │ default     │ N/A     │ fork    │ 11978    │ 6D     │ 0    │ online    │ 0%       │ 131.2mb  │ simon-p… │ disabled │\n│ 78 │ apika-scheduler          │ default     │ N/A     │ fork    │ 58993    │ 4D     │ 6    │ online    │ 0%       │ 11.5gb   │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api         │ default     │ N/A     │ fork    │ 4186     │ 16D    │ 6    │ online    │ 0%       │ 219.7mb  │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer     │ default     │ N/A     │ fork    │ 54216    │ 16D    │ 3    │ online    │ 0%       │ 812.6mb  │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok       │ default     │ N/A     │ fork    │ 60713    │ 16D    │ 0    │ online    │ 0%       │ 151.4mb  │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok            │ default     │ N/A     │ fork    │ 63473    │ 5D     │ 0    │ online    │ 0%       │ 175.3mb  │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync             │ default     │ N/A     │ fork    │ 81871    │ 4h     │ 11   │ online    │ 0%       │ 521.7mb  │ simon-p… │ disabled │\n│ 87 │ crea-ka-web              │ default     │ N/A     │ fork    │ 77232    │ 3h     │ 33   │ online    │ 0%       │ 249.3mb  │ simon-p… │ disabled │\n│ 28 │ draft2027                │ default     │ N/A     │ fork    │ 89898    │ 13D    │ 0    │ online    │ 0%       │ 41.3mb   │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok          │ default     │ N/A     │ fork    │ 89910    │ 13D    │ 0    │ online    │ 0%       │ 62.0mb   │ simon-p… │ disabled │\n│ 60 │ earthnow-api             │ default     │ N/A     │ fork    │ 87198    │ 13D    │ 0    │ online    │ 0%       │ 80.0mb   │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok           │ default     │ N/A     │ fork    │ 87239    │ 13D    │ 0    │ online    │ 0%       │ 77.0mb   │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"3dc1b281-d9f0-4a8b-914b-d25e6b1b0fea","timestamp":"2026-08-22T23:04:33.690Z","tool_use_result":{"stdout":"┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name                     │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6  │ airiskindex-ngrok        │ default     │ N/A     │ fork    │ 13980    │ 17D    │ 0    │ online    │ 0%       │ 171.9mb  │ simon-p… │ disabled │\n│ 5  │ airiskindex-web          │ default     │ N/A     │ fork    │ 65729    │ 17D    │ 13   │ online    │ 0%       │ 185.7mb  │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas            │ default     │ N/A     │ fork    │ 33650    │ 10D    │ 14   │ online    │ 0%       │ 74.3mb   │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok      │ default     │ N/A     │ fork    │ 61023    │ 10D    │ 0    │ online    │ 0%       │ 73.1mb   │ simon-p… │ disabled │\n│ 77 │ apika-api                │ default     │ N/A     │ fork    │ 13966    │ 3D     │ 21   │ online    │ 0%       │ 702.3mb  │ simon-p… │ disabled │\n│ 79 │ apika-ngrok              │ default     │ N/A     │ fork    │ 11978    │ 6D     │ 0    │ online    │ 0%       │ 131.2mb  │ simon-p… │ disabled │\n│ 78 │ apika-scheduler          │ default     │ N/A     │ fork    │ 58993    │ 4D     │ 6    │ online    │ 0%       │ 11.5gb   │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api         │ default     │ N/A     │ fork    │ 4186     │ 16D    │ 6    │ online    │ 0%       │ 219.7mb  │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer     │ default     │ N/A     │ fork    │ 54216    │ 16D    │ 3    │ online    │ 0%       │ 812.6mb  │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok       │ default     │ N/A     │ fork    │ 60713    │ 16D    │ 0    │ online    │ 0%       │ 151.4mb  │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok            │ default     │ N/A     │ fork    │ 63473    │ 5D     │ 0    │ online    │ 0%       │ 175.3mb  │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync             │ default     │ N/A     │ fork    │ 81871    │ 4h     │ 11   │ online    │ 0%       │ 521.7mb  │ simon-p… │ disabled │\n│ 87 │ crea-ka-web              │ default     │ N/A     │ fork    │ 77232    │ 3h     │ 33   │ online    │ 0%       │ 249.3mb  │ simon-p… │ disabled │\n│ 28 │ draft2027                │ default     │ N/A     │ fork    │ 89898    │ 13D    │ 0    │ online    │ 0%       │ 41.3mb   │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok          │ default     │ N/A     │ fork    │ 89910    │ 13D    │ 0    │ online    │ 0%       │ 62.0mb   │ simon-p… │ disabled │\n│ 60 │ earthnow-api             │ default     │ N/A     │ fork    │ 87198    │ 13D    │ 0    │ online    │ 0%       │ 80.0mb   │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok           │ default     │ N/A     │ fork    │ 87239    │ 13D    │ 0    │ online    │ 0%       │ 77.0mb   │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}80{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"74c1d36f-fbe1-4e1e-84f3-8a365fcb6179","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}81{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd7F2RwghoHBRmEKJgn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PkxhJLugVwsc8zpVRvdk7C","name":"Bash","input":{"command":"pm2 ls | grep -i resto","description":"Find resto-ka PM2 process"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2113,"cache_read_input_tokens":74803,"cache_creation":{"ephemeral_5m_input_tokens":2113,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"36d5895e-d061-4f95-ad6e-839c5afafe66"}}82{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PkxhJLugVwsc8zpVRvdk7C","type":"tool_result","content":"│ 84 │ resto-ka                 │ default     │ N/A     │ fork    │ 78693    │ 3h     │ 20   │ online    │ 0%       │ 104.2mb  │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok           │ default     │ N/A     │ fork    │ 35426    │ 5D     │ 0    │ online    │ 0%       │ 139.7mb  │ simon-p… │ disabled │\n│ 86 │ resto-ka-sync            │ default     │ N/A     │ fork    │ 81889    │ 4h     │ 7    │ online    │ 0%       │ 282.3mb  │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"5019f5ed-b5a1-48da-953f-3890d9f97def","timestamp":"2026-08-22T23:04:38.333Z","tool_use_result":{"stdout":"│ 84 │ resto-ka                 │ default     │ N/A     │ fork    │ 78693    │ 3h     │ 20   │ online    │ 0%       │ 104.2mb  │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok           │ default     │ N/A     │ fork    │ 35426    │ 5D     │ 0    │ online    │ 0%       │ 139.7mb  │ simon-p… │ disabled │\n│ 86 │ resto-ka-sync            │ default     │ N/A     │ fork    │ 81889    │ 4h     │ 7    │ online    │ 0%       │ 282.3mb  │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}83{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5f4a141b-4ca5-4ca8-b680-af11a7dee172","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}84{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd7aePGDGZTEvygutey","type":"message","role":"assistant","content":[{"type":"text","text":"Build OK. Je redémarre l'app et je teste l'endpoint `uids` en live."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":494,"cache_read_input_tokens":76916,"cache_creation":{"ephemeral_5m_input_tokens":494,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"14077308-a399-4076-9fe4-2019d8882d53"}}85{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd7aePGDGZTEvygutey","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NYUZtUfkofFa27SQPo8s3G","name":"Bash","input":{"command":"pm2 restart resto-ka --update-env >/dev/null && sleep 2 && curl -s -o /dev/null -w \"%{http_code}\" http://localhost:$(pm2 describe resto-ka | grep -o 'port[^,]*' | head -1 | grep -o '[0-9]*' || echo \"\")/healthz 2>/dev/null; pm2 describe resto-ka | grep -E \"script|args\" | head -4","description":"Restart resto-ka and inspect config"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":494,"cache_read_input_tokens":76916,"cache_creation":{"ephemeral_5m_input_tokens":494,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"0e22294c-da25-475a-948a-129d526f9ab0"}}86{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NYUZtUfkofFa27SQPo8s3G","type":"tool_result","content":"000│ script path       │ /Users/simon-pierreboucher/apps/resto-ka/.venv/bin/python │\n│ script args       │ run.py serve 8115                                         │\n│ interpreter args  │ N/A                                                       │\n│ script id         │ 84                                                        │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"878e64d7-2867-4ebf-b0e5-1876c841c342","timestamp":"2026-08-22T23:04:47.268Z","tool_use_result":{"stdout":"000│ script path       │ /Users/simon-pierreboucher/apps/resto-ka/.venv/bin/python │\n│ script args       │ run.py serve 8115                                         │\n│ interpreter args  │ N/A                                                       │\n│ script id         │ 84                                                        │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}87{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"79585a83-0e01-4f05-a30d-d29aa470e266","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}88{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd8FAtfQnxE5FimUomf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017mAfqzJPJvXtxtpcFW7Gu9","name":"Bash","input":{"command":"sleep 2; curl -s http://localhost:8115/healthz; echo; UID1=$(curl -s \"http://localhost:8115/api/restaurants?limit=2\" | python3 -c \"import json,sys; d=json.load(sys.stdin); print(','.join(r['uid'] for r in d['restaurants']))\"); echo \"uids=$UID1\"; curl -s \"http://localhost:8115/api/restaurants?uids=$UID1&limit=5\" | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d['total'], [r['name'] for r in d['restaurants']])\"","description":"Healthcheck + test uids filter live"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":354,"cache_read_input_tokens":77410,"cache_creation":{"ephemeral_5m_input_tokens":354,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"20675be7-c5ec-4ae5-bbb3-7afb094d6306"}}89{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017mAfqzJPJvXtxtpcFW7Gu9","type":"tool_result","content":"{\"status\":\"ok\"}\nuids=site-resto:node/2861312352,site-resto:node/4907803404\n2 ['3 Brasseurs', '3 Madames Dumpling']","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e4d9e9d5-d634-49b0-9fe4-32ae770a88b2","timestamp":"2026-08-22T23:04:58.122Z","tool_use_result":{"stdout":"{\"status\":\"ok\"}\nuids=site-resto:node/2861312352,site-resto:node/4907803404\n2 ['3 Brasseurs', '3 Madames Dumpling']","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}90{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"17e4ca73-7e8d-4ecf-9b2f-34d46397c4e8","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}91{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd93f7t28B4FEieANv5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISgAUKpwEIERgCKkDHIOLCPzhp+0jx70TNv0DX54279HJlYYEoDuedM+MRJ2J6SDkIna9ITrqRi4H5OUl24Ydd3d2UXdpn77ZmK3zMMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGh1qjUBrABAhIMW10c3FR3pMGFiLG+GgyYmXWfL9pHkpVHFvEiMBk+SjvTfrIbA+qDNwaku6bERihY2XDdKzNF0xod7VWA53j8OkMrA6a24Fv3+mddUyqFA9VFX2zDUBIW7cFEOyx4tFZHoZ6FQLXPj9DjKZxZuawuc2zgZFZnsG2QVUg3pNkGxQTDr8qE4p5vaS8w1qt+yAnswwZ4Fu9+/teeL1juCPLY1F2QOfdWh/WqEMxR7P/GZ29Wb8Irj4v9d7gzKs8438yRldUENFoP5WuoUab0fT0y0J8N5Q7JTjCP+NHBM0np5N1evP9+NeHr8Ywua4dDibjYWmECD0MjgpxXWqV/10kyW7/G9HY5p7G3rdXwDpwrtnMhFV+nrxwSXxTi1rdeJSRfYJIMnYwK3u4ce2gFLCKw5g05oxyfufr89v3kUakHoms4zc2G8xA7/r5gch/5jkwfex+a6ARjBV1IZqKQDwISxmoCGR6dj3SSbqygZ5CW4ktnzyO2XePaujRCShP8bcE9NOe8U2CMrOxcoI8ize0ijv9w/6jcVPDh8n+r/gwEAtLRo8YIa5gJLy+dREPXXS1gEJDxiC6lFCpzicZ57kqlb0XLcbsn44K113NGm3hLT7mOtCzHGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":352,"cache_read_input_tokens":77764,"cache_creation":{"ephemeral_5m_input_tokens":352,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"7f5cf586-5b67-4ec5-8a50-9c1f0443cd45"}}92{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd93f7t28B4FEieANv5","type":"message","role":"assistant","content":[{"type":"text","text":"Le backend répond et le filtre `uids` fonctionne (ordre remis côté client). Je vérifie que le site sert le nouveau build."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":352,"cache_read_input_tokens":77764,"cache_creation":{"ephemeral_5m_input_tokens":352,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"08fa28ad-1eba-4584-ba4b-054bbf208398"}}93{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd93f7t28B4FEieANv5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01L5Co665chw4XKUXzWFtmJZ","name":"Bash","input":{"command":"curl -s http://localhost:8115/ | grep -o 'index-[^\"]*\\.js' | head -1; curl -s -o /dev/null -w \"%{http_code}\\n\" https://resto-ka.com/healthz","description":"Verify new bundle served + public healthcheck"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":352,"cache_read_input_tokens":77764,"cache_creation":{"ephemeral_5m_input_tokens":352,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"41722ee6-999d-45e8-821e-375b2c5e7707"}}94{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 6\nindex-CDRAbc8P.js\n000","is_error":true,"tool_use_id":"toolu_01L5Co665chw4XKUXzWFtmJZ"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ef8cf4cf-ed0a-4742-8fce-cd351da0b68a","timestamp":"2026-08-22T23:05:05.992Z","tool_use_result":"Error: Exit code 6\nindex-CDRAbc8P.js\n000"}}95{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c3af226f-fe20-43ea-80c2-25d72df30a6f","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}96{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd9d8mUZ9oju1sFDFG6","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvQMKpwEIERgCKkClIhwf4ziMqotF00Ut3/pl1DXp95WbnVd1YS7Pvo2MZXpKeVo3rT/FTEzndd3z8B4wCtgXEOVqkuH/BB3d56cEMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAGn1qjUBrABAhIMdh4rmzj65HRk+Mj3Ggzxr4ceimng+gJCyNMiML6BocOQCo3TMLj98RcdeNZyC7Xk9I9xIRk3BnWbqo+icMvUqA7V0DudYWCzQRFMmCrCATNJhReR2kOPsP3sb+Y54rxOTWEPDs/rBGood9HMNdsbtCSg/vcU9YfgkMa3I/5Ex5G+E2PbmLA49nePkZlJgQEwl4VPZlDsxYFESmK2Z1hFETXq43X2xQ0Rf0OeooXvVwNqAKV3uZhLQXVZLyW7dIwLKAkMcd6keV65ZhWhkuwCzRYvQPU0zYwKnz3i/RpDOwrlfRBYOimeFcpp6L7HXD12I0UWCNAquG0C+7kX7NlwQMsUSIUdCcawA+f9+Ij15ZHmGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":78116,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"58aa7d81-0c16-4ce6-bed8-fd6549c1e878"}}97{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJd9d8mUZ9oju1sFDFG6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ae6Hk88LSA213xrRYLV8GW","name":"Bash","input":{"command":"pm2 describe resto-ka-ngrok | grep -E \"script args\" ","description":"Check ngrok domain for resto-ka"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":78116,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9536e241-4e74-46df-bb26-f8d4ccbdd96a"}}98{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ae6Hk88LSA213xrRYLV8GW","type":"tool_result","content":"│ script args       │ http --url=www.resto-ka.com 8115                              │","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1a7f1644-5391-4672-b306-da77f8c5c03f","timestamp":"2026-08-22T23:05:12.445Z","tool_use_result":{"stdout":"│ script args       │ http --url=www.resto-ka.com 8115                              │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}99{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4057d8d7-6d24-45c5-a5ce-69e405974f32","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}100{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdA6aGiMaEk98DRbGwa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JrKXRBLYSbf1wkTN8ZG45q","name":"Bash","input":{"command":"curl -s -o /dev/null -w \"%{http_code}\\n\" https://www.resto-ka.com/healthz","description":"Public healthcheck www.resto-ka.com"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":221,"cache_read_input_tokens":78493,"cache_creation":{"ephemeral_5m_input_tokens":221,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"1dc9cab5-686a-45e6-8669-9371f80bf4e4"}}101{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JrKXRBLYSbf1wkTN8ZG45q","type":"tool_result","content":"200","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e65e7595-e057-490a-a496-e0215206f61b","timestamp":"2026-08-22T23:05:18.491Z","tool_use_result":{"stdout":"200","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}102{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6f3613bf-f086-40bc-8e5a-03319ecf8fcd","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}103{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdAZUmyamkMP6p6N96C","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzwQKpwEIERgCKkBT0Xtd93TAz/CgI8jZSWjhqZAFGR3o/tcrik5SxtXsZ5kNJQWt+nStNDgI7JTbBqtNkrryH756L94u/oqt79gGMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAG21qjUBrABAhIM033+e+aMLiSsVdESGgxa0IGIwpOs6IZgWG4iMOLJnhfElbmcdlPew8yAvFbUbXhOIJ82Y4b11QHkvm0GmjThHTQDA+nj4Wonj0BqgSrUArM6HJGbRo0Nax9lVNSVhnCbhvixPTexwqvuU6byfnUYQzAg4dDhRfn8uGFIA4uISGVKMUzTZjp19Tiih/J9pfDQYu2GxfMRcjtczvJp66RDSdygVMRuMuMAZU6Bmi1mZo5F4BfP4T3HEt5p/NZQQEF+yF9FdEhEN10Hhr/fgOjr0CiLDLsqHS7f03bmQLqbGtYrZmA1uN62P2WHVdXUr15JN8MCbbWXSJQBxdHVfXkk0lWvJzOJna0YzaBIJb+7tQnw9TvPEXQPd2lx2nXCmgnBOVya352TUMobmTQV3mlRpaP/U3xX1ubq3oeRxlwkLi85JPeYxVZSmneWiRZ28wWM5rNGRy9G43rGFAjMaxIypAfzTkTCl5A5cm7zgAtw2g6/o6OxxlD93Psx3epwIinDAslx6iEC+4DPPnYgwwoVzMEYEcgfUozfZg1ItfnE1ZmXDRgYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":78714,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"a33c1577-11bd-4638-86d3-2afa0eaa031f"}}104{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdAZUmyamkMP6p6N96C","type":"message","role":"assistant","content":[{"type":"text","text":"Tout est en ligne. Je committe et pousse (seulement mes fichiers — `data/ueat-discovered.json` est modifié par le sync, je n'y touche pas)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":78714,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"5038a88c-3b97-4f91-afbf-dfbe0c628e6f"}}105{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdAZUmyamkMP6p6N96C","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G773q1g1yb1EJWEmdsND3Z","name":"Bash","input":{"command":"git add restoka/web.py frontend/src/api.ts frontend/src/pages/Resto.tsx frontend/src/pages/Home.tsx frontend/src/styles.css && git commit -m \"$(cat <<'EOF'\naccueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>\nEOF\n)\" && git push origin main && git status --short | head -5","description":"Commit and push to spbgit origin main"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":78714,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"4186ff38-0313-45e1-a42b-a1728257d539"}}106{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 128\nfatal: pathspec 'restoka/web.py' did not match any files","is_error":true,"tool_use_id":"toolu_01G773q1g1yb1EJWEmdsND3Z"}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"f211afc7-6023-4ae5-8a53-20a5a609abc9","timestamp":"2026-08-22T23:05:29.040Z","tool_use_result":"Error: Exit code 128\nfatal: pathspec 'restoka/web.py' did not match any files"}}107{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"53f8b9e0-c1d7-4231-9282-350e7aceae7e","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}108{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdBKgLp9MxdfCJj5CVe","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011SraJZMU1e8PdFH2vCLTWq","name":"Bash","input":{"command":"pwd; git -C /Users/simon-pierreboucher/apps/resto-ka status --short | head","description":"Check cwd and git status"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":472,"cache_read_input_tokens":78840,"cache_creation":{"ephemeral_5m_input_tokens":472,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"8ec4f867-6776-4e0e-ac73-dab036aa382f"}}109{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011SraJZMU1e8PdFH2vCLTWq","type":"tool_result","content":"/Users/simon-pierreboucher/apps/resto-ka/frontend\n M data/ueat-discovered.json\n M frontend/src/api.ts\n M frontend/src/pages/Home.tsx\n M frontend/src/pages/Resto.tsx\n M frontend/src/styles.css\n M restoka/web.py","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"9aebfe70-ae26-419d-a50b-0f3cdd6ac897","timestamp":"2026-08-22T23:05:33.779Z","tool_use_result":{"stdout":"/Users/simon-pierreboucher/apps/resto-ka/frontend\n M data/ueat-discovered.json\n M frontend/src/api.ts\n M frontend/src/pages/Home.tsx\n M frontend/src/pages/Resto.tsx\n M frontend/src/styles.css\n M restoka/web.py","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}110{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"39c5729e-c597-45af-9f23-16da962d83b2","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}111{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdBfmpZ8MJU6q8qP1ML","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrQIKpwEIERgCKkDoUHz8FUWHmJYbgeWGtHOmq8AMDJfJs5OqYvR9og+5yoV3PfzIDsvwNXOZgYqiJibWOP2keQ0hRS42RJqj8P0aMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chCLToDU25FEmjjVqZecraU5iAEBqAHC1qjUBrABAhIMsskVaf6yggaPPj4oGgzG/u/L9UoCURXcX9kiMCDZCeVQ7wcfgMtz5Vq0SkiZg8ybWHI6zod+2nggp1q5vyjKqpc8BwAjkHdZJvwVdCozJ1GFeYFVZdCKoR9dBJxOBYfV0Wmugl9b7/MivnhWhKgeQyYroi5l6LLMpL7jek/ZVQKYGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":79312,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"ff6d5347-9efa-4222-b7e8-1d82493e01c2"}}112{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdBfmpZ8MJU6q8qP1ML","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AVfDYDjapVib9kaRKHeeQP","name":"Bash","input":{"command":"cd /Users/simon-pierreboucher/apps/resto-ka && git add restoka/web.py frontend/src/api.ts frontend/src/pages/Resto.tsx frontend/src/pages/Home.tsx frontend/src/styles.css && git commit -m \"$(cat <<'EOF'\naccueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>\nEOF\n)\" && git push origin main","description":"Commit and push to origin main (spbgit)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":79312,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"51d36152-d710-4a9b-82c6-26e233959bee"}}113{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AVfDYDjapVib9kaRKHeeQP","type":"tool_result","content":"[main 10ba8a7] accueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n 5 files changed, 74 insertions(+), 3 deletions(-)\nTo gitsrv:srv/git/resto-ka.git\n   6a43314..10ba8a7  main -> main","is_error":false}]},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"02ec9c7e-9e35-42dd-a617-bf4fdcd5e63e","timestamp":"2026-08-22T23:05:42.045Z","tool_use_result":{"stdout":"[main 10ba8a7] accueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=\n 5 files changed, 74 insertions(+), 3 deletions(-)\nTo gitsrv:srv/git/resto-ka.git\n   6a43314..10ba8a7  main -> main","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}}114{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"242049ba-f0dd-4d19-942a-5803bede5306","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7"}}115{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeJdCH767VRoWTsE6qbHP","type":"message","role":"assistant","content":[{"type":"text","text":"Fait — les restos/plats récemment consultés s'affichent maintenant en premier sur l'accueil.\n\n**Fonctionnement**\n- Chaque fiche resto ouverte (y compris via un plat, qui mène à la fiche) est mémorisée en localStorage (`restoka:recent`, max 12, plus récent en tête) — `frontend/src/pages/Resto.tsx:117`.\n- L'accueil affiche une bande horizontale **« Récemment consultés »** (jusqu'à 8 cartes, ordre de consultation) juste au-dessus des résultats, en modes Restos et Plats, sur la première page seulement — `frontend/src/pages/Home.tsx`.\n- Côté API, `/api/restaurants` accepte un paramètre `uids` (liste d'uids, plafonné à 24) pour récupérer ces fiches en un seul appel — `restoka/web.py:151`.\n\n**Déploiement** : build Vite OK, `pm2 restart resto-ka`, healthcheck local et https://www.resto-ka.com → 200, filtre `uids` testé en live, commit `10ba8a7` poussé sur spbgit."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":426,"cache_read_input_tokens":79694,"cache_creation":{"ephemeral_5m_input_tokens":426,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","uuid":"e4eb6a25-6a20-4bba-b886-567c508a9461"}}116{"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":234490,"duration_api_ms":227810,"num_turns":33,"result":"Fait — les restos/plats récemment consultés s'affichent maintenant en premier sur l'accueil.\n\n**Fonctionnement**\n- Chaque fiche resto ouverte (y compris via un plat, qui mène à la fiche) est mémorisée en localStorage (`restoka:recent`, max 12, plus récent en tête) — `frontend/src/pages/Resto.tsx:117`.\n- L'accueil affiche une bande horizontale **« Récemment consultés »** (jusqu'à 8 cartes, ordre de consultation) juste au-dessus des résultats, en modes Restos et Plats, sur la première page seulement — `frontend/src/pages/Home.tsx`.\n- Côté API, `/api/restaurants` accepte un paramètre `uids` (liste d'uids, plafonné à 24) pour récupérer ces fiches en un seul appel — `restoka/web.py:151`.\n\n**Déploiement** : build Vite OK, `pm2 restart resto-ka`, healthcheck local et https://www.resto-ka.com → 200, filtre `uids` testé en live, commit `10ba8a7` poussé sur spbgit.","stop_reason":"end_turn","session_id":"0d91ea11-f058-4169-aee9-fa8ff15b2ed7","total_cost_usd":0.9551052000000002,"usage":{"input_tokens":48,"cache_creation_input_tokens":80120,"cache_read_input_tokens":1580334,"output_tokens":11999,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":80120},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":395,"cache_read_input_tokens":79694,"cache_creation_input_tokens":426,"cache_creation":{"ephemeral_5m_input_tokens":426,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":356,"outputTokens":14,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.00042599999999999995,"contextWindow":200000,"maxOutputTokens":32000},"claude-fable-5":{"inputTokens":48,"outputTokens":11999,"cacheReadInputTokens":1580334,"cacheCreationInputTokens":80120,"webSearchRequests":0,"costUSD":0.9546792000000002,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"8b90a74a-9b4d-42f3-a3da-346f568ff92e"}}117{"type":"status","state":"idle"}118
Files · af81ba22ed344a656eec6ad8750033d9da3cf173
Menu

Theme