From a3aba1b02aff3cf9d7dbf4a9ab0bddfc36b772e8 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Wed, 4 Feb 2026 12:55:08 +0000 Subject: [PATCH] feat(home): replace scan page with home dashboard and check-in flow - Add home page at / with scan, last brew, open bags, activity, stats - Add check-in page at /check-in with cafe search + roast scan + rating - Use Datastar signals for check-in UI state (steps, rating, selection) - Bridge async JS (geolocation, fetch) to Datastar via custom events - Replace nav camera icon with house icon (always visible) - Redirect /scan to / for backward compatibility - Delete scan.html, add home.html, checkin.html, checkin.js --- src/application/routes/checkin.rs | 34 +++ src/application/routes/home.rs | 192 ++++++++++++++ src/application/routes/mod.rs | 19 +- src/application/routes/scan.rs | 27 +- src/application/server.rs | 4 + src/presentation/web/templates.rs | 22 +- src/presentation/web/views/mod.rs | 10 + templates/checkin.html | 273 ++++++++++++++++++++ templates/checkin.js | 257 +++++++++++++++++++ templates/home.html | 409 ++++++++++++++++++++++++++++++ templates/nav.html | 16 +- templates/scan.html | 211 --------------- 12 files changed, 1223 insertions(+), 251 deletions(-) create mode 100644 src/application/routes/checkin.rs create mode 100644 src/application/routes/home.rs create mode 100644 templates/checkin.html create mode 100644 templates/checkin.js create mode 100644 templates/home.html delete mode 100644 templates/scan.html diff --git a/src/application/routes/checkin.rs b/src/application/routes/checkin.rs new file mode 100644 index 0000000..5bb9457 --- /dev/null +++ b/src/application/routes/checkin.rs @@ -0,0 +1,34 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; + +use crate::application::errors::map_app_error; +use crate::application::routes::render_html; +use crate::application::routes::support::{load_cafe_options, load_roast_options}; +use crate::application::server::AppState; +use crate::presentation::web::templates::CheckInTemplate; + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn checkin_page( + State(state): State, + cookies: tower_cookies::Cookies, +) -> Result { + let is_authenticated = super::is_authenticated(&state, &cookies).await; + if !is_authenticated { + return Ok(Redirect::to("/login").into_response()); + } + + let roast_options = load_roast_options(&state).await.map_err(map_app_error)?; + let cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?; + + let template = CheckInTemplate { + nav_active: "home", + is_authenticated: true, + has_ai_extract: state.has_ai_extract(), + has_foursquare: state.has_foursquare(), + roast_options, + cafe_options, + }; + + render_html(template).map(IntoResponse::into_response) +} diff --git a/src/application/routes/home.rs b/src/application/routes/home.rs new file mode 100644 index 0000000..a94a2a4 --- /dev/null +++ b/src/application/routes/home.rs @@ -0,0 +1,192 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +use crate::application::errors::{AppError, map_app_error}; +use crate::application::routes::render_html; +use crate::application::server::AppState; +use crate::domain::bags::{BagFilter, BagSortKey}; +use crate::domain::brews::{BrewFilter, BrewSortKey}; +use crate::domain::cafes::CafeSortKey; +use crate::domain::cups::CupFilter; +use crate::domain::gear::GearFilter; +use crate::domain::listing::{ListRequest, PageSize, SortDirection, SortKey}; +use crate::domain::roasters::RoasterSortKey; +use crate::domain::roasts::RoastSortKey; +use crate::domain::timeline::TimelineSortKey; +use crate::presentation::web::templates::HomeTemplate; +use crate::presentation::web::views::{BagView, BrewView, StatsView, TimelineEventView}; + +#[allow(clippy::similar_names)] +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn home_page( + State(state): State, + cookies: tower_cookies::Cookies, +) -> Result { + let is_authenticated = super::is_authenticated(&state, &cookies).await; + + let (content, stats) = + tokio::try_join!(load_home_content(&state), load_stats(&state),).map_err(map_app_error)?; + + let template = HomeTemplate { + nav_active: "home", + is_authenticated, + has_ai_extract: state.has_ai_extract(), + has_foursquare: state.has_foursquare(), + last_brew: content.last_brew, + open_bags: content.open_bags, + recent_events: content.recent_events, + stats, + }; + + render_html(template).map(IntoResponse::into_response) +} + +struct HomeContent { + last_brew: Option, + open_bags: Vec, + recent_events: Vec, +} + +/// Build a `ListRequest` that fetches page 1 with 1 item, using a sort key's +/// defaults. Used to obtain `Page.total` for entity counts. +fn count_request() -> ListRequest { + let key = K::default(); + ListRequest::new(1, PageSize::limited(1), key, key.default_direction()) +} + +async fn load_home_content(state: &AppState) -> Result { + let last_brew_req = ListRequest::new( + 1, + PageSize::limited(1), + BrewSortKey::CreatedAt, + SortDirection::Desc, + ); + let open_bags_req = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc); + let recent_events_req = ListRequest::new( + 1, + PageSize::limited(5), + TimelineSortKey::default(), + TimelineSortKey::default().default_direction(), + ); + + let (last_brew_page, open_bags_page, recent_events_page) = tokio::try_join!( + async { + state + .brew_repo + .list(BrewFilter::all(), &last_brew_req, None) + .await + .map_err(AppError::from) + }, + async { + state + .bag_repo + .list(BagFilter::open(), &open_bags_req, None) + .await + .map_err(AppError::from) + }, + async { + state + .timeline_repo + .list(&recent_events_req) + .await + .map_err(AppError::from) + }, + )?; + + let last_brew = last_brew_page + .items + .into_iter() + .next() + .map(BrewView::from_domain); + + let open_bags = open_bags_page + .items + .into_iter() + .map(BagView::from_domain) + .collect(); + + let recent_events = recent_events_page + .items + .into_iter() + .map(TimelineEventView::from_domain) + .collect(); + + Ok(HomeContent { + last_brew, + open_bags, + recent_events, + }) +} + +async fn load_stats(state: &AppState) -> Result { + let req_roasters: ListRequest = count_request(); + let req_roasts: ListRequest = count_request(); + let req_bags: ListRequest = count_request(); + let req_brews: ListRequest = count_request(); + let req_gear: ListRequest = count_request(); + let req_cafes: ListRequest = count_request(); + let req_cups: ListRequest = count_request(); + + let (roasters, roasts, bags, brews, gear, cafes, cups) = tokio::try_join!( + async { + state + .roaster_repo + .list(&req_roasters, None) + .await + .map_err(AppError::from) + }, + async { + state + .roast_repo + .list(&req_roasts, None) + .await + .map_err(AppError::from) + }, + async { + state + .bag_repo + .list(BagFilter::all(), &req_bags, None) + .await + .map_err(AppError::from) + }, + async { + state + .brew_repo + .list(BrewFilter::all(), &req_brews, None) + .await + .map_err(AppError::from) + }, + async { + state + .gear_repo + .list(GearFilter::all(), &req_gear, None) + .await + .map_err(AppError::from) + }, + async { + state + .cafe_repo + .list(&req_cafes, None) + .await + .map_err(AppError::from) + }, + async { + state + .cup_repo + .list(CupFilter::all(), &req_cups, None) + .await + .map_err(AppError::from) + }, + )?; + + Ok(StatsView { + brews: brews.total, + roasts: roasts.total, + roasters: roasters.total, + cups: cups.total, + cafes: cafes.total, + bags: bags.total, + gear: gear.total, + }) +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 7fbddba..2a6cd7d 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -2,8 +2,10 @@ pub mod auth; pub mod bags; pub mod brews; pub mod cafes; +pub mod checkin; pub mod cups; pub mod gear; +pub mod home; mod macros; pub mod roasters; pub mod roasts; @@ -94,7 +96,7 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/tokens/:id/revoke", post(tokens::revoke_token)); axum::Router::new() - .route("/", get(root_redirect)) + .route("/", get(home::home_page)) .route("/login", get(auth::login_page).post(auth::login_submit)) .route("/logout", post(auth::logout)) .route("/roasters", get(roasters::roasters_page)) @@ -110,18 +112,20 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/cafes", get(cafes::cafes_page)) .route("/cafes/:slug", get(cafes::cafe_page)) .route("/cups", get(cups::cups_page)) - .route("/scan", get(scan::scan_page)) + .route("/scan", get(scan_redirect)) + .route("/check-in", get(checkin::checkin_page)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) .route("/extract.js", get(extract_js)) + .route("/checkin.js", get(checkin_js)) .route("/favicon.ico", get(favicon)) .nest("/api/v1", api_routes) .layer(ServiceBuilder::new().layer(CookieManagerLayer::new())) .with_state(state) } -async fn root_redirect() -> Redirect { - Redirect::temporary("/timeline") +async fn scan_redirect() -> Redirect { + Redirect::permanent("/") } async fn styles() -> impl IntoResponse { @@ -138,6 +142,13 @@ async fn extract_js() -> impl IntoResponse { ) } +async fn checkin_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript; charset=utf-8")], + include_str!("../../../templates/checkin.js"), + ) +} + async fn favicon() -> impl IntoResponse { ( [("content-type", "image/x-icon")], diff --git a/src/application/routes/scan.rs b/src/application/routes/scan.rs index b009514..fb5fa49 100644 --- a/src/application/routes/scan.rs +++ b/src/application/routes/scan.rs @@ -1,38 +1,17 @@ use axum::Json; use axum::extract::State; use axum::http::StatusCode; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; -use crate::application::routes::render_html; use crate::application::routes::roasts::TastingNotesInput; use crate::application::server::AppState; use crate::domain::errors::RepositoryError; use crate::domain::roasters::NewRoaster; use crate::domain::roasts::NewRoast; use crate::infrastructure::ai::{self, ExtractedBagScan, ExtractionInput}; -use crate::presentation::web::templates::ScanTemplate; - -#[tracing::instrument(skip(state, cookies))] -pub(crate) async fn scan_page( - State(state): State, - cookies: tower_cookies::Cookies, -) -> Result { - let is_authenticated = super::is_authenticated(&state, &cookies).await; - if !is_authenticated || !state.has_ai_extract() { - return Ok(Redirect::to("/timeline").into_response()); - } - - let template = ScanTemplate { - nav_active: "scan", - is_authenticated: true, - has_ai_extract: true, - }; - - render_html(template).map(IntoResponse::into_response) -} #[tracing::instrument(skip(state, _auth_user))] pub(crate) async fn extract_bag_scan( @@ -69,6 +48,7 @@ pub(crate) struct BagScanSubmission { #[derive(Debug, Serialize)] struct ScanResult { redirect: String, + roast_id: i64, } #[tracing::instrument(skip(state, _auth_user))] @@ -131,5 +111,6 @@ pub(crate) async fn submit_scan( .map_err(AppError::from)?; let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug); - Ok((StatusCode::CREATED, Json(ScanResult { redirect })).into_response()) + let roast_id = roast.id.into_inner(); + Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response()) } diff --git a/src/application/server.rs b/src/application/server.rs index af385d8..d55dc2e 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -100,6 +100,10 @@ impl AppState { pub fn has_ai_extract(&self) -> bool { self.openrouter_api_key.is_some() } + + pub fn has_foursquare(&self) -> bool { + self.foursquare_api_key.is_some() + } } pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 611fb43..8af3164 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -3,7 +3,7 @@ use askama::Template; use super::views::{ BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView, GearOptionView, GearView, ListNavigator, Paginated, RoastOptionView, RoastView, - RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, + RoasterOptionView, RoasterView, StatsView, TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; use crate::domain::brews::BrewSortKey; @@ -207,11 +207,27 @@ pub struct CupListTemplate { } #[derive(Template)] -#[template(path = "scan.html")] -pub struct ScanTemplate { +#[template(path = "home.html")] +pub struct HomeTemplate { pub nav_active: &'static str, pub is_authenticated: bool, pub has_ai_extract: bool, + pub has_foursquare: bool, + pub last_brew: Option, + pub open_bags: Vec, + pub recent_events: Vec, + pub stats: StatsView, +} + +#[derive(Template)] +#[template(path = "checkin.html")] +pub struct CheckInTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub has_ai_extract: bool, + pub has_foursquare: bool, + pub roast_options: Vec, + pub cafe_options: Vec, } pub fn render_template(template: T) -> Result { diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 8f9504f..da91c3e 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -18,6 +18,16 @@ pub use timeline::{ TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, }; +pub struct StatsView { + pub brews: u64, + pub roasts: u64, + pub roasters: u64, + pub cups: u64, + pub cafes: u64, + pub bags: u64, + pub gear: u64, +} + use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; pub struct Paginated { diff --git a/templates/checkin.html b/templates/checkin.html new file mode 100644 index 0000000..2e201d6 --- /dev/null +++ b/templates/checkin.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} {% block title %}Brewlog · Check In{% endblock %} + +{% block head %} + +{% if has_ai_extract %} + +{% endif %} +{% endblock %} + +{% block content %} +
+
+

Check In

+

+ Record a cup of coffee at a cafe. +

+
+ + + + + +
+ 1. Cafe + 2. Coffee + 3. Rate +
+ + + + + + + + +
+
+

What are you drinking?

+ + {% if has_ai_extract %} + + + +
+

Or select an existing roast:

+ {% else %} +
+ {% endif %} + +
+
+
+ + +
+
+

How was it? (optional)

+
+ {% for i in 1..=5 %} + + {% endfor %} +
+ +
+
+
+{% endblock %} diff --git a/templates/checkin.js b/templates/checkin.js new file mode 100644 index 0000000..69e71cb --- /dev/null +++ b/templates/checkin.js @@ -0,0 +1,257 @@ +// Check-in page — minimal JS for browser APIs and async operations. +// All state management and UI logic lives in Datastar signals (checkin.html). +// JS dispatches custom events to bridge async results back to Datastar. + +const _root = () => document.getElementById('checkin-root'); + +const emit = (name, detail = {}) => + _root().dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); + +// --- State mirror --- +// Tracks signal values via event listeners so submitCheckIn() can read them +// without accessing Datastar internals. + +let _cafeId = ''; +let _cafeName = ''; +let _cafeCity = ''; +let _cafeCountry = ''; +let _cafeLat = 0; +let _cafeLng = 0; +let _cafeWebsite = ''; +let _roastId = ''; +let _rating = 0; +let _submitting = false; + +document.addEventListener('DOMContentLoaded', () => { + const root = _root(); + if (!root) return; + + root.addEventListener('cafe-selected', (e) => { + _cafeId = String(e.detail.id); + _cafeName = e.detail.name; + _cafeCity = e.detail.city || ''; + _cafeCountry = e.detail.country || ''; + _cafeLat = e.detail.lat || 0; + _cafeLng = e.detail.lng || 0; + _cafeWebsite = e.detail.website || ''; + }); + + root.addEventListener('scan-complete', (e) => { + _roastId = e.detail.roastId; + }); + + // Track roast selection from the dropdown + root.addEventListener('change', (e) => { + if (e.target.tagName === 'SELECT' && e.target.value) { + _roastId = e.target.value; + } + }); + + // Track rating clicks via aria-label convention + root.addEventListener('click', (e) => { + const star = e.target.closest('[aria-label^="Rate"]'); + if (!star) return; + const match = star.getAttribute('aria-label')?.match(/Rate (\d)/); + if (match) _rating = parseInt(match[1], 10); + }); +}); + +// --- Geolocation (browser API) --- + +const locateUser = () => { + if (!navigator.geolocation) { + emit('location-error', { message: 'Geolocation is not supported by your browser.' }); + return; + } + + emit('location-start'); + + navigator.geolocation.getCurrentPosition( + (pos) => emit('location-found', { lat: pos.coords.latitude, lng: pos.coords.longitude }), + (err) => emit('location-error', { + message: err.code === 1 + ? 'Location access denied. You can search by name instead.' + : 'Could not determine your location. You can search by name instead.', + }), + { enableHighAccuracy: true, timeout: 15000 }, + ); +}; + +// --- Nearby cafe search (Foursquare API) --- + +let _nearbyCafes = []; + +const searchNearbyCafes = async (query, lat, lng) => { + if (!lat || !query || query.length < 2) return; + + try { + const resp = await fetch( + `/api/v1/nearby-cafes?lat=${lat}&lng=${lng}&q=${encodeURIComponent(query)}`, + { credentials: 'same-origin' }, + ); + if (!resp.ok) throw new Error(`${resp.status}`); + + const cafes = await resp.json(); + _nearbyCafes = cafes; + renderNearbyCafes(cafes); + } catch { + emit('checkin-error', { message: 'Nearby search failed. Please try again.' }); + } +}; + +const renderNearbyCafes = (cafes) => { + const el = document.getElementById('nearby-results'); + + if (!cafes.length) { + el.innerHTML = '

No nearby cafes found.

'; + } else { + let html = '

Nearby

'; + cafes.forEach((cafe, i) => { + const dist = cafe.distance_meters < 1000 + ? `${cafe.distance_meters} m` + : `${(cafe.distance_meters / 1000).toFixed(1)} km`; + const loc = [cafe.city, cafe.country].filter(Boolean).join(', '); + html += ``; + }); + el.innerHTML = html; + } + el.classList.remove('hidden'); +}; + +const selectNearby = (i) => { + const c = _nearbyCafes[i]; + if (!c) return; + emit('cafe-selected', { + id: '', + name: c.name, + city: c.city || '', + country: c.country || '', + lat: c.latitude || 0, + lng: c.longitude || 0, + website: c.website || '', + }); +}; + +// --- Client-side cafe filtering --- + +const filterExistingCafes = (query) => { + const container = document.getElementById('existing-cafes'); + if (!container) return; + const lower = query.toLowerCase(); + let visible = 0; + container.querySelectorAll('[data-cafe-name]').forEach((btn) => { + const show = !query || btn.dataset.cafeName.toLowerCase().includes(lower); + btn.style.display = show ? '' : 'none'; + if (show) visible++; + }); + const noMatch = container.querySelector('[data-no-match]'); + if (noMatch) noMatch.style.display = query && !visible ? '' : 'none'; +}; + +// --- Scan callback (extract.js integration) --- + +const onScanExtracted = async (data) => { + emit('scan-start'); + + const body = { + roaster_name: data.roaster?.name || '', + roaster_country: data.roaster?.country || '', + roaster_city: data.roaster?.city || '', + roaster_homepage: data.roaster?.homepage || '', + roast_name: data.roast?.name || '', + origin: data.roast?.origin || '', + region: data.roast?.region || '', + producer: data.roast?.producer || '', + process: data.roast?.process || '', + tasting_notes: (data.roast?.tasting_notes || []).join(', '), + }; + + try { + const resp = await fetch('/api/v1/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `Server returned ${resp.status}`); + } + const result = await resp.json(); + emit('scan-complete', { roastId: String(result.roast_id), name: body.roast_name }); + } catch (e) { + emit('scan-error', { message: `Scan failed: ${e.message}` }); + } +}; + +// --- Submit check-in --- + +const submitCheckIn = async () => { + if (_submitting) return; + if (!_cafeName) { emit('checkin-error', { message: 'Please select a cafe.' }); return; } + if (!_roastId) { emit('checkin-error', { message: 'Please select or scan a coffee.' }); return; } + + _submitting = true; + emit('submit-start'); + + try { + let cafeId = _cafeId; + + // Create cafe from Foursquare if no existing ID + if (!cafeId) { + const cafeResp = await fetch('/api/v1/cafes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + name: _cafeName, + city: _cafeCity || null, + country: _cafeCountry || null, + latitude: _cafeLat || null, + longitude: _cafeLng || null, + website: _cafeWebsite || null, + }), + }); + if (!cafeResp.ok) { + const err = await cafeResp.json().catch(() => ({})); + throw new Error(err.message || `Failed to create cafe (${cafeResp.status})`); + } + const newCafe = await cafeResp.json(); + cafeId = String(newCafe.id); + } + + const cupBody = { + roast_id: parseInt(_roastId, 10), + cafe_id: parseInt(cafeId, 10), + }; + if (_rating) cupBody.rating = _rating; + + const cupResp = await fetch('/api/v1/cups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(cupBody), + }); + if (!cupResp.ok) { + const err = await cupResp.json().catch(() => ({})); + throw new Error(err.message || `Failed to create cup (${cupResp.status})`); + } + + window.location.href = '/'; + } catch (e) { + _submitting = false; + emit('checkin-error', { message: `Check-in failed: ${e.message}` }); + emit('submit-error'); + } +}; + +// --- Utility --- + +const esc = (t) => { + const el = document.createElement('span'); + el.textContent = t; + return el.innerHTML; +}; diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..570125e --- /dev/null +++ b/templates/home.html @@ -0,0 +1,409 @@ +{% extends "base.html" %} {% block title %}Brewlog{% endblock %} + +{% block head %} +{% if is_authenticated && has_ai_extract %} + + +{% endif %} +{% endblock %} + +{% block content %} + +{% if is_authenticated %} +
+ {% if has_ai_extract %} + + {% endif %} + + + Check In + +
+ + +{% if has_ai_extract %} + +{% endif %} +{% endif %} + + +{% if let Some(brew) = last_brew %} +
+
+

Last Brew

+ {{ brew.created_at }} +
+
+
+ {{ brew.roast_name }} + {{ brew.roaster_name }} +
+
+ {{ brew.coffee_weight }} + {{ brew.water_volume }} @ {{ brew.water_temp }} + {{ brew.ratio }} + {{ brew.grinder_name }} @ {{ brew.grind_setting }} + {{ brew.brewer_name }} + {% if let Some(fp) = brew.filter_paper_name %} + {{ fp }} + {% endif %} +
+ {% if is_authenticated %} +
+
+ + + + + + {% if let Some(fp_id) = brew.filter_paper_id %} + + {% endif %} + + + +
+
+ {% endif %} +
+
+{% endif %} + + +{% if !open_bags.is_empty() %} +
+
+

Open Bags

+ View all bags → +
+
+ {% for bag in open_bags %} +
+
+ {{ bag.roast_name }} +

{{ bag.roaster_name }}

+
+

+ {{ bag.remaining }}g + of {{ bag.amount }}g remaining +

+ {% if is_authenticated %} +
+ + + Brew + + +
+ {% endif %} +
+ {% endfor %} +
+
+{% endif %} + + +{% if !recent_events.is_empty() %} +
+
+

Recent Activity

+ View full timeline → +
+
+ {% for event in recent_events %} +
+
+ {{ event.kind_label }} + {{ event.title }} +
+ +
+ {% endfor %} +
+
+{% endif %} + + +
+

Your Coffee Journey

+ +
+{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 4ec5b5b..a57863d 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -1,6 +1,6 @@