plan: Flavor B — 🔮 forgotten gems pulls from Discover too
The suggestion panel now folds enriched-but-unimported Discover entries
into the same Hecate-driven pool as library recipes. Pinning a discover
suggestion auto-imports it to the household's Mealie library and adds
the resulting slug to cauldron_meal_picks.
- db: list_discover_eligible_for_group(mealie_group_id, limit=80) — joins
cauldron_discovered_recipes against cauldron_discover_imports to find
enriched rows no household in the caller's group has imported yet
- forge.suggest_recipes: accepts a per-entry `source` field; when any
entry is source='discover' the prompt gains an explicit hint so Hecate
treats discover entries as "something new to try" with a soft cap on
proportion (~half max unless library exhausted)
- /api/plan/suggest: builds a unified pool with prefixed IDs (lib:<slug>
vs disc:<id>) so library and discover entries can coexist in Sonnet's
validation map; decorates each suggestion with kind+image+meta_summary+
hecate_quip; discover entries also carry source_url and source_host
- /api/plan/suggest/pin: new dispatch on body.kind:
- kind=library (default — back-compat with existing Flavor A callers):
same as before
- kind=discover: looks up the discover row, short-circuits to cached
Mealie slug if THIS household already imported it, else
mealie.import_from_url() + record_discover_import() + add to picks.
Returns {kind, mealie_slug, imported, added_to_picks}
- plan.html: card render is kind-aware. Discover entries get a
"📬 from Discover · <host>" footer instead of last-planned recall, an
"import + pin" button label, and use data-* attributes for the click
payload so the JS doesn't need to template-interpolate slugs into
onclick handlers
This commit is contained in:
parent
b41c93e559
commit
8752fcd340
4 changed files with 285 additions and 35 deletions
|
|
@ -2338,6 +2338,46 @@ class DB:
|
||||||
row["imported_at"] = row["imported_at"].isoformat()
|
row["imported_at"] = row["imported_at"].isoformat()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def list_discover_eligible_for_group(
|
||||||
|
self, *, mealie_group_id: str | None, limit: int = 100
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return enriched discover rows that no household in the given Mealie
|
||||||
|
group has imported yet. Used by Flavor B's pool builder — these are
|
||||||
|
the 'new gems' Hecate can suggest alongside library recipes.
|
||||||
|
|
||||||
|
If `mealie_group_id` is None we still return the unimported global
|
||||||
|
list (no group context yet — first-boot edge case)."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
if mealie_group_id:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT d.id, d.slug, d.source_url, d.name, d.description,
|
||||||
|
d.image_url, d.meta_json
|
||||||
|
FROM cauldron_discovered_recipes d
|
||||||
|
LEFT JOIN cauldron_discover_imports i
|
||||||
|
ON i.discover_id = d.id
|
||||||
|
LEFT JOIN cauldron_households h
|
||||||
|
ON h.id = i.household_id
|
||||||
|
AND h.mealie_group_id = %s
|
||||||
|
WHERE d.status = 'enriched'
|
||||||
|
AND h.id IS NULL
|
||||||
|
ORDER BY d.scraped_at DESC
|
||||||
|
LIMIT %s""",
|
||||||
|
(mealie_group_id, int(limit)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT d.id, d.slug, d.source_url, d.name, d.description,
|
||||||
|
d.image_url, d.meta_json
|
||||||
|
FROM cauldron_discovered_recipes d
|
||||||
|
LEFT JOIN cauldron_discover_imports i ON i.discover_id = d.id
|
||||||
|
WHERE d.status = 'enriched'
|
||||||
|
AND i.discover_id IS NULL
|
||||||
|
ORDER BY d.scraped_at DESC
|
||||||
|
LIMIT %s""",
|
||||||
|
(int(limit),),
|
||||||
|
)
|
||||||
|
return list(cur.fetchall() or [])
|
||||||
|
|
||||||
def get_discovered_recipe(self, discover_id: int) -> dict | None:
|
def get_discovered_recipe(self, discover_id: int) -> dict | None:
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -983,11 +983,19 @@ class Forge:
|
||||||
if isinstance(fit, dict) and fit:
|
if isinstance(fit, dict) and fit:
|
||||||
fit_str = ",".join(f"{n}:{s}" for n, s in fit.items())
|
fit_str = ",".join(f"{n}:{s}" for n, s in fit.items())
|
||||||
extras.append(f"fit:{fit_str}")
|
extras.append(f"fit:{fit_str}")
|
||||||
|
# Source hint — Flavor B passes "library" or "discover" so Hecate
|
||||||
|
# can reason about whether an entry is already in the household's
|
||||||
|
# Mealie library (free pick) vs scraped from the open web (would
|
||||||
|
# need to be imported on pin).
|
||||||
|
src = r.get("source")
|
||||||
|
if src:
|
||||||
|
extras.append(f"source:{src}")
|
||||||
line = f"- {slug} | {name}"
|
line = f"- {slug} | {name}"
|
||||||
if extras:
|
if extras:
|
||||||
line += f" [{' · '.join(extras)}]"
|
line += f" [{' · '.join(extras)}]"
|
||||||
pool_lines.append(line)
|
pool_lines.append(line)
|
||||||
pool_block = "\n".join(pool_lines)
|
pool_block = "\n".join(pool_lines)
|
||||||
|
has_discover = any(r.get("source") == "discover" for r in eligible_pool)
|
||||||
|
|
||||||
# Picker profile block (same shape as planner uses, abbreviated)
|
# Picker profile block (same shape as planner uses, abbreviated)
|
||||||
profile_block = ""
|
profile_block = ""
|
||||||
|
|
@ -1025,13 +1033,25 @@ class Forge:
|
||||||
if in_plan_slugs:
|
if in_plan_slugs:
|
||||||
already_block = f"\n(For context: this week's plan already has {len(in_plan_slugs)} recipe(s). They're NOT in the pool above.)\n"
|
already_block = f"\n(For context: this week's plan already has {len(in_plan_slugs)} recipe(s). They're NOT in the pool above.)\n"
|
||||||
|
|
||||||
|
discover_hint = (
|
||||||
|
"\nNOTE: some entries below have `source:discover` — those are "
|
||||||
|
"RECIPES NEW TO THIS HOUSEHOLD, scraped from the open web and "
|
||||||
|
"enriched but not yet imported to the family's Mealie library. "
|
||||||
|
"Treat them as 'something new to try' — bias toward them when "
|
||||||
|
"the family's history is heavy on the same handful of cuisines/"
|
||||||
|
"proteins, but don't overdo it (max ~half the picks should be "
|
||||||
|
"discover-source unless the library is exhausted). Pinning a "
|
||||||
|
"discover entry will trigger an automatic import.\n"
|
||||||
|
if has_discover else ""
|
||||||
|
)
|
||||||
prompt = (
|
prompt = (
|
||||||
"You are Hecate, a Greek-mythology witch goddess of crossroads, "
|
"You are Hecate, a Greek-mythology witch goddess of crossroads, "
|
||||||
"herbs, and magic — and the family's meal planner. The household "
|
"herbs, and magic — and the family's meal planner. The household "
|
||||||
"asked you to look through their recipe library and surface "
|
"asked you to look through their recipe library and surface "
|
||||||
"'forgotten gems': recipes that fit who they are but haven't "
|
"'forgotten gems': recipes that fit who they are but haven't "
|
||||||
"been served recently (or ever).\n\n"
|
"been served recently (or ever).\n\n"
|
||||||
f"This is for the week of {week_start}.\n\n"
|
f"This is for the week of {week_start}.\n"
|
||||||
|
f"{discover_hint}\n"
|
||||||
f"ELIGIBLE POOL (already filtered: nothing seen in the last 90 days, "
|
f"ELIGIBLE POOL (already filtered: nothing seen in the last 90 days, "
|
||||||
f"nothing already on this week's plan):\n{pool_block}\n"
|
f"nothing already on this week's plan):\n{pool_block}\n"
|
||||||
f"{profile_block}"
|
f"{profile_block}"
|
||||||
|
|
|
||||||
|
|
@ -1113,6 +1113,10 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
eligible: list[dict] = []
|
eligible: list[dict] = []
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
# Library entries get prefixed slugs ("lib:<slug>") so they can
|
||||||
|
# coexist with discover entries ("disc:<id>") in the same Sonnet
|
||||||
|
# pool without collision. forge.suggest_recipes only needs the
|
||||||
|
# slug to be unique; the prefix is server-side bookkeeping.
|
||||||
for r in rows:
|
for r in rows:
|
||||||
slug = r["slug"]
|
slug = r["slug"]
|
||||||
if slug in in_plan_slugs:
|
if slug in in_plan_slugs:
|
||||||
|
|
@ -1140,10 +1144,11 @@ def create_app() -> Flask:
|
||||||
if any(contains.get(e) for e in excl_set):
|
if any(contains.get(e) for e in excl_set):
|
||||||
continue
|
continue
|
||||||
entry = {
|
entry = {
|
||||||
"slug": slug,
|
"slug": f"lib:{slug}",
|
||||||
"name": r["name"],
|
"name": r["name"],
|
||||||
"meta": m,
|
"meta": m,
|
||||||
"history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {},
|
"history": {"weeks_ago": weeks_ago} if weeks_ago is not None else {},
|
||||||
|
"source": "library",
|
||||||
}
|
}
|
||||||
# Per-user fit (same scoring as planner)
|
# Per-user fit (same scoring as planner)
|
||||||
fit_scores: dict[str, int] = {}
|
fit_scores: dict[str, int] = {}
|
||||||
|
|
@ -1158,6 +1163,52 @@ def create_app() -> Flask:
|
||||||
entry["fit"] = fit_scores
|
entry["fit"] = fit_scores
|
||||||
eligible.append(entry)
|
eligible.append(entry)
|
||||||
|
|
||||||
|
# Flavor B — also pull from Discover for "things you've never had"
|
||||||
|
# variety. Group-aware: skip rows ANY household in this group has
|
||||||
|
# already imported (since group-shared read makes them visible to
|
||||||
|
# everyone). Tag entries with source='discover' so Hecate's prompt
|
||||||
|
# treats them differently and the pin endpoint dispatches to
|
||||||
|
# import-then-pick.
|
||||||
|
my_household = db.get_household(hid)
|
||||||
|
my_group_id = (my_household or {}).get("mealie_group_id")
|
||||||
|
discover_rows = db.list_discover_eligible_for_group(
|
||||||
|
mealie_group_id=my_group_id, limit=80
|
||||||
|
)
|
||||||
|
discover_by_id: dict[int, dict] = {}
|
||||||
|
for d in discover_rows:
|
||||||
|
d_meta = d.get("meta_json")
|
||||||
|
if isinstance(d_meta, str):
|
||||||
|
try:
|
||||||
|
d_meta = _json_loads(d_meta)
|
||||||
|
except Exception:
|
||||||
|
d_meta = None
|
||||||
|
if not isinstance(d_meta, dict):
|
||||||
|
continue # Hecate needs meta to score fit — skip un-enriched
|
||||||
|
if excl_set:
|
||||||
|
contains = d_meta.get("contains") or {}
|
||||||
|
if any(contains.get(e) for e in excl_set):
|
||||||
|
continue
|
||||||
|
d_id = d["id"]
|
||||||
|
discover_by_id[d_id] = {**d, "meta_json": d_meta}
|
||||||
|
entry = {
|
||||||
|
"slug": f"disc:{d_id}",
|
||||||
|
"name": d.get("name") or d.get("slug") or f"discover-{d_id}",
|
||||||
|
"meta": d_meta,
|
||||||
|
"history": {}, # never-planned by definition
|
||||||
|
"source": "discover",
|
||||||
|
}
|
||||||
|
fit_scores: dict[str, int] = {}
|
||||||
|
for sub, prof in (picker_profiles or {}).items():
|
||||||
|
if not isinstance(prof, dict):
|
||||||
|
continue
|
||||||
|
score = _compute_fit_score(d_meta, prof)
|
||||||
|
if score > 0:
|
||||||
|
nm = (prof.get("display_name") or sub).split("@")[0][:12]
|
||||||
|
fit_scores[nm] = score
|
||||||
|
if fit_scores:
|
||||||
|
entry["fit"] = fit_scores
|
||||||
|
eligible.append(entry)
|
||||||
|
|
||||||
if not eligible:
|
if not eligible:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"suggestions": [],
|
"suggestions": [],
|
||||||
|
|
@ -1188,11 +1239,47 @@ def create_app() -> Flask:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
||||||
# Decorate with image_url + a tiny meta summary for the UI cards.
|
# Decorate with image_url + a tiny meta summary for the UI cards.
|
||||||
# Look up image_url from the indexed recipe row (raw_json.image when present).
|
# Each suggestion's `recipe_slug` is prefixed (lib: or disc:) — split
|
||||||
|
# on the prefix to figure out where to source the display data.
|
||||||
meta_summary_keys = ("cuisine", "complexity", "estimated_minutes", "primary_protein", "comfort_tier")
|
meta_summary_keys = ("cuisine", "complexity", "estimated_minutes", "primary_protein", "comfort_tier")
|
||||||
rows_by_slug = {r["slug"]: r for r in rows}
|
rows_by_slug = {r["slug"]: r for r in rows}
|
||||||
|
decorated = []
|
||||||
for s in suggestions:
|
for s in suggestions:
|
||||||
slug = s["recipe_slug"]
|
prefixed = s.get("recipe_slug") or ""
|
||||||
|
if prefixed.startswith("disc:"):
|
||||||
|
# Discover entry — image + meta come from cauldron_discovered_recipes
|
||||||
|
try:
|
||||||
|
d_id = int(prefixed[5:])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
d = discover_by_id.get(d_id)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
d_meta = d.get("meta_json") or {}
|
||||||
|
source_url = d.get("source_url") or ""
|
||||||
|
source_host = ""
|
||||||
|
if source_url:
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
source_host = urlparse(source_url).hostname or ""
|
||||||
|
except Exception:
|
||||||
|
source_host = ""
|
||||||
|
decorated.append({
|
||||||
|
"kind": "discover",
|
||||||
|
"discover_id": d_id,
|
||||||
|
"recipe_name": d.get("name") or s.get("recipe_name"),
|
||||||
|
"fit_score": s.get("fit_score"),
|
||||||
|
"reason": s.get("reason"),
|
||||||
|
"image_url": d.get("image_url"),
|
||||||
|
"source_url": source_url,
|
||||||
|
"source_host": source_host,
|
||||||
|
"meta_summary": {k: d_meta.get(k) for k in meta_summary_keys if d_meta.get(k)},
|
||||||
|
"hecate_quip": d_meta.get("hecate_quip") or "",
|
||||||
|
"last_planned": None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Library entry — prefixed as "lib:<slug>"; strip and look up.
|
||||||
|
slug = prefixed[4:] if prefixed.startswith("lib:") else prefixed
|
||||||
m = meta_by_slug.get(slug) or {}
|
m = meta_by_slug.get(slug) or {}
|
||||||
r = rows_by_slug.get(slug) or {}
|
r = rows_by_slug.get(slug) or {}
|
||||||
img = None
|
img = None
|
||||||
|
|
@ -1204,26 +1291,89 @@ def create_app() -> Flask:
|
||||||
raw = None
|
raw = None
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
img = raw.get("image") or raw.get("imageUrl")
|
img = raw.get("image") or raw.get("imageUrl")
|
||||||
s["image_url"] = img
|
|
||||||
s["meta_summary"] = {k: m.get(k) for k in meta_summary_keys if m.get(k)}
|
|
||||||
s["hecate_quip"] = m.get("hecate_quip") or ""
|
|
||||||
h = history_by_slug.get(slug) or {}
|
h = history_by_slug.get(slug) or {}
|
||||||
last_p = h.get("last_planned")
|
last_p = h.get("last_planned")
|
||||||
s["last_planned"] = last_p.isoformat() if last_p is not None else None
|
decorated.append({
|
||||||
|
"kind": "library",
|
||||||
|
"recipe_slug": slug,
|
||||||
|
"recipe_name": s.get("recipe_name") or r.get("name") or slug,
|
||||||
|
"fit_score": s.get("fit_score"),
|
||||||
|
"reason": s.get("reason"),
|
||||||
|
"image_url": img,
|
||||||
|
"meta_summary": {k: m.get(k) for k in meta_summary_keys if m.get(k)},
|
||||||
|
"hecate_quip": m.get("hecate_quip") or "",
|
||||||
|
"last_planned": last_p.isoformat() if last_p is not None else None,
|
||||||
|
})
|
||||||
|
|
||||||
return jsonify({"suggestions": suggestions})
|
return jsonify({"suggestions": decorated})
|
||||||
|
|
||||||
@app.post("/api/plan/suggest/pin")
|
@app.post("/api/plan/suggest/pin")
|
||||||
@require_session
|
@require_session
|
||||||
def plan_suggest_pin():
|
def plan_suggest_pin():
|
||||||
"""Pin a suggested recipe → adds to cauldron_meal_picks for the
|
"""Pin a suggested recipe → adds to cauldron_meal_picks for the
|
||||||
session user. The next /api/plan/generate will naturally pull it
|
session user. Two kinds of suggestions:
|
||||||
in as one of that user's picks. Body: {recipe_slug, recipe_name?}."""
|
|
||||||
|
- kind=library (default): {recipe_slug} — recipe must already be
|
||||||
|
in the household's Mealie library (indexed). Direct pin.
|
||||||
|
- kind=discover: {discover_id} — recipe lives in the discover
|
||||||
|
corpus only. Imports via Mealie's create-from-url first, records
|
||||||
|
the import to cauldron_discover_imports, then pins the resulting
|
||||||
|
Mealie slug.
|
||||||
|
|
||||||
|
Back-compat: a body with only {recipe_slug} (no kind) is treated
|
||||||
|
as kind=library so existing Flavor A callers keep working."""
|
||||||
u = session["user"]
|
u = session["user"]
|
||||||
hid = current_household_id()
|
hid = current_household_id()
|
||||||
if not hid:
|
if not hid:
|
||||||
return jsonify({"error": "no household"}), 409
|
return jsonify({"error": "no household"}), 409
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
kind = (body.get("kind") or "library").strip().lower()
|
||||||
|
|
||||||
|
if kind == "discover":
|
||||||
|
try:
|
||||||
|
discover_id = int(body.get("discover_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "missing discover_id"}), 400
|
||||||
|
row = db.get_discovered_recipe(discover_id)
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "discover_not_found"}), 404
|
||||||
|
if row.get("status") == "rejected":
|
||||||
|
return jsonify({"error": "row_rejected"}), 409
|
||||||
|
|
||||||
|
# If THIS household has already imported this discover row we
|
||||||
|
# short-circuit to its existing Mealie slug — no double-import,
|
||||||
|
# no Mealie 'Cookies (1)' duplicate.
|
||||||
|
existing = db.discover_imported_by_household(
|
||||||
|
discover_id=discover_id, household_id=hid
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
new_slug = existing["mealie_slug"]
|
||||||
|
else:
|
||||||
|
client = current_user_mealie()
|
||||||
|
if client is None:
|
||||||
|
return jsonify({"error": "mealie_not_connected"}), 409
|
||||||
|
try:
|
||||||
|
new_slug = client.import_from_url(row["source_url"])
|
||||||
|
except MealieError as e:
|
||||||
|
return jsonify({"error": "mealie_import_failed", "detail": str(e)[:300]}), 502
|
||||||
|
db.record_discover_import(
|
||||||
|
discover_id=discover_id,
|
||||||
|
household_id=hid,
|
||||||
|
mealie_slug=new_slug,
|
||||||
|
imported_by_sub=u["sub"],
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = row.get("name") or new_slug
|
||||||
|
added = db.add_meal_pick(u["sub"], new_slug, display_name)
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"kind": "discover",
|
||||||
|
"mealie_slug": new_slug,
|
||||||
|
"imported": existing is None,
|
||||||
|
"added_to_picks": added,
|
||||||
|
})
|
||||||
|
|
||||||
|
# kind = library (default)
|
||||||
slug = (body.get("recipe_slug") or "").strip()
|
slug = (body.get("recipe_slug") or "").strip()
|
||||||
if not slug:
|
if not slug:
|
||||||
return jsonify({"error": "missing recipe_slug"}), 400
|
return jsonify({"error": "missing recipe_slug"}), 400
|
||||||
|
|
@ -1233,7 +1383,12 @@ def create_app() -> Flask:
|
||||||
if not idx:
|
if not idx:
|
||||||
return jsonify({"error": "recipe_not_indexed"}), 404
|
return jsonify({"error": "recipe_not_indexed"}), 404
|
||||||
added = db.add_meal_pick(u["sub"], slug, idx.get("name") or slug)
|
added = db.add_meal_pick(u["sub"], slug, idx.get("name") or slug)
|
||||||
return jsonify({"ok": True, "added": added})
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"kind": "library",
|
||||||
|
"recipe_slug": slug,
|
||||||
|
"added_to_picks": added,
|
||||||
|
})
|
||||||
|
|
||||||
@app.get("/list")
|
@app.get("/list")
|
||||||
@require_session
|
@require_session
|
||||||
|
|
|
||||||
|
|
@ -468,11 +468,13 @@
|
||||||
<section class="panel" id="suggest-panel">
|
<section class="panel" id="suggest-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>forgotten gems</h2>
|
<h2>forgotten gems</h2>
|
||||||
<span class="ctx">recipes you haven't seen in 90+ days</span>
|
<span class="ctx">your library + new finds from discover</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 4px 0 12px 0; opacity: 0.8;">
|
<p style="margin: 4px 0 12px 0; opacity: 0.8;">
|
||||||
let hecate look through your library for recipes that fit who you are
|
let hecate look through your library for recipes that fit who you are
|
||||||
but haven't crossed your table recently.
|
but haven't crossed your table recently — and pull in fresh enriched
|
||||||
|
finds from /discover the family hasn't imported yet. pinning a discover
|
||||||
|
pick auto-imports it to your mealie household.
|
||||||
</p>
|
</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-purple" type="button" id="suggest-btn" onclick="askHecate(this, 3)">🔮 suggest 3 gems</button>
|
<button class="btn btn-purple" type="button" id="suggest-btn" onclick="askHecate(this, 3)">🔮 suggest 3 gems</button>
|
||||||
|
|
@ -691,22 +693,43 @@ function _renderSuggestion(s) {
|
||||||
if (meta.primary_protein) metaBits.push('protein: ' + _escapeHtml(meta.primary_protein));
|
if (meta.primary_protein) metaBits.push('protein: ' + _escapeHtml(meta.primary_protein));
|
||||||
if (meta.comfort_tier) metaBits.push('comfort: ' + _escapeHtml(meta.comfort_tier));
|
if (meta.comfort_tier) metaBits.push('comfort: ' + _escapeHtml(meta.comfort_tier));
|
||||||
const metaLine = metaBits.length ? `<div class="ctx" style="margin: 4px 0;">${metaBits.join(' · ')}</div>` : '';
|
const metaLine = metaBits.length ? `<div class="ctx" style="margin: 4px 0;">${metaBits.join(' · ')}</div>` : '';
|
||||||
const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household';
|
const isDiscover = s.kind === 'discover';
|
||||||
const img = s.image_url ? `<img src="${_escapeHtml(s.image_url)}" alt="" style="width: 100%; max-height: 140px; object-fit: cover; border-radius: 6px; margin-bottom: 8px;">` : '';
|
const img = s.image_url ? `<img src="${_escapeHtml(s.image_url)}" alt="" style="width: 100%; max-height: 140px; object-fit: cover; border-radius: 6px; margin-bottom: 8px;">` : '';
|
||||||
const quip = s.hecate_quip ? `<div style="font-style: italic; opacity: 0.85; margin: 6px 0;">"${_escapeHtml(s.hecate_quip)}"</div>` : '';
|
const quip = s.hecate_quip ? `<div style="font-style: italic; opacity: 0.85; margin: 6px 0;">"${_escapeHtml(s.hecate_quip)}"</div>` : '';
|
||||||
|
// Source-aware footer line: for library entries, show last-planned recall;
|
||||||
|
// for discover entries, show source host so the user knows where it came from.
|
||||||
|
let footer;
|
||||||
|
if (isDiscover) {
|
||||||
|
const host = s.source_host || 'web';
|
||||||
|
footer = `<div class="ctx" style="margin: 4px 0 8px 0; color: var(--purple-bright);">📬 from Discover · ${_escapeHtml(host)}</div>`;
|
||||||
|
} else {
|
||||||
|
const lastSeen = s.last_planned ? `last planned ${s.last_planned}` : 'never planned in this household';
|
||||||
|
footer = `<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div>`;
|
||||||
|
}
|
||||||
|
// Identifier used for the pin click + DOM data attr. Library uses recipe_slug;
|
||||||
|
// discover uses discover_id (passed back to the pin endpoint as kind='discover').
|
||||||
|
const ident = isDiscover ? `disc-${s.discover_id}` : (s.recipe_slug || '');
|
||||||
|
const pinAttr = isDiscover
|
||||||
|
? `data-kind="discover" data-discover-id="${s.discover_id}"`
|
||||||
|
: `data-kind="library" data-slug="${_escapeHtml(s.recipe_slug)}"`;
|
||||||
|
// Discover entries don't have a Mealie page yet → no clickable name link.
|
||||||
|
const nameHtml = isDiscover
|
||||||
|
? `<span class="rname" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</span>`
|
||||||
|
: `<a class="rname" href="/recipes/${_escapeHtml(s.recipe_slug)}" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</a>`;
|
||||||
|
const pinLabel = isDiscover ? '📌 import + pin' : '📌 pin to picks';
|
||||||
return `
|
return `
|
||||||
<div class="day-card" data-slug="${_escapeHtml(s.recipe_slug)}" style="margin-bottom: 12px;">
|
<div class="day-card" data-ident="${_escapeHtml(ident)}" style="margin-bottom: 12px;">
|
||||||
${img}
|
${img}
|
||||||
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 8px;">
|
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 8px;">
|
||||||
<a class="rname" href="/recipes/${_escapeHtml(s.recipe_slug)}" style="font-weight: 600;">${_escapeHtml(s.recipe_name)}</a>
|
${nameHtml}
|
||||||
<span title="hecate's fit score (1-5)" style="font-size: 0.85em; opacity: 0.75; white-space: nowrap;">${fitDots}</span>
|
<span title="hecate's fit score (1-5)" style="font-size: 0.85em; opacity: 0.75; white-space: nowrap;">${fitDots}</span>
|
||||||
</div>
|
</div>
|
||||||
${metaLine}
|
${metaLine}
|
||||||
${quip}
|
${quip}
|
||||||
<div style="margin: 6px 0; opacity: 0.9;">${_escapeHtml(s.reason || '')}</div>
|
<div style="margin: 6px 0; opacity: 0.9;">${_escapeHtml(s.reason || '')}</div>
|
||||||
<div class="ctx" style="margin: 4px 0 8px 0;">${_escapeHtml(lastSeen)}</div>
|
${footer}
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-purple" type="button" onclick="pinSuggestion(this, '${_escapeHtml(s.recipe_slug)}')">📌 pin to picks</button>
|
<button class="btn btn-purple" type="button" ${pinAttr} onclick="pinSuggestion(this)">${pinLabel}</button>
|
||||||
<button class="btn" type="button" onclick="this.closest('.day-card').style.display='none'">✗ skip</button>
|
<button class="btn" type="button" onclick="this.closest('.day-card').style.display='none'">✗ skip</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -744,19 +767,31 @@ async function askHecate(btn, count) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pinSuggestion(btn, slug) {
|
async function pinSuggestion(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const original = btn.textContent;
|
const original = btn.textContent;
|
||||||
|
const kind = btn.dataset.kind || 'library';
|
||||||
|
let body;
|
||||||
|
if (kind === 'discover') {
|
||||||
|
btn.textContent = 'importing…';
|
||||||
|
body = { kind: 'discover', discover_id: parseInt(btn.dataset.discoverId, 10) };
|
||||||
|
} else {
|
||||||
btn.textContent = '…';
|
btn.textContent = '…';
|
||||||
|
body = { kind: 'library', recipe_slug: btn.dataset.slug };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/plan/suggest/pin', {
|
const r = await fetch('/api/plan/suggest/pin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ recipe_slug: slug }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
if (!r.ok) throw new Error(data.detail || data.error || r.status);
|
if (!r.ok) throw new Error(data.detail || data.error || r.status);
|
||||||
btn.textContent = data.added ? '✓ pinned' : '✓ already pinned';
|
if (data.kind === 'discover') {
|
||||||
|
btn.textContent = data.imported ? '✓ imported & pinned' : '✓ already in library — pinned';
|
||||||
|
} else {
|
||||||
|
btn.textContent = data.added_to_picks ? '✓ pinned' : '✓ already pinned';
|
||||||
|
}
|
||||||
btn.classList.remove('btn-purple');
|
btn.classList.remove('btn-purple');
|
||||||
// Leave the card visible so user can see it pinned, but disable further pinning
|
// Leave the card visible so user can see it pinned, but disable further pinning
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue