feat: add Doris Kitchen import review workflow

This commit is contained in:
Fizzlepoof
2026-05-21 23:31:53 +00:00
parent 3f333595a2
commit d5cfdbe8b5
11 changed files with 934 additions and 265 deletions

View File

@@ -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}

View File

@@ -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"})

View File

@@ -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()

View File

@@ -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));
}

View File

@@ -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'
? '<p class="pill pill-good">KitchenOwl: ' + escapeHtml(item.kitchenowl_status) + '</p>'
: '';
const canFlagImport = ['created', 'updated', 'duplicate'].includes(String(item.kitchenowl_status || ''));
const flagButton = canFlagImport
? '<button type="button" class="ghost" data-flag-suggestion="' + escapeHtml(item.id) + '">Flag import issue</button>'
: '';
return `
<article class="suggestion-card" data-suggestion-id="${escapeHtml(item.id)}">
<div class="suggestion-top">
@@ -45,6 +57,7 @@ function renderSuggestionCards(target, items) {
<button type="button" data-decision="approve">Yes, add to KitchenOwl</button>
<button type="button" class="ghost" data-decision="later">Save for later</button>
<button type="button" class="ghost danger" data-decision="reject">No thanks</button>
${flagButton}
</div>
</article>
`;
@@ -86,6 +99,45 @@ function renderFailureCards(target, items) {
}).join('');
}
function renderImportFlagCards(target, items) {
if (!target) return;
if (!items.length) {
target.textContent = 'No import issues flagged yet.';
target.classList.add('muted');
return;
}
target.classList.remove('muted');
target.innerHTML = items.map((item) => {
const status = String(item.status || 'open');
const resolved = status === 'resolved';
const history = (item.notes_history || []).slice(-3).reverse().map((entry) => '<li><strong>' + escapeHtml(entry.issue_type || 'issue') + ':</strong> ' + escapeHtml(entry.notes || '') + '</li>').join('');
const sourceLink = item.recipe_url ? '<a href="' + escapeHtml(item.recipe_url) + '" target="_blank" rel="noreferrer">Open recipe source</a>' : '<span>No source URL saved</span>';
const recipeId = item.kitchenowl_recipe_id ? '<span>KitchenOwl #' + escapeHtml(item.kitchenowl_recipe_id) + '</span>' : '';
const resolveButton = resolved ? '' : '<button type="button" class="ghost" data-import-flag-resolve="' + escapeHtml(item.id) + '">Mark resolved</button>';
return `
<article class="failure-card" data-import-flag-id="${escapeHtml(item.id)}">
<div class="suggestion-top">
<div>
<p class="section-label">${escapeHtml(item.issue_type || 'issue')}</p>
<h3>${escapeHtml(item.recipe_title || item.recipe_url || 'KitchenOwl recipe issue')}</h3>
</div>
<p class="pill ${resolved ? 'status-resolved' : 'status-open'}">${escapeHtml(status)}</p>
</div>
<p>${escapeHtml(item.notes || '')}</p>
<div class="chip-list">
${recipeId}
<span>Flags: ${escapeHtml(item.flag_count || 1)}</span>
<span>Last flagged: ${escapeHtml(item.last_flagged_at || item.updated_at || item.created_at || '')}</span>
</div>
<div class="flag-meta">${sourceLink}</div>
<ul class="flag-history">${history || '<li>No extra notes logged yet.</li>'}</ul>
<div class="action-row">${resolveButton}</div>
</article>
`;
}).join('');
}
async function loadDashboard() {
const data = await fetchJson('/api/dashboard/summary');
const counts = data.counts || {};
@@ -100,6 +152,7 @@ async function loadDashboard() {
setText('count-rejected', counts.rejected || 0);
setText('count-plans', counts.plans || 0);
setText('count-failed-searches', counts.failed_searches || 0);
setText('count-import-flags', counts.open_import_flags || 0);
setText('backend-note', 'Storage backend: ' + data.backend);
const blacklist = document.getElementById('blacklist-items');
@@ -122,6 +175,7 @@ async function loadDashboard() {
renderSuggestionCards(document.getElementById('suggestion-list'), data.recent_suggestions || []);
renderFailureCards(document.getElementById('failed-search-preview'), data.recent_failed_searches || []);
renderImportFlagCards(document.getElementById('import-flag-preview'), data.recent_import_flags || []);
}
async function handleDecision(event) {
@@ -132,13 +186,34 @@ async function handleDecision(event) {
const decision = button.dataset.decision;
if (!suggestionId || !decision) return;
const failurePanel = card ? card.closest('[data-failure-results]') : null;
button.disabled = true;
const buttons = card ? Array.from(card.querySelectorAll('button')) : [button];
const originalText = button.textContent;
const workingText = decision === 'approve'
? 'Adding to KitchenOwl…'
: (decision === 'later' ? 'Saving…' : 'Rejecting…');
buttons.forEach((entry) => { entry.disabled = true; });
button.textContent = workingText;
setStatusMessage(workingText, 'muted');
try {
const data = await fetchJson('/api/suggestions/' + encodeURIComponent(suggestionId) + '/decision', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({decision, import_to_kitchenowl: decision === 'approve'}),
});
const suggestion = data.suggestion || {};
const title = suggestion.title || 'Recipe';
const kitchenowl = data.kitchenowl || {};
if (decision === 'approve') {
const outcome = kitchenowl.status || suggestion.kitchenowl_status || 'updated';
const verb = outcome === 'duplicate' ? 'already exists in' : 'added to';
setStatusMessage(title + ' ' + verb + ' KitchenOwl. Status: ' + outcome + '.', 'pill pill-good');
} else if (decision === 'later') {
setStatusMessage(title + ' saved for later review.', 'pill pill-good');
} else {
setStatusMessage(title + ' rejected.', 'pill pill-good');
}
await loadDashboard();
if (failurePanel && card) {
card.remove();
@@ -165,12 +240,29 @@ async function handleDecision(event) {
}
return data;
} catch (error) {
setStatusMessage('KitchenOwl action failed: ' + error.message, 'pill status-open');
alert(error.message);
} finally {
button.disabled = false;
buttons.forEach((entry) => { entry.disabled = false; });
button.textContent = originalText;
}
}
async function createImportFlag(payload) {
return fetchJson('/api/import-flags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
}
async function loadImportsInto(target) {
const data = await fetchJson('/api/import-flags');
renderImportFlagCards(target, data.items || []);
}
async function loadSuggestionsInto(target) {
const data = await fetchJson('/api/suggestions');
renderSuggestionCards(target, data.items || []);
@@ -232,6 +324,33 @@ function wireDashboard() {
await handleDecision(event);
return;
}
if (event.target.matches('button[data-flag-suggestion]')) {
const button = event.target;
const suggestionId = button.dataset.flagSuggestion;
const issueType = window.prompt('What kind of import issue is this? (formatting, ingredients, timings, duplicate, other)', 'formatting');
if (!issueType) return;
const notes = window.prompt('What went wrong with this import?');
if (!notes) return;
await createImportFlag({suggestion_id: suggestionId, issue_type: issueType, notes});
await loadDashboard();
const importsTarget = document.getElementById('import-flags-list');
if (importsTarget) await loadImportsInto(importsTarget);
return;
}
if (event.target.matches('button[data-import-flag-resolve]')) {
const button = event.target;
const importFlagId = button.dataset.importFlagResolve;
const resolutionNotes = window.prompt('Optional resolution note:', '');
await fetchJson('/api/import-flags/' + encodeURIComponent(importFlagId) + '/resolve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({resolution_notes: resolutionNotes || ''}),
});
await loadDashboard();
const importsTarget = document.getElementById('import-flags-list');
if (importsTarget) await loadImportsInto(importsTarget);
return;
}
if (event.target.matches('button[data-failure-open]')) {
const button = event.target;
const failureId = button.dataset.failureOpen;
@@ -435,6 +554,42 @@ function wirePlanner() {
});
}
function wireImports() {
const target = document.getElementById('import-flags-list');
if (target) {
loadImportsInto(target).catch(() => {
target.textContent = 'Could not load import flags.';
});
}
const form = document.getElementById('import-flag-form');
if (!form) return;
form.addEventListener('submit', async (event) => {
event.preventDefault();
const urlEl = document.getElementById('import-flag-url');
const titleEl = document.getElementById('import-flag-title');
const typeEl = document.getElementById('import-flag-type');
const notesEl = document.getElementById('import-flag-notes');
const payload = {
recipe_url: urlEl ? urlEl.value.trim() : '',
recipe_title: titleEl ? titleEl.value.trim() : '',
issue_type: typeEl ? typeEl.value : 'formatting',
notes: notesEl ? notesEl.value.trim() : '',
};
if (!payload.notes) {
alert('Tell me what is wrong with the import first.');
return;
}
await createImportFlag(payload);
if (urlEl) urlEl.value = '';
if (titleEl) titleEl.value = '';
if (notesEl) notesEl.value = '';
await loadDashboard();
if (target) await loadImportsInto(target);
});
}
function wireFailures() {
const target = document.getElementById('failures-list');
if (target) {
@@ -462,6 +617,7 @@ loadDashboard().catch(() => {});
wireDashboard();
wireSearch();
wirePlanner();
wireImports();
wireFailures();
if (document.getElementById('search-results')) {
loadSuggestionsInto(document.getElementById('search-results')).catch(() => {});

View File

@@ -4,25 +4,67 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'Doris Kitchen' }}</title>
<link rel="stylesheet" href="/static/app.css?v=5">
<link rel="stylesheet" href="/static/app.css?v=9">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Doris Kitchen</p>
<h1>Household Recipe Queue</h1>
<p class="lede">Pick recipes, bin bad ones, learn the household taste, and build a cheaper meal week without wasting half a packet of mince. Ha ha.</p>
<body class="doris-hot-fuzz app-kitchen">
<div class="incident-tape" aria-hidden="true"></div>
<header class="site-header casefile-header app-casefile app-casefile-kitchen">
<div class="film-grain" aria-hidden="true"></div>
<div class="cinematic-glow" aria-hidden="true"></div>
<div class="brand-row">
<div class="nav-brand">
<span class="nav-kicker">N.W.A. Case File</span>
<strong>Doris Constabulary</strong>
</div>
<nav>
<a href="/">Queue</a>
<a href="/search">Search</a>
<a href="/planner">Planner</a>
<a href="/failures">Failures</a>
<nav class="nav-links" aria-label="Doris Kitchen navigation">
<a class="nav-link" href="http://10.5.1.16:8787/">Dashboard</a>
<a class="nav-link" href="/">Queue</a>
<a class="nav-link" href="/search">Search</a>
<a class="nav-link" href="/planner">Planner</a>
<a class="nav-link" href="/imports">Imports</a>
<a class="nav-link" href="/failures">Failures</a>
</nav>
</div>
<div class="casefile-title-row">
<div>
<p class="eyebrow">Sandford pantry desk</p>
<div class="casefile-stamp recipe-evidence-tag">Pantry Evidence</div>
<h1>Doris Kitchen</h1>
<p class="lede">Household recipe triage, import cleanup, and meal-planning evidence review with more cinematic energy and less boring admin chrome.</p>
<div class="brand-badges" aria-label="Theme badges">
<span class="pill badge-hotfuzz">For the Greater Good</span>
<span class="pill">Evidence locker: recipes</span>
<span class="pill">Case type: Pantry Evidence</span>
</div>
</div>
<div class="hot-fuzz-art" aria-hidden="true">
<svg viewBox="0 0 320 220" class="poster-illustration" role="presentation">
<defs>
<linearGradient id="kitchen-sunset" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f2c14e"></stop>
<stop offset="50%" stop-color="#d53600"></stop>
<stop offset="100%" stop-color="#910f3f"></stop>
</linearGradient>
</defs>
<rect x="8" y="8" width="304" height="204" rx="24" class="poster-frame"></rect>
<circle cx="228" cy="72" r="58" class="poster-halo"></circle>
<path d="M18 170 L112 100 L134 124 L40 198 Z" class="poster-tape tape-left"></path>
<path d="M202 82 L302 32 L314 60 L214 112 Z" class="poster-tape tape-right"></path>
<path d="M112 180 C110 150 116 126 132 110 C140 100 148 94 154 90 C160 94 168 100 176 110 C192 126 198 150 196 180 Z" class="constable-silhouette lead"></path>
<path d="M174 184 C172 154 178 132 194 118 C202 110 210 104 218 100 C226 104 234 110 242 118 C258 132 264 154 262 184 Z" class="constable-silhouette partner"></path>
<circle cx="84" cy="60" r="28" class="swan-stamp"></circle>
<path d="M76 64 C80 54 91 48 101 53 C93 52 88 57 89 63 C90 69 101 68 103 76 C94 78 83 76 78 69 L71 76 L66 71 L74 64 Z" class="swan-mark"></path>
<path d="M126 82 h68 v14 h-68z" class="recipe-card"></path>
<path d="M126 104 h82 v10 h-82z" class="recipe-card"></path>
<path d="M126 122 h54 v10 h-54z" class="recipe-card"></path>
<text x="160" y="204" class="poster-callout">PANTRY EVIDENCE</text>
</svg>
</div>
</div>
</header>
<main class="page-shell">
{% block content %}{% endblock %}
</main>
<script src="/static/app.js?v=5"></script>
<script src="/static/app.js?v=6"></script>
</body>
</html>

View File

@@ -1,8 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero-grid">
<article class="card spotlight">
<p class="section-label">Household taste</p>
<section class="hero-grid evidence-board dossier-grid">
<article class="card spotlight evidence-card">
<div class="case-legend">
<span class="section-label">Kitchen queue board</span>
<span class="pill">Case focus: approvals</span>
</div>
<h2>Leanne picks. Doris learns.</h2>
<p class="muted">Queue up web recipes, teach the household profile with yes/no decisions, and keep KitchenOwl for the final recipes worth keeping.</p>
<div class="action-row">
@@ -10,7 +13,11 @@
<a class="button-link" href="/search">Search a recipe idea</a>
</div>
</article>
<article class="card stats-card">
<article class="card stats-card evidence-card">
<div class="case-legend">
<span class="section-label">Case counts</span>
<span class="pill">Pantry Evidence</span>
</div>
<div class="stats-grid">
<div><span>Suggested</span><strong id="count-suggested">0</strong></div>
<div><span>Approved</span><strong id="count-approved">0</strong></div>
@@ -18,19 +25,22 @@
<div><span>Rejected</span><strong id="count-rejected">0</strong></div>
<div><span>Plans</span><strong id="count-plans">0</strong></div>
<div><span>Failures</span><strong id="count-failed-searches">0</strong></div>
<div><span>Import flags</span><strong id="count-import-flags">0</strong></div>
</div>
<p class="muted" id="backend-note">Loading state…</p>
<p class="muted dossier-note" id="backend-note">Loading state…</p>
</article>
</section>
<section class="split-grid">
<article class="card">
<section class="split-grid dossier-grid">
<article class="card evidence-card">
<div class="section-heading">
<div>
<p class="section-label">Soft blacklist</p>
<h2>Terms to push down</h2>
</div>
<span class="pill">Lead filter</span>
</div>
<p class="muted case-legend">Mark ingredients, cuisines, or phrases Doris should treat like weak leads.</p>
<form id="blacklist-form" class="inline-form">
<input type="text" id="blacklist-name" placeholder="Add ingredient or term">
<button type="submit">Add / enable</button>
@@ -38,12 +48,13 @@
<div id="blacklist-items" class="chip-list muted">Loading…</div>
</article>
<article class="card">
<article class="card evidence-card">
<div class="section-heading">
<div>
<p class="section-label">Preference notes</p>
<h2>Household guidance</h2>
</div>
<span class="pill">Witness notes</span>
</div>
<form id="settings-form" class="stack-form">
<label>Notes
@@ -61,17 +72,52 @@
</article>
</section>
<section class="card">
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Queue</p>
<h2>Recent recipe suggestions</h2>
</div>
<span class="pill">Kitchen queue board</span>
</div>
<div id="suggestion-list" class="suggestion-grid muted">Loading…</div>
<p class="muted case-legend">Review the current stack like pinned case cards: promote the good leads, bury the junk.</p>
<div id="suggestion-list" class="suggestion-grid muted dossier-grid">Loading…</div>
</section>
<section class="card">
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Import repair queue</p>
<h2>Flag recipe import problems</h2>
</div>
<a class="button-link" href="/imports">Open import review</a>
</div>
<p class="muted case-legend">Bad imports get tagged like busted evidence bags so Doris can come back and repair them later.</p>
<form id="import-flag-form" class="stack-form intake-docket">
<label>Recipe URL or KitchenOwl link
<input type="url" id="import-flag-url" placeholder="https://...">
</label>
<label>Recipe title (optional)
<input type="text" id="import-flag-title" placeholder="Halal Guys Chicken over Rice But Better">
</label>
<label>Issue type
<select id="import-flag-type">
<option value="formatting">Formatting</option>
<option value="ingredients">Ingredients</option>
<option value="timings">Timings</option>
<option value="duplicate">Duplicate</option>
<option value="other">Other</option>
</select>
</label>
<label>What went wrong
<textarea id="import-flag-notes" rows="4" placeholder="Steps merged together, ingredients split badly, wrong yield, duplicate recipe, or whatever else looks off."></textarea>
</label>
<button type="submit">Flag for Doris review</button>
</form>
<div id="import-flag-preview" class="failure-list muted dossier-grid">Loading flagged imports…</div>
</section>
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Troubleshooting</p>
@@ -79,6 +125,7 @@
</div>
<a class="button-link" href="/failures">Open failure review</a>
</div>
<div id="failed-search-preview" class="failure-list muted">Loading…</div>
<p class="muted case-legend">Search misses stay pinned on the board until Doris can turn them into usable dinner intelligence.</p>
<div id="failed-search-preview" class="failure-list muted dossier-grid">Loading…</div>
</section>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
<section class="card">
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Failure review</p>
@@ -8,7 +8,7 @@
</div>
<button type="button" id="refresh-failures">Refresh progress</button>
</div>
<p class="muted">Open searches stay here until Doris can turn them into usable recipe results.</p>
<div id="failures-list" class="failure-list muted">Loading failed searches…</div>
<p class="muted case-legend">Open searches stay here until Doris can turn them into usable recipe results.</p>
<div id="failures-list" class="failure-list muted dossier-grid">Loading failed searches…</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero-grid dossier-grid">
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Import review</p>
<h2>Flagged KitchenOwl recipe issues</h2>
</div>
<span class="pill">Repair queue</span>
</div>
<p class="muted case-legend">Use this queue to track recipes that need Doris to clean up a bad import, fix ingredients, or inspect some other KitchenOwl problem.</p>
<form id="import-flag-form" class="stack-form intake-docket">
<label>Recipe URL or KitchenOwl link
<input type="url" id="import-flag-url" placeholder="https://...">
</label>
<label>Recipe title (optional)
<input type="text" id="import-flag-title" placeholder="Recipe title">
</label>
<label>Issue type
<select id="import-flag-type">
<option value="formatting">Formatting</option>
<option value="ingredients">Ingredients</option>
<option value="timings">Timings</option>
<option value="duplicate">Duplicate</option>
<option value="other">Other</option>
</select>
</label>
<label>What went wrong
<textarea id="import-flag-notes" rows="4" placeholder="Describe what needs fixing."></textarea>
</label>
<button type="submit">Add to import repair queue</button>
</form>
</section>
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Queue</p>
<h2>Open and resolved import flags</h2>
</div>
<span class="pill">Evidence tags</span>
</div>
<div id="import-flags-list" class="failure-list muted dossier-grid">Loading import flags…</div>
</section>
</section>
{% endblock %}

View File

@@ -1,14 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero-grid">
<article class="card">
<section class="hero-grid dossier-grid">
<article class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Meal planner</p>
<h2>Build the week around a few anchor meals</h2>
</div>
<span class="pill">Planning board</span>
</div>
<form id="planner-form" class="stack-form">
<form id="planner-form" class="stack-form intake-docket">
<label>Week start
<input type="date" id="week-start">
</label>
@@ -21,21 +22,21 @@
<button type="submit">Build the week</button>
</form>
</article>
<article class="card">
<p class="section-label">What this does</p>
<article class="card evidence-board">
<div class="case-legend"><span class="section-label">What this does</span><span class="pill">Waste-aware</span></div>
<h2>Waste-aware planning</h2>
<p class="muted">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.</p>
</article>
</section>
<section class="card">
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Plan output</p>
<h2>Suggested meal week</h2>
</div>
<span class="pill">Lineup board</span>
</div>
<div id="plan-output" class="plan-grid muted">Generate a plan to see the meal lineup.</div>
<div id="plan-output" class="plan-grid muted dossier-grid">Generate a plan to see the meal lineup.</div>
</section>
{% endblock %}

View File

@@ -1,13 +1,16 @@
{% extends 'base.html' %}
{% block content %}
<section class="card search-card">
<section class="hero-grid dossier-grid">
<section class="card search-card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Recipe search</p>
<h2>Tell Doris what sort of dinner you want</h2>
</div>
<span class="pill">Lead intake</span>
</div>
<form id="search-form" class="stack-form">
<p class="muted case-legend">Describe the target meal like a witness statement and let Doris pull leads.</p>
<form id="search-form" class="stack-form intake-docket">
<label>Search request
<textarea id="search-query" rows="3" placeholder="Asian inspired using ground beef with potatoes"></textarea>
</label>
@@ -24,29 +27,33 @@
</div>
</section>
<section class="card search-card">
<section class="card search-card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Direct link</p>
<h2>Paste a recipe URL for Doris to ingest</h2>
</div>
<span class="pill">Search warrant</span>
</div>
<form id="url-form" class="stack-form">
<p class="muted case-legend">Drop the exact source when you already know the target recipe is worth hauling in.</p>
<form id="url-form" class="stack-form intake-docket">
<label>Recipe URL
<input id="recipe-url" type="url" placeholder="https://example.com/recipe">
</label>
<button type="submit">Ingest recipe link</button>
</form>
</section>
</section>
<section class="card">
<section class="card evidence-board">
<div class="section-heading">
<div>
<p class="section-label">Results</p>
<h2>Candidate recipes</h2>
</div>
<span class="pill">Evidence table</span>
</div>
<p id="search-status" class="muted"></p>
<div id="search-results" class="suggestion-grid muted">Search for something to load recipe cards.</div>
<p id="search-status" class="muted case-legend"></p>
<div id="search-results" class="suggestion-grid muted dossier-grid">Search for something to load recipe cards.</div>
</section>
{% endblock %}