feat: added new appearance and nose to wine ratings 355171f6
Steve · 2026-04-05 20:06 5 file(s) · +184 −11
apps/cellar/src/db.rs +30 −8
44 44
    pub tannin: i32,
45 45
    pub alcohol: i32,
46 46
    pub body: i32,
47 +
    pub clarity: i32,
48 +
    pub color_intensity: i32,
49 +
    pub aroma_intensity: i32,
50 +
    pub nose_complexity: i32,
47 51
    pub background: String,
48 52
    pub created_at: String,
49 53
}
80 84
81 85
    // Migration: add background column if it doesn't exist
82 86
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN background TEXT NOT NULL DEFAULT ''", []);
87 +
88 +
    // Migration: add appearance and nose tasting attributes
89 +
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN clarity INTEGER NOT NULL DEFAULT 3", []);
90 +
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN color_intensity INTEGER NOT NULL DEFAULT 3", []);
91 +
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN aroma_intensity INTEGER NOT NULL DEFAULT 3", []);
92 +
    let _ = conn.execute("ALTER TABLE wines ADD COLUMN nose_complexity INTEGER NOT NULL DEFAULT 3", []);
83 93
84 94
    Arc::new(Mutex::new(conn))
85 95
}
99 109
        tannin: row.get(10)?,
100 110
        alcohol: row.get(11)?,
101 111
        body: row.get(12)?,
102 -
        background: row.get(13)?,
103 -
        created_at: row.get(14)?,
112 +
        clarity: row.get(13)?,
113 +
        color_intensity: row.get(14)?,
114 +
        aroma_intensity: row.get(15)?,
115 +
        nose_complexity: row.get(16)?,
116 +
        background: row.get(17)?,
117 +
        created_at: row.get(18)?,
104 118
    })
105 119
}
106 120
107 121
const WINE_COLUMNS: &str =
108 -
    "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, background, created_at";
122 +
    "id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at";
109 123
110 124
pub fn create_wine(
111 125
    db: &Db,
120 134
    tannin: i32,
121 135
    alcohol: i32,
122 136
    body: i32,
137 +
    clarity: i32,
138 +
    color_intensity: i32,
139 +
    aroma_intensity: i32,
140 +
    nose_complexity: i32,
123 141
    background: &str,
124 142
) -> Result<Wine, DbError> {
125 143
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
126 144
    let short_id = nanoid!(10);
127 145
    conn.execute(
128 -
        "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, background)
129 -
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
130 -
        params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, background],
146 +
        "INSERT INTO wines (short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background)
147 +
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
148 +
        params![short_id, name, origin, grape, notes, image, image_mime, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background],
131 149
    )?;
132 150
    let id = conn.last_insert_rowid();
133 151
    let wine = conn.query_row(
195 213
    tannin: i32,
196 214
    alcohol: i32,
197 215
    body: i32,
216 +
    clarity: i32,
217 +
    color_intensity: i32,
218 +
    aroma_intensity: i32,
219 +
    nose_complexity: i32,
198 220
    background: &str,
199 221
) -> Result<Option<Wine>, DbError> {
200 222
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
201 223
    let rows = conn.execute(
202 -
        "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, sweetness = ?5, acidity = ?6, tannin = ?7, alcohol = ?8, body = ?9, background = ?10 WHERE short_id = ?11",
203 -
        params![name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, background, short_id],
224 +
        "UPDATE wines SET name = ?1, origin = ?2, grape = ?3, notes = ?4, sweetness = ?5, acidity = ?6, tannin = ?7, alcohol = ?8, body = ?9, clarity = ?10, color_intensity = ?11, aroma_intensity = ?12, nose_complexity = ?13, background = ?14 WHERE short_id = ?15",
225 +
        params![name, origin, grape, notes, sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, short_id],
204 226
    )?;
205 227
    if rows == 0 {
206 228
        return Ok(None);
apps/cellar/src/server.rs +108 −2
56 56
struct WineDetailTemplate {
57 57
    wine: Wine,
58 58
    pentagon_svg: String,
59 +
    bars_svg: String,
59 60
}
60 61
61 62
#[derive(Template)]
143 144
        .collect();
144 145
145 146
    let mut svg = format!(
146 -
        r#"<svg viewBox="0 0 {s} {s}" width="{s}" height="{s}" xmlns="http://www.w3.org/2000/svg">"#,
147 +
        r#"<svg viewBox="0 0 {s} {s}" width="100%" xmlns="http://www.w3.org/2000/svg">"#,
147 148
        s = size
148 149
    );
149 150
225 226
    svg
226 227
}
227 228
229 +
fn build_bars_svg(
230 +
    clarity: i32,
231 +
    color_intensity: i32,
232 +
    aroma_intensity: i32,
233 +
    nose_complexity: i32,
234 +
    width: f64,
235 +
) -> String {
236 +
    let bar_height = 4.0;
237 +
    let row_height = 22.0;
238 +
    let section_gap = 14.0;
239 +
    let label_width = 100.0;
240 +
    let track_left = label_width + 4.0;
241 +
    let track_width = width - track_left - 10.0;
242 +
    let header_size = 9.0;
243 +
244 +
    let sections: &[(&str, &[(&str, i32)])] = &[
245 +
        ("Appearance", &[("Clarity", clarity), ("Intensity", color_intensity)]),
246 +
        ("Nose", &[("Aroma", aroma_intensity), ("Complexity", nose_complexity)]),
247 +
    ];
248 +
249 +
    let total_rows: usize = sections.iter().map(|(_, attrs)| attrs.len()).sum();
250 +
    let total_height = (sections.len() as f64) * (header_size + 8.0)
251 +
        + (total_rows as f64) * row_height
252 +
        + section_gap;
253 +
254 +
    let mut svg = format!(
255 +
        r#"<svg viewBox="0 0 {w} {h}" width="100%" xmlns="http://www.w3.org/2000/svg">"#,
256 +
        w = width,
257 +
        h = total_height
258 +
    );
259 +
260 +
    let mut y = 4.0;
261 +
262 +
    for (si, (section_name, attrs)) in sections.iter().enumerate() {
263 +
        if si > 0 {
264 +
            y += section_gap;
265 +
        }
266 +
267 +
        svg.push_str(&format!(
268 +
            r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.4" font-size="{}" font-family="Commit Mono, monospace" text-transform="uppercase" letter-spacing="1">{}</text>"#,
269 +
            y + header_size, header_size, section_name
270 +
        ));
271 +
        y += header_size + 8.0;
272 +
273 +
        for (label, score) in *attrs {
274 +
            let bar_y = y + (row_height - bar_height) / 2.0;
275 +
            let fill_width = (*score as f64 / 5.0) * track_width;
276 +
277 +
            svg.push_str(&format!(
278 +
                r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace">{}</text>"#,
279 +
                y + row_height / 2.0 + 3.0, label
280 +
            ));
281 +
282 +
            svg.push_str(&format!(
283 +
                r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.08"/>"#,
284 +
                track_left, bar_y, track_width, bar_height
285 +
            ));
286 +
287 +
            if fill_width > 0.0 {
288 +
                svg.push_str(&format!(
289 +
                    r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.6"/>"#,
290 +
                    track_left, bar_y, fill_width, bar_height
291 +
                ));
292 +
293 +
            }
294 +
295 +
            y += row_height;
296 +
        }
297 +
    }
298 +
299 +
    svg.push_str("</svg>");
300 +
    svg
301 +
}
302 +
228 303
// --- Auth handlers ---
229 304
230 305
async fn get_login(Query(q): Query<FlashQuery>) -> Response {
348 423
                250.0,
349 424
                true,
350 425
            );
351 -
            WebTemplate(WineDetailTemplate { wine, pentagon_svg }).into_response()
426 +
            let bars_svg = build_bars_svg(
427 +
                wine.clarity,
428 +
                wine.color_intensity,
429 +
                wine.aroma_intensity,
430 +
                wine.nose_complexity,
431 +
                250.0,
432 +
            );
433 +
            WebTemplate(WineDetailTemplate { wine, pentagon_svg, bars_svg }).into_response()
352 434
        }
353 435
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
354 436
        Err(e) => {
466 548
    tannin: i32,
467 549
    alcohol: i32,
468 550
    body: i32,
551 +
    clarity: i32,
552 +
    color_intensity: i32,
553 +
    aroma_intensity: i32,
554 +
    nose_complexity: i32,
469 555
}
470 556
471 557
async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> {
481 567
    let mut tannin = 3;
482 568
    let mut alcohol = 3;
483 569
    let mut body = 3;
570 +
    let mut clarity = 3;
571 +
    let mut color_intensity = 3;
572 +
    let mut aroma_intensity = 3;
573 +
    let mut nose_complexity = 3;
484 574
485 575
    while let Ok(Some(field)) = multipart.next_field().await {
486 576
        let field_name = field.name().unwrap_or("").to_string();
503 593
            "tannin" => tannin = field.text().await.unwrap_or_default().parse().unwrap_or(3),
504 594
            "alcohol" => alcohol = field.text().await.unwrap_or_default().parse().unwrap_or(3),
505 595
            "body" => body = field.text().await.unwrap_or_default().parse().unwrap_or(3),
596 +
            "clarity" => clarity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
597 +
            "color_intensity" => color_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
598 +
            "aroma_intensity" => aroma_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
599 +
            "nose_complexity" => nose_complexity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
506 600
            _ => {}
507 601
        }
508 602
    }
526 620
        tannin: clamp(tannin),
527 621
        alcohol: clamp(alcohol),
528 622
        body: clamp(body),
623 +
        clarity: clamp(clarity),
624 +
        color_intensity: clamp(color_intensity),
625 +
        aroma_intensity: clamp(aroma_intensity),
626 +
        nose_complexity: clamp(nose_complexity),
529 627
    })
530 628
}
531 629
554 652
        data.tannin,
555 653
        data.alcohol,
556 654
        data.body,
655 +
        data.clarity,
656 +
        data.color_intensity,
657 +
        data.aroma_intensity,
658 +
        data.nose_complexity,
557 659
        &data.background,
558 660
    ) {
559 661
        Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(),
590 692
        data.tannin,
591 693
        data.alcohol,
592 694
        data.body,
695 +
        data.clarity,
696 +
        data.color_intensity,
697 +
        data.aroma_intensity,
698 +
        data.nose_complexity,
593 699
        &data.background,
594 700
    ) {
595 701
        Ok(Some(_)) => {
apps/cellar/static/styles.css +16 −1
269 269
270 270
.wine-detail-chart {
271 271
  display: flex;
272 -
  justify-content: center;
272 +
  flex-direction: column;
273 +
  align-items: center;
274 +
  gap: 1rem;
275 +
  padding: 0.75rem;
273 276
}
274 277
275 278
.wine-detail-notes {
349 352
  flex-direction: column;
350 353
  gap: 0.5rem;
351 354
  margin-top: 0.5rem;
355 +
}
356 +
357 +
.score-section-label {
358 +
  font-size: 11px;
359 +
  opacity: 0.4;
360 +
  text-transform: uppercase;
361 +
  letter-spacing: 1px;
362 +
  margin-top: 0.75rem;
363 +
}
364 +
365 +
.score-section-label:first-child {
366 +
  margin-top: 0;
352 367
}
353 368
354 369
.score-row {
apps/cellar/templates/wine.html +1 −0
16 16
      {% endif %}
17 17
      <div class="wine-detail-chart">
18 18
        {{ pentagon_svg|safe }}
19 +
        {{ bars_svg|safe }}
19 20
      </div>
20 21
    </div>
21 22
    <div class="wine-detail-meta">
apps/cellar/templates/wine_form.html +29 −0
35 35
    <textarea id="background" name="background" rows="5">{% if let Some(w) = wine %}{{ w.background }}{% endif %}</textarea>
36 36
37 37
    <div class="score-group">
38 +
      <div class="score-section-label">appearance</div>
39 +
      <div class="score-row">
40 +
        <label for="clarity">clarity</label>
41 +
        <input type="range" id="clarity" name="clarity" min="1" max="5"
42 +
          value="{% if let Some(w) = wine %}{{ w.clarity }}{% else %}3{% endif %}">
43 +
        <span class="score-value" data-for="clarity">{% if let Some(w) = wine %}{{ w.clarity }}{% else %}3{% endif %}</span>
44 +
      </div>
45 +
      <div class="score-row">
46 +
        <label for="color_intensity">intensity</label>
47 +
        <input type="range" id="color_intensity" name="color_intensity" min="1" max="5"
48 +
          value="{% if let Some(w) = wine %}{{ w.color_intensity }}{% else %}3{% endif %}">
49 +
        <span class="score-value" data-for="color_intensity">{% if let Some(w) = wine %}{{ w.color_intensity }}{% else %}3{% endif %}</span>
50 +
      </div>
51 +
52 +
      <div class="score-section-label">nose</div>
53 +
      <div class="score-row">
54 +
        <label for="aroma_intensity">aroma</label>
55 +
        <input type="range" id="aroma_intensity" name="aroma_intensity" min="1" max="5"
56 +
          value="{% if let Some(w) = wine %}{{ w.aroma_intensity }}{% else %}3{% endif %}">
57 +
        <span class="score-value" data-for="aroma_intensity">{% if let Some(w) = wine %}{{ w.aroma_intensity }}{% else %}3{% endif %}</span>
58 +
      </div>
59 +
      <div class="score-row">
60 +
        <label for="nose_complexity">complexity</label>
61 +
        <input type="range" id="nose_complexity" name="nose_complexity" min="1" max="5"
62 +
          value="{% if let Some(w) = wine %}{{ w.nose_complexity }}{% else %}3{% endif %}">
63 +
        <span class="score-value" data-for="nose_complexity">{% if let Some(w) = wine %}{{ w.nose_complexity }}{% else %}3{% endif %}</span>
64 +
      </div>
65 +
66 +
      <div class="score-section-label">palate</div>
38 67
      <div class="score-row">
39 68
        <label for="sweetness">sweetness</label>
40 69
        <input type="range" id="sweetness" name="sweetness" min="1" max="5"