Initial commit: Firewood Stack calculator with CI/CD
Static web app (nginx) with cord calculation, wood species/seasoning adjustments, season planning, and month-by-month coverage timeline. GitHub Actions workflow builds and pushes container to ghcr.io on push to main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
93b9cdf00b
3 files changed
+834
No files matched your search
@@ -0,0 +1,43 @@
|
||||
name: Build & Push Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/nmemmert/firewood
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
EXPOSE 80
|
||||
+788
@@ -0,0 +1,788 @@
|
||||
<title>Firewood Stack</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #EFEBE2;
|
||||
--surface: #FDFAF5;
|
||||
--surface-2: #F2EDE4;
|
||||
--text: #221E19;
|
||||
--text-2: #79706A;
|
||||
--accent: #C55418;
|
||||
--accent-soft: #EDD9C8;
|
||||
--border: #DDD5C8;
|
||||
--ok: #2A6B2E;
|
||||
--ok-soft: #E4F0E5;
|
||||
--warn: #7A5500;
|
||||
--warn-soft: #FEF3D4;
|
||||
--danger: #A81C1C;
|
||||
--danger-soft: #FEECEC;
|
||||
--res-bg: #221E19;
|
||||
--res-text: #F8F4EE;
|
||||
--res-muted: rgba(248,244,238,0.45);
|
||||
--res-sep: rgba(248,244,238,0.15);
|
||||
--shadow: 0 1px 6px rgba(34,30,25,0.07);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #151210;
|
||||
--surface: #201C18;
|
||||
--surface-2: #27221E;
|
||||
--text: #F2EDE4;
|
||||
--text-2: #968C82;
|
||||
--accent: #D0611E;
|
||||
--accent-soft: #3B2214;
|
||||
--border: #302824;
|
||||
--ok: #5CB860;
|
||||
--ok-soft: #182A19;
|
||||
--warn: #FFB300;
|
||||
--warn-soft: #332800;
|
||||
--danger: #EF5350;
|
||||
--danger-soft: #341010;
|
||||
--res-bg: #C55418;
|
||||
--res-text: #FFF8F0;
|
||||
--res-muted: rgba(255,248,240,0.5);
|
||||
--res-sep: rgba(255,248,240,0.2);
|
||||
--shadow: 0 1px 6px rgba(0,0,0,0.35);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #151210;
|
||||
--surface: #201C18;
|
||||
--surface-2: #27221E;
|
||||
--text: #F2EDE4;
|
||||
--text-2: #968C82;
|
||||
--accent: #D0611E;
|
||||
--accent-soft: #3B2214;
|
||||
--border: #302824;
|
||||
--ok: #5CB860;
|
||||
--ok-soft: #182A19;
|
||||
--warn: #FFB300;
|
||||
--warn-soft: #332800;
|
||||
--danger: #EF5350;
|
||||
--danger-soft: #341010;
|
||||
--res-bg: #C55418;
|
||||
--res-text: #FFF8F0;
|
||||
--res-muted: rgba(255,248,240,0.5);
|
||||
--res-sep: rgba(255,248,240,0.2);
|
||||
--shadow: 0 1px 6px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100dvh;
|
||||
padding-bottom: calc(108px + env(safe-area-inset-bottom, 16px));
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.hdr { display: flex; align-items: center; gap: 11px; padding: 22px 18px 16px; }
|
||||
.hdr-icon {
|
||||
flex-shrink: 0; width: 40px; height: 40px; border-radius: 10px;
|
||||
background: var(--accent-soft); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.hdr-title { font-size: 20px; font-weight: 700; letter-spacing: -0.3px; line-height: 1.15; }
|
||||
.hdr-sub { font-size: 12.5px; color: var(--text-2); margin-top: 2px; }
|
||||
|
||||
/* Layout */
|
||||
.content { padding: 0 14px 8px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border-radius: 16px; padding: 18px 16px; box-shadow: var(--shadow); }
|
||||
.card-label {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: var(--text-2); margin-bottom: 14px;
|
||||
}
|
||||
.group-label {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: var(--text-2); margin-bottom: 8px;
|
||||
}
|
||||
.card-group { margin-top: 16px; }
|
||||
|
||||
/* Stack cards */
|
||||
.stack-card { background: var(--surface-2); border-radius: 12px; padding: 13px 12px; margin-bottom: 10px; }
|
||||
.stack-hdr { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.stack-name { font-size: 12px; font-weight: 600; color: var(--text-2); }
|
||||
.stack-rm {
|
||||
width: 26px; height: 26px; border-radius: 13px; background: var(--border);
|
||||
border: none; color: var(--text-2); font-size: 17px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; touch-action: manipulation;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.stack-rm:hover { background: var(--danger); color: white; }
|
||||
.stack-rm:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.dims-row { display: flex; gap: 6px; align-items: center; }
|
||||
.dim-field { flex: 1; min-width: 0; }
|
||||
.dim-label { display: block; font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-2); margin-bottom: 5px; text-align: center; }
|
||||
.dim-input {
|
||||
display: block; width: 100%;
|
||||
background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px;
|
||||
color: var(--text); font-size: 21px; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
padding: 10px 4px; text-align: center;
|
||||
-webkit-appearance: none; appearance: none; outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.dim-input:focus { border-color: var(--accent); }
|
||||
.dim-input::placeholder { color: var(--border); font-weight: 400; font-size: 18px; }
|
||||
.dim-sep { color: var(--text-2); font-size: 15px; font-weight: 300; flex-shrink: 0; margin-top: 16px; }
|
||||
|
||||
.unit-row { display: flex; justify-content: space-between; align-items: center; margin-top: 13px; }
|
||||
.unit-hint { font-size: 12px; color: var(--text-2); }
|
||||
|
||||
.add-btn {
|
||||
width: 100%; background: none; border: 1.5px dashed var(--border);
|
||||
border-radius: 12px; padding: 12px; color: var(--accent);
|
||||
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
touch-action: manipulation; transition: background 0.15s;
|
||||
}
|
||||
.add-btn:hover { background: var(--accent-soft); }
|
||||
.add-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* Measure mode */
|
||||
.measure-toggle {
|
||||
display: flex; align-items: center; gap: 6px; margin-top: 13px;
|
||||
background: none; border: none; color: var(--accent);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer; padding: 0; touch-action: manipulation;
|
||||
}
|
||||
.measure-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
.measure-toggle .chev { transition: transform 0.2s; }
|
||||
.measure-toggle.open .chev { transform: rotate(180deg); }
|
||||
.measure-panel { display: none; margin-top: 13px; padding-top: 13px; border-top: 1px solid var(--border); flex-direction: column; gap: 10px; }
|
||||
.measure-panel.open { display: flex; }
|
||||
.measure-steps { display: flex; flex-direction: column; gap: 5px; }
|
||||
.measure-step { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; color: var(--text-2); line-height: 1.4; }
|
||||
.step-num { font-size: 10px; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 50%; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; }
|
||||
.photo-upload-label { display: flex; align-items: center; justify-content: center; gap: 7px; border: 1.5px dashed var(--border); border-radius: 10px; padding: 13px; color: var(--accent); font-size: 13.5px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
.photo-upload-label:hover { background: var(--accent-soft); }
|
||||
.photo-upload-label input { display: none; }
|
||||
.photo-preview { display: none; border-radius: 10px; overflow: hidden; max-height: 280px; position: relative; }
|
||||
.photo-preview img { width: 100%; height: 100%; object-fit: contain; display: block; background: var(--surface-2); }
|
||||
.photo-clear { position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border-radius: 13px; background: rgba(0,0,0,0.55); border: none; color: white; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.photo-hint { font-size: 11.5px; color: var(--text-2); text-align: center; line-height: 1.4; }
|
||||
|
||||
/* Segmented controls */
|
||||
.seg { display: flex; background: var(--surface-2); border-radius: 10px; padding: 3px; gap: 2px; }
|
||||
.seg button { flex: 1; background: none; border: none; padding: 9px 4px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); cursor: pointer; touch-action: manipulation; transition: background 0.15s, color 0.15s; text-align: center; line-height: 1.2; }
|
||||
.seg button.active { background: var(--accent); color: white; }
|
||||
.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.seg-mini { display: flex; background: var(--surface-2); border-radius: 8px; padding: 2px; gap: 2px; }
|
||||
.seg-mini button { background: none; border: none; padding: 5px 11px; border-radius: 6px; font-size: 13px; font-weight: 600; color: var(--text-2); cursor: pointer; touch-action: manipulation; transition: background 0.15s, color 0.15s; }
|
||||
.seg-mini button.active { background: var(--accent); color: white; }
|
||||
.seg-mini button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.seg-desc { margin-top: 10px; font-size: 12.5px; color: var(--text-2); line-height: 1.45; }
|
||||
|
||||
/* Custom rate */
|
||||
.custom-rate { display: none; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.custom-rate.show { display: flex; }
|
||||
.custom-input { width: 88px; background: var(--surface-2); border: 1.5px solid var(--border); border-radius: 10px; color: var(--text); font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; padding: 9px 10px; text-align: center; -webkit-appearance: none; appearance: none; outline: none; transition: border-color 0.15s; }
|
||||
.custom-input:focus { border-color: var(--accent); }
|
||||
.custom-suffix { font-size: 14px; color: var(--text-2); }
|
||||
|
||||
/* Ref note */
|
||||
.ref-note { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-2); line-height: 1.55; }
|
||||
.ref-note strong { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Green wood warning */
|
||||
.green-warn {
|
||||
display: none; margin-top: 10px; padding: 10px 12px; border-radius: 10px;
|
||||
background: var(--warn-soft); border-left: 3px solid var(--warn);
|
||||
font-size: 12.5px; color: var(--warn); line-height: 1.4;
|
||||
}
|
||||
.green-warn.show { display: block; }
|
||||
|
||||
/* Date selectors */
|
||||
.date-pair { display: flex; flex-direction: column; gap: 10px; }
|
||||
.date-field { display: flex; align-items: center; gap: 10px; }
|
||||
.date-field-label { font-size: 13px; font-weight: 600; color: var(--text-2); min-width: 68px; }
|
||||
.date-picks { display: flex; gap: 6px; flex: 1; }
|
||||
.select-wrap { position: relative; flex: 1; }
|
||||
.select-wrap::after {
|
||||
content: ''; position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||||
border-left: 4px solid transparent; border-right: 4px solid transparent;
|
||||
border-top: 5px solid var(--text-2); pointer-events: none;
|
||||
}
|
||||
.sel {
|
||||
width: 100%; background: var(--surface-2); border: 1.5px solid var(--border);
|
||||
border-radius: 10px; color: var(--text); font-size: 14px; font-weight: 600;
|
||||
padding: 10px 28px 10px 10px; -webkit-appearance: none; appearance: none;
|
||||
outline: none; cursor: pointer; transition: border-color 0.15s;
|
||||
}
|
||||
.sel:focus { border-color: var(--accent); }
|
||||
.season-preview { margin-top: 12px; font-size: 13px; color: var(--text-2); line-height: 1.4; }
|
||||
|
||||
/* Month strip */
|
||||
.month-strip {
|
||||
display: flex; gap: 5px; overflow-x: auto; padding-bottom: 4px; margin-bottom: 12px;
|
||||
-webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||||
}
|
||||
.month-strip::-webkit-scrollbar { display: none; }
|
||||
.mpill {
|
||||
flex-shrink: 0; width: 50px; border-radius: 10px; padding: 9px 4px;
|
||||
text-align: center; display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.mpill-name { font-size: 13px; font-weight: 700; }
|
||||
.mpill-yr { font-size: 10px; font-weight: 500; }
|
||||
.mpill-ok { background: var(--ok-soft); }
|
||||
.mpill-ok .mpill-name, .mpill-ok .mpill-yr { color: var(--ok); }
|
||||
.mpill-warn { background: var(--warn-soft); }
|
||||
.mpill-warn .mpill-name, .mpill-warn .mpill-yr { color: var(--warn); }
|
||||
.mpill-deficit { background: var(--danger-soft); }
|
||||
.mpill-deficit .mpill-name, .mpill-deficit .mpill-yr { color: var(--danger); }
|
||||
|
||||
/* Season status */
|
||||
.season-status {
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
.season-status.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.season-status.deficit { background: var(--danger-soft); color: var(--danger); }
|
||||
.season-status strong { font-weight: 700; display: block; margin-bottom: 2px; font-size: 14px; }
|
||||
|
||||
/* Sticky result */
|
||||
.result {
|
||||
position: fixed; inset: auto 0 0 0;
|
||||
background: var(--res-bg);
|
||||
padding: 14px 22px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; z-index: 50;
|
||||
}
|
||||
.res-empty { width: 100%; text-align: center; font-size: 14px; font-weight: 500; color: var(--res-muted); }
|
||||
.res-col { display: none; flex-direction: column; gap: 2px; }
|
||||
.res-col.show { display: flex; }
|
||||
.res-num { font-size: 54px; font-weight: 800; color: var(--res-text); font-variant-numeric: tabular-nums; letter-spacing: -2.5px; line-height: 1; }
|
||||
.res-lbl { font-size: 13px; font-weight: 500; color: var(--res-muted); letter-spacing: 0.025em; }
|
||||
.res-right { text-align: right; }
|
||||
.res-divider { display: none; width: 1px; height: 54px; background: var(--res-sep); flex-shrink: 0; }
|
||||
.res-divider.show { display: block; }
|
||||
.res-until { font-size: 22px; font-weight: 800; color: var(--res-text); letter-spacing: -0.5px; line-height: 1.2; }
|
||||
</style>
|
||||
|
||||
<header class="hdr">
|
||||
<div class="hdr-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 21.5C15.6 21.5 18.5 18.5 18.5 15C18.5 12.8 17.4 11 15.5 9.5C15.1 12.3 13.4 13.7 12 14.1C12.6 12 11.8 9.8 10 8C9.1 11.2 7.4 12.3 6.7 14C6.2 15.2 6 16.4 6 17.1C6 19.5 8.7 21.5 12 21.5Z" fill="var(--accent)"/>
|
||||
<path d="M12 19C13.2 19 14.5 18.1 14.5 16.5C14.5 15.7 14 14.9 13.2 14.4C13 15.5 12.4 16.1 12 16.3C12.2 15.6 12 14.7 11 14.2C10.8 15 10.2 15.7 10.2 16.5C10.2 18 11 19 12 19Z" fill="white" opacity="0.35"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hdr-title">Firewood Stack</div>
|
||||
<div class="hdr-sub">Cord & season calculator</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<!-- Stack Dimensions -->
|
||||
<div class="card">
|
||||
<div class="card-label" id="dims-label">Stack Dimensions (ft)</div>
|
||||
<div id="stacks-list"></div>
|
||||
<button class="add-btn" id="add-btn">
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><path d="M6.5 1v11M1 6.5h11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Add another stack
|
||||
</button>
|
||||
<div class="unit-row">
|
||||
<span class="unit-hint">Unit</span>
|
||||
<div class="seg-mini">
|
||||
<button id="btn-ft" class="active">ft</button>
|
||||
<button id="btn-m">m</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wood Details -->
|
||||
<div class="card">
|
||||
<div class="card-label">Wood Details</div>
|
||||
|
||||
<div class="group-label">Species</div>
|
||||
<div class="seg" id="sp-seg">
|
||||
<button data-sp="hardwood" class="active">Hardwood</button>
|
||||
<button data-sp="mixed">Mixed</button>
|
||||
<button data-sp="softwood">Softwood</button>
|
||||
</div>
|
||||
<p class="seg-desc" id="sp-desc">Oak, hickory, ash, maple — highest heat output per cord.</p>
|
||||
|
||||
<div class="card-group">
|
||||
<div class="group-label">Condition</div>
|
||||
<div class="seg" id="cond-seg">
|
||||
<button data-cond="seasoned" class="active">Seasoned</button>
|
||||
<button data-cond="green">Green / Wet</button>
|
||||
</div>
|
||||
<p class="seg-desc" id="cond-desc">Dried 1+ years — ready to burn at full efficiency.</p>
|
||||
<div class="green-warn" id="green-warn">
|
||||
Green wood burns ~25% less efficiently and produces more creosote. Season it 6–12 months before burning for best results.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-group">
|
||||
<div class="group-label">Log length</div>
|
||||
<div class="seg-mini" id="ll-seg">
|
||||
<button data-ll="14">14"</button>
|
||||
<button data-ll="16" class="active">16"</button>
|
||||
<button data-ll="18">18"</button>
|
||||
<button data-ll="20">20"</button>
|
||||
<button data-ll="24">24"</button>
|
||||
</div>
|
||||
<p class="seg-desc" id="ll-desc">At 16" a face cord (4 × 8 × 1.3 ft = 42 cu ft) is ⅓ of a full cord. Enter actual stack depth when measuring.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stacking Quality -->
|
||||
<div class="card">
|
||||
<div class="card-label">Stacking Quality</div>
|
||||
<div class="seg" id="q-seg">
|
||||
<button data-q="loose">Loose</button>
|
||||
<button data-q="average" class="active">Average</button>
|
||||
<button data-q="tight">Tight</button>
|
||||
</div>
|
||||
<p class="seg-desc" id="q-desc">Typical hand-stacked pile with moderate gaps — roughly 70–75% solid wood by volume.</p>
|
||||
</div>
|
||||
|
||||
<!-- Burn Rate -->
|
||||
<div class="card">
|
||||
<div class="card-label">Burn Rate</div>
|
||||
<div class="seg" id="r-seg">
|
||||
<button data-r="light">Light</button>
|
||||
<button data-r="moderate" class="active">Moderate</button>
|
||||
<button data-r="heavy">Heavy</button>
|
||||
<button data-r="custom">Custom</button>
|
||||
</div>
|
||||
<p class="seg-desc" id="r-desc">Daily fires, primary supplemental heat — about 1 cord/month.</p>
|
||||
<div class="custom-rate" id="custom-rate-row">
|
||||
<input type="number" class="custom-input" id="custom-val" min="0.1" max="20" step="0.1" value="1.0" inputmode="decimal">
|
||||
<span class="custom-suffix">cords / month</span>
|
||||
</div>
|
||||
<div class="ref-note">
|
||||
A <strong>cord</strong> is 4 × 4 × 8 ft = 128 cu ft. Burn rates assume seasoned hardwood — softwood or green wood adjusts the effective duration automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Burn Season -->
|
||||
<div class="card">
|
||||
<div class="card-label">Burn Season</div>
|
||||
<div class="date-pair">
|
||||
<div class="date-field">
|
||||
<span class="date-field-label">Start</span>
|
||||
<div class="date-picks">
|
||||
<div class="select-wrap"><select class="sel" id="start-month"></select></div>
|
||||
<div class="select-wrap"><select class="sel" id="start-year"></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="date-field">
|
||||
<span class="date-field-label">Target end</span>
|
||||
<div class="date-picks">
|
||||
<div class="select-wrap"><select class="sel" id="end-month"></select></div>
|
||||
<div class="select-wrap"><select class="sel" id="end-year"></select></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="season-preview" id="season-preview"></p>
|
||||
</div>
|
||||
|
||||
<!-- Year Summary (shown when dates set + volume > 0) -->
|
||||
<div class="card" id="year-card" style="display:none">
|
||||
<div class="card-label" id="year-label">Season Overview</div>
|
||||
<div class="month-strip" id="month-strip"></div>
|
||||
<div class="season-status" id="season-status"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<div class="result" id="result-panel">
|
||||
<span class="res-empty" id="res-empty">Enter stack dimensions to calculate</span>
|
||||
<div class="res-col" id="res-cords">
|
||||
<div class="res-num" id="res-cord-num">—</div>
|
||||
<div class="res-lbl">cords</div>
|
||||
</div>
|
||||
<div class="res-divider" id="res-div"></div>
|
||||
<div class="res-col res-right" id="res-dur">
|
||||
<div class="res-until" id="res-until"></div>
|
||||
<div class="res-lbl" id="res-dur-lbl"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
const SPECIES = {
|
||||
hardwood: { f: 1.00, desc: 'Oak, hickory, ash, maple — highest heat output per cord.' },
|
||||
mixed: { f: 0.82, desc: 'Beech, birch, elm, mixed hardwood/softwood — moderately efficient.' },
|
||||
softwood: { f: 0.65, desc: 'Pine, fir, cedar, spruce — lower BTU, burns faster. About 65% as efficient as hardwood.' }
|
||||
};
|
||||
const COND = {
|
||||
seasoned: { f: 1.00, desc: 'Dried 1+ years — ready to burn at full efficiency.' },
|
||||
green: { f: 0.75, desc: 'Freshly cut — burns 25% less efficiently. Best to season 6–12 months first.' }
|
||||
};
|
||||
const LL_NOTES = {
|
||||
14: 'At 14" a face cord (4 × 8 × 1.17 ft = 37 cu ft) is about 0.29 of a full cord.',
|
||||
16: 'At 16" a face cord (4 × 8 × 1.3 ft = 42 cu ft) is ⅓ of a full cord. Enter actual stack depth when measuring.',
|
||||
18: 'At 18" a face cord (4 × 8 × 1.5 ft = 48 cu ft) is 0.38 of a full cord.',
|
||||
20: 'At 20" a face cord (4 × 8 × 1.67 ft = 53 cu ft) is 0.42 of a full cord.',
|
||||
24: 'At 24" a face cord (4 × 8 × 2 ft = 64 cu ft) is ½ of a full cord.'
|
||||
};
|
||||
const QUALITY = {
|
||||
loose: { f: 0.60, desc: 'Rough pile or tossed heap — lots of air gaps.' },
|
||||
average: { f: 0.75, desc: 'Typical hand-stacked pile with moderate gaps — roughly 70–75% solid wood by volume.' },
|
||||
tight: { f: 0.90, desc: 'Tight rows, ends aligned. Close to a textbook cord — roughly 85–90% solid wood.' }
|
||||
};
|
||||
const RATE = {
|
||||
light: { r: 0.4, desc: 'A few fires per week, shoulder season — ~0.4 cords/month.' },
|
||||
moderate: { r: 1.0, desc: 'Daily fires, primary supplemental heat — about 1 cord/month.' },
|
||||
heavy: { r: 2.0, desc: 'Multiple fires daily, main heat in cold climate — ~2 cords/month.' },
|
||||
custom: { r: null, desc: 'Set your own rate below.' }
|
||||
};
|
||||
|
||||
const state = {
|
||||
stacks: [], nextId: 1,
|
||||
unit: 'ft', quality: 'average', rate: 'moderate', customRate: 1.0,
|
||||
species: 'hardwood', condition: 'seasoned', logLength: 16,
|
||||
startMonth: null, startYear: null, endMonth: null, endYear: null
|
||||
};
|
||||
|
||||
/* ── Stack management ─────────────────────────────── */
|
||||
function addStack() {
|
||||
const id = state.nextId++;
|
||||
state.stacks.push({ id, l: '', w: '', h: '' });
|
||||
renderStacks();
|
||||
setTimeout(() => { const el = document.querySelector(`#stack-${id} .dim-input`); if (el) el.focus(); }, 60);
|
||||
}
|
||||
|
||||
function removeStack(id) {
|
||||
state.stacks = state.stacks.filter(s => s.id !== id);
|
||||
renderStacks(); calc();
|
||||
}
|
||||
|
||||
function setDim(id, key, val) {
|
||||
const s = state.stacks.find(x => x.id === id);
|
||||
if (s) { s[key] = val; calc(); }
|
||||
}
|
||||
|
||||
function renderStacks() {
|
||||
const list = document.getElementById('stacks-list');
|
||||
list.innerHTML = '';
|
||||
const multi = state.stacks.length > 1;
|
||||
state.stacks.forEach((s, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'stack-card'; div.id = `stack-${s.id}`;
|
||||
div.innerHTML = `
|
||||
${multi ? `<div class="stack-hdr"><span class="stack-name">Stack ${i+1}</span><button class="stack-rm" onclick="removeStack(${s.id})" aria-label="Remove stack ${i+1}">×</button></div>` : ''}
|
||||
<div class="dims-row">
|
||||
<div class="dim-field"><label class="dim-label" for="l-${s.id}">Length</label><input class="dim-input" id="l-${s.id}" type="number" inputmode="decimal" min="0" step="0.5" placeholder="—" value="${s.l}" oninput="setDim(${s.id},'l',this.value)"></div>
|
||||
<span class="dim-sep" aria-hidden="true">×</span>
|
||||
<div class="dim-field"><label class="dim-label" for="w-${s.id}">Width</label><input class="dim-input" id="w-${s.id}" type="number" inputmode="decimal" min="0" step="0.5" placeholder="—" value="${s.w}" oninput="setDim(${s.id},'w',this.value)"></div>
|
||||
<span class="dim-sep" aria-hidden="true">×</span>
|
||||
<div class="dim-field"><label class="dim-label" for="h-${s.id}">Height</label><input class="dim-input" id="h-${s.id}" type="number" inputmode="decimal" min="0" step="0.5" placeholder="—" value="${s.h}" oninput="setDim(${s.id},'h',this.value)"></div>
|
||||
</div>
|
||||
<button class="measure-toggle" id="mt-${s.id}" onclick="toggleMeasure(${s.id})">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><rect x="1" y="3" width="12" height="9" rx="2" stroke="currentColor" stroke-width="1.4"/><circle cx="7" cy="7.5" r="2" stroke="currentColor" stroke-width="1.4"/><path d="M4.5 3V2.5A1.5 1.5 0 016 1h2a1.5 1.5 0 011.5 1.5V3" stroke="currentColor" stroke-width="1.4"/></svg>
|
||||
Use Measure app photo
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true"><path d="M2 4l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<div class="measure-panel" id="mp-${s.id}">
|
||||
<div class="measure-steps">
|
||||
<div class="measure-step"><span class="step-num">1</span><span>Open the <strong>Measure</strong> app on your iPhone</span></div>
|
||||
<div class="measure-step"><span class="step-num">2</span><span>Tap <strong>+</strong> to measure Length, Width, and Height</span></div>
|
||||
<div class="measure-step"><span class="step-num">3</span><span>Tap the <strong>camera icon</strong> to save a photo with measurements overlaid</span></div>
|
||||
<div class="measure-step"><span class="step-num">4</span><span>Upload that photo below, then enter the numbers you see into the fields above</span></div>
|
||||
</div>
|
||||
<label class="photo-upload-label" for="photo-${s.id}">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="1" y="4" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="9" r="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M5 4V3.5A1.5 1.5 0 016.5 2h3A1.5 1.5 0 0111 3.5V4" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
Upload Measure photo
|
||||
<input type="file" id="photo-${s.id}" accept="image/*" onchange="loadPhoto(${s.id},this)">
|
||||
</label>
|
||||
<div class="photo-preview" id="pp-${s.id}"><img id="pi-${s.id}" src="" alt="Measure screenshot"><button class="photo-clear" onclick="clearPhoto(${s.id})">×</button></div>
|
||||
<p class="photo-hint" id="ph-${s.id}" style="display:none">Read the measurements shown in the photo and enter them in the fields above.</p>
|
||||
</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMeasure(id) {
|
||||
document.getElementById(`mt-${id}`).classList.toggle('open');
|
||||
document.getElementById(`mp-${id}`).classList.toggle('open');
|
||||
}
|
||||
function loadPhoto(id, input) {
|
||||
if (!input.files[0]) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
document.getElementById(`pi-${id}`).src = e.target.result;
|
||||
document.getElementById(`pp-${id}`).style.display = 'block';
|
||||
document.getElementById(`ph-${id}`).style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
function clearPhoto(id) {
|
||||
document.getElementById(`pi-${id}`).src = '';
|
||||
document.getElementById(`pp-${id}`).style.display = 'none';
|
||||
document.getElementById(`ph-${id}`).style.display = 'none';
|
||||
const inp = document.getElementById(`photo-${id}`);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
|
||||
/* ── Setters ──────────────────────────────────────── */
|
||||
function setUnit(u) {
|
||||
state.unit = u;
|
||||
document.getElementById('btn-ft').classList.toggle('active', u === 'ft');
|
||||
document.getElementById('btn-m').classList.toggle('active', u === 'm');
|
||||
document.getElementById('dims-label').textContent = `Stack Dimensions (${u})`;
|
||||
calc();
|
||||
}
|
||||
function setQuality(q) {
|
||||
state.quality = q;
|
||||
document.querySelectorAll('#q-seg button').forEach(b => b.classList.toggle('active', b.dataset.q === q));
|
||||
document.getElementById('q-desc').textContent = QUALITY[q].desc;
|
||||
calc();
|
||||
}
|
||||
function setRate(r) {
|
||||
state.rate = r;
|
||||
document.querySelectorAll('#r-seg button').forEach(b => b.classList.toggle('active', b.dataset.r === r));
|
||||
document.getElementById('r-desc').textContent = RATE[r].desc;
|
||||
document.getElementById('custom-rate-row').classList.toggle('show', r === 'custom');
|
||||
calc();
|
||||
}
|
||||
function setSpecies(sp) {
|
||||
state.species = sp;
|
||||
document.querySelectorAll('#sp-seg button').forEach(b => b.classList.toggle('active', b.dataset.sp === sp));
|
||||
document.getElementById('sp-desc').textContent = SPECIES[sp].desc;
|
||||
calc();
|
||||
}
|
||||
function setCondition(c) {
|
||||
state.condition = c;
|
||||
document.querySelectorAll('#cond-seg button').forEach(b => b.classList.toggle('active', b.dataset.cond === c));
|
||||
document.getElementById('cond-desc').textContent = COND[c].desc;
|
||||
document.getElementById('green-warn').classList.toggle('show', c === 'green');
|
||||
calc();
|
||||
}
|
||||
function setLogLength(ll) {
|
||||
state.logLength = ll;
|
||||
document.querySelectorAll('#ll-seg button').forEach(b => b.classList.toggle('active', +b.dataset.ll === ll));
|
||||
document.getElementById('ll-desc').textContent = LL_NOTES[ll];
|
||||
}
|
||||
|
||||
/* ── Date selectors ───────────────────────────────── */
|
||||
function initDates() {
|
||||
const now = new Date();
|
||||
const cy = now.getFullYear();
|
||||
const cm = now.getMonth();
|
||||
|
||||
['start-month','end-month'].forEach(id => {
|
||||
const sel = document.getElementById(id);
|
||||
MONTHS.forEach((m, i) => sel.add(new Option(m, i)));
|
||||
});
|
||||
['start-year','end-year'].forEach(id => {
|
||||
const sel = document.getElementById(id);
|
||||
for (let y = cy; y <= cy + 3; y++) sel.add(new Option(y, y));
|
||||
});
|
||||
|
||||
// Default: next upcoming Oct → Apr
|
||||
const nextOctYear = cm < 9 ? cy : cy + 1;
|
||||
document.getElementById('start-month').value = 9;
|
||||
document.getElementById('start-year').value = nextOctYear;
|
||||
document.getElementById('end-month').value = 3;
|
||||
document.getElementById('end-year').value = nextOctYear + 1;
|
||||
|
||||
updateDates();
|
||||
['start-month','start-year','end-month','end-year'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('change', updateDates)
|
||||
);
|
||||
}
|
||||
|
||||
function updateDates() {
|
||||
state.startMonth = +document.getElementById('start-month').value;
|
||||
state.startYear = +document.getElementById('start-year').value;
|
||||
state.endMonth = +document.getElementById('end-month').value;
|
||||
state.endYear = +document.getElementById('end-year').value;
|
||||
|
||||
const sm = MONTHS[state.startMonth], sy = state.startYear;
|
||||
const em = MONTHS[state.endMonth], ey = state.endYear;
|
||||
const startDate = new Date(sy, state.startMonth, 1);
|
||||
const endDate = new Date(ey, state.endMonth + 1, 0);
|
||||
const totalMonths = monthsBetween(startDate, new Date(ey, state.endMonth + 1, 1));
|
||||
|
||||
document.getElementById('season-preview').textContent =
|
||||
`${sm} ${sy} → ${em} ${ey} · ${totalMonths} month${totalMonths !== 1 ? 's' : ''}`;
|
||||
calc();
|
||||
}
|
||||
|
||||
/* ── Date helpers ─────────────────────────────────── */
|
||||
function monthsBetween(a, b) {
|
||||
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
||||
}
|
||||
function addMonthsFrac(date, months) {
|
||||
const d = new Date(date);
|
||||
const full = Math.floor(months);
|
||||
const frac = months - full;
|
||||
d.setMonth(d.getMonth() + full);
|
||||
d.setDate(d.getDate() + Math.round(frac * 30));
|
||||
return d;
|
||||
}
|
||||
function fmtMY(date) {
|
||||
return `${MONTHS[date.getMonth()]} '${String(date.getFullYear()).slice(2)}`;
|
||||
}
|
||||
|
||||
/* ── Formatting ───────────────────────────────────── */
|
||||
function fmtCords(v) {
|
||||
if (v < 0.1) return v.toFixed(2);
|
||||
if (v < 10) return v.toFixed(1);
|
||||
return String(Math.round(v));
|
||||
}
|
||||
function fmtDuration(months) {
|
||||
if (months < 0.5) {
|
||||
const d = Math.max(1, Math.round(months * 30));
|
||||
return { n: String(d), u: d === 1 ? 'day' : 'days' };
|
||||
}
|
||||
if (months < 3) {
|
||||
const w = Math.max(1, Math.round(months * 4.33));
|
||||
return { n: String(w), u: w === 1 ? 'week' : 'weeks' };
|
||||
}
|
||||
if (months < 24) {
|
||||
const m = Math.max(1, Math.round(months));
|
||||
return { n: String(m), u: m === 1 ? 'month' : 'months' };
|
||||
}
|
||||
const y = Math.round(months / 12);
|
||||
return { n: String(y), u: y === 1 ? 'year' : 'years' };
|
||||
}
|
||||
|
||||
/* ── Core calculation ─────────────────────────────── */
|
||||
function calc() {
|
||||
let totalFt3 = 0;
|
||||
for (const s of state.stacks) {
|
||||
const l = parseFloat(s.l) || 0, w = parseFloat(s.w) || 0, h = parseFloat(s.h) || 0;
|
||||
if (l > 0 && w > 0 && h > 0) {
|
||||
let v = l * w * h;
|
||||
if (state.unit === 'm') v *= 35.3147;
|
||||
totalFt3 += v;
|
||||
}
|
||||
}
|
||||
|
||||
const elEmpty = document.getElementById('res-empty');
|
||||
const elCords = document.getElementById('res-cords');
|
||||
const elDiv = document.getElementById('res-div');
|
||||
const elDur = document.getElementById('res-dur');
|
||||
const yearCard = document.getElementById('year-card');
|
||||
|
||||
if (totalFt3 <= 0) {
|
||||
elEmpty.style.display = 'block';
|
||||
elCords.classList.remove('show'); elDiv.classList.remove('show'); elDur.classList.remove('show');
|
||||
yearCard.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
elEmpty.style.display = 'none';
|
||||
elCords.classList.add('show'); elDiv.classList.add('show'); elDur.classList.add('show');
|
||||
|
||||
const qualF = QUALITY[state.quality].f;
|
||||
const specF = SPECIES[state.species].f;
|
||||
const condF = COND[state.condition].f;
|
||||
|
||||
const physicalCords = (totalFt3 / 128) * qualF;
|
||||
const effectiveCords = physicalCords * specF * condF;
|
||||
|
||||
const burnRate = state.rate === 'custom'
|
||||
? (parseFloat(document.getElementById('custom-val').value) || 1)
|
||||
: RATE[state.rate].r;
|
||||
|
||||
const months = effectiveCords / burnRate;
|
||||
|
||||
document.getElementById('res-cord-num').textContent = fmtCords(physicalCords);
|
||||
|
||||
// Result right-side: "Until [Month YYYY]" if start date is set, else duration
|
||||
if (state.startMonth !== null) {
|
||||
const startDate = new Date(state.startYear, state.startMonth, 1);
|
||||
const endDate = addMonthsFrac(startDate, months);
|
||||
document.getElementById('res-until').textContent = 'Until';
|
||||
document.getElementById('res-dur-lbl').textContent = fmtMY(endDate);
|
||||
document.getElementById('res-until').style.fontSize = '14px';
|
||||
document.getElementById('res-until').style.opacity = '0.7';
|
||||
|
||||
// Rebuild — show end month prominently
|
||||
elDur.innerHTML = `
|
||||
<div class="res-lbl" style="margin-bottom:2px">until</div>
|
||||
<div class="res-until" style="font-size:26px;letter-spacing:-0.5px;color:var(--res-text);font-weight:800;line-height:1">${fmtMY(endDate)}</div>
|
||||
`;
|
||||
} else {
|
||||
const { n, u } = fmtDuration(months);
|
||||
elDur.innerHTML = `<div class="res-num">${n}</div><div class="res-lbl">${u}</div>`;
|
||||
}
|
||||
|
||||
// Year summary
|
||||
if (state.startMonth !== null && state.endMonth !== null) {
|
||||
yearCard.style.display = 'block';
|
||||
buildYearSummary(effectiveCords, burnRate, specF, condF);
|
||||
} else {
|
||||
yearCard.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Year summary ─────────────────────────────────── */
|
||||
function buildYearSummary(effectiveCords, burnRate, specF, condF) {
|
||||
const startDate = new Date(state.startYear, state.startMonth, 1);
|
||||
const targetEnd = new Date(state.endYear, state.endMonth + 1, 1); // exclusive
|
||||
|
||||
document.getElementById('year-label').textContent =
|
||||
`${MONTHS[state.startMonth]} '${String(state.startYear).slice(2)} → ${MONTHS[state.endMonth]} '${String(state.endYear).slice(2)}`;
|
||||
|
||||
// Build month pills
|
||||
const strip = document.getElementById('month-strip');
|
||||
strip.innerHTML = '';
|
||||
let remaining = effectiveCords;
|
||||
let cur = new Date(startDate);
|
||||
|
||||
while (cur < targetEnd) {
|
||||
const covered = remaining > 0;
|
||||
const partial = remaining > 0 && remaining < burnRate;
|
||||
const cls = !covered ? 'mpill-deficit' : partial ? 'mpill-warn' : 'mpill-ok';
|
||||
|
||||
const pill = document.createElement('div');
|
||||
pill.className = `mpill ${cls}`;
|
||||
pill.innerHTML = `<span class="mpill-name">${MONTHS[cur.getMonth()]}</span><span class="mpill-yr">'${String(cur.getFullYear()).slice(2)}</span>`;
|
||||
strip.appendChild(pill);
|
||||
|
||||
remaining -= burnRate;
|
||||
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
// Status
|
||||
const seasonMonths = monthsBetween(startDate, targetEnd);
|
||||
const cordsNeeded = burnRate * seasonMonths; // effective cords for full season
|
||||
const deficit = cordsNeeded - effectiveCords;
|
||||
const statusEl = document.getElementById('season-status');
|
||||
|
||||
if (deficit > 0) {
|
||||
const physDeficit = deficit / (specF * condF);
|
||||
const runOutDate = addMonthsFrac(startDate, effectiveCords / burnRate);
|
||||
const orderByDate = new Date(runOutDate.getFullYear(), runOutDate.getMonth() - 1, 1);
|
||||
const spName = state.species === 'hardwood' ? 'hardwood' : state.species === 'softwood' ? 'softwood' : 'mixed wood';
|
||||
|
||||
statusEl.className = 'season-status deficit';
|
||||
statusEl.innerHTML = `
|
||||
<strong>Short by ${fmtCords(physDeficit)} cords of ${spName}</strong>
|
||||
Wood runs out around ${fmtMY(runOutDate)} — ${monthsBetween(runOutDate, new Date(state.endYear, state.endMonth, 1))} months before your target.
|
||||
Order by ${fmtMY(orderByDate)} to be ready.
|
||||
`;
|
||||
} else {
|
||||
const surplus = (-deficit) / (specF * condF);
|
||||
statusEl.className = 'season-status ok';
|
||||
statusEl.innerHTML = `
|
||||
<strong>You're covered through ${MONTHS[state.endMonth]}</strong>
|
||||
${fmtCords(surplus)} cords to spare after your target end date.
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Wire up events ───────────────────────────────── */
|
||||
document.getElementById('add-btn').addEventListener('click', addStack);
|
||||
document.getElementById('btn-ft').addEventListener('click', () => setUnit('ft'));
|
||||
document.getElementById('btn-m').addEventListener('click', () => setUnit('m'));
|
||||
document.querySelectorAll('#q-seg button').forEach(b => b.addEventListener('click', () => setQuality(b.dataset.q)));
|
||||
document.querySelectorAll('#r-seg button').forEach(b => b.addEventListener('click', () => setRate(b.dataset.r)));
|
||||
document.querySelectorAll('#sp-seg button').forEach(b => b.addEventListener('click', () => setSpecies(b.dataset.sp)));
|
||||
document.querySelectorAll('#cond-seg button').forEach(b => b.addEventListener('click', () => setCondition(b.dataset.cond)));
|
||||
document.querySelectorAll('#ll-seg button').forEach(b => b.addEventListener('click', () => setLogLength(+b.dataset.ll)));
|
||||
document.getElementById('custom-val').addEventListener('input', calc);
|
||||
|
||||
addStack();
|
||||
initDates();
|
||||
</script>
|
||||
Reference in new issue
Block a user