diff --git a/home/doris-kitchen/app/routes/api_dashboard.py b/home/doris-kitchen/app/routes/api_dashboard.py index 53c05a6..efbe7ac 100644 --- a/home/doris-kitchen/app/routes/api_dashboard.py +++ b/home/doris-kitchen/app/routes/api_dashboard.py @@ -1,5 +1,4 @@ -from fastapi import APIRouter -from fastapi import HTTPException +from fastapi import APIRouter, HTTPException, Request from app.services.repository import repo from app.services.recipe_search import recipe_search @@ -14,6 +13,27 @@ def usable_results(items): return [entry for entry in items if str(entry.get("status") or "suggested") != "rejected"] +def _find_suggestion(suggestion_id: str | None): + if not suggestion_id: + return None + return next((item for item in repo.list_suggestions() if str(item.get("id")) == str(suggestion_id)), None) + + +def _find_existing_open_import_flag(recipe_url: str, kitchenowl_recipe_id: str | int | None): + recipe_url = str(recipe_url or "").strip().lower() + recipe_id = str(kitchenowl_recipe_id or "").strip() + for item in repo.list_import_flags(): + if str(item.get("status") or "open") != "open": + continue + item_url = str(item.get("recipe_url") or "").strip().lower() + item_recipe_id = str(item.get("kitchenowl_recipe_id") or "").strip() + if recipe_id and item_recipe_id and recipe_id == item_recipe_id: + return item + if recipe_url and item_url and recipe_url == item_url: + return item + return None + + @router.get("/health") async def health(): return {"status": "ok", "backend": repo.backend} @@ -25,6 +45,7 @@ async def summary(): suggestions = repo.list_suggestions() plans = repo.list_meal_plans() failed_searches = repo.recent_failed_searches(limit=6) + import_flags = repo.list_import_flags() counts = { "suggested": sum(1 for item in suggestions if item.get("status") == "suggested"), "approved": sum(1 for item in suggestions if item.get("status") == "approved"), @@ -32,6 +53,7 @@ async def summary(): "rejected": sum(1 for item in suggestions if item.get("status") == "rejected"), "plans": len(plans), "failed_searches": len(repo.list_failed_searches()), + "open_import_flags": sum(1 for item in import_flags if str(item.get("status") or "open") == "open"), } return { "backend": repo.backend, @@ -40,6 +62,7 @@ async def summary(): "recent_suggestions": repo.recent_suggestions(), "recent_plans": list(reversed(plans[-4:])), "recent_failed_searches": failed_searches, + "recent_import_flags": repo.recent_import_flags(limit=6), } @@ -96,3 +119,87 @@ async def refresh_failed_searches(): saved = repo.update_failed_search(str(item.get("id")), updates) or {**item, **updates} refreshed.append(saved) return {"status": "ok", "items": refreshed} + + +@router.get("/import-flags") +async def list_import_flags(): + return {"status": "ok", "items": repo.list_import_flags()} + + +@router.post("/import-flags") +async def create_import_flag(request: Request): + payload = await request.json() + suggestion_id = str(payload.get("suggestion_id") or "").strip() or None + suggestion = _find_suggestion(suggestion_id) + if suggestion_id and not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found.") + + recipe_title = str(payload.get("recipe_title") or (suggestion or {}).get("title") or ((suggestion or {}).get("payload") or {}).get("name") or "").strip() + recipe_url = str(payload.get("recipe_url") or ((suggestion or {}).get("payload") or {}).get("source") or (suggestion or {}).get("url") or "").strip() + issue_type = str(payload.get("issue_type") or "formatting").strip().lower() or "formatting" + notes = str(payload.get("notes") or "").strip() + kitchenowl_recipe_id = payload.get("kitchenowl_recipe_id") + if kitchenowl_recipe_id in {None, "", 0}: + kitchenowl_recipe_id = (((suggestion or {}).get("kitchenowl_result") or {}).get("recipe") or {}).get("id") + + if not notes: + raise HTTPException(status_code=400, detail="Issue notes are required.") + if not recipe_title and not recipe_url and not kitchenowl_recipe_id: + raise HTTPException(status_code=400, detail="Provide at least a recipe title, recipe URL, or KitchenOwl recipe id.") + + existing = _find_existing_open_import_flag(recipe_url, kitchenowl_recipe_id) + if existing: + history = list(existing.get("notes_history") or []) + history.append({"at": store.now(), "issue_type": issue_type, "notes": notes}) + updates = { + "updated_at": store.now(), + "last_flagged_at": store.now(), + "issue_type": issue_type, + "notes": notes, + "notes_history": history[-20:], + "flag_count": int(existing.get("flag_count") or 1) + 1, + "recipe_title": recipe_title or existing.get("recipe_title"), + "recipe_url": recipe_url or existing.get("recipe_url"), + "kitchenowl_recipe_id": kitchenowl_recipe_id or existing.get("kitchenowl_recipe_id"), + "suggestion_id": suggestion_id or existing.get("suggestion_id"), + "kitchenowl_status": (suggestion or {}).get("kitchenowl_status") or existing.get("kitchenowl_status"), + } + saved = repo.update_import_flag(str(existing.get("id")), updates) or {**existing, **updates} + return {"status": "ok", "item": saved, "deduplicated": True} + + item = { + "id": store.new_id("import-flag"), + "status": "open", + "issue_type": issue_type, + "notes": notes, + "notes_history": [{"at": store.now(), "issue_type": issue_type, "notes": notes}], + "flag_count": 1, + "created_at": store.now(), + "updated_at": store.now(), + "last_flagged_at": store.now(), + "suggestion_id": suggestion_id, + "recipe_title": recipe_title, + "recipe_url": recipe_url, + "kitchenowl_recipe_id": kitchenowl_recipe_id, + "kitchenowl_status": (suggestion or {}).get("kitchenowl_status"), + } + saved = repo.log_import_flag(item) + return {"status": "ok", "item": saved, "deduplicated": False} + + +@router.post("/import-flags/{import_flag_id}/resolve") +async def resolve_import_flag(import_flag_id: str, request: Request): + payload = await request.json() + current = next((entry for entry in repo.list_import_flags() if str(entry.get("id")) == str(import_flag_id)), None) + if not current: + raise HTTPException(status_code=404, detail="Import flag not found.") + resolution_notes = str(payload.get("resolution_notes") or "").strip() + updates = { + "status": "resolved", + "updated_at": store.now(), + "resolved_at": store.now(), + } + if resolution_notes: + updates["resolution_notes"] = resolution_notes + saved = repo.update_import_flag(import_flag_id, updates) or {**current, **updates} + return {"status": "ok", "item": saved} diff --git a/home/doris-kitchen/app/routes/ui.py b/home/doris-kitchen/app/routes/ui.py index ad1d039..b2d1875 100644 --- a/home/doris-kitchen/app/routes/ui.py +++ b/home/doris-kitchen/app/routes/ui.py @@ -27,3 +27,8 @@ async def planner_page(request: Request): @router.get("/failures", response_class=HTMLResponse) async def failures_page(request: Request): return templates.TemplateResponse("failures.html", {"request": request, "title": "Failed Searches"}) + + +@router.get("/imports", response_class=HTMLResponse) +async def imports_page(request: Request): + return templates.TemplateResponse("imports.html", {"request": request, "title": "Import Review"}) diff --git a/home/doris-kitchen/app/services/repository.py b/home/doris-kitchen/app/services/repository.py index dfe58f2..108856f 100644 --- a/home/doris-kitchen/app/services/repository.py +++ b/home/doris-kitchen/app/services/repository.py @@ -115,5 +115,25 @@ class JsonRepository: lambda item: {**item, **updates}, ) + def list_import_flags(self) -> list[dict[str, Any]]: + return self.store.load("import_flags", []) + + def log_import_flag(self, item: dict[str, Any]) -> dict[str, Any]: + current = self.list_import_flags() + current.append(item) + current = sorted(current, key=lambda entry: entry.get("updated_at") or entry.get("created_at") or "", reverse=True)[:200] + self.store.save("import_flags", current) + return item + + def recent_import_flags(self, limit: int = 12) -> list[dict[str, Any]]: + return self.list_import_flags()[:limit] + + def update_import_flag(self, import_flag_id: str, updates: dict[str, Any]) -> dict[str, Any] | None: + return self.store.update_collection_item( + "import_flags", + lambda item: str(item.get("id")) == str(import_flag_id), + lambda item: {**item, **updates}, + ) + repo = JsonRepository() diff --git a/home/doris-kitchen/app/static/app.css b/home/doris-kitchen/app/static/app.css index 29ceaec..2a51e33 100644 --- a/home/doris-kitchen/app/static/app.css +++ b/home/doris-kitchen/app/static/app.css @@ -1,25 +1,47 @@ :root { - --ink: #2c1810; - --paper: #f8f0df; - --paper-2: #fff8eb; - --accent: #c7682d; - --accent-deep: #8b3d14; - --olive: #6c7a3d; - --rose: #a74735; - --line: rgba(92, 52, 28, 0.2); - --muted: #755f53; - --shadow: 0 18px 40px rgba(73, 34, 11, 0.13); + color-scheme: dark; + --hf-night: #090b10; + --hf-panel: rgba(17, 21, 29, 0.94); + --hf-panel-raise: rgba(25, 31, 43, 0.96); + --hf-paper: #f2efdd; + --hf-muted: #cbbda7; + --hf-line: rgba(242, 239, 221, 0.12); + --hf-shadow: 0 20px 48px rgba(0, 0, 0, 0.34); + --hf-red: #d53600; + --hf-red-deep: #910f3f; + --hf-amber: #f2c14e; + --hf-blue: #7b8fb2; + --hf-green: #5c7f69; + --ink: var(--hf-paper); + --paper: var(--hf-panel); + --paper-2: var(--hf-panel-raise); + --accent: var(--hf-red); + --accent-deep: var(--hf-amber); + --olive: var(--hf-green); + --rose: #f27d72; + --line: var(--hf-line); + --muted: var(--hf-muted); + --shadow: var(--hf-shadow); } * { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; color: var(--ink); - font-family: Georgia, "Palatino Linotype", serif; + font-family: Inter, system-ui, sans-serif; background: - radial-gradient(circle at top left, rgba(199, 104, 45, 0.14), transparent 24rem), - radial-gradient(circle at 85% 10%, rgba(108, 122, 61, 0.14), transparent 18rem), - linear-gradient(180deg, #f4ead7, #efe0c8 55%, #ead7bf); + radial-gradient(circle at top left, rgba(213, 54, 0, 0.22), transparent 22rem), + radial-gradient(circle at top right, rgba(123, 143, 178, 0.16), transparent 20rem), + linear-gradient(180deg, #07090d 0%, #0d1017 52%, #131824 100%); +} + +a { color: inherit; } + +.incident-tape { + height: 14px; + background: repeating-linear-gradient(135deg, var(--hf-amber) 0 18px, #111 18px 36px); + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.32); } .site-header, @@ -29,93 +51,14 @@ body { padding: 24px; } -.site-header { +.casefile-header { display: grid; gap: 18px; + padding-top: 18px; } -.eyebrow, -.section-label { - text-transform: uppercase; - letter-spacing: 0.14em; - font-size: 0.75rem; - color: var(--accent-deep); - margin: 0 0 8px; -} - -h1, h2, h3, p { - margin-top: 0; -} - -.lede, -.muted { - color: var(--muted); -} - -nav { - display: flex; - gap: 14px; - flex-wrap: wrap; -} - -nav a, -.button-link { - color: var(--accent-deep); - text-decoration: none; - font-weight: 700; -} - -.page-shell { - display: grid; - gap: 22px; - padding-bottom: 44px; -} - -.hero-grid, -.split-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 22px; -} - -.card { - background: rgba(255, 248, 235, 0.92); - border: 1px solid var(--line); - border-radius: 24px; - padding: 24px; - box-shadow: var(--shadow); -} - -.spotlight { - background: - linear-gradient(145deg, rgba(199, 104, 45, 0.12), rgba(255, 248, 235, 0.88)), - rgba(255, 248, 235, 0.92); -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; -} - -.stats-grid div { - background: var(--paper); - border: 1px solid var(--line); - border-radius: 16px; - padding: 14px; - display: grid; - gap: 8px; -} - -.stats-grid span { - color: var(--muted); - font-size: 0.85rem; -} - -.stats-grid strong { - font-size: 1.6rem; -} - +.brand-row, +.casefile-title-row, .section-heading, .suggestion-top, .action-row, @@ -128,110 +71,251 @@ nav a, flex-wrap: wrap; } -.stack-form { +.nav-brand { display: grid; - gap: 14px; + gap: 3px; } -.search-examples { - margin-top: 16px; - display: grid; - gap: 10px; -} - -.search-examples .chip-list { - align-items: flex-start; -} - -.search-examples button { - text-align: left; -} - -label { - display: grid; - gap: 8px; - color: var(--muted); -} - -input, -textarea, +.nav-brand strong, +.nav-link, +.button-link, button { - font: inherit; - border-radius: 14px; - border: 1px solid var(--line); - padding: 12px 14px; + font-weight: 700; } -input, -textarea { - background: rgba(255, 255, 255, 0.7); - color: var(--ink); +.nav-kicker, +.eyebrow, +.section-label { + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.72rem; + color: var(--hf-amber); + margin: 0; } -button, -.button-link { - background: var(--accent); - color: #fff7ef; - border: none; - padding: 12px 16px; - border-radius: 999px; - cursor: pointer; +.nav-links { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } +.nav-link, .button-link { display: inline-flex; align-items: center; -} - -button.ghost { - background: rgba(255, 255, 255, 0.7); - color: var(--accent-deep); + text-decoration: none; + padding: 9px 13px; + border-radius: 999px; border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.04); + color: var(--ink); } -button.danger { - color: var(--rose); +.nav-link:hover, +.button-link:hover { + border-color: rgba(242, 193, 78, 0.58); + background: rgba(242, 193, 78, 0.12); } -.chip-list { +h1, h2, h3, p { margin-top: 0; } + +h1 { + margin-bottom: 10px; + font-size: clamp(2.3rem, 5vw, 4rem); + line-height: 0.95; + letter-spacing: -0.04em; +} + +.lede, +.muted, +label, +.reason-list, +.flag-history { + color: var(--muted); +} + +.brand-badges, +.chip-list, +.flag-meta { display: flex; flex-wrap: wrap; gap: 8px; } +.hot-fuzz-art { + position: relative; + flex: 0 1 320px; + min-width: min(100%, 280px); +} + +.poster-illustration { + width: 100%; + height: auto; + display: block; + filter: drop-shadow(0 24px 30px rgba(0, 0, 0, 0.35)); +} + +.poster-frame { + fill: rgba(10, 12, 18, 0.88); + stroke: rgba(242, 239, 221, 0.2); + stroke-width: 2; +} + +.poster-halo { + fill: url(#kitchen-sunset); + opacity: 0.9; +} + +.poster-tape { + fill: rgba(242, 193, 78, 0.88); + stroke: rgba(17, 17, 17, 0.8); + stroke-width: 2; +} + +.constable-silhouette { + fill: rgba(8, 10, 15, 0.92); + stroke: rgba(242, 239, 221, 0.12); + stroke-width: 1.5; +} + +.constable-silhouette.partner { + fill: rgba(24, 30, 42, 0.94); +} + +.swan-stamp { + fill: rgba(123, 143, 178, 0.2); + stroke: rgba(242, 239, 221, 0.42); + stroke-width: 2; +} + +.swan-mark { + fill: rgba(242, 239, 221, 0.94); +} + +.poster-callout { + fill: rgba(242, 239, 221, 0.82); + font-size: 18px; + font-weight: 800; + letter-spacing: 0.24em; + text-anchor: middle; +} + +.recipe-card, +.folder-body, +.folder-tab, +.barbell-bar, +.plate-outer, +.plate-inner { + fill: rgba(242, 239, 221, 0.9); +} + +.plate-inner { + fill: rgba(10, 12, 18, 0.72); +} + +.film-grain, +.cinematic-glow { + position: absolute; + inset: 0; + pointer-events: none; + border-radius: 28px; +} + +.film-grain { + background: + repeating-linear-gradient(0deg, rgba(255,255,255,0.025) 0 2px, transparent 2px 5px), + repeating-linear-gradient(90deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 4px); + mix-blend-mode: soft-light; + opacity: 0.35; +} + +.cinematic-glow { + background: + radial-gradient(circle at 16% 18%, rgba(123, 143, 178, 0.22), transparent 24%), + radial-gradient(circle at 84% 24%, rgba(213, 54, 0, 0.24), transparent 26%), + linear-gradient(115deg, rgba(242, 193, 78, 0.05), transparent 40%, rgba(145, 15, 63, 0.1) 78%, transparent 100%); + animation: emergencyPulse 8s ease-in-out infinite alternate; +} + +.app-casefile { + position: relative; + overflow: hidden; + isolation: isolate; +} + +.casefile-stamp { + display: inline-flex; + align-items: center; + margin: 0 0 0.85rem; + padding: 0.35rem 0.7rem; + border: 1px solid rgba(242, 193, 78, 0.45); + border-radius: 999px; + color: #ffe5b0; + background: rgba(242, 193, 78, 0.1); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; +} + +.app-casefile-kitchen .casefile-stamp { + box-shadow: 0 0 0 1px rgba(213, 54, 0, 0.12), 0 0 28px rgba(213, 54, 0, 0.16); +} + +@keyframes emergencyPulse { + 0% { opacity: 0.72; transform: scale(1); } + 50% { opacity: 0.92; transform: scale(1.015); } + 100% { opacity: 0.78; transform: scale(1.03); } +} + +.pill, .chip-list span, .chip-toggle, .score-pill, .status-pill, -.pill { +.pill-good, +.pill-warn { border-radius: 999px; padding: 6px 10px; font-size: 0.82rem; -} - -.chip-list span, -.chip-toggle { - background: var(--paper); border: 1px solid var(--line); } +.pill, +.chip-list span, +.chip-toggle { + background: rgba(255, 255, 255, 0.05); +} + +.badge-hotfuzz, +.score-pill, .chip-toggle.active, -.score-pill { - background: rgba(199, 104, 45, 0.12); - color: var(--accent-deep); -} - -.status-pill { - background: rgba(108, 122, 61, 0.12); - color: var(--olive); +button, +.button-link { + background: linear-gradient(135deg, var(--hf-red) 0%, var(--hf-red-deep) 100%); + color: #fff4ea; + border-color: transparent; } +.status-pill, .pill-good { - background: rgba(108, 122, 61, 0.14); - color: var(--olive); - display: inline-block; + background: rgba(92, 127, 105, 0.18); + color: #cfe8d8; } +.pill-warn { + background: rgba(242, 193, 78, 0.16); + color: var(--hf-amber); +} + +.page-shell { + display: grid; + gap: 22px; + padding-bottom: 44px; +} + +.hero-grid, +.split-grid, .suggestion-grid, .plan-grid, .failure-list { @@ -240,51 +324,199 @@ button.danger { gap: 18px; } +.card, .suggestion-card, .plan-card, .plan-summary, .failure-card { + background: var(--paper); border: 1px solid var(--line); + border-radius: 22px; + padding: 22px; + box-shadow: var(--shadow); +} + +.card, +.suggestion-card, +.plan-card, +.failure-card { + position: relative; + overflow: hidden; +} + +.card::before, +.suggestion-card::before, +.plan-card::before, +.failure-card::before { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--hf-red), var(--hf-amber), var(--hf-blue)); +} + +.spotlight { + background: + radial-gradient(circle at top right, rgba(242, 193, 78, 0.16), transparent 15rem), + linear-gradient(145deg, rgba(145, 15, 63, 0.2), rgba(17, 21, 29, 0.96)); +} + +.evidence-board, +.evidence-card, +.evidence-slab, +.intake-docket { + position: relative; +} + +.dossier-grid { + align-items: stretch; +} + +.case-legend { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.case-legend::after { + content: 'FOR THE GREATER GOOD'; + font-size: 0.67rem; + letter-spacing: 0.16em; + color: rgba(242, 193, 78, 0.7); +} + +.evidence-board::after, +.evidence-card::after { + content: ''; + position: absolute; + inset: 14px; + border: 1px dashed rgba(242, 193, 78, 0.09); + border-radius: 16px; + pointer-events: none; +} + +.dossier-note { + padding-left: 12px; + border-left: 3px solid rgba(242, 193, 78, 0.4); +} + +.case-list li { + padding-left: 18px; + position: relative; +} + +.case-list li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--hf-amber); +} + +.intake-docket { + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015)); + border: 1px solid rgba(242, 239, 221, 0.06); border-radius: 18px; - background: rgba(255, 255, 255, 0.55); - padding: 18px; + padding: 14px; } -.failure-results { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--line); +.chain-of-custody { + padding-bottom: 10px; + border-bottom: 1px dashed rgba(242, 193, 78, 0.2); } -.failure-results .suggestion-grid, -.failure-results.suggestion-grid { - margin-top: 8px; +.evidence-slab { + background: rgba(255, 255, 255, 0.035); + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px; } -.status-open { - background: rgba(167, 71, 53, 0.12); - color: var(--rose); +.stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; } -.status-resolved { - background: rgba(108, 122, 61, 0.14); - color: var(--olive); +.stats-grid div, +.suggestion-card, +.plan-summary, +.failure-results, +.suggestion-grid .muted, +.list-item { + background: rgba(255, 255, 255, 0.035); + border: 1px solid var(--line); + border-radius: 16px; } -.suggestion-card h3, -.plan-card h3 { - margin-bottom: 8px; +.stats-grid div { + padding: 14px; + display: grid; + gap: 8px; } -.suggestion-card a, -.plan-card a { +.stats-grid span { font-size: 0.85rem; } +.stats-grid strong { font-size: 1.6rem; } + +.stack-form, +.search-examples, +.flag-meta { display: grid; gap: 12px; } + +label { display: grid; gap: 8px; } + +input, +textarea, +select, +button { + font: inherit; + border-radius: 14px; + padding: 12px 14px; +} + +input, +textarea, +select { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.05); color: var(--ink); } -.reason-list { - margin: 12px 0; - padding-left: 18px; - color: var(--muted); +button { + border: none; + cursor: pointer; +} + +button.ghost { + background: rgba(255, 255, 255, 0.06); + color: var(--hf-amber); + border: 1px solid var(--line); +} + +button.danger { color: #ffd7d1; } + +.reason-list, +.flag-history { margin: 12px 0 0; padding-left: 18px; } +.failure-results { margin-top: 14px; padding: 14px; } +.failure-results .suggestion-grid, +.failure-results.suggestion-grid { margin-top: 8px; } + +.status-open { + background: rgba(210, 55, 0, 0.16); + color: #ffbeb0; +} + +.status-resolved { + background: rgba(92, 127, 105, 0.16); + color: #d7efdf; +} + +.suggestion-card a, +.plan-card a, +.flag-meta a { + color: var(--hf-amber); } @media (max-width: 640px) { @@ -293,6 +525,11 @@ button.danger { padding: 18px; } + .hot-fuzz-art { + flex-basis: 100%; + min-width: 0; + } + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/home/doris-kitchen/app/static/app.js b/home/doris-kitchen/app/static/app.js index 4a7da1c..a15bbca 100644 --- a/home/doris-kitchen/app/static/app.js +++ b/home/doris-kitchen/app/static/app.js @@ -14,6 +14,14 @@ function escapeHtml(value) { .replace(/'/g, '''); } +function setStatusMessage(message, tone = 'muted') { + const status = document.getElementById('search-status'); + if (!status) return; + status.textContent = message || ''; + status.className = tone; +} + + function renderSuggestionCards(target, items) { if (!target) return; if (!items.length) { @@ -28,6 +36,10 @@ function renderSuggestionCards(target, items) { const kitchenowl = item.kitchenowl_status && item.kitchenowl_status !== 'not-imported' ? '
KitchenOwl: ' + escapeHtml(item.kitchenowl_status) + '
' : ''; + const canFlagImport = ['created', 'updated', 'duplicate'].includes(String(item.kitchenowl_status || '')); + const flagButton = canFlagImport + ? '' + : ''; return `${escapeHtml(item.issue_type || 'issue')}
+${escapeHtml(status)}
+${escapeHtml(item.notes || '')}
+Doris Kitchen
-Pick recipes, bin bad ones, learn the household taste, and build a cheaper meal week without wasting half a packet of mince. Ha ha.
+ + +Sandford pantry desk
+Household recipe triage, import cleanup, and meal-planning evidence review with more cinematic energy and less boring admin chrome.
+Household taste
+Queue up web recipes, teach the household profile with yes/no decisions, and keep KitchenOwl for the final recipes worth keeping.
Loading state…
+Loading state…
Soft blacklist
Mark ingredients, cuisines, or phrases Doris should treat like weak leads.
Preference notes
Queue
Review the current stack like pinned case cards: promote the good leads, bury the junk.
+Import repair queue
+Bad imports get tagged like busted evidence bags so Doris can come back and repair them later.
+ +Troubleshooting
@@ -79,6 +125,7 @@Search misses stay pinned on the board until Doris can turn them into usable dinner intelligence.
+Failure review
@@ -8,7 +8,7 @@Open searches stay here until Doris can turn them into usable recipe results.
-Open searches stay here until Doris can turn them into usable recipe results.
+Import review
+Use this queue to track recipes that need Doris to clean up a bad import, fix ingredients, or inspect some other KitchenOwl problem.
+ +Queue
+Meal planner
What this does
+The planner looks at approved and queued meals, then tries to pair dishes that reuse overlapping ingredients. That means less waste, less random spending, and fewer half-dead veg rattling in the fridge drawer.
Plan output
Recipe search
-Recipe search
+Example full searches:
-Describe the target meal like a witness statement and let Doris pull leads.
+ +Example full searches:
+Direct link
+Drop the exact source when you already know the target recipe is worth hauling in.
+ +Direct link
-Results