1
0
Fork 0
mirror of https://github.com/shouptech/humulus.git synced 2026-02-03 16:09:44 +00:00

Add progressbars to recipe stats (#23)

Closes #20
This commit is contained in:
Emma 2019-07-11 09:05:06 -06:00 committed by GitHub
parent 95e5a35295
commit 3e79dd086f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 255 additions and 59 deletions

View file

@ -14,6 +14,25 @@
limitations under the License. limitations under the License.
*/ */
// Calculate percentage and background color for display functions
function calculatePct(value, low, high) {
var pct = Math.round(100 * (value-low )/ (high - low));
var color = 'bg-success';
if (pct > 85) {
color = 'bg-danger';
if (pct > 100) {
pct = 100;
}
} else if (pct <=15 ) {
color = 'bg-warning';
if (pct <= 0) {
pct = 5; // We want a little bit of bar showing
}
}
return {pct: pct, color: color};
}
// unbinds and re-binds the change event // unbinds and re-binds the change event
function rebindChangeEvents() { function rebindChangeEvents() {
$('.ingredient-field').unbind('change'); $('.ingredient-field').unbind('change');
@ -90,7 +109,7 @@ function addFerm() {
// Type field // Type field
'<div class="col-sm"><div class="form-group">' + '<div class="col-sm"><div class="form-group">' +
`<label for="fermentables-${fermsLength}-type">Type</label>` + `<label for="fermentables-${fermsLength}-type">Type</label>` +
`<select class="form-control form-control-sm ingredient-field" id="fermentables-${fermsLength}-type"` + `<select class="custom-select custom-select-sm ingredient-field" id="fermentables-${fermsLength}-type"` +
` name="fermentables-${fermsLength}-type" required>` + ` name="fermentables-${fermsLength}-type" required>` +
'<option value="Grain">Grain</option>' + '<option value="Grain">Grain</option>' +
'<option value="LME">LME</option>' + '<option value="LME">LME</option>' +
@ -152,7 +171,7 @@ function addHop() {
// Usage field // Usage field
'<div class="col-sm"><div class="form-group">' + '<div class="col-sm"><div class="form-group">' +
`<label for="hops-${hopsLength}-use">Usage</label>` + `<label for="hops-${hopsLength}-use">Usage</label>` +
`<select class="form-control form-control-sm ingredient-field" id="hops-${hopsLength}-use"` + `<select class="custom-select custom-select-sm ingredient-field" id="hops-${hopsLength}-use"` +
` name="hops-${hopsLength}-use" required>` + ` name="hops-${hopsLength}-use" required>` +
'<option value="Boil">Boil</option>' + '<option value="Boil">Boil</option>' +
'<option value="FWH">FWH</option>' + '<option value="FWH">FWH</option>' +
@ -220,8 +239,16 @@ function calculateOG() {
} }
// display OG // display OG
function displayOG() { function displayOG(data) {
$('#estimated-og').text(calculateOG().toFixed(3)); var og = calculateOG().toFixed(3);
if (data !== null) {
var result = calculatePct(og, data.og.low, data.og.high)
$('#estimated-og').html(`<div class="progress spec-progress"><div class="progress-bar ${result.color}" ` +
`role="progressbar" style="width: ${result.pct}%;" aria-valuenow="${og}" aria-valuemin="${data.og.low}" ` +
`aria-valuemax="${data.og.high}">${og}</div></div>`);
} else {
$('#estimated-og').text(og);
}
} }
// Calculate final gravity // Calculate final gravity
@ -249,8 +276,16 @@ function calculateFG() {
} }
// Display FG // Display FG
function displayFG() { function displayFG(data) {
$('#estimated-fg').text(calculateFG().toFixed(3)); var fg = calculateFG().toFixed(3);
if (data !== null) {
var result = calculatePct(fg, data.fg.low, data.fg.high)
$('#estimated-fg').html(`<div class="progress spec-progress"><div class="progress-bar ${result.color}" ` +
`role="progressbar" style="width: ${result.pct}%;" aria-valuenow="${fg}" aria-valuemin="${data.fg.low}" ` +
`aria-valuemax="${data.fg.high}">${fg}</div></div>`);
} else {
$('#estimated-fg').text(fg);
}
} }
// Calculate ABV // Calculate ABV
@ -259,10 +294,17 @@ function calculate_abv() {
} }
// Display ABV // Display ABV
function displayABV() { function displayABV(data) {
var abv = calculate_abv().toFixed(1); var abv = calculate_abv().toFixed(1);
if (data !== null) {
var result = calculatePct(abv, data.abv.low, data.abv.high)
$('#estimated-abv').html(`<div class="progress spec-progress"><div class="progress-bar ${result.color}" ` +
`role="progressbar" style="width: ${result.pct}%;" aria-valuenow="${abv}" aria-valuemin="${data.abv.low}" ` +
`aria-valuemax="${data.abv.high}">${abv} %/vol.</div></div>`);
} else {
$('#estimated-abv').text(`${abv} %/vol.`); $('#estimated-abv').text(`${abv} %/vol.`);
} }
}
// Calculate IBU // Calculate IBU
function calculateIBU() { function calculateIBU() {
@ -290,10 +332,18 @@ function calculateIBU() {
} }
// Display IBU // Display IBU
function displayIBU() { function displayIBU(data) {
var ibu = calculateIBU().toFixed(); var ibu = calculateIBU().toFixed();
if (data !== null) {
var result = calculatePct(ibu, data.ibu.low, data.ibu.high)
$('#estimated-ibu').html(`<div class="progress spec-progress"><div class="progress-bar ${result.color}" ` +
`role="progressbar" style="width: ${result.pct}%;" aria-valuenow="${ibu}" aria-valuemin="${data.ibu.low}" ` +
`aria-valuemax="${data.ibu.high}">${ibu} IBU</div></div>`);
} else {
$('#estimated-ibu').text(`${ibu} IBU`); $('#estimated-ibu').text(`${ibu} IBU`);
} }
}
// Calculate IBU Ratio // Calculate IBU Ratio
function calculateIBURatio() { function calculateIBURatio() {
@ -323,19 +373,39 @@ function calculateSRM() {
} }
// Display SRM // Display SRM
function displaySRM() { function displaySRM(data) {
var srm = calculateSRM().toFixed(); var srm = calculateSRM().toFixed();
if (data !== null) {
var result = calculatePct(srm, data.srm.low, data.srm.high)
$('#estimated-srm').html(`<div class="progress spec-progress"><div class="progress-bar ${result.color}" ` +
`role="progressbar" style="width: ${result.pct}%;" aria-valuenow="${srm}" aria-valuemin="${data.srm.low}" ` +
`aria-valuemax="${data.srm.high}">${srm} SRM</div></div>`);
} else {
$('#estimated-srm').text(`${srm} SRM`); $('#estimated-srm').text(`${srm} SRM`);
} }
}
// Display all specifications // Display all specifications
function displayAll() { function displayAll() {
displayOG(); $.getJSON(getSpecsURL()).done(
displayFG(); function (json) {
displayABV(); displayOG(json);
displayIBU(); displayFG(json);
displayABV(json);
displayIBU(json);
displayIBURatio(); displayIBURatio();
displaySRM(); displaySRM(json);
}
).fail(
function () {
displayOG(null);
displayFG(null);
displayABV(null);
displayIBU(null);
displayIBURatio();
displaySRM(null);
}
);
} }
$(document).ready(function() { $(document).ready(function() {

View file

@ -35,3 +35,8 @@ body {
.above-footer { .above-footer {
padding-bottom: 14rem; padding-bottom: 14rem;
} }
.spec-progress {
height: 1.5rem;
font-size: 1.125rem;
}

View file

@ -19,7 +19,8 @@ import xml.etree.ElementTree as ET
import click import click
import requests import requests
from flask import Blueprint, abort, current_app, render_template, request from flask import (Blueprint, abort, current_app, render_template, request,
jsonify)
from flask.cli import with_appcontext from flask.cli import with_appcontext
from humulus.auth import login_required from humulus.auth import login_required
@ -168,6 +169,31 @@ def info(id):
return render_template('styles/info.html', style=get_doc_or_404(id)) return render_template('styles/info.html', style=get_doc_or_404(id))
@bp.route('/info/<id>/json')
@login_required
def info_json(id):
"""Returns JSON for the style.
If the 'specs' argument is present (regardless of value), only return
specs.
"""
style = get_doc_or_404(id)
# Remove fields not needed for specs
if request.args.get('specs', None) is not None:
return jsonify({
'ibu': style['ibu'],
'og': style['og'],
'fg': style['fg'],
'abv': style['abv'],
'srm': style['srm']
})
# Remove fields not needed for export
style.pop('_id')
style.pop('_rev')
style.pop('$type')
return jsonify(style)
@bp.route('/info/<id>/recipes') @bp.route('/info/<id>/recipes')
def recipes(id): def recipes(id):
style = get_doc_or_404(id) style = get_doc_or_404(id)

View file

@ -57,7 +57,7 @@
</ul> </ul>
</div> </div>
</nav> </nav>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='moment.min.js')}}"></script> <script src="{{ url_for('static', filename='moment.min.js')}}"></script>
<main role="main" class="container"> <main role="main" class="container">
{% block alert %} {% block alert %}

View file

@ -77,3 +77,30 @@
{% macro moment(timestamp, format="llll") %} {% macro moment(timestamp, format="llll") %}
<script>document.write(moment.utc("{{ timestamp }}").local().format("llll"));</script> <script>document.write(moment.utc("{{ timestamp }}").local().format("llll"));</script>
{% endmacro %} {% endmacro %}
{#
Render a bootstrap progressbar
#}
{% macro render_progressbar(value, min, max, unit='', class='') %}
{#
Determine percentage. Coerce to between 0 and 100.
Set an appropriate background.
#}
{% set pct = (100*(value-min))/(max-min) %}
{% if pct > 85 %}
{% set color = 'bg-danger' %}
{% if pct > 100 %}
{% set pct = 100 %}
{% endif %}
{% elif pct > 15 %}
{% set color = 'bg-success' %}
{% else %}
{% set color = 'bg-warning' %}
{% if pct <= 0 %}
{% set pct = 5 %} {# we want a little bit of the bar showing. #}
{% endif %}
{% endif %}
<div class="progress {{ class }}">
<div class="progress-bar {{ color }}" role="progressbar" style="width: {{ pct|int }}%;" aria-valuenow="{{ value }}" aria-valuemin="{{ min }}" aria-valuemax="{{ max }}">{{ value }}{{ unit }}</div>
</div>
{% endmacro %}

View file

@ -15,6 +15,19 @@
-#} -#}
{% from "_macros.html" import render_field_with_errors %} {% from "_macros.html" import render_field_with_errors %}
{#
Renders a javascript function to return a style's URL.
#}
{% macro get_specs_url() %}
{% set id = 'ID' %}
{% set specs = 'y' %}
<script type="text/javascript">
function getSpecsURL() {
return '{{ url_for("styles.info_json", id=id, specs=specs) }}'.replace('ID', $('#style').val());
}
</script>
{% endmacro %}
{# {#
Used to render a form for creating/updating a recipe Used to render a form for creating/updating a recipe
#} #}
@ -30,8 +43,8 @@
<div class="col-sm-3">{{ render_field_with_errors(form.volume, 'ingredient-field') }}</div> <div class="col-sm-3">{{ render_field_with_errors(form.volume, 'ingredient-field') }}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm">{{ render_field_with_errors(form.style) }}</div> <div class="col-sm">{{ render_field_with_errors(form.style, 'ingredient-field', base_class='custom-select') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.type) }}</div> <div class="col-sm">{{ render_field_with_errors(form.type, base_class='custom-select') }}</div>
</div> </div>
{#- {#-
Fermentable Ingredients Fermentable Ingredients
@ -46,7 +59,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm">{{ render_field_with_errors(fermentable.form.type, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(fermentable.form.type, 'ingredient-field custom-select-sm', base_class='custom-select') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm ingredient-field') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm ingredient-field') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.color, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(fermentable.form.color, 'form-control-sm ingredient-field') }}</div>
@ -77,7 +90,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm">{{ render_field_with_errors(hop.form.use, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(hop.form.use, 'ingredient-field custom-select-sm', base_class='custom-select') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.alpha, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(hop.form.alpha, 'form-control-sm ingredient-field') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.duration, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(hop.form.duration, 'form-control-sm ingredient-field') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.amount, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(hop.form.amount, 'form-control-sm ingredient-field') }}</div>
@ -103,14 +116,14 @@
<div class="border pl-2 pr-2 pt-1 pb-1 yeast-form"> <div class="border pl-2 pr-2 pt-1 pb-1 yeast-form">
<div class="row"> <div class="row">
<div class="col-sm-6">{{ render_field_with_errors(form.yeast.form.name, 'form-control-sm') }}</div> <div class="col-sm-6">{{ render_field_with_errors(form.yeast.form.name, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.type, 'form-control-sm') }}</div> <div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.type, 'custom-select-sm', base_class='custom-select') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.lab, 'form-control-sm') }}</div> <div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.lab, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}</div> <div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm ingredient-field') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm ingredient-field') }}</div> <div class="col-sm">{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm ingredient-field') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.flocculation, 'form-control-sm') }}</div> <div class="col-sm">{{ render_field_with_errors(form.yeast.form.flocculation, 'custom-select-sm', base_class='custom-select') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}</div> <div class="col-sm">{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}</div> <div class="col-sm">{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.abv_tolerance, 'form-control-sm') }}</div> <div class="col-sm">{{ render_field_with_errors(form.yeast.form.abv_tolerance, 'form-control-sm') }}</div>
@ -130,6 +143,7 @@
<div class="col"><button type="submit" class="btn btn-primary">{{ button_content }}</button></div> <div class="col"><button type="submit" class="btn btn-primary">{{ button_content }}</button></div>
</div> </div>
</form> </form>
{{ get_specs_url() }}
<script src="{{ url_for('static', filename='recipes.js') }}"></script> <script src="{{ url_for('static', filename='recipes.js') }}"></script>
{% endmacro %} {% endmacro %}

View file

@ -13,7 +13,7 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
-#} -#}
{% from "_macros.html" import render_field_with_errors, render_delete_button, render_delete_modal, moment %} {% import '_macros.html' as macros %}
{% extends '_base.html' %} {% extends '_base.html' %}
{% block title %}{{ recipe.name }}{% endblock %} {% block title %}{{ recipe.name }}{% endblock %}
@ -30,7 +30,7 @@
-#} -#}
<div class="row border-top"><h2>Details</h2></div> <div class="row border-top"><h2>Details</h2></div>
<div class="row"> <div class="row">
<div class="col"> <div class="col-sm">
<dl> <dl>
<dt>Recipe Type</dt> <dt>Recipe Type</dt>
<dd>{{ recipe.type }}</dd> <dd>{{ recipe.type }}</dd>
@ -40,39 +40,73 @@
<dd>{{ recipe.volume|float|round(1) }} gal.</dd> <dd>{{ recipe.volume|float|round(1) }} gal.</dd>
</dl> </dl>
</div> </div>
<div class="col"> <div class="col-sm">
<dl> <dl>
{% if 'created' in recipe %} {% if 'created' in recipe %}
<dt>Created</dt> <dt>Created</dt>
<dd>{{ moment(recipe.created) }}</dd> <dd>{{ macros.moment(recipe.created) }}</dd>
{% endif %} {% endif %}
{% if 'updated' in recipe %} {% if 'updated' in recipe %}
<dt>Updated</dt> <dt>Updated</dt>
<dd>{{ moment(recipe.updated) }}</dd> <dd>{{ macros.moment(recipe.updated) }}</dd>
{% endif %} {% endif %}
</dl> </dl>
</div> </div>
</div> </div>
<div class="row"><h3>Estimated Specifications</h3></div> <div class="row"><h3>Estimated Specifications</h3></div>
<div class="row"> <div class="row">
<div class="col"> <div class="col-sm">
<dl> <dl>
<dt>Original Gravity</dt> <dt>Original Gravity</dt>
<dd>{{ recipe|recipe_og }}</dd> <dd>
{% if style %}
{{ macros.render_progressbar(recipe|recipe_og|float, style.og.low|float, style.og.high|float, class='spec-progress') }}
{% else %}
{{ recipe|recipe_og }}
{% endif %}
</dd>
<dt>Final Gravity</dt> <dt>Final Gravity</dt>
<dd>{{ recipe|recipe_fg }}</dd> <dd>
<dt>Alcohol</dt> {% if style %}
<dd>{{ recipe|recipe_abv }} %/vol.</dd> {{ macros.render_progressbar(recipe|recipe_fg|float, style.fg.low|float, style.fg.high|float, class='spec-progress') }}
{% else %}
{{ recipe|recipe_fg }}
{% endif %}
</dd>
</dl> </dl>
</div> </div>
<div class="col"> <div class="col-sm">
<dl>
<dt>Alcohol</dt>
<dd>
{% if style %}
{{ macros.render_progressbar(recipe|recipe_abv|float, style.abv.low|float, style.abv.high|float, unit=' %/vol', class='spec-progress') }}
{% else %}
{{ recipe|recipe_abv }} %/vol.
{% endif %}
</dd>
<dt>Color</dt>
<dd>
{% if style %}
{{ macros.render_progressbar(recipe|recipe_srm|float, style.srm.low|float, style.srm.high|float, unit=' SRM', class='spec-progress') }}
{% else %}
{{ recipe|recipe_srm }} SRM
{% endif %}
</dd>
</dl>
</div>
<div class="col-sm">
<dl> <dl>
<dt>Bitterness</dt> <dt>Bitterness</dt>
<dd>{{ recipe|recipe_ibu }} IBU</dd> <dd>
{% if style %}
{{ macros.render_progressbar(recipe|recipe_ibu|float, style.ibu.low|float, style.ibu.high|float, unit=' IBU', class='spec-progress') }}
{% else %}
{{ recipe|recipe_ibu }} IBU
{% endif %}
</dd>
<dt>Bitterness Ratio</dt> <dt>Bitterness Ratio</dt>
<dd>{{ recipe|recipe_ibu_ratio }} IBU/OG</dd> <dd>{{ recipe|recipe_ibu_ratio }} IBU/OG</dd>
<dt>Color</dt>
<dd>{{ recipe|recipe_srm }} SRM</dd>
</dl> </dl>
</div> </div>
</div> </div>
@ -175,9 +209,9 @@
{% if session.logged_in %} {% if session.logged_in %}
<div class="row mt-4 pt-1 border-top"> <div class="row mt-4 pt-1 border-top">
<a class="btn btn-secondary mr-1" href="{{ url_for('recipes.update', id=recipe._id) }}">Update Recipe</a> <a class="btn btn-secondary mr-1" href="{{ url_for('recipes.update', id=recipe._id) }}">Update Recipe</a>
{{ render_delete_button('Delete Recipe', 'deleteRecipe', 'btn-danger') }} {{ macros.render_delete_button('Delete Recipe', 'deleteRecipe', 'btn-danger') }}
</div> </div>
{{ render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }} {{ macros.render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }}
{% endif %} {% endif %}
<div class="row mt-4"><a href="{{ url_for('recipes.info_json', id=recipe._id) }}">Export JSON</a></div> <div class="row mt-4"><a href="{{ url_for('recipes.info_json', id=recipe._id) }}">Export JSON</a></div>
<div class="row">Recipe revision: {{ recipe._rev }}</div> <div class="row">Recipe revision: {{ recipe._rev }}</div>

View file

@ -226,3 +226,23 @@ def test_recipes(client):
response = client.get('/styles/info/1A/recipes') response = client.get('/styles/info/1A/recipes')
assert response.status_code == 200 assert response.status_code == 200
assert b'Awesome Beer' in response.data assert b'Awesome Beer' in response.data
def test_info_json(auth, client):
"""Test success in retrieving a style's json document."""
# Test not logged in
response = client.get('/styles/info/1A/json')
assert response.status_code == 302
# Login and test
auth.login()
response = client.get('/styles/info/1A/json')
assert response.status_code == 200
assert response.is_json
assert response.get_json()['name'] == 'Test Style'
# Test for specs only
response = client.get('/styles/info/1A/json?specs=y')
assert response.status_code == 200
assert response.is_json
assert 'name' not in response.get_json()