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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-17 12:06:07 -04:00
commit 93b9cdf00b
3 files changed
+834

No files matched your search

+43
View File
@@ -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 }}
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
+788
View File
@@ -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 &amp; 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>