<|start_filename|>app/src/components/CrossUnitsInput.test.js<|end_filename|>
import React from "react";
import renderer from "react-test-renderer";
import CrossUnitsInput from "./CrossUnitsInput";

test("renders without crashing", () => {
  const tree = renderer
    .create(
      <CrossUnitsInput
        description="description"
        name="Boil Size"
        measurable={{ value: 20, unit: "l" }}
        units={["l", "gal"]}
      />
    )
    .toJSON();
  expect(tree).toMatchSnapshot();
});

test("renders without crashing on US units", () => {
  const tree = renderer
    .create(
      <CrossUnitsInput
        description="description"
        name="Boil Size"
        measurable={{ value: 5, unit: "gal" }}
        units={["l", "gal"]}
      />
    )
    .toJSON();
  expect(tree).toMatchSnapshot();
});


<|start_filename|>app/src/components/RecipeSpecs.test.js<|end_filename|>
import React from 'react'
import RecipeSpecs from './RecipeSpecs'
import renderer from 'react-test-renderer'
import { recipe } from '../data/recipe'
import { equipment } from '../data/equipment'

it('renders correctly', () => {
  const tree = renderer
    .create(
    <RecipeSpecs recipe={recipe} equipment={equipment} />
    )
    .toJSON()
  expect(tree).toMatchSnapshot()
})

it('renders correctly if equipment is null', () => {
  const tree = renderer
    .create(
    <RecipeSpecs recipe={recipe} equipment={null} />
    )
    .toJSON()
  expect(tree).toMatchSnapshot()
})

<|start_filename|>app/src/containers/PageRecipe.js<|end_filename|>
import React from "react";
import { Container } from "react-bootstrap";
import Recipe from "../components/Recipe";
import ImportArea from "./ImportArea";
import EditorContainer from "./EditorContainer";

import { connect } from "react-redux";

const PageRecipe = ({ editorState }) => {
  const tryParse = (editorState) => {
    try {
      return JSON.parse(editorState);
    } catch (error) {
      return null;
    }
  };
  return (
    <Container>
      <ImportArea />
      <Recipe {...tryParse(editorState)} />
      <EditorContainer />
    </Container>
  );
};

const mapStateToProps = ({ editorState }) => ({ editorState });

export default connect(mapStateToProps)(PageRecipe);


<|start_filename|>app/src/components/Recipe.js<|end_filename|>
import React from 'react'
import RecipeSpecs from './RecipeSpecs'
import Ingredients from './Ingredients'
import Stats from './Stats'
import MashSteps from './MashSteps'

const Recipe = ({ recipe, equipment }) =>
  recipe !== undefined &&
  <div>
    <RecipeSpecs recipe={recipe} equipment={equipment} />
    <Ingredients recipe={recipe} />

    {equipment !== null &&
      <MashSteps recipe={recipe} equipment={equipment} />
    }
    <Stats recipe={recipe} equipment={equipment} />

  </div>
export default Recipe


<|start_filename|>jest.config.js<|end_filename|>
const path = require("path");

module.exports = {
  rootDir: process.cwd(),
  roots: ["<rootDir>/tests"],
  testEnvironment: "node",
  transform: {
    "^.+\\.(t|j)s$": path.resolve(__dirname, "jest.transform.js"),
  },
};


<|start_filename|>app/src/index.css<|end_filename|>
body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
}

table.table {
  margin-bottom: 0;
}

.App {
  padding-bottom: 16px;
}

.card {
  margin-top: 8px;
}

.accordion {
  margin: 8px 0;
}


<|start_filename|>app/src/data/recipe.js<|end_filename|>
export const recipe = {
  name: "<NAME>",
  version: "1",
  type: "All Grain",
  author: "<NAME>",
  asstBrewer: "",
  batch_size: {
    value: 23.02,
    unit: "l",
  },
  boil: {
    pre_boil_size: {
      value: 37.1203164,
      unit: "l",
    },
    boil_time: { value: 90, unit: "min" },
  },
  efficiency: { brewhouse: { value: 68, unit: "%" } },
  ingredients: {
    fermentable_additions: [
      {
        name: "<NAME> (2 Row) UK",
        type: "grain",
        amount: { value: 4.4969891, unit: "kg" },
        yield: {
          fine_coarse_difference: { value: 1.5, unit: "%" },
          potential: { value: 1.029992, unit: "sg" },
        },
        color: { value: 4.925, unit: "EBC" },
        origin: "United Kingdom",
        supplier: "",
        notes:
          "Base malt for all English beer styles\r\nLower diastatic power than American 2 Row Pale Malt",
        moisture: {
          value: 4,
          unit: "%",
        },
        diastatic_power: {
          value: 45,
          unit: "Lintner",
        },
        protein: {
          value: 10.1,
          unit: "%",
        },
        max_in_batch: {
          value: 100,
          unit: "%",
        },
        recommend_mash: false,
      },
      {
        name: "<NAME> - 20L",
        type: "grain",
        amount: { value: 518.8834, unit: "g" },
        yield: {
          fine_coarse_difference: { value: 2.8, unit: "%" },
          potential: { value: 1.0345, unit: "sg" },
        },
        color: { value: 39.4, unit: "EBC" },
        origin: "US",
        supplier: "",
        notes:
          "Malty-sweet flavor characteristic and adds a orange to deep orange color to the beer.\r\nDoes not contribute signficantly to body or head retention.\r\nUse for: Bock, Porter, Marzen, Oktoberfest beers",
        moisture: {
          value: 5,
          unit: "%",
        },
        diastatic_power: {
          value: 25,
          unit: "Lintner",
        },
        protein: {
          value: 13.5,
          unit: "%",
        },
        max_in_batch: {
          value: 80,
          unit: "%",
        },
        recommend_mash: true,
      },
      {
        name: "Caramel/Crystal Malt - 20L",
        type: "grain",
        amount: { value: 0.201788, unit: "kg" },
        yield: {
          fine_coarse_difference: { value: 1.5, unit: "%" },
          potential: { value: 1.0345, unit: "sg" },
        },
        color: { value: 39.4, unit: "EBC" },
        origin: "US",
        supplier: "",
        notes:
          "Adds body, color and improves head retention.\r\nAlso called &#34;Crystal&#34; malt.",
        moisture: {
          value: 4,
          unit: "%",
        },
        diastatic_power: {
          value: 0,
          unit: "Lintner",
        },
        protein: {
          value: 13.2,
          unit: "%",
        },
        max_in_batch: {
          value: 20,
          unit: "%",
        },
        recommend_mash: false,
      },
      {
        name: "<NAME>",
        type: "grain",
        amount: { value: 0.046123, unit: "kg" },
        yield: {
          fine_coarse_difference: { value: 1.5, unit: "%" },
          potential: { value: 1.0253, unit: "sg" },
        },
        color: { value: 591.0, unit: "EBC" },
        origin: "US",
        supplier: "",
        notes:
          "Roasted at high temperature to create a burnt, grainy, coffee like flavor.\r\nImparts a red to deep brown color to beer, and very strong roasted flavor.\r\nUse 2-4% in Brown ales to add a nutty flavor, or 3-10% in Porters and Stouts for coffee flavor.",
        moisture: {
          value: 5,
          unit: "%",
        },
        diastatic_power: {
          value: 0,
          unit: "Lintner",
        },
        protein: {
          value: 13.2,
          unit: "%",
        },
        max_in_batch: {
          value: 10,
          unit: "%",
        },
        recommend_mash: false,
      },
      {
        name: "Cane (Beet) Sugar",
        type: "sugar",
        amount: { value: 0.2613635, unit: "kg" },
        yield: {
          fine_coarse_difference: { value: 1.5, unit: "%" },
          potential: { value: 1.046, unit: "sg" },
        },
        color: { value: 0, unit: "EBC" },
        origin: "US",
        supplier: "",
        notes:
          "Common household baking sugar.\r\nLightens flavor and body of beer.\r\nCan contribute a cider-like flavor to the beer if not cold-fermented or used in large quantities.",
        moisture: {
          value: 4,
          unit: "%",
        },
        diastatic_power: {
          value: 120,
          unit: "Lintner",
        },
        protein: {
          value: 11.7,
          unit: "%",
        },
        max_in_batch: {
          value: 7,
          unit: "%",
        },
        recommend_mash: false,
      },
    ],
    hop_additions: [
      {
        name: "<NAME>",
        origin: "Australia",
        alpha_acid: {
          value: 10,
          unit: "%",
        },
        amount: { value: 5.2055, unit: "g" },
        timing: {
          // TODO: first wort???
          use: "add_to_boil",
          time: { value: 60, unit: "min" },
        },
        notes:
          "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena",
        type: "Both",
        form: "leaf",
        beta_acid: {
          value: 5.8,
          unit: "%",
        },
        percent_lost: {
          value: 45,
          unit: "%",
        },
      },
      {
        name: "<NAME>",
        origin: "Australia",
        alpha_acid: {
          value: 10,
          unit: "%",
        },
        amount: { value: 5.2055, unit: "g" },
        timing: {
          use: "add_to_boil",
          time: { value: 45, unit: "min" },
        },
        notes:
          "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena",
        type: "bittering",
        form: "pellet",
        beta_acid: {
          value: 5.8,
          unit: "%",
        },
        percent_lost: {
          value: 45,
          unit: "%",
        },
      },
      {
        name: "<NAME>",
        origin: "Australia",
        alpha_acid: {
          value: 10,
          unit: "%",
        },
        amount: { value: 31.2328, unit: "g" },
        timing: {
          use: "add_to_boil",
          time: { value: 15, unit: "min" },
        },
        notes:
          "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena",
        type: "aroma/bittering",
        form: "leaf",
        beta_acid: {
          value: 5.8,
          unit: "%",
        },
        percent_lost: {
          value: 45,
          unit: "%",
        },
      },
      {
        name: "<NAME>",
        origin: "Australia",
        alpha_acid: {
          value: 10,
          unit: "%",
        },
        amount: { value: 15.6818, unit: "g" },
        timing: {
          use: "add_to_boil",
          time: { value: 0, unit: "min" },
        },
        notes:
          "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena",
        type: "bittering",
        form: "leaf",
        beta_acid: {
          value: 5.8,
          unit: "%",
        },
        percent_lost: {
          value: 45,
          unit: "%",
        },
      },
    ],
    culture_additions: [
      {
        name: "American Ale",
        type: "ale",
        form: "liquid",
        amount: {
          value: 0.125048,
          unit: "ml",
        },
        producer: "<NAME>",
        productId: "1056",
        temperature_range: {
          minimum: {
            value: 15.5555556,
            unit: "C",
          },
          maximum: {
            value: 22.2222222,
            unit: "C",
          },
        },
        flocculation: "medium",
        attenuation: { value: 75, unit: "%" },
        notes:
          "Soft, smooth, clean finish.  Very well balanced.  Very versitile -- works well with many ale styles.",
        best_for:
          "American Pale Ale, Scottish Ale, Porters, Sweet Stout, Barley Wine, Alt",
        max_reuse: 5,
        times_cultured: 0,
        addToSecondary: false,
        cultureDate: "18 Jun 2003",
      },
    ],
  },
  style: {
    name: "Australian Ale",
    category: "beer",
    category_number: 1,
    style_letter: "A",
    style_guide: "",
    type: "Ale",
    original_gravity: {
      minimum: {
        value: 1.035,
        unit: "sg",
      },
      maximum: {
        value: 1.06,
        unit: "sg",
      },
    },
    final_gravity: {
      minimum: {
        value: 1.008,
        unit: "sg",
      },
      maximum: {
        value: 1.015,
        unit: "sg",
      },
    },
    international_bitterness_units: {
      minimum: {
        value: 10,
        unit: "IBUs",
      },
      maximum: {
        value: 30,
        unit: "IBUs",
      },
    },
    color: {
      minimum: {
        value: 3.94,
        unit: "EBC",
      },
      maximum: {
        value: 19.7,
        unit: "EBC",
      },
    },
    carbonation: {
      minimum: {
        value: 2.2,
        unit: "vols",
      },
      maximum: {
        value: 2.8,
        unit: "vols",
      },
    },
    alcohol_by_volume: {
      minimum: {
        value: 2,
        unit: "%",
      },
      maximum: {
        value: 5,
        unit: "%",
      },
    },
    notes: "Medium malt with slight grain dryness",
    ingredients: "Pride of Ringwood hops.",
    examples: "Cooper's Pale Ale",
  },
  mash: {
    name: "Single Infusion, Medium Body",
    grain_temperature: {
      value: 22.22,
      unit: "C",
    },
    notes:
      "Simple single infusion mash for use with most modern well modified grains (about 95% of the time).",
    ph: "5.4000000",
    mash_steps: [
      {
        name: "Mash In",
        type: "infusion",
        infuse_temperature: {
          value: 74.1,
          unit: "C",
        },
        amount: {
          value: 16.76,
          //value: 13.7276426,
          unit: "l",
        },
        step_time: {
          value: 60,
          unit: "min",
        },
        step_temperature: {
          value: 66.67,
          unit: "C",
        },
      },
      {
        name: "Mash Out",
        type: "infusion",
        infuse_temperature: {
          value: 98.5,
          unit: "C",
        },
        amount: {
          value: 7.6874799,
          unit: "l",
        },
        step_time: {
          value: 10,
          unit: "min",
        },
        step_temperature: {
          value: 75.55,
          unit: "C",
        },
      },
      {
        name: "Sparge",
        type: "sparge",
        step_temperature: {
          value: 75.5555556,
          unit: "C",
        },
        step_time: {
          value: 10,
          unit: "min",
        },
      },
    ],
  },
  notes:
    "FWH the first  hop addition.\r\nAllow last addition to sit for 5 minutes to release aroma.",
  tasteNotes:
    "Very similar to Australian beers in the 60&#39;s.  Pride of Ringwood is the traditional hop used for a very large number of Australian beers. Although not considered a typical flavour hop it works very well as a single hopped beer.  Aim for 50 - 100 ppm of C",
  tasteRating: "41.0000000",
  og: 1.044,
  fg: 1.008,
  carbonation: "2.4000000",
  fermentationStages: "2",
  primaryAge: "4.0000000",
  primaryTemp: "19.4444444",
  secondaryAge: "10.0000000",
  secondaryTemp: "19.4444444",
  tertiaryAge: "7.0000000",
  age: "30.0000000",
  ageTemp: "18.3333333",
  carbonationUsed: "Bottle with 133.96 g Corn Sugar",
  forcedCarbonation: true,
  primingSugarName: "Corn Sugar",
  primingSugarEquiv: "1.0000000",
  kegPrimingFactor: "0.5000000",
  carbonationTemp: "21.1111111",
  displayCarbTemp: "21.1 C",
  date: "14 May 2011",
  estOg: "1.044 SG",
  estFg: "1.008 SG",
  estColor: "16.8 EBC",
  ibu: "28.0 IBUs",
  ibuMethod: "Tinseth",
  estAbv: "4.7 %",
  abv: "4.7 %",
  actualEfficiency: "68.0 %",
  calories: "405.4 kcal/l",
  displayBatchSize: "23.02 l",
  displayBoilSize: "37.12 l",
  displayOg: "1.044 SG",
  displayFg: "1.008 SG",
  displayPrimaryTemp: "19.4 C",
  displaySecondaryTemp: "19.4 C",
  displayTertiaryTemp: "18.3 C",
  displayAgeTemp: "18.3 C",
};


<|start_filename|>app/src/components/Recipe.test.js<|end_filename|>
import React from "react";
import Recipe from "./Recipe";
import { recipe } from "../data/recipe";
import { equipment } from "../data/equipment";
import renderer from "react-test-renderer";

test("renders without crashing", () => {
  const tree = renderer
    .create(<Recipe recipe={recipe} equipment={equipment} />)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

test("renders without crashing with null equipment", () => {
  const tree = renderer
    .create(<Recipe recipe={recipe} equipment={null} />)
    .toJSON();
  expect(tree).toMatchSnapshot();
});


<|start_filename|>webpack.config.js<|end_filename|>
const commonConfig = {
  entry: './src/index.ts',
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  target: 'web',
}

const prodConfig = {
  mode: 'production',
  output: {
    library: 'brewcalc',
    libraryTarget: 'umd',
    path: __dirname + '/web',
    filename: 'brewcalc.min.js',
  },
  ...commonConfig,
}

const devConfig = {
  mode: 'development',
  output: {
    library: 'brewcalc',
    libraryTarget: 'umd',
    path: __dirname + '/web',
    filename: 'brewcalc.js',
  },
  ...commonConfig,
}

module.exports = [prodConfig, devConfig]


<|start_filename|>app/src/redux/reducers/updateEditor.js<|end_filename|>
import { recipe } from '../../data/recipe'
import { equipment } from '../../data/equipment'

const defaultState = {
  editorState: JSON.stringify({ recipe, equipment }, null, 4)
}

const updateEditor = (state = defaultState, { payload, type }) => {
  if (type === 'UPDATE_EDITOR_STATE') {
    return {
      ...state,
      editorState: payload
    }
  }
  return state
}

export const persistedState = localStorage.getItem('brewCalcState')
  ? JSON.parse(localStorage.getItem('brewCalcState'))
  : defaultState

export default updateEditor


<|start_filename|>app/src/containers/ImportArea.js<|end_filename|>
import React from "react";
import { FormGroup, FormControl, Card, Row, Col } from "react-bootstrap";
import importFromBeerXml from "@beerjson/beerjson/js/beerxml-to-beerjson";
import { equipment } from "../data/equipment";
import { connect } from "react-redux";

const ImportArea = ({ editorState, onReloadEditorState }) => {
  const onXmlLoaded = (e) => {
    const reader = new FileReader();
    const file = e.target.files[0];
    reader.readAsText(file);
    reader.onloadend = function () {
      try {
        const result = file.type.includes("xml")
          ? importFromBeerXml(reader.result)
          : reader.result;

        const { beerjson } = JSON.parse(result);
        // TODO: equipment
        onReloadEditorState(
          JSON.stringify(
            {
              recipe: beerjson.recipes[0],
              equipment: equipment /*beerjson.equipment*/,
            },
            null,
            4
          )
        );
      } catch (err) {
        console.warn(err);
        alert("Can't import from BeerXML, see console for the details");
      }
    };
  };

  return (
    <Row className="show-grid">
      <Col md={6}>
        <Card>
          <Card.Header>Upload BeerXML / BeerJSON file</Card.Header>
          <Card.Body>
            <FormGroup>
              <FormControl
                id="formControlsFile"
                type="file"
                label="File"
                accept="application/xml,application/json"
                onChange={onXmlLoaded}
              />
            </FormGroup>
          </Card.Body>
        </Card>
      </Col>
      <Col md={6}>
        <Card>
          <Card.Header>brewcalc</Card.Header>
          <Card.Body>
            <div>
              A modern (ES6) functional JavaScript library for brewing
              calculations.
            </div>
            <a href="https://github.com/brewcomputer/brewcalc">
              brewcalc lib on the GitHub (MIT license)
            </a>
          </Card.Body>
        </Card>
      </Col>
    </Row>
  );
};

const mapStateToProps = ({ editorState }) => ({ editorState });

const mapDispatchToProps = (dispatch) => ({
  onReloadEditorState: (editorState) => {
    dispatch({
      type: "UPDATE_EDITOR_STATE",
      payload: editorState,
    });
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(ImportArea);


<|start_filename|>app/src/components/RecipeSpecs.js<|end_filename|>
import React from "react";
import { Card, Col, Row } from "react-bootstrap";

import CrossUnitsInput from "./CrossUnitsInput";

const RecipeSpecs = ({ recipe, equipment }) => {
  const {
    name,
    author,
    type,
    batch_size,
    boil: { boil_time, pre_boil_size },
    efficiency,
  } = recipe;
  return (
    <Card>
      <Card.Header>Recipe Specs and Equipment</Card.Header>
      <Card.Body>
        <Row className="show-grid">
          <Col md={6}>
            <div>
              <div title="Name of the recipe">
                {" "}
                <b>Name</b> {name}{" "}
              </div>
              <div title="Name of the brewer">
                {" "}
                <b>Brewer</b> {author}{" "}
              </div>
              <div title="May be one of “Extract”, “Partial Mash” or “All Grain”">
                {" "}
                <b>Type</b> {type}{" "}
              </div>
              <CrossUnitsInput
                description="Target size of the finished batch in liters"
                name="Batch Size"
                measurable={batch_size}
                units={["l", "gal"]}
              />
              <CrossUnitsInput
                description="Starting size for the main boil of the wort in liters"
                name="Boil Size"
                measurable={pre_boil_size}
                units={["l", "gal"]}
              />
              <CrossUnitsInput
                description="The total time to boil the wort in minutes"
                name="Boil Time"
                measurable={boil_time}
              />
              <CrossUnitsInput
                description="The percent brewhouse efficiency to be used for estimating the starting gravity of the beer"
                name="Efficiency"
                measurable={efficiency.brewhouse}
              />
            </div>
          </Col>
          <Col md={6}>
            {equipment === null ? (
              <div>Equipment is not set</div>
            ) : (
              <div>
                <div title="Name of the equipment profile – usually a text description of the brewing setup">
                  {" "}
                  <b>Equipment Name</b> {equipment.name}{" "}
                </div>
                <CrossUnitsInput
                  description="The target volume of the batch at the start of fermentation"
                  name="Batch Size"
                  measurable={batch_size}
                  units={["l", "gal"]}
                />
                <CrossUnitsInput
                  description="The pre-boil volume used in this particular instance for this equipment setup.  Note that this may be a calculated"
                  name="Boil Size"
                  measurable={pre_boil_size}
                  units={["l", "gal"]}
                />
                <CrossUnitsInput
                  description="The volume of wort lost to evaporation per hour of the boil"
                  name="Evap Rate"
                  measurable={
                    equipment.equipment_items.brew_kettle.boil_rate_per_hour
                  }
                />
                <CrossUnitsInput
                  description="The amount of wort normally lost during transition from the boiler to the fermentation vessel"
                  name="Trub Chiller Loss"
                  measurable={equipment.equipment_items.brew_kettle.loss}
                  units={["l", "gal"]}
                />
                <CrossUnitsInput
                  description="Amount lost to the lauter tun and equipment associated with the lautering process"
                  name="Lauter Deadspace"
                  measurable={equipment.lauterDeadspace}
                  units={["l", "gal"]}
                  precision={2}
                />
                <CrossUnitsInput
                  description="Amount normally added to the boil kettle before the boil"
                  name="TopUpKettle"
                  measurable={equipment.topUpKettle}
                  units={["l", "gal"]}
                  precision={2}
                />
              </div>
            )}
          </Col>
        </Row>
      </Card.Body>
    </Card>
  );
};
export default RecipeSpecs;


<|start_filename|>app/src/components/MashSteps.js<|end_filename|>
import React from "react";
import { Card, Table } from "react-bootstrap";
import {
  recalculateMashSteps,
  calcBoilVolumes,
  calcMashVolumes,
  calcMashGrainWeight,
} from "brewcalc";
import CrossUnitsInput, { printMeasurable } from "./CrossUnitsInput";

//TODO: Add BIAB
const MashStepDescription = ({ step, spargeVolume }) => {
  switch (step.type) {
    case "decoction":
      return (
        <td style={{ display: "flex" }}>
          Decoct&nbsp;
          <CrossUnitsInput measurable={step.amount} units={["l", "gal"]} />
          &nbsp;of mash and boil it
        </td>
      );
    case "temperature":
      if (step.infuseAmount > 0)
        return (
          <td style={{ display: "flex" }}>
            Add&nbsp;
            <CrossUnitsInput measurable={step.amount} units={["l", "gal"]} />
            &nbsp;of water and heat to&nbsp;
            <CrossUnitsInput
              measurable={step.step_temperature}
              units={["C", "F"]}
              precision={0}
            />
          </td>
        );
      else
        return (
          <td style={{ display: "flex" }}>
            Heat to&nbsp;
            <CrossUnitsInput
              measurable={step.step_temperature}
              units={["C", "F"]}
              precision={0}
            />
            &nbsp;over&nbsp;{printMeasurable(step.step_time)}&nbsp;min
          </td>
        );
    case "sparge":
      return (
        <td style={{ display: "flex" }}>
          Fly sparge with&nbsp;
          <CrossUnitsInput measurable={spargeVolume} units={["l", "gal"]} />
          &nbsp;water at&nbsp;
          <CrossUnitsInput
            measurable={step.step_temperature}
            units={["C", "F"]}
            precision={0}
          />
        </td>
      );
    case "infusion":
    default:
      return (
        <td style={{ display: "flex" }}>
          Add&nbsp;
          <CrossUnitsInput measurable={step.amount} units={["l", "gal"]} />
          &nbsp;of water at&nbsp;
          <CrossUnitsInput
            measurable={step.step_temperature}
            units={["C", "F"]}
            precision={0}
          />
        </td>
      );
  }
};

const MashSteps = ({ recipe, equipment }) => {
  const mashGrainWeight = calcMashGrainWeight(
    recipe.ingredients.fermentable_additions
  );
  const recalculatedMashSteps = recalculateMashSteps(
    recipe.mash.mash_steps,
    recipe.mash.grain_temperature,
    mashGrainWeight
  );

  const { pre_boil_size } = calcBoilVolumes(
    recipe.batch_size,
    recipe.boil,
    equipment
  );

  const { sparge_volume } = calcMashVolumes(
    pre_boil_size,
    recalculatedMashSteps,
    mashGrainWeight,
    equipment
  );

  return (
    <Card>
      <Card.Header>Mash Steps</Card.Header>
      <Table striped bordered hover>
        <thead>
          <tr>
            <th>Name</th>
            <th>Description</th>
            <th>Step Temperature</th>
            <th>Step Time</th>
          </tr>
        </thead>
        <tbody>
          {recalculatedMashSteps.map((step, index) => (
            <tr key={index}>
              <td>{step.name}</td>
              <MashStepDescription step={step} spargeVolume={sparge_volume} />
              <td>
                <CrossUnitsInput
                  measurable={step.step_temperature}
                  units={["C", "F"]}
                  precision={0}
                />
              </td>
              <td>
                <CrossUnitsInput
                  measurable={step.step_time}
                  units={["min"]}
                  precision={0}
                />
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </Card>
  );
};

export default MashSteps;


<|start_filename|>app/src/containers/EditorContainer.js<|end_filename|>
import React from "react";
import { Accordion } from "react-bootstrap";
import { connect } from "react-redux";

import AceEditor from "react-ace";
import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/mode-json";

//TODO:
//https://github.com/securingsincity/react-ace/issues/240

const EditorContainer = ({ editorState, onContentUpdate }) => (
  <Accordion>
    <Accordion.Toggle eventKey="1">
      &#8680; Expand and edit recipe and equipment JSON
    </Accordion.Toggle>
    <Accordion.Collapse eventKey="1">
      <AceEditor
        mode="json"
        theme="github"
        onChange={onContentUpdate}
        value={editorState}
        width="100%"
        maxLines={100}
        editorProps={{ $blockScrolling: Infinity }}
      />
    </Accordion.Collapse>
  </Accordion>
);

const mapStateToProps = ({ editorState }) => ({ editorState });

const mapDispatchToProps = (dispatch) => ({
  onContentUpdate: (editorState) => {
    dispatch({
      type: "UPDATE_EDITOR_STATE",
      payload: editorState,
    });
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(EditorContainer);


<|start_filename|>app/src/data/equipment.js<|end_filename|>
export const equipment = {
  name: "Pot (13 Gal/50 L) - BIAB",
  equipment_items: {
    brew_kettle: {
      name: "Pot (13 Gal/50 L) - BIAB",
      form: "Brew Kettle",
      maximum_volume: {
        value: 50,
        unit: "l",
      },
      loss: {
        value: 3.8232879,
        unit: "l",
      },
      boil_rate_per_hour: {
        value: 5.364,
        unit: "l",
      },
    },
  },

  /*boilSize: 36.019978,
  batchSize: 23.0154363,
  tunVolume: 50.0055779,
  tunWeight: 6.9989246,
  tunSpecificHeat: 0.12,
  topUpWater: "0.0000000",
  trubChillerLoss: 3.8232879,
  evapRate: 0.14923151,
  boilTime: "90.0000000",
  calcBoilVolume: "TRUE",
  lauterDeadspace: 0,
  topUpKettle: 0,
  hopUtilization: "100.0000000",
  coolingLossPct: 0.04,
  efficiency: 0.68,
  BIAB: false,


  tunWeight: "144.0000000",
  tunSpecificHeat: "0.3000000",
  displayTunTemp: "22.2 C",
  displayTunWeight: "4.08 kg",

  */
};


<|start_filename|>app/src/components/CrossUnitsInput.js<|end_filename|>
import React from "react";
import PropTypes from "prop-types";
import { convert } from "brewcalc";

const unitLabelMap = {
  sg: "SG",
  plato: "°P",
  F: "°F",
  C: "°C",
};

const stringifyMeasurable = (measurable, precision) => {
  const unit = unitLabelMap[measurable.unit] || measurable.unit;
  const value =
    precision != null ? +measurable.value.toFixed(precision) : measurable.value;
  return `${value} ${unit}`;
};

const convertMeasurable = (measurable, unit, precision) => {
  return {
    value: convert(measurable.value, measurable.unit, unit, precision),
    unit: unit,
  };
};

export const printMeasurable = (measurable, convertTo, precision = 0) => {
  if (measurable == null) {
    return "";
  }
  if (convertTo == null) {
    return stringifyMeasurable(measurable, precision);
  }
  return stringifyMeasurable(
    convertMeasurable(measurable, convertTo, precision)
  );
};

const CrossUnitsInput = ({
  name,
  description,
  measurable,
  units = [],
  precision = 2,
}) => {
  if (measurable == null || measurable.value == null) {
    return null;
  }

  const [primary, secondary] = units;

  return (
    <div title={description}>
      {name && <b>{name} </b>}
      {printMeasurable(measurable, primary, precision)}
      {secondary && ` (${printMeasurable(measurable, secondary, precision)})`}
    </div>
  );
};

CrossUnitsInput.propTypes = {
  name: PropTypes.string,
  description: PropTypes.string,
  measurable: PropTypes.shape({
    value: PropTypes.number,
    unit: PropTypes.string,
  }),
  units: PropTypes.arrayOf(PropTypes.string),
  precision: PropTypes.number,
};

export default CrossUnitsInput;


<|start_filename|>app/src/App.js<|end_filename|>
import React from 'react'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import PageRecipe from './containers/PageRecipe'
import updateEditor from './redux/reducers/updateEditor'
import { persistedState } from './redux/reducers/updateEditor'

const store = createStore(
  updateEditor,
  persistedState,
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
)
store.subscribe(() => {
  localStorage.setItem('brewCalcState', JSON.stringify(store.getState()))
})

class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <div className="App">
          <PageRecipe />
        </div>
      </Provider>
    )
  }
}

export default App


<|start_filename|>app/src/components/Stats.js<|end_filename|>
import React from "react";
import { Card, Row, Col } from "react-bootstrap";
import CrossUnitsInput, { printMeasurable } from "./CrossUnitsInput";

import {
  calcOriginalGravity,
  calcFinalGravity,
  calcBoilGravity,
  calcColor,
  calcABV,
  calcCalories,
  srmToCss,
  bitternessIbuTinseth,
  convert,
  calcBoilVolumes,
} from "brewcalc";

const Stats = ({ recipe, equipment }) => {
  const { batch_size, boil, ingredients, efficiency } = recipe;

  const { fermentable_additions, hop_additions, culture_additions } =
    ingredients;

  const og = calcOriginalGravity(batch_size, fermentable_additions, efficiency);

  const fg = calcFinalGravity(
    batch_size,
    fermentable_additions,
    efficiency,
    culture_additions
  );

  const { pre_boil_size } = calcBoilVolumes(batch_size, boil, equipment);

  const boilGravity = calcBoilGravity(batch_size, pre_boil_size, og);

  const ibu = bitternessIbuTinseth(hop_additions, boilGravity, batch_size);

  const color = calcColor(fermentable_additions, batch_size);

  const abv = calcABV(og, fg);
  const calories = calcCalories(og.value, fg.value);
  const caloriesInOneL = calories / (12 * convert(1, "floz", "l"));

  return (
    <Card>
      <Card.Header>Gravity, Alcohol Content and Color</Card.Header>
      <Card.Body>
        <Row className="show-grid">
          <Col md={6}>
            <div>
              <CrossUnitsInput
                description="The estimated original gravity of this recipe"
                name="Original Gravity"
                measurable={og}
                units={["sg", "plato"]}
                precision={3}
              />
              <CrossUnitsInput
                description="The estimated final gravity of this recipe"
                name="Final Gravity"
                measurable={fg}
                units={["sg", "plato"]}
                precision={3}
              />
              <div title="The bitterness of the recipe as measured in International Bitterness Units">
                <b>Bitterness (IBUs): </b>
                {printMeasurable(ibu, null, 0)} by Tinseth formula
              </div>
              <CrossUnitsInput
                description="Estimated color of this beer"
                name="Color"
                measurable={color}
                units={["SRM", "EBC"]}
                precision={0}
              />
              <div title="The estimated alcohol by volume for this recipe">
                <b>Alcohol by volume : </b>
                {printMeasurable(abv)}
              </div>
              <div title="Calories in one liter of the beer, based on original and final gravities">
                <b>Calories: </b>
                {caloriesInOneL.toFixed(0)} per one L
              </div>
            </div>
          </Col>
          <Col md={6}>
            <div
              style={{
                backgroundColor: srmToCss(color.value),
                height: 150,
              }}
            />
          </Col>
        </Row>
      </Card.Body>
    </Card>
  );
};

export default Stats;


<|start_filename|>package.json<|end_filename|>
{
  "name": "brewcalc",
  "version": "0.2.4",
  "description": "A modern (ES6) functional JavaScript library for brewing calculations.",
  "main": "lib/index.js",
  "scripts": {
    "compile": "npx tsc",
    "build": "webpack --env build",
    "build:dev": "webpack --env dev",
    "dev": "webpack --progress --colors --watch --env dev",
    "test": "jest",
    "test:coverage": "jest --coverage",
    "test:watch": "jest --watch",
    "pack": "yarn compile && npm pack",
    "pack:app": "yarn run pack && mv -v ./brewcalc-* app/brewcalc.tgz"
  },
  "directories": {
    "lib": "./lib"
  },
  "dependencies": {
    "@babel/runtime": "^7.0.0"
  },
  "devDependencies": {
    "@babel/core": "7.0.0",
    "@babel/plugin-proposal-class-properties": "7.0.0",
    "@babel/plugin-proposal-export-default-from": "7.0.0",
    "@babel/plugin-proposal-export-namespace-from": "7.0.0",
    "@babel/plugin-transform-react-jsx": "7.14.5",
    "@babel/plugin-transform-runtime": "7.0.0",
    "@babel/preset-env": "7.0.0",
    "@babel/preset-typescript": "7.14.5",
    "@beerjson/beerjson": "1.0.0",
    "@types/jest": "26.0.24",
    "babel-core": "^7.0.0-0",
    "babel-jest": "26.6.3",
    "jest": "26.6.3",
    "pixl-xml": "^1.0.13",
    "ts-loader": "8.3.0",
    "typescript": "4.3.5",
    "webpack": "4.46.0",
    "webpack-cli": "4.9.0"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/brewcomputer/brewcalc.git"
  },
  "keywords": [],
  "author": "<NAME>",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/brewcomputer/brewcalc/issues"
  },
  "homepage": "https://github.com/brewcomputer/brewcalc"
}


<|start_filename|>app/src/components/Ingredients.js<|end_filename|>
import React from "react";
import { Card, Table } from "react-bootstrap";
import CrossUnitsInput, { printMeasurable } from "./CrossUnitsInput";

const Ingredients = ({ recipe }) => {
  const { fermentable_additions, hop_additions, culture_additions } =
    recipe.ingredients;
  return (
    <Card>
      <Card.Header>Ingredients</Card.Header>

      <Table striped bordered hover>
        <thead>
          <tr>
            <th>Amount</th>
            <th>Name</th>
            <th>Type</th>
          </tr>
        </thead>
        <tbody>
          {fermentable_additions.map((i, index) => (
            <tr key={index}>
              <td>
                <CrossUnitsInput measurable={i.amount} units={["kg", "lb"]} />
              </td>
              <td>
                {i.name} ({printMeasurable(i.color, "SRM", 0)})
              </td>
              <td style={{ textTransform: "capitalize" }}>{i.type}</td>
            </tr>
          ))}
          {hop_additions.map((i, index) => (
            <tr key={index}>
              <td>
                <CrossUnitsInput measurable={i.amount} units={["g", "oz"]} />
              </td>
              <td>
                {i.name} ({printMeasurable(i.alpha_acid)} Alpha, Boil time{" "}
                {printMeasurable(i.timing.time)})
              </td>
              <td>Hop</td>
            </tr>
          ))}
          {culture_additions.map((i, index) => (
            <tr key={index}>
              <td>{printMeasurable(i.amount, null, 2)}</td>
              <td>
                {i.name} (Attenuation {printMeasurable(i.attenuation, null, 2)},
                Form {i.form})
              </td>
              <td>Yeast</td>
            </tr>
          ))}
        </tbody>
      </Table>
    </Card>
  );
};

export default Ingredients;
