Design overhoul

This commit is contained in:
Anders Englöf Ytterström 2025-04-20 19:32:38 +02:00
parent edc0a00ef9
commit 44b6bdfea2
12 changed files with 435 additions and 492 deletions

View file

@ -1,47 +1,43 @@
# Svelte + TS + Vite
# Kalkylatorer
This template should help get you started developing with Svelte and TypeScript in Vite.
This is 2 things:
## Recommended IDE Setup
- A set of formulaes for speedy calculation for
those times when a spreadsheet is to overwhelming. Basically related to strength training and body fat.
- An personal exercise to learn CSS subgrids, as well
as grinding code with Svelte.
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## How it works
## Need an official Svelte framework?
- Choose a formula (top row), and set the values
with the keypad.
- Separate the values using semicolons.
- Add decimals by using a comma (sorry not sorry).
- Get result by pressing "=" button.
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
## Built-in calculators
**Why use this over SvelteKit?**
### 1 repetition max calculator
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
1rm(weight: number, reps: number, variant: "lower" | "upper")
```
### KG to LBS converter
```
lbs(weight: number)
```
### Army body fat composition calculator
```
abf(length: number, neck: number, waist: number, hips?: number, gender: "male" | "female", metric: boolean)
```
### Navy body fat composition calculator
```
nbf(length: number, neck: number, waist: number, hips?: number, gender: "male" | "female", metric: boolean)
```

View file

@ -1,42 +1,29 @@
<script lang="ts">
import svelteLogo from "./assets/svelte.svg";
import viteLogo from "/vite.svg";
import { navigate } from "./lib/common";
import { currentView } from "./lib/store";
import ArmyFatPercentage from "./lib/ArmyFatPercentage.svelte";
import NavyFatPercentage from "./lib/NavyFatPercentage.svelte";
import OneRepMax from "./lib/OneRepMax.svelte";
import Display from "./lib/Display.svelte";
import Keypad from "./lib/Keypad.svelte";
</script>
{#if $currentView === "armyfatcalc"}
<ArmyFatPercentage />
{/if}
{#if $currentView === "navyfatcalc"}
<NavyFatPercentage />
{/if}
{#if $currentView === "onerepmax"}
<OneRepMax />
{/if}
{#if $currentView === "start"}
<main>
<nav>
<button onclick={() => navigate("armyfatcalc")}
>Kroppsfettkalkylator, Army</button
>
<button onclick={() => navigate("navyfatcalc")}
>Kroppsfettkalkylator, Navy</button
>
<button onclick={() => navigate("onerepmax")}>1RM-kalkylator</button
>
</nav>
</main>
{/if}
<main>
<Display />
<Keypad />
</main>
<footer>
<p>
Made by <a href="https://madr.se">Anders</a>. Source on
<a href="https://github.com/madr/kalkylatorer">Github</a>.
</p>
</footer>
<style>
nav {
display: grid;
grid-template: subgrid / subgrid;
grid-column: 2 / 2;
grid-row: 2 / 6;
footer {
font-size: x-small;
position: absolute;
bottom: 0.25rem;
right: 0.25rem;
text-align: center;
> p {
margin: 0;
}
}
</style>

View file

@ -5,7 +5,13 @@
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
background-color: #333;
background-image: linear-gradient(
hsl(0 25% 10%),
hsl(90 25% 10%),
hsl(180 25% 10%),
hsl(270 25% 10%)
);
font-synthesis: none;
text-rendering: optimizeLegibility;
@ -14,90 +20,27 @@
}
body {
min-height: 100vh;
min-width: 100vw;
margin: 0;
}
button {
border: 5px solid crimson;
background-color: transparent;
border-radius: 0;
color: inherit;
&:hover,
&:focus {
border-color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
}
header {
background-color: crimson;
}
h1,
h2,
h2 {
font-size: 1em;
margin: 0;
text-wrap: balance;
display: flex;
justify-content: center;
align-items: center;
}
main {
min-width: 100vw;
min-height: 100vh;
width: 14rem;
height: 14rem;
max-width: 97%;
max-height: 97%;
background: #543;
padding: 0.5em;
border: 3px solid #000;
background-image: linear-gradient(135deg, #432, #654, #432);
border-radius: 5px;
display: grid;
gap: 1em;
padding: 1em;
gap: 0.2em;
box-sizing: border-box;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(7, 1fr);
}
output {
background: rgba(0, 0, 0, 0.25);
display: grid;
grid-column: 1 / 4;
grid-row: 2 / 4;
grid-template: subgrid / subgrid;
font-size: 2em;
> span {
grid-column: 1 / 6;
grid-row: 1 / 2;
text-align: right;
&::before {
content: "=";
color: #888;
}
&::after {
content: "%";
color: #888;
}
}
}
form {
background: rgba(255, 255, 255, 0.25);
display: grid;
grid-column: 1 / 4;
grid-row: 4 / 8;
grid-template: subgrid / subgrid;
}
input {
max-width: 4em;
display: block;
}
header {
display: grid;
grid-column: 1 / 4;
grid-row: 1 / 1;
grid-template: subgrid / subgrid;
}
h1 {
grid-column: 2 / 4;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(8, 1fr);
}

View file

@ -1,130 +0,0 @@
<script lang="ts">
import { navigate } from "./common";
let fatPercentage = $state(0);
let gender = $state("male");
let height = $state(null);
let waist = $state(null);
let neck = $state(null);
let hips = $state(null);
// https://www.gigacalculator.com/calculators/army-body-fat-calculator.php
const male = (waist: number, neck: number, height: number) =>
86.01 * Math.log10(waist - neck) - 70.041 * Math.log10(height) + 30.3;
const female = (
waist: number,
neck: number,
hips: number,
height: number,
) =>
163.205 * Math.log10(waist + hips - neck) -
97.684 * Math.log10(height) -
104.912;
const calculate = () => {
if (gender == "male") {
if (!waist || !neck || !height) {
return;
}
fatPercentage = male(waist, neck, height);
} else {
if (!waist || !neck || !height || !hips) {
return;
}
fatPercentage = female(waist, neck, hips, height);
}
};
</script>
<main>
<header>
<span>
<button onclick={() => navigate("start")}>Tillbaka</button>
</span>
<h1>Kroppsfettkalkylator, Army</h1>
</header>
<output>
{#if fatPercentage > 0}
<span>{Math.round(fatPercentage * 100) / 100}</span>
{/if}
</output>
<form>
<div class="gender">
<label>
Man
<input
bind:group={gender}
type="radio"
name="gender"
value="male"
id="male"
/>
</label>
<label>
Kvinna
<input
bind:group={gender}
type="radio"
name="gender"
value="female"
id="female"
/>
</label>
</div>
<label>
Kroppslängd
<input
onchange={() => calculate()}
bind:value={height}
type="number"
id="height"
name="height"
size="5"
/>
</label>
<label>
Midja
<input
onchange={() => calculate()}
type="number"
bind:value={waist}
id="waist"
name="waist"
size="5"
/>
</label>
{#if gender == "female"}
<label>
Höft
<input
onchange={() => calculate()}
type="number"
bind:value={hips}
id="hips"
name="hips"
size="5"
/>
</label>
{/if}
<label>
Hals
<input
onchange={() => calculate()}
type="number"
bind:value={neck}
id="neck"
name="neck"
size="5"
/>
</label>
</form>
</main>
<style>
.gender {
grid-column: 1 / 2;
grid-row: 1 / 4;
}
</style>

View file

@ -1,10 +0,0 @@
<script lang="ts">
let count: number = $state(0)
const increment = () => {
count += 1
}
</script>
<button onclick={increment}>
count is {count}
</button>

119
src/lib/Display.svelte Normal file
View file

@ -0,0 +1,119 @@
<script lang="ts">
import { display, formula, calculated } from "./store";
const paramCount = $derived($display.split(";").length);
const copyToClipboard = () => {
const text = $calculated;
navigator.clipboard.writeText(text).then(
() => {
console.info("clipboard successfully set");
},
() => {
console.error("clipboard write failed");
},
);
};
</script>
<div class="output">
<div class="modifiers">
{#if $formula}
<b>{$formula}</b>
{#if $formula === "1rm"}
<i class={paramCount > 0 ? "done" : ""}>weight</i>
<i class={paramCount > 1 ? "done" : ""}>reps</i>
{/if}
{#if $formula === "lbs"}
<i class={paramCount > 0 ? "done" : ""}>weight</i>
{/if}
{#if $formula === "akf" || $formula === "nkf"}
<i class={paramCount > 0 ? "done" : ""}>hgt</i>
<i class={paramCount > 1 ? "done" : ""}>nck</i>
<i class={paramCount > 2 ? "done" : ""}>wst</i>
<i class={paramCount > 3 ? "done" : ""}>hps?</i>
{/if}
{/if}
</div>
<pre class={formula}>{$display}</pre>
<output onclick={() => copyToClipboard()}>
{#if $calculated}=
{/if}
{$calculated}
</output>
</div>
<style>
.modifiers {
display: flex;
border-bottom: 1px solid rgba(128, 128, 128, 0.75);
gap: 1em;
align-items: center;
font-size: 0.66em;
padding: 0 0.5em;
}
output {
border-top: 1px solid rgba(128, 128, 128, 0.75);
font-weight: bold;
text-align: right;
padding-right: 0.33rem;
place-content: center;
}
b {
font-weight: normal;
background-color: #333;
color: #fff;
padding: 2px 5px;
}
i {
color: #888;
&.done {
color: #000;
font-style: normal;
}
}
.output {
background: hsl(120 16% 66%);
display: grid;
color: #444;
grid-column: 1 / 5;
grid-row: 1 / 4;
grid-template: subgrid / subgrid;
> * {
grid-column: 1 / 5;
}
> pre {
--mod-txt: "akf(";
font-size: 125%;
place-content: center;
margin: 0;
padding: 0 0.33rem;
&::before {
color: #b74;
}
&::after {
color: #b74;
}
&.akf {
&::before {
content: var(--mod-txt);
}
&::after {
content: ")";
}
}
}
}
</style>

132
src/lib/Keypad.svelte Normal file
View file

@ -0,0 +1,132 @@
<script lang="ts">
import { display, formula, calculated } from "./store";
import { calculate } from "./formulaes";
const setFormula = (value: string) => {
if ($display == "hi.") {
display.set("");
}
if ($formula == value) {
formula.set("");
display.set("");
} else {
formula.set(value);
}
};
const append = (value: string) => {
if ($display == "hi.") {
display.set("");
}
display.set($display + value);
};
const clear = () => {
display.set("");
calculated.set("");
formula.set("");
};
const popright = () => {
display.set($display.substring(0, $display.length - 1));
};
const eq = () => {
try {
const params = $display.split(";");
calculated.set(calculate($formula, params));
} catch {
calculated.set("!Error");
}
};
</script>
<nav>
<button on:click={() => setFormula("akf")}>akf</button>
<button on:click={() => setFormula("nkf")}>nkf</button>
<button on:click={() => setFormula("1rm")}>1rm</button>
<button on:click={() => setFormula("lbs")}>lbs</button>
<button on:click={() => clear()} data-clear>ac</button>
<button on:click={() => popright()} data-erase>del</button>
<button on:click={() => append("7")}>7</button>
<button on:click={() => append("8")}>8</button>
<button on:click={() => append("9")}>9</button>
<button on:click={() => append("4")}>4</button>
<button on:click={() => append("5")}>5</button>
<button on:click={() => append("6")}>6</button>
<button on:click={() => append("1")}>1</button>
<button on:click={() => append("2")}>2</button>
<button on:click={() => append("3")}>3</button>
<button on:click={() => append(",")}>,</button>
<button on:click={() => append("0")}>0</button>
<button on:click={() => append(";")} data-separator>;</button>
<button on:click={() => eq()} data-equals>=</button>
</nav>
<style>
nav {
display: grid;
grid-template: subgrid / subgrid;
grid-column: 1 / 5;
grid-row: 4 / 9;
}
button {
--btn-bg: #222;
--btn-lg-s: #333;
--btn-lg-e: #111;
border: 2px solid #000;
background-color: var(--btn-bg);
background-image: linear-gradient(
135deg,
var(--btn-lg-s),
var(--btn-lg-s) 33%,
var(--btn-lg-e) 66%,
var(--btn-lg-e)
);
color: inherit;
&[data-lbs] {
grid-column: 4 / 5;
grid-row: 1 / 2;
}
&[data-erase] {
--btn-bg: #422;
--btn-lg-s: #533;
--btn-lg-e: #311;
grid-column: 4 / 5;
grid-row: 3 / 4;
}
&[data-clear] {
grid-column: 4 / 5;
grid-row: 2 / 3;
}
&[data-separator] {
grid-column: 4 / 5;
grid-row: 4 / 5;
}
&[data-equals] {
--btn-bg: #242;
--btn-lg-s: #353;
--btn-lg-e: #131;
grid-column: 3 / 5;
grid-row: 5 / 6;
}
&:hover,
&:focus {
background-color: #333;
background-image: linear-gradient(
135deg,
#555,
#555 33%,
#333 66%,
#333
);
}
}
</style>

View file

@ -1,136 +0,0 @@
<script lang="ts">
import { navigate } from "./common";
let fatPercentage = $state(0);
let gender = $state("male");
let height = $state(null);
let waist = $state(null);
let neck = $state(null);
let hips = $state(null);
// https://www.omnicalculator.com/health/navy-body-fat
const male = (waist: number, neck: number, height: number) =>
495 /
(1.0324 -
0.19077 * Math.log10(waist - neck) +
0.15456 * Math.log10(height)) -
450;
const female = (
waist: number,
neck: number,
hips: number,
height: number,
) =>
495 /
(1.29579 -
0.35004 * Math.log10(waist + hips - neck) +
0.221 * Math.log10(height)) -
450;
const calculate = () => {
if (gender == "male") {
if (!waist || !neck || !height) {
return;
}
fatPercentage = male(waist, neck, height);
} else {
if (!waist || !neck || !height || !hips) {
return;
}
fatPercentage = female(waist, neck, hips, height);
}
};
</script>
<main>
<header>
<span>
<button onclick={() => navigate("start")}>Tillbaka</button>
</span>
<h1>Kroppsfettkalkylator, Navy</h1>
</header>
<output>
{#if fatPercentage > 0}
<span>{Math.round(fatPercentage * 100) / 100}</span>
{/if}
</output>
<form>
<div class="gender">
<label>
Man
<input
bind:group={gender}
type="radio"
name="gender"
value="male"
id="male"
/>
</label>
<label>
Kvinna
<input
bind:group={gender}
type="radio"
name="gender"
value="female"
id="female"
/>
</label>
</div>
<label>
Kroppslängd
<input
onchange={() => calculate()}
bind:value={height}
type="number"
id="height"
name="height"
size="5"
/>
</label>
<label>
Midja
<input
onchange={() => calculate()}
type="number"
bind:value={waist}
id="waist"
name="waist"
size="5"
/>
</label>
{#if gender == "female"}
<label>
Höft
<input
onchange={() => calculate()}
type="number"
bind:value={hips}
id="hips"
name="hips"
size="5"
/>
</label>
{/if}
<label>
Hals
<input
onchange={() => calculate()}
type="number"
bind:value={neck}
id="neck"
name="neck"
size="5"
/>
</label>
</form>
</main>
<style>
.gender {
grid-column: 1 / 2;
grid-row: 1 / 4;
}
</style>

View file

@ -1,56 +0,0 @@
<script lang="ts">
import { navigate } from "./common";
let oneRepMax = $state(0);
let reps = $state(null);
let weight = $state(null);
// https://www.athlegan.com/calculate-1rm
const calculate = () => {
if (weight && reps) {
oneRepMax = weight / (1.0278 - 0.0278 * reps);
}
};
</script>
<main>
<header>
<span>
<button onclick={() => navigate("start")}>Tillbaka</button>
</span>
<h1>1RM</h1>
</header>
<output>
{#if oneRepMax > 0}
<span>{Math.round(oneRepMax * 100) / 100}</span>
{/if}
</output>
<form>
<label>
Reps
<input
onchange={() => calculate()}
bind:value={reps}
type="number"
id="reps"
name="reps"
size="5"
/>
</label>
<label>
Vikt
<input
onchange={() => calculate()}
type="number"
bind:value={weight}
id="weight"
name="weight"
size="5"
/>
</label>
</form>
</main>
<style>
</style>

View file

@ -1,5 +0,0 @@
import { currentView } from "./store";
export const navigate = (
page: "start" | "armyfatcalc" | "navyfatcalc" | "onerepmax",
) => currentView.update((_) => page);

101
src/lib/formulaes.ts Normal file
View file

@ -0,0 +1,101 @@
type Gender = "male" | "female";
type Formula = "akf" | "nkf" | "1rm" | "lbs";
export const calculate = (f: Formula, params: string[]) => {
let G: Gender = "male";
let H = undefined;
switch (f) {
case "1rm":
return round(oneRepMax(parseFloat(params[0]), parseInt(params[1], 10)));
case "lbs":
return round(kg2lbs(parseFloat(params[0].replace(",", "."))));
case "akf":
if (params.length > 3) {
H = parseFloat(params[3].replace(",", "."));
G = "female";
}
return round(
armyBodyFatComposition(
G,
parseFloat(params[0].replace(",", ".")),
parseFloat(params[1].replace(",", ".")),
parseFloat(params[2].replace(",", ".")),
H,
),
);
case "nkf":
if (params.length > 3) {
H = parseFloat(params[3].replace(",", "."));
G = "female";
}
return round(
navyBodyFatComposition(
G,
parseFloat(params[0].replace(",", ".")),
parseFloat(params[1].replace(",", ".")),
parseFloat(params[2].replace(",", ".")),
H,
),
);
}
};
// https://www.athlegan.com/calculate-1rm
const oneRepMax = (weight: number, reps: number) => {
return weight / (1.0278 - 0.0278 * reps);
};
// https://www.gigacalculator.com/calculators/army-body-fat-calculator.php
const armyBodyFatComposition = (
gender: Gender,
height: number,
neck: number,
waist: number,
hips?: number,
) => {
if (gender == "male") {
return (
86.01 * Math.log10(waist - neck) - 70.041 * Math.log10(height) + 30.3
);
} else {
return (
163.205 * Math.log10(waist + hips! - neck) -
97.684 * Math.log10(height) -
104.912
);
}
};
// https://www.omnicalculator.com/health/navy-body-fat
const navyBodyFatComposition = (
gender: Gender,
height: number,
neck: number,
waist: number,
hips?: number,
) => {
if (gender == "male") {
return (
495 /
(1.0324 -
0.19077 * Math.log10(waist - neck) +
0.15456 * Math.log10(height)) -
450
);
} else {
return (
495 /
(1.29579 -
0.35004 * Math.log10(waist + hips! - neck) +
0.221 * Math.log10(height)) -
450
);
}
};
// https://www.unitconverters.net/weight-and-mass/kg-to-lbs.htm
const kg2lbs = (weight: number) => 2.2046226218 * weight;
const round = (i: number) => {
return Math.round(i * 1000) / 1000;
};

View file

@ -1,3 +1,5 @@
import { writable } from "svelte/store";
export const currentView = writable("start");
export const calculated = writable("");
export const formula = writable("");
export const display = writable("hi.");