├── .gitignore ├── .node-version ├── LICENSE ├── README.md ├── doc └── soda-demo-cropped.gif ├── netlify.toml ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── og-image.png ├── postlight-logo.svg └── robots.txt ├── src ├── App.css ├── App.js ├── App.test.js ├── Nav.css ├── Nav.js ├── Section.css ├── Section.js ├── Slider.css ├── Slider.js ├── Source.css ├── Statement.css ├── Statement.js ├── Text.css ├── Text.js ├── compile-texts.js ├── index.css ├── index.js ├── serviceWorker.js ├── setupTests.js ├── smarter-text.js └── texts │ ├── blog.txt │ ├── coffee.txt │ ├── disease.txt │ ├── fire.txt │ ├── freelancer.txt │ ├── funnel.txt │ ├── hankies.txt │ ├── magazine.txt │ ├── soda.txt │ └── vc.txt └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | *~ 26 | \#* 27 | .#* 28 | 29 | src/texts/compiled.json 30 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v16.14.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Postlight 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Account: A tiny tool for accounts that account! 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/0ccc4ad6-ff9f-472a-b1e8-41dd333a6c02/deploy-status)](https://app.netlify.com/sites/account-account/deploys) 4 | 5 | [Postlight](https://postlight.com)'s Account is a markup format for making web pages like this: 6 | 7 | ![An animated gif of a demo of this code.](./doc/soda-demo-cropped.gif) 8 | 9 | Check out [a live demo of Account](https://account.postlight.com/), and [read more about the project](https://postlight.com/trackchanges/the-worlds-worst-calculator). 10 | 11 | ## What is Account? 12 | 13 | It's a tool for making short accounts, which are accounts that account for themselves using accounting. 14 | 15 | In less annoying terms, it parses a tiny markup format and makes interactive web content with sliders. When you change a value in one slider it may change lots of other values. 16 | 17 | My name is [Paul Ford](https://github.com/ftrain/) and I made it because I make a lot of little spreadsheets to work out how things work, and it's hard to share them and make them comprehensible. Plus it was a fun two-weekend project while we're all at home. 18 | 19 | Now that I've made it I will return to it when I want to use it. 20 | 21 | ## Why wouldn't I use Tangle/Idyll/Smalltalk-80/Excel? 22 | 23 | You should use those, they do more and are better. [Tangle](http://worrydream.com/Tangle/), [Idyll](https://idyll-lang.org/), [Smalltalk-80](https://pharo.org/), or a [spreadsheet](https://en.wikipedia.org/wiki/VisiCalc) are tools for smart people who like code, or spreadsheet people who like numbers. Account is a tool for dumb people who like moving sliders around so they can watch the numbers go, like me. 24 | 25 | ## How do I edit the text? 26 | 27 | You don't yet, you have to pull this repository and make your own. I'm releasing early. Pull requests welcome. 28 | 29 | ## Sample text 30 | 31 | To make the page shown in the screenshot above, you'd write: 32 | 33 | ``` 34 | :cup_with_straw: You drink 35 | {0-4:sodas_daily} 36 | Diet Cokes per day, at a cost of 37 | ${0.00-3.50:soda_cost} per Diet 38 | Coke. 39 | 40 | If you'd put that into an index 41 | fund with a {-10.00-12.00:annual_yield}% 42 | annual rate of return, you'd have 43 | ${=((((sodas_daily * 365) * soda_cost) / 12) * 44 | (((1 + ((annual_yield/100)/12)) ^ 120) - 1) 45 | / ((annual_yield/100)/12)):total} 46 | 47 | within a decade. :+1: 48 | 49 | ``` 50 | 51 | That's the formula for compound interest I got off some website. I'm sure I screwed something up. Pull requests welcomed. 52 | 53 | Notice that newlines don't really matter. They're not real and they can't hurt you. If you want to include spacing between lines you can't. Paragraphs were a wasteful orthographic indulgence by lazy monks and we don't allow them here. 54 | 55 | ## What it does 56 | 57 | - Reads a text file, and by text I mean text. 58 | - Respects emojis between ```:``` colons like ```:+1:```. 59 | - Respects two special bracketing formats: 60 | 1. ```{[single number or range]:[variable name]}``` 61 | 2. ```{=[expression]:[variable name]}``` 62 | 63 | So: 64 | 65 | ```{10-20:wholes} wholes is {=wholes * 2:halves} halves.``` 66 | 67 | Yields: 68 | 69 | > ```=O=``` 15 wholes is *20* halves. 70 | 71 | - (Where ```=O=``` is a `````` slider in HTML5.) And when you move the slider around the numbers change. Whoo hoo! 72 | - Numbers are just numbers, and can be negative (currently only on the left-hand-side of a statement, sorry!) or have decimal points. 73 | - If you use a dollar sign ala ```${100:dollars}``` it will try to format things intelligently. 74 | - It'll try to keep the number of decimal points steady, i.e. if you type ```{0.00-100.00:rating}``` it'll format the output to the hundredth after the decimal. (Most of that stuff is hacky, YMMV.) 75 | - It respects Markdown-style link formatting, so `[Wikipedia](https://www.wikipedia.org)` will render `Wikipedia`. 76 | 77 | ## Under the hood 78 | 79 | I run a software firm which means I'm an executive programmer: I did very little and delegated all the hard work to libraries, while taking all the credit. 80 | 81 | ### Mathing 82 | The thing that does the math is [expr-eval](https://github.com/silentmatt/expr-eval), which has most of the regular functions you'd expect and is pretty nice about symbols, and is both fast and reliable after trying a few alternatives. 83 | 84 | - The math works like math. 85 | - You need to declare variables in the order you expect them to be evaluated; i.e. you can't declare ```x``` at the bottom of your document and expect ```x``` to be available at the top. 86 | 87 | ### Parsing 88 | The text is parsed by [parsimmon](https://github.com/jneen/parsimmon), which was fun to learn, once I gave up on regular expressions and just accepted that I could concat unmatched text after parse. 89 | 90 | ### Formatting 91 | 92 | The numbers are formatted by [numeral.js](http://numeraljs.com/), which does what it says. 93 | 94 | ### Hamburgling 95 | 96 | Note as well the very fine [React Hamburger Menu](https://www.npmjs.com/package/react-hamburger-menu) which gave this site a hamburger menu so that I didn't have to read through five React Hamburger Menu tutorials while cutting-and-pasting the one approach that would work with Router and React hooks 16.8 or greater. 97 | 98 | ## Code Notes 99 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). It has yet to be ejected. 100 | 101 | If you'd like to run it locally, you can: 102 | 103 | ```bash 104 | yarn install 105 | yarn start 106 | ``` 107 | 108 | ## Could this be used for evil? 109 | - Yes, if people used it to "prove" things that are nonsense, like a Eugenics calculator about improving the genetic stock of humanity, or a calculator that proved that a certain percentage of humans must be turned into food. 110 | - C.f. also "How to Lie with Statistics." 111 | - I'll consider those risks as time passes. Since the only way to publish is to set up a whole new thingy on the web and deploy it, or to issue a pull request, the risk of malice is low. 112 | - The risk of incompetence is extremely high as always. 113 | 114 | ## TODOs 115 | - Math 116 | - Some sort of array generator so that you can do sigmas via the ```fold``` inside of the ```expr-eval``` math functions. Maybe you have something like ```{#48:months}``` and that knows to generate an array from ```[n..48]``` that you can then use in sigma functions to calculate IRR or what-have-you. 117 | - Interface 118 | - A charting module, given the above; if I know I'm over 48 months then anyting that interacts with months returns an array, I should be able to drop a chart in there. 119 | - A way to edit in the browser and save somehow or other. Since it's just ASCII maybe it could be hacked to just save into some simple CMS. It'd be fine except for then needing to set up accounts, and moderate, and do all the other things. Maybe it could just pull live from a Gist. Maybe I'll use some auth service like a young person. 120 | - Live editing! Very simple because the parser is all JavaScript. Text on the left, live results on the right. 121 | - Content 122 | - Many more fun calculators made of text. 123 | - The ability to inline HTML. 124 | - Citations so that we know where the math is coming from. 125 | - Design 126 | - Actual design by designers who design 127 | 128 | --- 129 | 130 | 🔬 A Labs project from your friends at [Postlight](https://postlight.com). Happy coding! 131 | -------------------------------------------------------------------------------- /doc/soda-demo-cropped.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/doc/soda-demo-cropped.gif -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 200 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "account", 3 | "version": "0.3.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "expr-eval": "^2.0.2", 10 | "numeral": "^2.0.6", 11 | "parsimmon": "^1.13.0", 12 | "prettier": "^2.0.5", 13 | "prismjs": "^1.20.0", 14 | "react": "18.2.0", 15 | "react-dom": "18.2.0", 16 | "react-emoji-render": "^1.2.2", 17 | "react-hamburger-menu": "^1.2.1", 18 | "react-router-dom": "6.4.3", 19 | "react-scripts": "5.0.1" 20 | }, 21 | "scripts": { 22 | "compile-texts": "node ./src/compile-texts.js", 23 | "start": "yarn compile-texts && react-scripts start", 24 | "build": "yarn compile-texts && react-scripts build", 25 | "test": "yarn compile-texts && react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | Account 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 38 | 39 | 40 | 41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/public/og-image.png -------------------------------------------------------------------------------- /public/postlight-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@200;400;700&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Serif:wght@200;400;700&display=swap'); 3 | 4 | body { 5 | font-size:1.5em; 6 | line-height:2em; 7 | font-family: 'IBM Plex Sans', sans-serif; 8 | color:#444; 9 | } 10 | 11 | .expression { 12 | font-weight:bold; 13 | color:#222; 14 | } 15 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import parse from "./smarter-text"; 2 | import React from "react"; 3 | import { useParams, Navigate } from "react-router-dom"; 4 | 5 | import "./App.css"; 6 | import Section from "./Section"; 7 | import Nav from "./Nav"; 8 | 9 | const textFiles = require('./texts/compiled.json') 10 | const textVars = Object.fromEntries( 11 | Object 12 | .entries(textFiles) 13 | .map(([filename, text]) => [filename, [...parse(text), text]]) 14 | ); 15 | 16 | function App() { 17 | let { page } = useParams(); 18 | if (!textVars[page]) return ; 19 | const [ast, astState, rawText] = textVars[page]; 20 | 21 | return ( 22 |
23 |
26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import App from "./App"; 4 | 5 | test("renders learn react link", () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/Nav.css: -------------------------------------------------------------------------------- 1 | .burger { 2 | position:fixed; 3 | margin-top: 15px; 4 | margin-left: 15px; 5 | } 6 | 7 | #nav { 8 | font-size:125%; 9 | right:0px; 10 | position:fixed; 11 | width:100%; 12 | height:100%; 13 | background:slategray; 14 | color:white; 15 | top:55px; 16 | left:0; 17 | right:100px; 18 | padding:1em 0 0 2em; 19 | left:-120%; 20 | transition:left 0.2s; 21 | overflow-y:scroll; 22 | } 23 | 24 | div#nav.hamburger-true { 25 | visibility:visible; 26 | left:0; 27 | transition:left 0.2s; 28 | } 29 | div#nav.hamburger-false { 30 | 31 | visibility:none; 32 | } 33 | 34 | #inner-nav { 35 | height:60%; 36 | overflow-y:scroll; 37 | 38 | } 39 | #nav a { 40 | color:white; 41 | } 42 | 43 | #footer { 44 | font-size:12pt; 45 | line-height:1.25em; 46 | width:200px; 47 | bottom:5em; 48 | position:absolute; 49 | right:70px; 50 | background:slategray; 51 | } 52 | -------------------------------------------------------------------------------- /src/Nav.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import HamburgerMenu from "react-hamburger-menu"; 3 | import "./Nav.css"; 4 | import { Link } from "react-router-dom"; 5 | 6 | function Nav(props) { 7 | const [hamburgerOpen, setHamburgerOpen] = useState(false); 8 | 9 | function makeLink(k, i) { 10 | return ( 11 |
12 | { 15 | setHamburgerOpen(false); 16 | }} 17 | className="navEl" 18 | > 19 | {k} 20 | 21 |
22 | ); 23 | } 24 | 25 | return ( 26 | <> 27 |
28 | setHamburgerOpen(hamburgerOpen ? false : true)} 31 | width={30} 32 | height={25} 33 | strokeWidth={4} 34 | borderRadius={2} 35 | rotate={0} 36 | animationDuration={0.2} 37 | className="burgerComponent" 38 | color="slategray" 39 | /> 40 |
41 | 59 | 60 | ); 61 | } 62 | 63 | export default Nav; 64 | -------------------------------------------------------------------------------- /src/Section.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | font-weight: 200; 3 | font-size: 2em; 4 | color: slategray; 5 | text-align: right; 6 | margin: 0em 0 0.25em 0; 7 | } 8 | 9 | button { 10 | align-items: center; 11 | background: none; 12 | border: none; 13 | color: slategray; 14 | display: flex; 15 | font-size: 0.7em; 16 | font-weight: bold; 17 | margin: 20px 0; 18 | } 19 | 20 | button:hover { 21 | cursor: pointer; 22 | } 23 | 24 | #text { 25 | padding: 0.25em 2em 0 2em; 26 | color: #444; 27 | } 28 | 29 | .source { 30 | border: 1px solid slategray; 31 | max-height: 250px; 32 | overflow-y: scroll; 33 | } 34 | 35 | .up-triangle { 36 | border-bottom: 8px solid transparent; 37 | border-left: 8px solid slategray; 38 | border-top: 8px solid transparent; 39 | margin-left: 8px; 40 | height: 0px; 41 | width: 0px; 42 | } 43 | 44 | .down-triangle { 45 | border-left: 8px solid transparent; 46 | border-right: 8px solid transparent; 47 | border-top: 8px solid slategray; 48 | margin-left: 8px; 49 | height: 0px; 50 | width: 0px; 51 | } 52 | 53 | .negative { 54 | color: red; 55 | } 56 | -------------------------------------------------------------------------------- /src/Section.js: -------------------------------------------------------------------------------- 1 | import numeral from "numeral"; 2 | import React, { useState, useEffect } from "react"; 3 | import Slider from "./Slider"; 4 | import Statement from "./Statement"; 5 | import Text from "./Text"; 6 | import Prism from "prismjs"; 7 | 8 | import "./Source.css"; 9 | import "./Section.css"; 10 | 11 | const template = Prism.languages.javascript["template-string"].inside; 12 | 13 | Prism.languages.account = { 14 | ...template, 15 | interpolation: { 16 | ...template.interpolation, 17 | pattern: /((?:^|[^\\])(?:\\{2})*){(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}/, 18 | }, 19 | }; 20 | 21 | function Section({ ast, astState, page, rawText }) { 22 | const [viewSource, setViewSource] = useState(false); 23 | const [state, setState] = useState(readFields()); 24 | const [historyState, setHistoryState] = 25 | useState(new URLSearchParams(window.location.search).toString()) 26 | 27 | function addField(k, v) { 28 | const newState = { ...state, [k]: v } 29 | const newHistoryState = new URLSearchParams(historyState) 30 | newHistoryState.set(k, v) 31 | setHistoryState(newHistoryState.toString()) 32 | window.history.replaceState({}, null, `/${page}?${newHistoryState.toString()}`) 33 | setState(newState) 34 | return v; 35 | } 36 | 37 | function readFields() { 38 | const searchParams = new URLSearchParams(window.location.search); 39 | 40 | return Object.fromEntries( 41 | Object.keys(astState).map((k) => { 42 | return [k, searchParams.get(k) || astState[k]]; 43 | }) 44 | ); 45 | } 46 | 47 | function toComponents(o, i) { 48 | switch (o.type) { 49 | case "text": 50 | return ; 51 | 52 | case "link": 53 | return {o.anchorText}; 54 | 55 | case "statement": 56 | return ( 57 | 58 | 65 | 66 | 67 | ); 68 | 69 | case "expression": 70 | function format(n) { 71 | const num = numeral(n); 72 | if (n < 10 && n > -10) { 73 | return num.format("0.00"); 74 | } 75 | return num.format("-0,0"); 76 | } 77 | function evaluate(o) { 78 | const n = o.eval(state); 79 | state[o.variable] = n; 80 | return n; 81 | } 82 | const n = evaluate(o); 83 | const sign = n > 0 ? "positive" : "negative"; 84 | return ( 85 | 86 | {format(n)} 87 | 88 | ); 89 | 90 | default: 91 | return undefined; 92 | } 93 | } 94 | 95 | useEffect(() => { 96 | if (viewSource) { 97 | Prism.highlightAll(); 98 | } 99 | }, [viewSource]); 100 | 101 | return ( 102 |
103 |

{page}

104 | {ast.map(toComponents)} 105 | 118 | {viewSource && ( 119 |
120 |
121 |             {rawText}
122 |           
123 |
124 | )} 125 |
126 | ); 127 | } 128 | 129 | export default Section; 130 | -------------------------------------------------------------------------------- /src/Slider.css: -------------------------------------------------------------------------------- 1 | .statement { 2 | text-decoration:underline; 3 | } 4 | 5 | .full-statement { 6 | white-space:nowrap; 7 | } 8 | 9 | 10 | #inputs { 11 | margin:4em 25% 2em 1em; 12 | left:0px; 13 | top:0px; 14 | position:fixed; 15 | } 16 | 17 | input[type=range] { 18 | width:6em; 19 | display:inline; 20 | padding:0; 21 | margin:0 0.5em 0 0.5em; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/Slider.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./Slider.css"; 3 | 4 | function Slider(props) { 5 | function handleChange(event) { 6 | props.addField(props.variable, event.target.value); 7 | } 8 | 9 | function getStep(s, max) { 10 | if (s.match(/\./)) { 11 | return s.replace(/.+?\.(\d+)\d$/, "0.$11"); 12 | } else { 13 | if (max < 1000) { 14 | return 1; 15 | } else { 16 | var order = Math.floor(Math.log(max) / Math.LN10 + 0.000000001); 17 | return Math.pow(10, order - 2); 18 | } 19 | } 20 | } 21 | return ( 22 | 23 | handleChange(e)} 26 | value={props.valueFromState} 27 | step={getStep(props.value.formatString, props.value.max)} 28 | max={props.value.max} 29 | min={props.value.min} 30 | /> 31 | 32 | ); 33 | } 34 | 35 | export default Slider; 36 | -------------------------------------------------------------------------------- /src/Source.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.20.0 2 | https://prismjs.com/download.html#themes=prism-coy&languages=clike+javascript&plugins=toolbar */ 3 | /** 4 | * prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML 5 | * Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics); 6 | * @author Tim Shedor 7 | */ 8 | 9 | code[class*="language-"], 10 | pre[class*="language-"] { 11 | color: green; 12 | background: none; 13 | font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; 14 | font-size: 14px; 15 | text-align: left; 16 | white-space: pre-wrap; 17 | word-spacing: normal; 18 | word-break: normal; 19 | word-wrap: normal; 20 | line-height: 24px; 21 | 22 | -moz-tab-size: 4; 23 | -o-tab-size: 4; 24 | tab-size: 4; 25 | 26 | -webkit-hyphens: none; 27 | -moz-hyphens: none; 28 | -ms-hyphens: none; 29 | hyphens: none; 30 | z-index: -1; 31 | } 32 | 33 | /* Code blocks */ 34 | pre[class*="language-"] { 35 | position: relative; 36 | margin: 0.5em 0; 37 | overflow: visible; 38 | padding: 0; 39 | } 40 | pre[class*="language-"] > code { 41 | position: relative; 42 | background-color: #fdfdfd; 43 | background-size: 3em 3em; 44 | background-origin: content-box; 45 | background-attachment: local; 46 | padding: 8px; 47 | } 48 | 49 | code[class*="language"] { 50 | max-height: inherit; 51 | height: inherit; 52 | padding: 0 1em; 53 | display: block; 54 | overflow: auto; 55 | } 56 | 57 | /* Margin bottom to accommodate shadow */ 58 | :not(pre) > code[class*="language-"], 59 | pre[class*="language-"] { 60 | background-color: #fdfdfd; 61 | -webkit-box-sizing: border-box; 62 | -moz-box-sizing: border-box; 63 | box-sizing: border-box; 64 | margin-bottom: 1em; 65 | } 66 | 67 | /* Inline code */ 68 | :not(pre) > code[class*="language-"] { 69 | position: relative; 70 | padding: 0.2em; 71 | border-radius: 0.3em; 72 | color: #c92c2c; 73 | border: 1px solid rgba(0, 0, 0, 0.1); 74 | display: inline; 75 | white-space: pre-wrap; 76 | } 77 | 78 | pre[class*="language-"]:before, 79 | pre[class*="language-"]:after { 80 | content: ""; 81 | /* z-index: -2; */ 82 | display: block; 83 | position: absolute; 84 | bottom: 0.75em; 85 | left: 0.18em; 86 | width: 40%; 87 | height: 20%; 88 | max-height: 13em; 89 | /* box-shadow: 0px 13px 8px #979797; */ 90 | -webkit-transform: rotate(-2deg); 91 | -moz-transform: rotate(-2deg); 92 | -ms-transform: rotate(-2deg); 93 | -o-transform: rotate(-2deg); 94 | transform: rotate(-2deg); 95 | } 96 | 97 | :not(pre) > code[class*="language-"]:after, 98 | pre[class*="language-"]:after { 99 | right: 0.75em; 100 | left: auto; 101 | -webkit-transform: rotate(2deg); 102 | -moz-transform: rotate(2deg); 103 | -ms-transform: rotate(2deg); 104 | -o-transform: rotate(2deg); 105 | transform: rotate(2deg); 106 | } 107 | 108 | .token.comment, 109 | .token.block-comment, 110 | .token.prolog, 111 | .token.doctype, 112 | .token.cdata { 113 | color: #7d8b99; 114 | } 115 | 116 | .token.punctuation { 117 | color: #5f6364; 118 | } 119 | 120 | .token.property, 121 | .token.tag, 122 | .token.boolean, 123 | .token.number, 124 | .token.function-name, 125 | .token.constant, 126 | .token.symbol, 127 | .token.deleted { 128 | color: #c92c2c; 129 | } 130 | 131 | .token.selector, 132 | .token.attr-name, 133 | .token.string, 134 | .token.char, 135 | .token.function, 136 | .token.builtin, 137 | .token.inserted { 138 | color: black; 139 | } 140 | 141 | .token.operator, 142 | .token.entity, 143 | .token.url, 144 | .token.variable { 145 | color: #a67f59; 146 | background: rgba(255, 255, 255, 0.5); 147 | } 148 | 149 | .token.atrule, 150 | .token.attr-value, 151 | .token.keyword, 152 | .token.class-name { 153 | color: #1990b8; 154 | } 155 | 156 | .token.regex, 157 | .token.important { 158 | color: #e90; 159 | } 160 | 161 | .language-css .token.string, 162 | .style .token.string { 163 | color: #a67f59; 164 | background: rgba(255, 255, 255, 0.5); 165 | } 166 | 167 | .token.important { 168 | font-weight: normal; 169 | } 170 | 171 | .token.bold { 172 | font-weight: bold; 173 | } 174 | .token.italic { 175 | font-style: italic; 176 | } 177 | 178 | .token.entity { 179 | cursor: help; 180 | } 181 | 182 | .token.namespace { 183 | opacity: 0.7; 184 | } 185 | 186 | @media screen and (max-width: 767px) { 187 | pre[class*="language-"]:before, 188 | pre[class*="language-"]:after { 189 | bottom: 14px; 190 | box-shadow: none; 191 | } 192 | } 193 | 194 | /* Plugin styles */ 195 | .token.tab:not(:empty):before, 196 | .token.cr:before, 197 | .token.lf:before { 198 | color: #e0d7d1; 199 | } 200 | 201 | /* Plugin styles: Line Numbers */ 202 | pre[class*="language-"].line-numbers.line-numbers { 203 | padding-left: 0; 204 | } 205 | 206 | pre[class*="language-"].line-numbers.line-numbers code { 207 | padding-left: 3.8em; 208 | } 209 | 210 | pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows { 211 | left: 0; 212 | } 213 | 214 | /* Plugin styles: Line Highlight */ 215 | pre[class*="language-"][data-line] { 216 | padding-top: 0; 217 | padding-bottom: 0; 218 | padding-left: 0; 219 | } 220 | pre[data-line] code { 221 | position: relative; 222 | padding-left: 4em; 223 | } 224 | pre .line-highlight { 225 | margin-top: 0; 226 | } 227 | 228 | div.code-toolbar { 229 | position: relative; 230 | } 231 | 232 | div.code-toolbar > .toolbar { 233 | position: absolute; 234 | top: 0.3em; 235 | right: 0.2em; 236 | transition: opacity 0.3s ease-in-out; 237 | opacity: 0; 238 | } 239 | 240 | div.code-toolbar:hover > .toolbar { 241 | opacity: 1; 242 | } 243 | 244 | /* Separate line b/c rules are thrown out if selector is invalid. 245 | IE11 and old Edge versions don't support :focus-within. */ 246 | div.code-toolbar:focus-within > .toolbar { 247 | opacity: 1; 248 | } 249 | 250 | div.code-toolbar > .toolbar .toolbar-item { 251 | display: inline-block; 252 | } 253 | 254 | div.code-toolbar > .toolbar a { 255 | cursor: pointer; 256 | } 257 | 258 | div.code-toolbar > .toolbar button { 259 | background: none; 260 | border: 0; 261 | color: inherit; 262 | font: inherit; 263 | line-height: normal; 264 | overflow: visible; 265 | padding: 0; 266 | -webkit-user-select: none; /* for button */ 267 | -moz-user-select: none; 268 | -ms-user-select: none; 269 | } 270 | 271 | div.code-toolbar > .toolbar a, 272 | div.code-toolbar > .toolbar button, 273 | div.code-toolbar > .toolbar span { 274 | color: #bbb; 275 | font-size: 0.8em; 276 | padding: 0 0.5em; 277 | background: #f5f2f0; 278 | background: rgba(224, 224, 224, 0.2); 279 | box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); 280 | border-radius: 0.5em; 281 | } 282 | 283 | div.code-toolbar > .toolbar a:hover, 284 | div.code-toolbar > .toolbar a:focus, 285 | div.code-toolbar > .toolbar button:hover, 286 | div.code-toolbar > .toolbar button:focus, 287 | div.code-toolbar > .toolbar span:hover, 288 | div.code-toolbar > .toolbar span:focus { 289 | color: inherit; 290 | text-decoration: none; 291 | } 292 | -------------------------------------------------------------------------------- /src/Statement.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/src/Statement.css -------------------------------------------------------------------------------- /src/Statement.js: -------------------------------------------------------------------------------- 1 | import numeral from "numeral"; 2 | 3 | import React from "react"; 4 | import "./Statement.css"; 5 | 6 | function Statement(props) { 7 | return ( 8 | 9 | {props.format === "dollar" ? "$" : ""} 10 | {numeral(props.valueFromState).format(props.value.formatString)} 11 | {props.format === "percentage" ? "%" : ""} 12 | 13 | ); 14 | } 15 | 16 | export default Statement; 17 | -------------------------------------------------------------------------------- /src/Text.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/account/92425cc51d29bc6e2fffb12eb284e3e3dc580b0d/src/Text.css -------------------------------------------------------------------------------- /src/Text.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./Text.css"; 3 | import Twemoji from "react-emoji-render"; 4 | 5 | function Text(props) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default Text; 14 | -------------------------------------------------------------------------------- /src/compile-texts.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const TEXTS_DIR = './src/texts' 4 | const filenames = fs.readdirSync(TEXTS_DIR) 5 | 6 | const data = Object.fromEntries( 7 | filenames 8 | .filter(f => f.endsWith('.txt')) 9 | .map((filename) => ([ 10 | filename.replace(/\.txt$/, ''), 11 | fs.readFileSync(`${TEXTS_DIR}/${filename}`).toString() 12 | ])) 13 | ) 14 | 15 | fs.writeFileSync(`${TEXTS_DIR}/compiled.json`, JSON.stringify(data)) 16 | 17 | console.log('Wrote src/texts/*.txt to src/texts/compiled.json') 18 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | #root { 16 | min-height: calc(100vh - (2em + 32px)); 17 | } 18 | 19 | footer { 20 | flex-shrink: 0; 21 | display: flex; 22 | justify-content: flex-end; 23 | font-size: 14px; 24 | line-height: 24px; 25 | align-items: top; 26 | padding: 1em 2em; 27 | } 28 | 29 | footer img { 30 | height: 32px; 31 | display: inline-block; 32 | vertical-align: top; 33 | margin-left: 10px; 34 | } 35 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import * as serviceWorker from "./serviceWorker"; 6 | import { 7 | BrowserRouter, 8 | Routes, 9 | Route, 10 | Navigate, 11 | } from "react-router-dom"; 12 | 13 | ReactDOM.render( 14 | 15 | 16 | 17 | } /> 18 | } /> 19 | 20 | 21 | , 22 | document.getElementById("root") 23 | ); 24 | 25 | // If you want your app to work offline and load faster, you can change 26 | // unregister() to register() below. Note this comes with some pitfalls. 27 | // Learn more about service workers: https://bit.ly/CRA-PWA 28 | serviceWorker.unregister(); 29 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener("load", () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | "This web app is being served cache-first by a service " + 46 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then((registration) => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === "installed") { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | "New content is available and will be used when all " + 74 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log("Content is cached for offline use."); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch((error) => { 97 | console.error("Error during service worker registration:", error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { "Service-Worker": "script" }, 105 | }) 106 | .then((response) => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get("content-type"); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf("javascript") === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | "No internet connection found. App is running in offline mode." 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ("serviceWorker" in navigator) { 133 | navigator.serviceWorker.ready 134 | .then((registration) => { 135 | registration.unregister(); 136 | }) 137 | .catch((error) => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | -------------------------------------------------------------------------------- /src/smarter-text.js: -------------------------------------------------------------------------------- 1 | const P = require("parsimmon"); 2 | 3 | // const nerdamer = require('nerdamer/all'); 4 | var ExpressionParser = require("expr-eval").Parser; 5 | var Exp = new ExpressionParser(); 6 | const numeral = require("numeral"); 7 | 8 | /* 9 | 10 | The goal is to make an AST out of a tiny templating DSL so that 11 | we can build interactive stories. For math we defer to one of 12 | the standard parsing libraries for basic equations. Math.js is 13 | vast but also was noticeably slow to load, so I went with 14 | expr-eval. 15 | 16 | let text = "If you had {10:apples_owned} and you gave me {0-10:apples_given}, you would have {=apples_owned - apples_given:apples_left}."; 17 | 18 | let ast = docParser.Doc.tryParse(text); 19 | 20 | // Which would give you ast back as... 21 | [ 22 | { type: 'text', value: 'If you had ' }, 23 | { type: 'number', key: 'apples_owned', value: 10 }, 24 | { type: 'text', value: ' and you gave me ' }, 25 | { type: 'range', key: 'apples_given', value: { low: 0, high: 10 } }, 26 | { type: 'text', value: ', you would have ' }, 27 | { 28 | type: 'math', 29 | key: 'apples_left', 30 | value: 'apples_owned - apples_given', 31 | asFunction: [Calculator function from expr-eval] 32 | }, 33 | { type: 'text', value: '.' } 34 | ] 35 | 36 | */ 37 | 38 | const makeStatement = function (parseResult, kind) { 39 | const [value, variable] = parseResult; 40 | return { 41 | type: "statement", 42 | format: kind, 43 | value: value, 44 | variable: variable, 45 | }; 46 | }; 47 | 48 | function explainDecimal(d) { 49 | const adjustedFormatString = d.replace(/\d/g, "0"); 50 | const formatString = adjustedFormatString.match(/^0+$/) 51 | ? "0,0" 52 | : adjustedFormatString; 53 | 54 | return { value: numeral(d).value(), formatString: formatString }; 55 | } 56 | 57 | // Add custom functions to Parser 58 | 59 | // With compounding interest, calculate how many months it will take to reach a goal balance 60 | // Given a start balance, a monthly investment, and an annual interest rate 61 | // Also make it readable 62 | Exp.functions.monthsToGoalBalanceViaCompoundInterest = function ( 63 | startBalance, 64 | goalBalance, 65 | monthlyInvestment, 66 | annualReturn 67 | ) { 68 | // Coerce to Numbers 69 | var balance = Number(startBalance); 70 | var endBalance = Number(goalBalance); 71 | var monthlyAddition = Number(monthlyInvestment); 72 | var monthlyRate = Number(annualReturn) / 12 / 100; 73 | var monthsPassed = 0; 74 | while (balance < endBalance) { 75 | balance += balance * monthlyRate + monthlyAddition; 76 | monthsPassed++; 77 | } 78 | return monthsPassed; 79 | }; 80 | 81 | // Same as above, but we don't know value yet 82 | function makeExpression(parseResult, kind) { 83 | const [expression, variable] = parseResult; 84 | return { 85 | type: "expression", 86 | format: kind, 87 | expression: expression, 88 | value: undefined, 89 | variable: variable, 90 | eval: (state) => Exp.evaluate(expression, state), 91 | }; 92 | } 93 | 94 | function makeRange(parseResult) { 95 | const x = parseResult; 96 | 97 | if (x.length > 1) { 98 | return { 99 | formatString: x[0].formatString, 100 | min: x[0].value, 101 | max: x[1].value, 102 | value: (x[1].value - x[0].value) / 2, 103 | }; 104 | } else { 105 | return { 106 | formatString: x[0].formatString, 107 | min: 0, 108 | max: x[0].value * 2, 109 | value: x[0].value, 110 | }; 111 | } 112 | } 113 | 114 | function makeLink(parseResult) { 115 | const matches = parseResult.match(/\[(.*?)\]\((.*?)\)/); 116 | 117 | return { 118 | type: "link", 119 | anchorText: matches[1], 120 | href: matches[2], 121 | }; 122 | } 123 | 124 | const docParser = P.createLanguage({ 125 | // The .many() is important -- we're parsing into an array of any 126 | // sequence of these Parser matches. The parser isn't expecting 127 | // that; it wants hierarchy. Without that, the parser fails and 128 | // says (most often) that it was expecting EOF. 129 | 130 | Doc: (r) => P.alt(r.DecimalExpression, r.Statement, r.Link, r.Text).many(), 131 | 132 | Statement: (r) => 133 | P.alt(r.DollarStatement, r.PercentageStatement, r.DecimalStatement), 134 | 135 | PlainStatement: (r) => P.string("{").then(r.Pair).skip(P.string("}")), 136 | 137 | DecimalStatement: (r) => 138 | r.PlainStatement.map((x) => makeStatement(x, "decimal")), 139 | 140 | DollarStatement: (r) => 141 | P.string("$") 142 | .then(r.PlainStatement) 143 | .map((x) => makeStatement(x, "dollar")), 144 | 145 | PercentageStatement: (r) => 146 | r.PlainStatement.skip(P.string("%")).map((x) => 147 | makeStatement(x, "percentage") 148 | ), 149 | 150 | DollarExpression: (r) => 151 | P.seq(P.string("$"), r.DecimalExpression).map((x) => 152 | makeExpression(x, "dollar") 153 | ), 154 | 155 | PercentageExpression: (r) => 156 | P.seq(r.DecimalExpression, P.string("%")).map((x) => 157 | makeExpression(x, "percentage") 158 | ), 159 | 160 | DecimalExpression: (r) => 161 | r.PlainExpression.map((x) => makeExpression(x, "decimal")), 162 | 163 | PlainExpression: (r) => P.string("{=").then(r.MathPair).skip(P.string("}")), 164 | 165 | Pair: (r) => P.seq(r.Range.skip(P.string(":")), r.Variable), 166 | 167 | MathPair: (r) => P.seq(r.Math.skip(P.string(":")), r.Variable), 168 | 169 | Range: (r) => P.sepBy1(r.Decimal, P.string("-")).map(makeRange), 170 | 171 | Variable: (r) => P.regexp(/[a-z_]+/), 172 | 173 | Math: (r) => P.regexp(/[^:]+/), 174 | 175 | Decimal: (r) => P.regexp(/[+-]?[0-9.]+/).map(explainDecimal), 176 | 177 | Link: (r) => P.regexp(/\[([^[\]]*)\]\((.*?)\)/).map(makeLink), 178 | 179 | Text: (r) => 180 | P.alt(P.any, P.whitespace).map((x) => ({ type: "text", value: x })), 181 | }); 182 | 183 | function parse(text) { 184 | const ast = docParser.Doc.tryParse(text); 185 | 186 | const concatted = ast.reduce((arr, el, i) => { 187 | if (el.type === "text") { 188 | const pos = arr.length - 1; 189 | if (arr[pos] && arr[pos].type === "text") { 190 | arr[pos].value = arr[pos].value + el.value; 191 | return arr; 192 | } else { 193 | return arr.concat(el); 194 | } 195 | } 196 | return arr.concat(el); 197 | }, []); 198 | 199 | let state = concatted.reduce(function (obj, value) { 200 | if (value.type === "statement") { 201 | Object.assign(obj, { [value.variable]: value.value.value }); 202 | } else if (value.type === "expression") { 203 | Object.assign(obj, { [value.variable]: value.value }); 204 | } 205 | return obj; 206 | }, {}); 207 | 208 | const mathed = concatted.filter((o) => o.type === "expression"); 209 | mathed.map((el) => { 210 | state[el.variable] = el.eval(state); 211 | return true; 212 | }); 213 | return [concatted, state]; 214 | } 215 | 216 | export default parse; 217 | -------------------------------------------------------------------------------- /src/texts/blog.txt: -------------------------------------------------------------------------------- 1 | You are a publisher on the Internet. 2 | 3 | You generate {40:posts} posts per day, for a total of {=posts * 365:annual_posts} posts a year. It costs ${1000:post_cost} to make a post, which means you spend ${=annual_posts * post_cost:annual_expenses} per year on content. 4 | 5 | Each post gets an average of {50000:readers} readers, and they go on to look at around {3:recirculation} other pages. That yields {=(recirculation + 1) * (readers * posts) * 30:pageviews} page views every 30 days (and as many as {=readers * posts * 30:uniques} unique readers, although let's be honest). Anyone who visits is {3:repeat}% likely to visit again next month. 6 | 7 | At a ${0.01-5.00:cpm} CPM (cost per mille), and with {4:placements} ad placements per page, that means you can make 8 | 9 | ${=((pageviews + (pageviews * (repeat/100)))/1000) * placements * cpm:revenue}/month, 10 | or ${=revenue * 12:annual_revenue} a year. 11 | 12 | This means that your annual profit is ${=annual_revenue - annual_expenses:profit}. 13 | -------------------------------------------------------------------------------- /src/texts/coffee.txt: -------------------------------------------------------------------------------- 1 | You drink {3:coffeeperday} coffees :coffee: per day, at an average cost of ${3.00:price}: a weekly spend of ${=coffeeperday*price*7:weeklyspend}. :money_with_wings: Instead, you could buy a coffee machine for ${1000:machinecost}, and bags of {250.0:beanweight}g of coffee beans for ${15.00:beanprice}. At a dosage of {20.0:dosage}g per cup, you could get {=beanweight/dosage:cupsperbag} cups per bag, each costing ${=beanprice/cupsperbag:priceperhomemadecup}. This would bring your weekly coffee bill to ${=coffeeperday*7*priceperhomemadecup:weeklyhomespend} and the machine will pay for itself after {=machinecost/(weeklyspend-weeklyhomespend):result} weeks. :calendar: -------------------------------------------------------------------------------- /src/texts/disease.txt: -------------------------------------------------------------------------------- 1 | The math here is very uninformed but, welp, {10000:cases} people have a virus. And since they live in a dense area, they come into close contact with {100:people} people before they get sick. Since {0.01-25.00:infected}% of people who come into contact catch the virus, that means that they've just infected {=floor(infected*people*cases):no_infected} people. And by day {1-300:day}, then {=1.565*1.1194^day:growth} are infected. 2 | 3 | -------------------------------------------------------------------------------- /src/texts/fire.txt: -------------------------------------------------------------------------------- 1 | You are a gainfully employed person :construction_worker: who spends less than you earn :muscle: and invests the difference. :medal: 2 | 3 | Your monthly take-home pay :dollar: is ${5000:monthlynet} and your monthly expenses :money_with_wings: are ${3000:monthlyexpenses}. That means your savings rate is {=100 - (monthlyexpenses * 100 / monthlynet):savingsrate}%. Good job! :trophy: (Unless that number's red. :disappointed: ) 4 | 5 | If you invest {=(100 / 4):multiple}x your annual expenses, or ${=multiple * (monthlyexpenses * 12):accumulationrequired}, a "safe" annual withdrawal rate of 4% :chart_with_downwards_trend: will cover :knife_fork_plate: :house: :pill: :car:. 6 | 7 | If you've already got ${10000:saved} invested :money_mouth: and you're depositing ${=monthlynet-monthlyexpenses:monthlysavings} a month, at a {6:return}% annual return, it will take {=ceil(monthsToGoalBalanceViaCompoundInterest(saved, accumulationrequired, monthlysavings, return)/12):years} years :older_adult: to build up enough :money_bag: quit working :wave: live on those returns :dancer: and achieve financial independence. :fire: :fire: :fire: 8 | 9 | -------------------------------------------------------------------------------- /src/texts/freelancer.txt: -------------------------------------------------------------------------------- 1 | :green_book: You are a freelance writer. 2 | 3 | You're able to charge ${0.01-3.00:rate} per word. 4 | 5 | :money_bag: You get {1-6:assignments} assignments a month, at an average length of {300-3000:words} words. 6 | 7 | This means that you make ${=rate * assignments * words:monthly} a month (assuming they pay), or ${=12*monthly:annual} a year, assuming this holds steady and everyone pays. 8 | 9 | :money_with_wings: Taxes at {20:average_tax}% will leave you with ${=annual * (1 - average_tax/100):after_taxes}. 10 | 11 | If you pay ${2000:rent} a month in rent and ${683:insurance} a month for insurance this leaves you with 12 | 13 | ${=after_taxes - (rent * 12) - (insurance * 12):profit} 14 | 15 | for :children_crossing: everything :takeout_box: else :airplane: you :pill: need, or just for you.:+1: 16 | -------------------------------------------------------------------------------- /src/texts/funnel.txt: -------------------------------------------------------------------------------- 1 | You run a SaaS business. 2 | 3 | Your website gets {1-250000:uniques} unique visitors per month. :chart_with_upwards_trend: 4 | Of those, {0.00-10:signuppercent}%, or {=(uniques*signuppercent)/100.00:signups} sign up. :thinking_face: 5 | {0.00-100:qualifiedpercent}% or {=(signups*qualifiedpercent)/100.00:qls} of these signups qualify :grinning: 6 | (you do have a definition for qualified, right?) 7 | and {0.00-100:purchasedpercent}% or {=(qls*purchasedpercent)/100.00:customers} become customers. :money_mouth_face: 8 | -------------------------------------------------------------------------------- /src/texts/hankies.txt: -------------------------------------------------------------------------------- 1 | :sneezing_face: You have allergies, and it's springtime in Pollenville. :blossom: :bee: 2 | 3 | If you buy boxes of {65:perbox} tissues and blow your :nose: {5:blows} times a day, in {5:years} years you'd use {=(blows*(365*years))/perbox:boxes} boxes. At ${1.50:costperbox} per box, that would cost you ${=costperbox * boxes:tissuespend}. :money_with_wings: 4 | 5 | If you switched to a set of ${5-15:hankiespend} hankerchiefs :sneezing_face: you'd save ${= tissuespend - hankiespend :savings} and several trees. :deciduous_tree: :sunflower: :deciduous_tree: :tulip: :deciduous_tree: :rose: :deciduous_tree: -------------------------------------------------------------------------------- /src/texts/magazine.txt: -------------------------------------------------------------------------------- 1 | Your magazine costs ${10-20:subcription_cost} a year to subscribe and ${5-10:newsstand_cost} on the newsstand. 2 | 3 | Advertisers will pay ${100-1000:ad_rate} a month per page. 4 | 5 | You have {10-100:subscriber_number} subscribers and {10:advertisers_number} advertisers. 6 | 7 | {90:renewal_rate} percent of subscribers renew every year. 8 | 9 | You sell {0-2000:newsstand_copies} copies a month at {10:newsstands} newsstands and bookstores. 10 | 11 | {5:newsstand_conversion_rate} percent of those buyers will become subscribers. 12 | 13 | This leads to ${=newsstands*newsstand_copies:newstand_revenue} in revenue on the newsstand and {=newsstand_copies * (newsstand_conversion_rate/100):new_subs} new subscribers. 14 | -------------------------------------------------------------------------------- /src/texts/soda.txt: -------------------------------------------------------------------------------- 1 | :cup_with_straw: You drink {1-4:sodas_daily} Diet Cokes per day, at a cost of ${0.00-3.50:soda_cost} per Diet Coke. (${=sodas_daily * soda_cost * 365.25/12:monthly} a month). If you'd put that into a fund with a {6.00:rate}% annual rate, you'd have 2 | ${= 3 | t = 12; 4 | period = 10 * t; 5 | i = rate/100; 6 | f() = (i>0.0000001); 7 | interest = if(f(i),i,0.0000001); 8 | adjusted = interest/t; 9 | 10 | monthly * ((((1 + adjusted)^period) - 1) / adjusted) 11 | 12 | :total} in a decade. 13 | 14 | -------------------------------------------------------------------------------- /src/texts/vc.txt: -------------------------------------------------------------------------------- 1 | You have a fund with ${20000000-1000000000:cash_out}. You make {0-200:investments} investments of ${=(cash_out / investments):individual_investment} each. 2 | 3 | {0-80:failure}% of those investments fail outright. {0-20:success}% achieve significant growth. That means that you lose your shirt on 4 | 5 | {=ceil(investments * (failure/100)):failures} companies, 6 | 7 | don't lose money on {=ceil(investments * (100 - failure - success)/100):averages} investments 8 | 9 | and achieve breakout growth on {=floor(investments * (success/100)):successes} investments. 10 | 11 | When you take an average of the successful ones you find that your investment in only those firms increased in value {1.0-200.0:multiple} times what you invested. 12 | 13 | :money_bag: That means you made ${=successes * multiple * individual_investment:cash_in}, and your exit multiple is {=cash_in/cash_out:exit_multiple}x. Good job! (As long as it was way above 1x, better if it's above 5x, otherwise bad job.) :+1: :+1: :+1: :flag-us: :flag-us: :flag-us: 14 | 15 | --------------------------------------------------------------------------------