brewlog/templates/pages/home.html
Jon Seager 202dafb9b9
feat(scan): show cards for existing roasters/roasts during bag scan
After AI extraction, check if the roaster and roast already exist by
slug. When matched, show compact summary cards instead of full edit
forms. Each card has a "Change" link to revert to the form if the
match is wrong.

- Add match_existing_entities() for slug-based roaster/roast lookup
- Return _matched-roaster-id and _matched-roast-id signals from extraction
- Add submit_existing_roast() path to skip creation when roast exists
- Dynamic submit buttons: Save Roaster & Roast / Save Roast / Open Bag
- Hidden inputs bound to signals handle all form submission cleanly
2026-02-06 12:27:25 +00:00

221 lines
11 KiB
HTML

{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import "partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% block title %}Brewlog{% endblock %}
{% block head %}
{% if is_authenticated %}
<script>
const closeBag = async (bagId, cardEl) => {
if (!confirm('Close this bag? This will mark it as finished.')) return;
try {
const today = new Date().toISOString().split('T')[0];
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
cardEl.remove();
const grid = document.getElementById('open-bags-grid');
if (grid && grid.children.length === 0) {
document.getElementById('open-bags-section')?.remove();
}
} catch (e) {
alert(`Failed to close bag: ${e.message}`);
}
};
</script>
{% endif %}
{% endblock %}
{% block content %}
<!-- Scan Bag -->
{% if is_authenticated %}
<section
data-signals:_extracting="false"
data-signals:_extract-error="''"
data-signals:_scan-extracted="false"
data-signals:_scan-submitting="false"
data-signals:_scan-error="''"
data-signals:_roaster-name="''"
data-signals:_roaster-country="''"
data-signals:_roaster-city="''"
data-signals:_roaster-homepage="''"
data-signals:_roast-name="''"
data-signals:_origin="''"
data-signals:_region="''"
data-signals:_producer="''"
data-signals:_process="''"
data-signals:_tasting-notes="''"
data-signals:_open-bag="true"
data-signals:_bag-amount="250"
data-signals:_matched-roaster-id="''"
data-signals:_matched-roast-id="''"
>
<!-- Input: photo or text (shown when not yet extracted) -->
<div data-show="!$_scanExtracted" class="rounded-lg border bg-surface p-5">
<form id="scan-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
>
{% call scan::scan_input("scan-extract-form", "scan-image", "$_extracting", "$_extractError", "Describe the coffee bag…", "Waiting for response…") %}
</form>
</div>
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
<div data-show="$_scanExtracted" style="display: none">
<form
class="rounded-lg border bg-surface p-5 flex flex-col gap-6"
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
>
{% include "partials/forms/scan_result_form.html" %}
</form>
</div>
</section>
{% endif %}
<!-- Recent Brews -->
{% if !recent_brews.is_empty() %}
<section data-signals:_brewing="false">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-text">Recent Brews</h2>
<a href="/data?type=brews" class="text-sm text-accent hover:text-accent-hover font-medium">View all brews &rarr;</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
{% for brew in recent_brews %}
<div class="rounded-lg border bg-surface p-4 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="block truncate font-semibold text-text">{{ brew.roast_name }}</span>
<p class="truncate text-sm text-text-muted">{{ brew.roaster_name }}</p>
</div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0">{{ brew.relative_date_label }}</time>
</div>
<div class="mt-2 space-y-0.5 text-sm">
<p class="truncate"><span class="font-medium text-text">{{ brew.coffee_weight }} · {{ brew.water_volume }} · {{ brew.water_temp }}</span> <span class="text-text-muted">· {{ brew.ratio }}</span></p>
<p class="truncate"><span class="font-medium text-text">{{ brew.grind_setting }}</span> <span class="text-text-muted">· {{ brew.grinder_name }}</span></p>
<p class="truncate"><span class="font-medium text-text">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-text-muted">· {{ fp }}</span>{% endif %}</p>
</div>
<div class="mt-3 pt-3 border-t flex items-center justify-between gap-3">
<div class="flex flex-wrap gap-1">
{% if !brew.quick_notes.is_empty() %}
{% for qn in brew.quick_notes %}
<span class="{{ qn.pill_class }}">{{ qn.label }}</span>
{% endfor %}
{% else %}
<span class="pill pill-muted">No Notes</span>
{% endif %}
</div>
{% if is_authenticated %}
<form
class="inline shrink-0"
data-on:submit="$_brewing = true; @post('/api/v1/brews', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_brewing) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_brewing = false }"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
{% if let Some(fp_id) = brew.filter_paper_id %}
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<input type="hidden" name="quick_notes" value="{{ brew.quick_notes_raw }}" />
<button
type="submit"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-accent transition hover:text-accent-hover hover:bg-surface-alt"
title="Brew Again"
>
{% call icons::refresh("h-4 w-4") %}
</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Open Bags -->
{% if !open_bags.is_empty() %}
<section id="open-bags-section">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-text">Open Bags</h2>
<a href="/data?type=bags" class="text-sm text-accent hover:text-accent-hover font-medium">View all bags &rarr;</a>
</div>
<div id="open-bags-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for bag in open_bags %}
{% call bag_card::card(bag, is_authenticated) %}
{% endfor %}
</div>
</section>
{% endif %}
<!-- Recent Activity -->
{% if !recent_events.is_empty() %}
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-text">Recent Activity</h2>
<a href="/timeline" class="text-sm text-accent hover:text-accent-hover font-medium">View full timeline &rarr;</a>
</div>
<div class="space-y-2">
{% for event in recent_events %}
<div class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="pill pill-muted w-28 shrink-0 justify-center whitespace-nowrap">{{ event.kind_label }}</span>
<p class="min-w-0 truncate text-sm"><a href="{{ event.link }}" class="font-medium text-text hover:text-accent">{{ event.title }}</a>{% if let Some(sub) = event.subtitle %} <span class="text-xs text-text-muted italic">· {{ sub }}</span>{% endif %}</p>
</div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Stats -->
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Stats</h2>
</div>
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-7">
<a href="/data?type=brews" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::beaker("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.brews }}</span>
<span class="text-xs text-text-muted">Brews</span>
</a>
<a href="/data?type=roasts" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::fire("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.roasts }}</span>
<span class="text-xs text-text-muted">Roasts</span>
</a>
<a href="/data?type=roasters" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::building("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.roasters }}</span>
<span class="text-xs text-text-muted">Roasters</span>
</a>
<a href="/data?type=bags" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::bag("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.bags }}</span>
<span class="text-xs text-text-muted">Bags</span>
</a>
<a href="/data?type=cups" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::cup("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.cups }}</span>
<span class="text-xs text-text-muted">Cups</span>
</a>
<a href="/data?type=cafes" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::location("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.cafes }}</span>
<span class="text-xs text-text-muted">Cafes</span>
</a>
<a href="/data?type=gear" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::wrench("h-6 w-6 text-accent") %}
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.gear }}</span>
<span class="text-xs text-text-muted">Gear</span>
</a>
</div>
</section>
{% endblock %}