{"schema":"barkday.ai-pack-core.v1","repo":"CandidQuality/Barkday","commit":"9e38500d723ed1e8e1ca93eb8ae821c831f1414d","updated_utc":"2026-05-05T03:48:27.039Z","count":9,"items":[{"path":"app-celebrate.js","size":5156,"sha":"cefb7a34b305fa5505f6388a80b08a1fdc9ac0e8","media_type":"application/javascript","raw_url":"https://raw.githubusercontent.com/CandidQuality/Barkday/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app-celebrate.js","html_url":"https://github.com/CandidQuality/Barkday/blob/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app-celebrate.js","inline_state":"full","max_inline_text_bytes":614400,"max_inline_bin_bytes":204800,"preview_text_bytes":65536,"encoding":"utf8","content":"/* Barkday™ Celebration Patch (confetti timing fix)\r\n - Waits for drawer/modal to be visible before firing confetti\r\n - No changes to app.js required\r\n - Safe to include multiple times (no double binding)\r\n - v2025-10-22\r\n*/\r\n(function () {\r\n if (window.__bdCelebratePatched) return; // idempotent\r\n window.__bdCelebratePatched = true;\r\n\r\n // --- small helper: wait for a selector to be present/visible\r\n function waitFor(selector, timeout = 4000) {\r\n return new Promise(resolve => {\r\n const hit = document.querySelector(selector);\r\n if (hit) return resolve(hit);\r\n const mo = new MutationObserver(() => {\r\n const found = document.querySelector(selector);\r\n if (found) { mo.disconnect(); resolve(found); }\r\n });\r\n mo.observe(document.body, {\r\n childList: true,\r\n subtree: true,\r\n attributes: true,\r\n attributeFilter: ['aria-hidden','open','class']\r\n });\r\n setTimeout(() => { mo.disconnect(); resolve(null); }, timeout);\r\n });\r\n }\r\n\r\n // Heuristic: these indicate your drawer/modal is actually open\r\n const READY_SEL = '#bdSaved[aria-hidden=\"false\"], .bd-modal[open], .bd-drawer[aria-hidden=\"false\"]';\r\n\r\n // Fireworks helper that always paints on top and never blocks clicks\r\n function fireConfettiSafe() {\r\n try {\r\n // double RAF: ensure layout has finished before we create a canvas\r\n requestAnimationFrame(() => requestAnimationFrame(() => {\r\n try {\r\n const before = document.querySelector('#__bdConfettiCanvas');\r\n if (before) before.remove();\r\n\r\n // If your confetti() already creates and manages its own canvas, this call is enough:\r\n // confetti();\r\n\r\n // If you use a canvas-based confetti that needs a target, create a temp canvas:\r\n const c = document.createElement('canvas');\r\n c.id = '__bdConfettiCanvas';\r\n c.style.position = 'fixed';\r\n c.style.left = '0';\r\n c.style.top = '0';\r\n c.style.width = '100vw';\r\n c.style.height = '100vh';\r\n c.style.pointerEvents = 'none';\r\n c.style.zIndex = '2147483647';\r\n document.body.appendChild(c);\r\n\r\n // Try native confetti() first if it exists\r\n if (typeof window.confetti === 'function') {\r\n window.confetti(); // many libs hook their own full-screen canvas\r\n } else {\r\n // minimal fallback sparkle (non-blocking) so users still see *something*\r\n const ctx = c.getContext('2d');\r\n const W = c.width = innerWidth;\r\n const H = c.height = innerHeight;\r\n const n = 120;\r\n for (let i=0;ic.remove(), 1200);\r\n }\r\n } catch (e) { console.warn('[Barkday] confetti failed', e); }\r\n }));\r\n } catch (e) { console.warn('[Barkday] confetti scheduling failed', e); }\r\n }\r\n\r\n // Patch strategy:\r\n // 1) Listen for clicks on “Save/Load” that create/open the drawer,\r\n // 2) After app.js finishes rendering the plan, if “today”, wait for drawer visible, then fire.\r\n //\r\n // We don’t rely on internal functions; we infer state from the DOM the same way a user would.\r\n\r\n // Helper: find the current “run” info if app.js exposes it on the card (data attributes) or in storage.\r\n function isTodayRun() {\r\n // Prefer explicit flag if app.js sets one (not required)\r\n if (window.bdCurrentInfo && window.bdCurrentInfo.isToday) return true;\r\n\r\n // Fallback: check if the UI shows the “today” badge somewhere\r\n // (Adjust these selectors if your markup differs.)\r\n const todayBadges = document.querySelectorAll('.bd-badge-today, [data-today=\"true\"]');\r\n if (todayBadges.length) return true;\r\n\r\n // As a general fallback, let’s be conservative: return false without a clear signal\r\n return false;\r\n }\r\n\r\n // When the user loads or saves a record we’ll get a click on these buttons.\r\n // We use event delegation so dynamically inserted items are handled.\r\n document.addEventListener('click', (e) => {\r\n const el = e.target.closest('[data-act=\"load\"], [data-act=\"save\"], [data-act=\"open\"]');\r\n if (!el) return;\r\n\r\n // Schedule a post-render check: after app.js updates the DOM for the drawer/plan\r\n setTimeout(() => {\r\n if (!isTodayRun()) return;\r\n waitFor(READY_SEL, 5000).then(() => fireConfettiSafe());\r\n }, 0);\r\n });\r\n\r\n // Also cover the “first time” flow when a brand-new profile is created\r\n // (app.js often shows the drawer automatically right after submit)\r\n document.addEventListener('submit', (e) => {\r\n const form = e.target.closest('form');\r\n if (!form) return;\r\n // Post-submit render tends to be async; give it a tick\r\n setTimeout(() => {\r\n if (!isTodayRun()) return;\r\n waitFor(READY_SEL, 5000).then(() => fireConfettiSafe());\r\n }, 0);\r\n });\r\n\r\n console.log('[Barkday] celebration patch ready');\r\n})();\r\n","inline_bytes":5128,"content_sha256":"413d4c8c713b31de7bc513956cc703991b3816cc0828ee1f3b9f780ab5a6277a"},{"path":"app-curves-inline.js","size":5752,"sha":"8006f5bbdf771c2f33ebbb2ce7911a9b7862b028","media_type":"application/javascript","raw_url":"https://raw.githubusercontent.com/CandidQuality/Barkday/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app-curves-inline.js","html_url":"https://github.com/CandidQuality/Barkday/blob/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app-curves-inline.js","inline_state":"full","max_inline_text_bytes":614400,"max_inline_bin_bytes":204800,"preview_text_bytes":65536,"encoding":"utf8","content":"/* Barkday™ curves inline add-on (non-module; safe to load after app.js)\r\n - Year 1 & 2: smooth monotone arithmetic gaps (LOCT 15/9), sums to 365 each year\r\n - >= 2y: smooth cumulative K(t) via monotone cubic (PCHIP) with anchors\r\n - Exposes window.bdCurves with:\r\n • loadCurves(): Promise\r\n • buildTimes(conf, opts): {k->tYears}\r\n • computeNext(dog): Promise<{k, date, tYears, curveLabel}>\r\n • dateFromYears(dob, tYears): Date\r\n*/\r\n\r\n(function(){\r\n // ---- Helpers: LOCT (Year 1 & 2) ----\r\n function yearGapsAP(n, a0Days, yearDays) {\r\n if (n <= 0) return [];\r\n const denom = n * (n - 1) / 2;\r\n const d = (yearDays - n * a0Days) / denom;\r\n const gaps = Array.from({ length: n }, (_, i) => a0Days + i * d);\r\n const sum = gaps.reduce((a, b) => a + b, 0);\r\n gaps[gaps.length - 1] += (yearDays - sum); // exact sum guard\r\n return gaps;\r\n }\r\n function kTimesYear1Year2(loctConf) {\r\n const n1 = loctConf?.y1?.n ?? 15;\r\n const a0y1 = loctConf?.y1?.a0_days ?? 10;\r\n const n2 = loctConf?.y2?.n ?? 9;\r\n const a0Hint = loctConf?.y2?.a0_hint_days ?? 38;\r\n\r\n // Year 1 → (0,1]\r\n const gapsY1 = yearGapsAP(n1, a0y1, 365);\r\n const tY1 = []; let acc = 0;\r\n for (const g of gapsY1) { acc += g; tY1.push(acc / 365); }\r\n\r\n // Year 2 → (1,2], first gap ≥ last Year1 gap\r\n const lastY1 = gapsY1[gapsY1.length - 1];\r\n const a0y2 = Math.max(a0Hint, lastY1);\r\n const gapsY2 = yearGapsAP(n2, a0y2, 365);\r\n const tY2 = []; acc = 0;\r\n for (const g of gapsY2) { acc += g; tY2.push(1 + acc / 365); }\r\n\r\n const out = {};\r\n tY1.forEach((t,i)=> out[String(i+1)] = +t.toFixed(6));\r\n tY2.forEach((t,i)=> out[String(n1+i+1)] = +t.toFixed(6));\r\n return out; // human years since DOB for k=1..24\r\n }\r\n\r\n // ---- PCHIP monotone cubic for t >= 2y ----\r\n function buildPCHIP(anchors){\r\n const n = anchors.length;\r\n if (n < 2) throw new Error('PCHIP: need >=2 anchors');\r\n const t = anchors.map(a=>+a.t_years);\r\n const y = anchors.map(a=>+a.K_total);\r\n const h = Array(n-1), d = Array(n-1);\r\n for (let i=0;i 0) || !(d[i] >= 0)) throw new Error('PCHIP: anchors must be increasing (t) and nondecreasing (K)');\r\n }\r\n const m = Array(n).fill(0);\r\n m[0]=d[0]; m[n-1]=d[n-2];\r\n for (let i=1;i= t[n-1]) return y[n-1];\r\n // locate segment\r\n let s=0,e=n-1;\r\n while (s+1>1;\r\n if (t[mid] <= x) s=mid; else e=mid;\r\n }\r\n const hS = h[s];\r\n const tau = (x - t[s]) / hS;\r\n const h00 = (1+2*tau)*(1-tau)*(1-tau);\r\n const h10 = tau*(1-tau)*(1-tau);\r\n const h01 = tau*tau*(3-2*tau);\r\n const h11 = tau*tau*(tau-1);\r\n return h00*y[s] + h10*hS*m[s] + h01*y[s+1] + h11*hS*m[s+1];\r\n }\r\n return (x)=>evalAt(x);\r\n }\r\n function invertK(evalK, k, tLo=2.0, tHi=12.0, iters=44){\r\n let lo=tLo, hi=tHi;\r\n if (k <= evalK(lo)) return lo;\r\n if (k >= evalK(hi)) return hi;\r\n for (let i=0;i nowYears && (bestT == null || t < bestT)){ bestT=t; bestK=+k; }\r\n }\r\n const dt = dateFromYears(dog.dob, bestT);\r\n return { k: bestK, date: dt, tYears: bestT, curveLabel: conf?.label || 'Default curve' };\r\n }\r\n\r\n // Expose\r\n window.bdCurves = { loadCurves, buildTimes, dateFromYears, computeNext };\r\n console.log('[Barkday] curves inline add-on ready');\r\n})();\r\n","inline_bytes":5736,"content_sha256":"e2573ec4a87a12047c712c93f530956472ee7cbc0d7f0a046f90792ec30ef250"},{"path":"app-pdf.js","size":15113,"sha":"96cdf3dd10ba13217ba5c6c6aa8e5110fac44e04","media_type":"application/javascript","raw_url":"https://raw.githubusercontent.com/CandidQuality/Barkday/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app-pdf.js","html_url":"https://github.com/CandidQuality/Barkday/blob/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app-pdf.js","inline_state":"full","max_inline_text_bytes":614400,"max_inline_bin_bytes":204800,"preview_text_bytes":65536,"encoding":"utf8","content":"/* Barkday™ PDF Add-on — per-record Print PDF (v2025-10-22-FINAL)\r\n - Vector SVG logo (fallback to PNG), cache-busted, GH Pages friendly\r\n - Real QR code (local lib if present; otherwise robust PNG fallback)\r\n - \"Certificate #\" + number always shown (uses run.id/hash; else deterministic fallback)\r\n - Footer pinned to the bottom, header stays header\r\n - Buttons injected for existing & future cards\r\n*/\r\n\r\n(function () {\r\n // ---------- small utils ----------\r\n const LOG = (...a)=>console.log('[Barkday][PDF]', ...a);\r\n const WARN = (...a)=>console.warn('[Barkday][PDF]', ...a);\r\n\r\n function onceDOMReady(fn){\r\n if (document.readyState === 'loading') {\r\n document.addEventListener('DOMContentLoaded', fn, {once:true});\r\n } else { fn(); }\r\n }\r\n const sleep = (ms)=>new Promise(r=>setTimeout(r, ms));\r\n\r\n function getStoreList(){\r\n try { return (window.bdStoreList ? bdStoreList() : []); }\r\n catch { return []; }\r\n }\r\n\r\n async function fetchFirst(urls){\r\n let lastErr;\r\n for(const url of urls){\r\n try {\r\n const r = await fetch(url, { cache:'no-store' });\r\n if (!r.ok) throw new Error(String(r.status));\r\n return r;\r\n } catch (e){ lastErr = e; }\r\n }\r\n throw lastErr || new Error('All fetch paths failed');\r\n }\r\n\r\n // --- Logo helpers: prefer vector SVG, fallback to PNG ---\r\n async function drawVectorLogo(doc, x, y, w, h){\r\n if (!window.svg2pdf) {\r\n await new Promise((res, rej)=>{\r\n const s = document.createElement('script');\r\n s.src = 'https://cdn.jsdelivr.net/npm/svg2pdf.js@2.2.4/dist/svg2pdf.umd.min.js';\r\n s.onload = res; s.onerror = rej; document.head.appendChild(s);\r\n });\r\n }\r\n const resp = await fetchFirst([\r\n 'barkday-logo-vector.svg?v=3',\r\n 'assets/barkday-logo-vector.svg?v=3'\r\n ]);\r\n const svgText = await resp.text();\r\n await doc.svg(svgText, { x, y, width: w, height: h });\r\n }\r\n\r\n async function drawPngLogo(doc, x, y, w, h){\r\n const r = await fetchFirst([\r\n 'barkday-logo2.png?v=2',\r\n 'assets/barkday-logo2.png?v=2'\r\n ]);\r\n const b = await r.blob();\r\n const dataUrl = await new Promise((res, rej)=>{\r\n const fr = new FileReader();\r\n fr.onload = ()=>res(fr.result);\r\n fr.onerror = rej;\r\n fr.readAsDataURL(b);\r\n });\r\n doc.addImage(dataUrl, 'PNG', x, y, w, h);\r\n }\r\n\r\n // ensure jsPDF (index already loads it; this is a guard)\r\n async function ensurePDFLibs(){\r\n if (!window.jspdf?.jsPDF) {\r\n await new Promise((res, rej)=>{\r\n const s = document.createElement('script');\r\n s.src = 'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js';\r\n s.onload = res; s.onerror = rej; document.head.appendChild(s);\r\n });\r\n }\r\n return window.jspdf.jsPDF;\r\n }\r\n\r\n // Try to find a local QR library; if not present, caller will use network PNG fallback\r\n async function ensureQRCode(){\r\n const pick = (w)=> (w && (w.QRCode || w.qrcode)) || null;\r\n let QR = pick(window);\r\n if (QR) return QR;\r\n if (window.opener) {\r\n QR = pick(window.opener);\r\n if (QR) return QR;\r\n }\r\n return null; // tell caller to fallback\r\n }\r\n\r\n // format helpers\r\n function fmtHumanYears(hy){\r\n if (hy == null || isNaN(+hy)) return '—';\r\n const yrs = Math.floor(hy);\r\n const mos = Math.round((hy - yrs) * 12);\r\n if (yrs === 0) return `${mos} mo`;\r\n if (mos === 0) return `${yrs} yr`;\r\n return `${yrs} yr ${mos} mo`;\r\n }\r\n const fmtDogAge = (d)=> d || '—';\r\n\r\n // Always provide a certificate number (prefer real id/hash; else deterministic fallback)\r\n function getCert(run){\r\n const s = (run?.id || run?.hash || '').toString().trim();\r\n if (s) return s;\r\n const seed = [run?.dog||'', run?.dob||'', run?.breed||'', run?.weight||''].join('|');\r\n let h = 0 >>> 0;\r\n for (let i = 0; i < seed.length; i++){\r\n h = (h * 1664525 + seed.charCodeAt(i) + 1013904223) >>> 0; // simple LCG-ish\r\n }\r\n return String(h).padStart(10, '0').slice(0, 10); // 10-digit fallback\r\n }\r\n\r\n // Build compact QR payload\r\n function buildQRPayload(run){\r\n const payload = {\r\n v: '1',\r\n id: run?.id || run?.hash || '',\r\n dog: run?.dog || '',\r\n dob: run?.dob || '',\r\n w: run?.weight ?? null,\r\n g: run?.group || '',\r\n b: run?.breed || '',\r\n };\r\n return JSON.stringify(payload);\r\n }\r\n\r\n // Robust QR render: local lib if present; otherwise network PNG fallback\r\n async function renderQRCodeToDataURL(text){\r\n const QR = await ensureQRCode();\r\n\r\n // A) local lib (preferred)\r\n if (QR && (QR.toCanvas || (QR.QRCode && QR.QRCode.toCanvas))) {\r\n const toCanvas = QR.toCanvas || QR.QRCode.toCanvas;\r\n return await new Promise((resolve, reject)=>{\r\n const c = document.createElement('canvas');\r\n try {\r\n toCanvas(c, text, { errorCorrectionLevel: 'M', margin: 1 }, (err)=>{\r\n if (err) return reject(err);\r\n try { resolve(c.toDataURL('image/png')); }\r\n catch(e){ reject(e); }\r\n });\r\n } catch(e){ reject(e); }\r\n });\r\n }\r\n\r\n // B) network PNG fallback (stable on GH Pages & blob print popups)\r\n const url = 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' + encodeURIComponent(text);\r\n const resp = await fetch(url, { cache: 'no-store' });\r\n if (!resp.ok) throw new Error('QR fallback fetch failed: ' + resp.status);\r\n const blob = await resp.blob();\r\n return await new Promise((res, rej)=>{\r\n const fr = new FileReader();\r\n fr.onload = ()=>res(fr.result);\r\n fr.onerror = rej;\r\n fr.readAsDataURL(blob);\r\n });\r\n }\r\n\r\n // --------- PDF LAYOUT ----------\r\n async function buildRunPDF(run){\r\n const jsPDF = await ensurePDFLibs();\r\n const doc = new jsPDF({ orientation:'landscape', unit:'pt', format:'letter' }); // 792x612\r\n\r\n const W=792, H=612, M=36;\r\n const RULE=[210,210,210];\r\n const LINE=16, BUL=14;\r\n let y = M; // header Y anchor\r\n\r\n const hr=(yy)=>{doc.setDrawColor(...RULE);doc.setLineWidth(1);doc.line(M,yy,W-M,yy);};\r\n const tx=(s,x,yy,fs=11,bold=false)=>{doc.setFontSize(fs);doc.setFont('helvetica', bold?'bold':'normal');doc.text(String(s??'—'),x,yy);};\r\n const wrap=(s,w)=>doc.splitTextToSize(String(s??'—'),w);\r\n\r\n // Header: logo (SVG-first) + title\r\n try {\r\n await drawVectorLogo(doc, M, y, 86.4, 86.4);\r\n } catch {\r\n try { await drawPngLogo(doc, M, y, 86.4, 86.4); }\r\n catch { doc.setDrawColor(255); doc.rect(M,y,86.4,86.4,'S'); }\r\n }\r\n\r\n // Right side of header: QR + Certificate #\r\n const headerLeftX = M + 86.4 + 18;\r\n const headerRightX = W - M - 150;\r\n\r\n tx('Barkday™', headerLeftX, y + 24, 22, true);\r\n if (run?.dog) tx(run.dog, headerLeftX, y + 44, 16, false);\r\n\r\n // Compact summary lines\r\n let sy = y + 64;\r\n const colGap = 14, col1W = 260, col2W = 300;\r\n function kvL(lbl, val){\r\n tx(lbl+':', headerLeftX, sy, 11, true);\r\n const lines = wrap(val, col1W-70);\r\n doc.setFontSize(11); doc.setFont('helvetica','normal'); doc.text(lines, headerLeftX+70, sy);\r\n sy += Math.max(LINE, lines.length*LINE);\r\n }\r\n let syR = y + 64;\r\n const rx = headerLeftX + col1W + colGap;\r\n function kvR(lbl, val){\r\n tx(lbl+':', rx, syR, 11, true);\r\n const lines = wrap(val, col2W-100);\r\n doc.setFontSize(11); doc.setFont('helvetica','normal'); doc.text(lines, rx+100, syR);\r\n syR += Math.max(LINE, lines.length*LINE);\r\n }\r\n\r\n const breedLine = (Array.isArray(run?.breeds) && run.breeds.length)\r\n ? run.breeds.slice(0,3).map(b=>`${b.name}${b.pct!=null?` (${b.pct}%)`:''}`).join(' · ')\r\n : (run?.breed || '-');\r\n\r\n kvL('Group', run?.group || '-');\r\n kvL('Birthdate', run?.dob || '-');\r\n kvL('Breed', breedLine);\r\n\r\n kvR('EST adult weight', (Number.isFinite(+run?.weight) ? `${run.weight} lb` : '—'));\r\n kvR('Chewer', run?.chewer || '—');\r\n kvR('Milestones', run?.smooth ? 'Smooth enabled' : 'Standard');\r\n\r\n // KPI snapshot\r\n const snapY = Math.max(sy, syR) + 8;\r\n const hyText = fmtHumanYears(run?.kpi?.hy);\r\n const dogAge = fmtDogAge(run?.kpi?.dogAge);\r\n tx(`Dog age today: ${dogAge}`, headerLeftX, snapY, 11, false);\r\n tx(`Human-years estimate: ${hyText}`, headerLeftX + 200, snapY, 11, false);\r\n\r\n const headerBottom = snapY + 12;\r\n hr(headerBottom + 10);\r\n\r\n // --- QR Code block (smaller + centered label/number) ---\r\ntry {\r\n const qrText = buildQRPayload(run);\r\n const qrDataURL = await renderQRCodeToDataURL(qrText);\r\n\r\n const QR_SCALE = 0.6; // ~60%\r\n const QR_SIZE = Math.round(96 * QR_SCALE); // 58 pt\r\n const qrX = headerRightX;\r\n const qrY = y + 6;\r\n\r\n doc.addImage(qrDataURL, 'PNG', qrX, qrY, QR_SIZE, QR_SIZE);\r\n\r\n const cert = getCert(run);\r\n const cx = qrX + QR_SIZE / 2;\r\n const labelY = qrY + QR_SIZE + 12; // tuck a little closer for the smaller code\r\n doc.setFontSize(10); doc.setFont('helvetica','bold');\r\n doc.text('Certificate #', cx, labelY, { align:'center' });\r\n doc.setFontSize(11); doc.setFont('courier','normal');\r\n doc.text(cert, cx, labelY + 14, { align:'center' });\r\n\r\n} catch (e) {\r\n WARN('QR generation failed', e);\r\n\r\n const QR_SCALE = 0.6;\r\n const QR_SIZE = Math.round(96 * QR_SCALE);\r\n const qrX = headerRightX;\r\n const qrY = y + 6;\r\n\r\n doc.setDrawColor(...RULE);\r\n doc.rect(qrX, qrY, QR_SIZE, QR_SIZE, 'S');\r\n doc.setFontSize(10); doc.setFont('helvetica','normal');\r\n doc.text('QR unavailable', qrX + 8, qrY + QR_SIZE / 2);\r\n\r\n const cert = getCert(run);\r\n const cx = qrX + QR_SIZE / 2;\r\n const labelY = qrY + QR_SIZE + 12;\r\n doc.setFontSize(10); doc.setFont('helvetica','bold');\r\n doc.text('Certificate #', cx, labelY, { align:'center' });\r\n doc.setFontSize(11); doc.setFont('courier','normal');\r\n doc.text(cert, cx, labelY + 14, { align:'center' });\r\n}\r\n\r\n // ---------- NOTES (center) ----------\r\n let notesTop = headerBottom + 26;\r\n tx('Next Barkday Plan', M, notesTop, 14, true);\r\n notesTop += LINE;\r\n\r\n const usableW = W - 2*M, innerGap = 18;\r\n const LEFT_W = (usableW - innerGap) * 0.54;\r\n const RIGHT_W = (usableW - innerGap) - LEFT_W;\r\n const X_L = M, X_R = M + LEFT_W + innerGap;\r\n let yL = notesTop, yR = notesTop;\r\n\r\n const lanes = (function(notes){\r\n const out=[]; if(!notes||typeof notes!=='string') return out;\r\n const blocks=notes.split(/\\n\\s*\\n+/);\r\n for(const b of blocks){\r\n const lines=b.split(/\\n/).map(s=>s.trim()).filter(Boolean);\r\n if(!lines.length) continue;\r\n const title=lines[0].replace(/^[-•]+\\s*/, '');\r\n const items=lines.slice(1).map(s=>s.replace(/^[-•]+\\s*/, '').trim()).filter(Boolean);\r\n if(items.length) out.push({title, items});\r\n }\r\n return out;\r\n })(run?.event?.notes);\r\n\r\n function laneBlock(title,items, colX, colW, colYRef){\r\n tx(title||'Plan', colX, colYRef.v, 12, true); colYRef.v += LINE-2;\r\n for(const b of (items||[])){\r\n const lines=wrap('• '+b, colW-14);\r\n doc.setFontSize(11); doc.setFont('helvetica','normal'); doc.text(lines, colX+10, colYRef.v);\r\n colYRef.v += lines.length*BUL;\r\n }\r\n colYRef.v += 6;\r\n }\r\n\r\n for(const lane of lanes){\r\n const leftRef = {v:yL}, rightRef = {v:yR};\r\n if (yL <= yR) laneBlock(lane.title, lane.items, X_L, LEFT_W, leftRef);\r\n else laneBlock(lane.title, lane.items, X_R, RIGHT_W, rightRef);\r\n yL = leftRef.v; yR = rightRef.v;\r\n }\r\n\r\n // --------- FOOTER pinned ----------\r\n const contentBottom = Math.max(yL, yR);\r\n const footerY = Math.max(contentBottom + 10, H - M - 50);\r\n hr(footerY);\r\n tx('Privacy: Barkday™ stores data only on this device. This PDF was generated locally in your browser.', M, footerY+18, 9, false);\r\n tx('Medical disclaimer: Barkday™ is general guidance only and not veterinary advice.', M, footerY+34, 9, false);\r\n\r\n return doc;\r\n }\r\n\r\n // -------- buttons & click handling --------\r\n function injectButtonsIn(container){\r\n const cards = container.querySelectorAll('[data-act=\"load\"]');\r\n cards.forEach(loadBtn=>{\r\n const card = loadBtn.closest('.bd-card, article, li, div') || loadBtn.parentNode;\r\n if (!card || card.querySelector('[data-act=\"pdf\"]')) return;\r\n\r\n const idx = loadBtn.dataset.idx || card.getAttribute('data-i');\r\n const actions = loadBtn.parentNode;\r\n const btn = document.createElement('button');\r\n btn.className = 'ghost';\r\n btn.type = 'button';\r\n btn.textContent = 'Print PDF';\r\n btn.setAttribute('data-act','pdf');\r\n if (idx != null) btn.setAttribute('data-idx', idx);\r\n actions.appendChild(btn);\r\n });\r\n }\r\n\r\n function computeIndexForButton(btn){\r\n let idx = btn.dataset.idx;\r\n if (idx != null) return Number(idx);\r\n\r\n const card = btn.closest('[data-i]');\r\n if (card) return Number(card.getAttribute('data-i'));\r\n\r\n const allCards = [...document.querySelectorAll('#bdSavedBody [data-act=\"load\"]')];\r\n const loadBtn = btn.parentNode?.querySelector?.('[data-act=\"load\"]');\r\n const pos = allCards.indexOf(loadBtn);\r\n return pos >= 0 ? pos : 0;\r\n }\r\n\r\n function bindGlobalClick(){\r\n document.addEventListener('click', async (e)=>{\r\n const btn = e.target.closest && e.target.closest('[data-act=\"pdf\"]');\r\n if (!btn) return;\r\n\r\n const idx = computeIndexForButton(btn);\r\n const list = getStoreList();\r\n const run = list[idx];\r\n if (!run){ (window.bdToast||alert)('Record not found'); return; }\r\n\r\n try{\r\n const doc = await buildRunPDF(run);\r\n const url = doc.output('bloburl');\r\n const w = window.open(url, '_blank');\r\n if (!w){\r\n (window.bdToast||alert)('Popup blocked — saving the PDF instead.');\r\n const safe = (run?.dog || 'Barkday').replace(/[^\\w\\- ]+/g,'').trim() || 'Barkday';\r\n doc.save(`${safe}-Barkday.pdf`);\r\n } else {\r\n setTimeout(()=>{ try { w.focus(); w.print(); } catch {} }, 300);\r\n }\r\n } catch(err){\r\n console.error(err);\r\n (window.bdToast||alert)('Sorry — could not build PDF.');\r\n }\r\n });\r\n }\r\n\r\n function startObserving(host){\r\n injectButtonsIn(host);\r\n const mo = new MutationObserver(muts=>{\r\n for(const m of muts){\r\n m.addedNodes && m.addedNodes.forEach(n=>{\r\n if (n.nodeType===1) injectButtonsIn(n);\r\n });\r\n }\r\n });\r\n mo.observe(host, {childList:true, subtree:true});\r\n }\r\n\r\n function findSavedHost(){\r\n return document.getElementById('bdSavedBody') || document.getElementById('bdSaved') || document.body;\r\n }\r\n\r\n onceDOMReady(async ()=>{\r\n let tries = 0;\r\n while(tries++ < 200){\r\n const host = findSavedHost();\r\n if (host){\r\n bindGlobalClick();\r\n startObserving(host);\r\n LOG('PDF add-on ready');\r\n return;\r\n }\r\n await sleep(100);\r\n }\r\n WARN('bdSavedBody not found; PDF buttons won’t inject yet.');\r\n });\r\n})();\r\n","inline_bytes":15078,"content_sha256":"495132fe9abae3dea6815dceb56606854c6c8f190a2301298d080e2fd3970617"},{"path":"app.js","size":94801,"sha":"0ab5b0bef2c56ab68b399fadb980657eb4a2dd2d","media_type":"application/javascript","raw_url":"https://raw.githubusercontent.com/CandidQuality/Barkday/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app.js","html_url":"https://github.com/CandidQuality/Barkday/blob/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/app.js","inline_state":"full","max_inline_text_bytes":614400,"max_inline_bin_bytes":204800,"preview_text_bytes":65536,"encoding":"utf8","content":"console.log('[Barkday] app.js loaded v2026-05-03-1 (v2 gift feed runtime adapter)');\n// ====================== Barkday app.js (complete, upgraded) ======================\n\n// ---------- Config ----------\nconst LOGO_SPLASH_SRC = \"barkday-logo.png?v=3\"; // full-size on splash\nconst LOGO_HEADER_SRC = \"barkday-logo2.png?v=2\"; // smaller header mark\n\n// Data sources\nconst GIFT_FEED_URL = \"data/dog-gifts-merged.json?v=2026-05-03-1\";\nconst RECO_BANDED_URL = \"data/reco-banded.json?v=2025-10-06-1\";\nconst RECO_BREED_URL = \"data/reco-breed.json?v=2026-05-04-1\";\nconst BREED_GROUPS_URL = \"data/breed_groups.json?v=2026-05-04-1\";\nconst BREED_ALIASES_URL = \"data/breed_aliases.json?v=2025-10-06-1\"; // NEW: external aliases\nconst TAXONOMY_URL = \"data/breed_taxonomy.json?v=2025-10-06-1\"; // NEW: AKC + behavioral mapping\nconst GROUP_RULES_URL = \"ddata/rules_group_defaults.json?v=2026-05-04-1\"; // NEW: group-level banded fallbacks (rich text)\nconst BARKDAY_DEBUG_MODE = [\"localhost\", \"127.0.0.1\"].includes(location.hostname) && location.search.includes(\"debug=1\");\n\n// --- Affiliate Config (Amazon live; Chewy placeholder) ---\nconst AMAZON_TAG = \"candidquality-20\"; // your live Amazon Associates tag\nconst CHEWY_IMPACT_BASE = \"\"; // leave empty until Chewy approves (e.g., \"https://chewy.pxf.io/c/XXXX/XXXX/XXXX\")\nconst CHEWY_ENABLED = !!CHEWY_IMPACT_BASE; // auto-toggle when you paste your Chewy Impact base\n\n// --- Affiliate link normalizer (Amazon live; Chewy future-proof) ---\nfunction resolveAffiliateUrl(url){\n try {\n const u = new URL(url, location.origin);\n\n // Amazon: ensure our tag is present\n if (u.hostname.includes('amazon.')) {\n if (!u.searchParams.get('tag')) {\n u.searchParams.set('tag', AMAZON_TAG);\n }\n return u.toString();\n }\n\n // Chewy: if/when enabled, wrap the destination with your Impact base\n if (CHEWY_ENABLED && CHEWY_IMPACT_BASE && u.hostname.includes('chewy.com')) {\n return CHEWY_IMPACT_BASE + encodeURIComponent(u.toString());\n }\n\n return url;\n } catch {\n return url;\n }\n}\n\nfunction rewriteAffiliateLinks(root){\n const scope = root || document;\n scope.querySelectorAll('#gifts a[href]').forEach(a => {\n const newHref = resolveAffiliateUrl(a.getAttribute('href') || '');\n if (newHref) a.setAttribute('href', newHref);\n a.setAttribute('rel', 'nofollow noopener sponsored');\n });\n}\n\n// Observe the gifts container so every render stays tagged without changing your loaders\ndocument.addEventListener('DOMContentLoaded', () => {\n const box = document.getElementById('gifts');\n if (!box) return;\n // One pass now (in case pre-rendered)\n rewriteAffiliateLinks(box);\n // Future mutations\n const mo = new MutationObserver(() => rewriteAffiliateLinks(box));\n mo.observe(box, { childList: true, subtree: true });\n\n // Safety: also rewrite after an explicit \"Load gift ideas\" click\n document.getElementById('loadGifts')?.addEventListener('click', () => {\n setTimeout(() => rewriteAffiliateLinks(box), 0);\n });\n});\n\n\n// Debug: show data file versions in console\nconsole.debug(\"[Barkday] data sources\", {\n groups: BREED_GROUPS_URL,\n banded: RECO_BANDED_URL,\n breed: RECO_BREED_URL,\n aliases: BREED_ALIASES_URL,\n gifts: GIFT_FEED_URL,\n taxonomy: TAXONOMY_URL // NEW\n});\n\n// ---------- Splash + Logos ----------\n(function(){\n const hideSplash = () => document.getElementById(\"splash\")?.classList.add(\"hide\");\n window.addEventListener(\"load\", () => setTimeout(hideSplash, 1800)); // doubled\n document.addEventListener(\"DOMContentLoaded\", () => {\n const h = document.getElementById(\"logoHeader\");\n const s = document.getElementById(\"logoSplash\");\n if (h) h.src = LOGO_HEADER_SRC;\n if (s) s.src = LOGO_SPLASH_SRC;\n setTimeout(hideSplash, 3000); // doubled fail-safe\n });\n})();\n\n// ---------- Theme ----------\nconst root=document.documentElement, themeBtn=document.getElementById('themeToggle');\nconst savedTheme = localStorage.getItem('barkday-theme') || 'dark';\nroot.setAttribute('data-theme', savedTheme==='light'?'light':'dark');\nif (themeBtn) {\n themeBtn.textContent = savedTheme==='light' ? '🌙 Dark' : '☀️ Light';\n themeBtn.addEventListener('click', () => {\n const now = root.getAttribute('data-theme')==='light'?'dark':'light';\n root.setAttribute('data-theme', now);\n localStorage.setItem('barkday-theme', now);\n themeBtn.textContent = now==='light' ? '🌙 Dark' : '☀️ Light';\n });\n}\n\n\n// ---------- DOM Shortcuts ----------\nconst $ = id => document.getElementById(id);\nconst els = {\n dogName: $('dogName'), dob: $('dob'), adultWeight: $('adultWeight'), adultWeightVal: $('adultWeightVal'),\n chewer: $('chewer'), showEpi: $('showEpigenetic'), smooth: $('smoothMilestones'),\n breed: $('breed'), breedGroup: $('breedGroup'), breedExamples: $('breedExamples'),\n ignoreAge: $('ignoreAge'), ignoreSize: $('ignoreSize'), ignoreChewer: $('ignoreChewer'),\n resetBtn: $('resetBtn'), shareBtn: $('shareBtn'), sizeWarn: $('sizeWarn'),\n nextHeadline: $('nextHeadline'), nextBday: $('nextBday'), nextBdayDelta: $('nextBdayDelta'),\n dogAge: $('dogAge'), humanYears: $('humanYears'), slopeNote: $('slopeNote'),\n loadGifts: $('loadGifts'), giftMeta: $('giftMeta'), gifts: $('gifts'),\n heroLine: $('heroLine'), profileLine: $('profileLine'), breedNotes: $('breedNotes'), epi: $('epi')\n};\n\n// --- Saved status pill (under Name) ---\nfunction getSavePill(){\n let el = document.getElementById('saveStatus');\n if (!el) {\n el = document.createElement('button');\n el.id = 'saveStatus';\n el.type = 'button';\n el.className = 'status-pill';\n el.textContent = 'No saves yet';\n }\n // Ensure it sits under the Dog name field container\n const nameField = els.dogName?.closest('div') || els.dogName?.parentElement;\n if (nameField && el.parentElement !== nameField) {\n nameField.appendChild(el);\n }\n // Open Saved drawer when tapped\n el.onclick = () => { BarkdaySaved.open(); };\n return el;\n}\nfunction markSaving(){\n const pill = getSavePill();\n pill.textContent = 'Saving…';\n pill.classList.add('saving');\n pill.classList.remove('error');\n}\nfunction markSaved(){\n const pill = getSavePill();\n const t = new Date().toLocaleTimeString([], { hour:'numeric', minute:'2-digit' });\n pill.textContent = `Saved ✓ · ${t}`;\n pill.classList.remove('saving','error');\n}\nfunction markSaveError(){\n const pill = getSavePill();\n pill.textContent = 'Save failed — retry';\n pill.classList.add('error');\n pill.classList.remove('saving');\n}\n// Mount once DOM is ready (in case HTML is loaded before JS runs)\ndocument.addEventListener('DOMContentLoaded', () => { getSavePill(); }, { once:true });\n\n// Gate Reset/Share before first calculation\ndocument.addEventListener('DOMContentLoaded', () => {\n if (els.resetBtn){ els.resetBtn.disabled = true; els.resetBtn.setAttribute('aria-disabled','true'); }\n if (els.shareBtn){ els.shareBtn.disabled = true; els.shareBtn.setAttribute('aria-disabled','true'); }\n}, { once:true });\n\n// ---------- Hero line ----------\nconst poss = n => { const t = String(n||'').trim(); if (!t) return \"your dog's\"; return /s$/i.test(t) ? `${t}’` : `${t}’s`; };\nfunction renderHero(){ els.heroLine.textContent = `Let’s find out together what ${poss(els.dogName.value)} birthdays are, so we can celebrate every single one.`; }\nels.dogName.addEventListener('input', renderHero);\nels.dogName.addEventListener('input', () => { markSaving(); });\nrenderHero();\n\n// ---------- Static group notes (UI hints under Results) ----------\nconst GROUP_META = {\n 'Working / Herding': {\n desc: 'Needs purposeful work and mental challenges; high handler focus and strong herding drive.',\n examples: ['Border Collie','Australian Shepherd','Corgi','Belgian Malinois','German Shepherd']\n },\n 'Guardian / Protection': {\n desc: 'Natural territorial guardians; confident and aloof with strangers; bond deeply with family.',\n examples: ['Rottweiler','Mastiff','Great Pyrenees','Akita','Cane Corso']\n },\n 'Sporting / Gun Dogs': {\n desc: 'High stamina and people‑oriented; enjoy water and retrieving but need variety to avoid boredom.',\n examples: ['Labrador Retriever','Golden Retriever','Vizsla','GSP','English Setter']\n },\n 'Scent Hounds': {\n desc: 'Nose‑driven trackers; independent with selective recall and vocal when on scent.',\n examples: ['Beagle','Bloodhound','Basset Hound','Coonhound','Foxhound']\n },\n 'Sight Hounds': {\n desc: 'Visual chasers and sprint athletes; restful indoors with bursts of speed; thin skin requires padding.',\n examples: ['Greyhound','Whippet','Afghan Hound','Saluki','Borzoi']\n },\n 'Terriers': {\n desc: 'Tenacious problem‑solvers; high prey drive; energetic and love to dig and tug.',\n examples: ['Jack Russell Terrier','West Highland White Terrier','Bull Terrier','Airedale Terrier','Border Terrier']\n },\n 'Toy / Companion Dogs': {\n desc: 'Small dogs bonded to people; build confidence to reduce reactivity; joints need gentle care.',\n examples: ['Chihuahua','Pomeranian','Yorkie','Maltese','Shih Tzu']\n },\n 'Nordic / Spitz Types': {\n desc: 'Endurance and independence with thick double coats; often vocal and strong pullers.',\n examples: ['Siberian Husky','Alaskan Malamute','Samoyed','Shiba Inu','Akita']\n },\n 'Bulldog / Molosser Types': {\n desc: 'Powerful bodies with brachycephalic limitations; affectionate and food‑motivated; watch joints.',\n examples: ['English Bulldog','French Bulldog','Boxer','Bullmastiff','Dogue de Bordeaux']\n },\n 'International Guardian Breeds': {\n desc: 'Large livestock guardians from around the world; independent and protective with strong instincts.',\n examples: ['Kangal','Central Asian Shepherd Dog','Caucasian Shepherd Dog','Boerboel','Spanish Mastiff']\n },\n 'Pariah & Landrace Types': {\n desc: 'Primitive or landrace dogs with strong survival instincts; high prey drive and independence.',\n examples: ['Thai Ridgeback','Canaan Dog','Carolina Dog','Africanis','Basenji']\n },\n 'Mixed / Other': {\n desc: 'Profile by observed drive and size; use weight, prey drive and independence to tailor activities.',\n examples: ['Mixed‑breed','Rescue','Unknown']\n }\n};\nconst GROUPS = {\n 'Working / Herding': [\n 'Daily jobs/puzzles like herding games and scentwork',\n 'Trick chaining and targeting games for mental challenge',\n 'Agility or obedience drills several times a week'\n ],\n 'Guardian / Protection': [\n 'Structured patrol‑style walks and neutrality practice',\n 'Mental jobs such as scent discrimination or carrying a backpack',\n 'Obstacle courses to build body awareness'\n ],\n 'Sporting / Gun Dogs': [\n 'Retrieve or swim sessions and field‑style searches',\n 'Scent or nosework games to tire the mind',\n 'Fitness drills like hill climbs and balance exercises'\n ],\n 'Scent Hounds': [\n 'Daily sniffari walks on a long line',\n 'Hide‑and‑seek or tracking games using toys or treats',\n 'Scent box or nosework courses'\n ],\n 'Sight Hounds': [\n 'Short sprint sets in a safe enclosed area',\n 'Lure coursing or flirt pole games',\n 'Long decompression walks with soft bedding for rest'\n ],\n 'Terriers': [\n 'Provide a dig box or sandbox for supervised digging',\n 'Tug games with take/drop rules',\n 'Scent and search games to challenge their minds'\n ],\n 'Toy / Companion Dogs': [\n 'Confidence games on varied surfaces and objects',\n 'Puzzle feeders to occupy the mind',\n 'Short indoor play sessions and gentle walks'\n ],\n 'Nordic / Spitz Types': [\n 'Backpacking or sledding in cool climates',\n 'Long decompression walks with sniffing time',\n 'Scent work or nose games to keep them engaged'\n ],\n 'Bulldog / Molosser Types': [\n 'Short, frequent sniff‑strolls with plenty of breaks',\n 'Low‑impact puzzle toys and chews',\n 'Regular cooperative care sessions for wrinkles, nails and ears'\n ],\n 'International Guardian Breeds': [\n 'Long patrol‑style walks and tasks that satisfy guardian instincts',\n 'Search or scent games to channel their intelligence',\n 'Early socialisation and boundaries to prevent reactivity'\n ],\n 'Pariah & Landrace Types': [\n 'Off‑leash decompression time in secure areas',\n 'Agility, lure coursing or chase games to channel prey drive',\n 'Recall and impulse‑control drills using long lines'\n ],\n 'Mixed / Other': [\n 'Tailor enrichment to the individual by observing drive and size'\n ]\n};\n\n// --- Ensure the UI never holds a blank group name ---\n// --- Group select helpers (handles name mismatches like \"Sporting / Gun Dogs\") ---\nfunction normalizeKey(s){ return String(s||'').toLowerCase().replace(/[^a-z]/g,''); }\n\n// Map an arbitrary group name to one of our GROUP_META/GROUPS keys,\n// so tips and examples always show up.\nfunction resolveGroupKey(name){\n const n = normalizeKey(name);\n for (const k of Object.keys(GROUP_META)){\n const nk = normalizeKey(k);\n if (n === nk || n.includes(nk) || nk.includes(n)) return k;\n }\n return 'Mixed / Other';\n}\n\n// ===== Round 2 helpers: toast + auto-scroll =====\nfunction bdToast(msg, ms = 2200){\n const el = document.getElementById('bdToast');\n const msgEl = document.getElementById('bdToastMsg');\n if (!el || !msgEl) return;\n msgEl.textContent = msg || 'Results updated.';\n el.classList.add('show');\n // Close handlers\n const closer = el.querySelector('.bd-x');\n const off = () => el.classList.remove('show');\n closer?.addEventListener('click', off, { once:true });\n // Auto-dismiss\n clearTimeout(bdToast._t); bdToast._t = setTimeout(off, ms);\n}\n\nfunction scrollResultsIntoView(){\n // Prefer the “Next Birthday Plan” header; fall back to first KPI\n const anchor = document.getElementById('nextPlanHeading')\n || document.querySelector('.kpi')\n || document.querySelector('h2');\n if (!anchor) return;\n // Small timeout so layout is final (after DOM writes)\n setTimeout(()=> anchor.scrollIntoView({ behavior:'smooth', block:'start' }), 50);\n}\n\n// ===== Round 3: Saved Results (mobile-safe) =====\nconst BD_STORE_KEY = 'barkday.runs.v1';\n// 1.0: ensure the Save pill flips to Saved whenever the run store updates\n(function hookSavedPill(){\n try {\n const _setItem = localStorage.setItem.bind(localStorage);\n localStorage.setItem = function(k, v){\n const r = _setItem(k, v);\n if (k === BD_STORE_KEY) { try { markSaved(); } catch(_){} }\n return r;\n };\n } catch {}\n})();\n\n// --- Monetization flags & limits (v3) ---\nconst BD_TIER = (window.BARKDAY_TIER || 'free'); // 'free' | 'pro' (placeholder for future)\nconst BD_LIMITS = {\n free: { maxDogs: 9999, maxRuns: 500 },\n pro: { maxDogs: 9999, maxRuns: 500 }\n};\n// Local-only, soft device hint (used only for gentle abuse checks and later upgrades)\nconst BD_DEVICE_KEY = 'barkday.device.v1';\n\n// Generate a semi-stable device id for soft abuse checks (no network, stays local)\n(function ensureDeviceId(){\n try{\n if (!localStorage.getItem(BD_DEVICE_KEY)) {\n const rnd = crypto.getRandomValues(new Uint32Array(4));\n const id = Array.from(rnd).map(n => n.toString(16).padStart(8,'0')).join('');\n localStorage.setItem(BD_DEVICE_KEY, id);\n }\n }catch{/* storage may be unavailable */}\n})();\n\n// Helper: how many unique dogs are saved (name trimmed, case-insensitive)\nfunction countUniqueDogs(items){\n const s = new Set();\n for (const r of (items||[])){\n const n = (r?.dog || '').trim().toLowerCase();\n if (n) s.add(n);\n }\n return s.size;\n}\n\n// Helper: current tier limits (default to 'free' if unknown)\nfunction currentLimits(){\n return BD_LIMITS[BD_TIER] || BD_LIMITS.free;\n}\n\n/* Mobile-safe storage shim\n Chooses localStorage → sessionStorage → in-memory fallback.\n Shows a small badge so you know which backend you're on. */\nconst BarkdayStore = (function(){\n let memory = {}; // last resort (per tab)\n function testArea(area){\n if (!area) return false;\n const k = '__bd_test__' + Math.random();\n try { area.setItem(k, '1'); area.removeItem(k); return true; }\n catch { return false; }\n }\n const hasLocal = testArea(window.localStorage);\n const hasSession = !hasLocal && testArea(window.sessionStorage);\n const area = hasLocal ? window.localStorage : (hasSession ? window.sessionStorage : null);\n const kind = hasLocal ? 'localStorage' : (hasSession ? 'sessionStorage' : 'memory');\n\n function get(key){\n try { return area ? area.getItem(key) : (memory[key] ?? null); }\n catch (e) { console.warn('[Barkday] storage.get failed', e); return null; }\n }\n function set(key, val){\n try {\n if (area) { area.setItem(key, val); }\n else { memory[key] = val; }\n return true;\n } catch (e) {\n console.warn('[Barkday] storage.set failed', e);\n return false;\n }\n }\n return { get, set, kind };\n})();\n\n// --- Save-status integration for the pill (runs after BarkdayStore is defined) ---\n(function wireSavePillToStore(){\n try {\n if (!window.BarkdayStore || typeof BarkdayStore.set !== 'function') return;\n\n // Wrap BarkdayStore.set so writes to the Runs list trigger \"Saved\" UI\n const _set = BarkdayStore.set;\n BarkdayStore.set = function patchedSet(key, val){\n const ok = _set(key, val);\n if (ok && key === BD_STORE_KEY) {\n try { markSaved(); } catch {}\n }\n return ok;\n };\n\n // Also catch direct localStorage/sessionStorage writes in case the backend falls back\n const hookArea = (areaName) => {\n const area = window[areaName];\n if (!area || typeof area.setItem !== 'function') return;\n const orig = area.setItem.bind(area);\n area.setItem = function(key, val){\n const out = orig(key, val);\n if (key === BD_STORE_KEY) { try { markSaved(); } catch {} }\n return out;\n };\n };\n hookArea('localStorage');\n hookArea('sessionStorage');\n } catch {}\n})();\n\n// --- Save-status visual state (classes for #saveStatus) ---\n(function enhanceSavePill(){\n function el(){ return document.getElementById('saveStatus'); }\n function setState(cls){\n const n = el(); if (!n) return;\n n.classList.remove('saving','saved','error');\n if (cls) n.classList.add(cls);\n }\n\n // Wrap markSaving so UI shows yellow state while persisting\n const __orig_markSaving = window.markSaving;\n window.markSaving = function(...args){\n try { setState('saving'); } catch {}\n if (typeof __orig_markSaving === 'function') return __orig_markSaving.apply(this, args);\n };\n\n // Wrap markSaved so UI shows green state upon success\n const __orig_markSaved = window.markSaved;\n window.markSaved = function(...args){\n try { setState('saved'); } catch {}\n if (typeof __orig_markSaved === 'function') return __orig_markSaved.apply(this, args);\n };\n\n // Optional helper you can call if a write fails\n window.markSaveError = function(){\n setState('error');\n };\n})();\n\n\n// Storage helpers (use the shim above)\nfunction bdStoreList(){\n try { const raw = BarkdayStore.get(BD_STORE_KEY); return raw ? JSON.parse(raw) : []; }\n catch { return []; }\n}\nfunction bdStoreSave(list){\n const ok = BarkdayStore.set(BD_STORE_KEY, JSON.stringify(list));\n if (!ok) bdToast('Could not save on this device (storage blocked).', 3500);\n}\n\n// Optional: show which backend we’re using (local/session/memory) — gated for dev\n(function showStorageStatus(){\n try{\n if (!(location.hostname === 'localhost' && location.search.includes('debug=1'))) return;\n const el = document.createElement('div');\n el.id = 'bdStorageStatus';\n el.textContent = `Storage: ${BarkdayStore.kind}`;\n el.style.cssText = 'position:fixed;right:10px;bottom:10px;font:12px/1.2 system-ui;padding:6px 8px;border-radius:8px;background:#0009;color:#fff;z-index:9999';\n document.addEventListener('DOMContentLoaded', ()=> document.body.appendChild(el), { once:true });\n }catch{}\n})();\n\n\n// Optional: quick self-test button (injects a dummy saved run)\n(function storageSelfTest(){\n try{\n const host = document.getElementById('selftestHost');\n if (!host) return;\n const btn = document.createElement('button');\n btn.className = 'ghost';\n btn.textContent = 'Storage Self-Test';\n btn.addEventListener('click', ()=>{\n const list = bdStoreList();\n list.unshift({\n ts: Date.now(),\n dog: 'Test Dog',\n dob: '2020-01-01',\n weight: 40,\n chewer: 'Normal',\n breed: 'Labrador Retriever',\n group: 'Sporting / Gun Dogs',\n smooth: true, epi: false,\n kpi: { nextHeadline:'Test', nextDate:new Date().toDateString(), nextDateISO:new Date().toISOString(), hy:'42.00', dogAge:'4y 0m' },\n event: { start:new Date().toISOString(), end:new Date(Date.now()+3600000).toISOString(), title:'Test', notes:'Test' }\n });\n bdStoreSave(list);\n bdToast('Dummy run saved. Open Saved ▾ to verify.');\n });\n host.appendChild(btn);\n }catch{}\n})();\n\n// Build the payload we store each time\nfunction currentRunPayload(){\n const { start, end, title, notes } = getContext(); // existing helper\n return {\n ts: Date.now(),\n dog: (els.dogName.value||'').trim(),\n dob: els.dob.value || '',\n weight: parseInt(els.adultWeight.value,10)||55,\n chewer: els.chewer.value,\n breed: (els.breed.value||'').trim(),\n group: els.breedGroup.value,\n smooth: !!els.smooth.checked,\n epi: !!els.showEpi.checked,\n kpi: {\n nextHeadline: els.nextHeadline.textContent,\n nextDate: els.nextBday.textContent,\n nextDateISO: els.nextBday.dataset.iso || null,\n hy: els.humanYears.textContent,\n dogAge: els.dogAge.textContent\n },\n event: { start, end, title, notes }\n };\n}\n\n// Save handler (session-only toast is always shown when not on localStorage)\nfunction bdSaveRun(){\n const list = bdStoreList();\n const now = Date.now();\n const cur = currentRunPayload();\n\n // --- Monetization: free-tier limits ---\n const lim = currentLimits();\n\n // Enforce unique-dog cap (free tier = 5)\n const beforeUnique = countUniqueDogs(list);\n const curDog = (cur.dog || '').trim().toLowerCase();\n const alreadyHaveDog = !!list.find(r => (r?.dog||'').trim().toLowerCase() === curDog);\n if (!alreadyHaveDog && beforeUnique >= lim.maxDogs){\n bdToast(`Free tier allows up to ${lim.maxDogs} dogs on this device. Export & delete one to add more.`, 4500);\n return;\n }\n\n\n // De-dupe: if the latest saved item matches same dog + same ISO date within 2 minutes, skip\n const last = list[0];\n const isDup = last\n && (last.dog||'').trim().toLowerCase() === (cur.dog||'').trim().toLowerCase()\n && (last.kpi?.nextDateISO || '') === (cur.kpi?.nextDateISO || '')\n && Math.abs((last.ts||0) - now) < 120000;\n\n if (!isDup) list.unshift(cur);\n // Tier-aware run cap (free=50)\n if (list.length > lim.maxRuns) list.length = lim.maxRuns;\n\n bdStoreSave(list);\n markSaved();\n bdToast(isDup ? 'Already saved recently' : 'Saved to this device');\n\n if (BarkdayStore?.kind && BarkdayStore.kind !== 'localStorage') {\n bdToast('Saved for this session only in this browser.', 3500);\n }\n\n if (document.getElementById('bdSaved')?.classList.contains('is-open')) {\n BarkdaySaved.render();\n }\n}\n\n\n\nfunction hydrateRun(run, doCompute=false){\n if (!run) return;\n els.dogName.value = run.dog || '';\n els.dob.value = run.dob || '';\n els.adultWeight.value = run.weight || 55;\n els.adultWeightVal.textContent = String(els.adultWeight.value);\n els.chewer.value = run.chewer || 'Normal';\n els.breed.value = run.breed || '';\n els.breedGroup.value = run.group || 'Working / Herding';\n els.smooth.checked = !!run.smooth;\n els.showEpi.checked = !!run.epi;\n renderHero(); updateBreedNotes();\n if (doCompute) compute();\n}\n\n// Drawer UI\nconst BarkdaySaved = (() => {\n const drawer = () => document.getElementById('bdSaved');\n const panel = () => drawer()?.querySelector('.bd-panel');\n const bodyEl = () => document.getElementById('bdSavedBody');\n\n function open(){\n const d = drawer(); if (!d) return;\n render();\n d.classList.add('is-open'); document.body.classList.add('body-lock');\n // backdrop + buttons\n d.addEventListener('click', onBackdrop);\n document.addEventListener('keydown', onEsc);\n panel()?.focus();\n }\n function close(){\n const d = drawer(); if (!d) return;\n d.classList.remove('is-open'); document.body.classList.remove('body-lock');\n d.removeEventListener('click', onBackdrop);\n document.removeEventListener('keydown', onEsc);\n }\n function onBackdrop(e){ if (e.target?.dataset?.close === 'drawer') close(); }\n function onEsc(e){ if (e.key === 'Escape') close(); }\n\n function render(){\n const host = bodyEl(); if (!host) return;\n\n // NEW: render into #bdSavedCards if present, otherwise fall back to the body\n const cardsHost = host.querySelector('#bdSavedCards') || host;\n\n const items = bdStoreList();\n if (!items.length){\n cardsHost.innerHTML = `
No saved results yet. Run Calculate — results auto-save.
`;\n return;\n }\n // Functional cap cue: always accurate, no styling\n try {\n const lim = currentLimits();\n const uniq = countUniqueDogs(items);\n\n // Remove any stale note to avoid duplication or wrong counts\n const old = document.getElementById('bdSavedCapNote');\n if (old && old.parentNode) old.parentNode.removeChild(old);\n\n // Create a fresh note each render so the count is current\n const bar = document.createElement('div');\n bar.id = 'bdSavedCapNote';\n bar.textContent = `Saved dogs: ${uniq}/${lim.maxDogs} (free tier)`;\n\n // Place the note directly above the cards container\n (cardsHost.parentElement || host).insertBefore(bar, cardsHost);\n } catch {}\n\n cardsHost.innerHTML = items.map((r, i) => {\n const date = new Date(r.ts).toLocaleString();\n const dog = r.dog || 'Your dog';\n const hy = r.kpi?.hy || '—';\n const next = r.kpi?.nextDate || '—';\n const w = r.weight ? `${r.weight} lb` : '';\n const grp = r.group || '';\n return `\n
\n

${dog}

\n
${grp}${grp&&w?' · ':''}${w} · Saved ${date}
\n
Next: ${r.kpi?.nextHeadline || '—'} (${next}) · Human-years: ${hy}
\n
\n \n \n \n \n
\n
\n `;\n }).join('');\n}\n\n function onListClick(e){\n const btn = e.target.closest('[data-act]'); if (!btn) return;\n const card = e.target.closest('.bd-card'); if (!card) return;\n const idx = parseInt(card.dataset.i,10);\n const items = bdStoreList();\n const run = items[idx];\n\n switch(btn.dataset.act){\n case 'load':\n hydrateRun(run, false);\n bdToast('Loaded inputs (not computed)');\n break;\n case 'compute':\n hydrateRun(run, true);\n bdToast('Loaded and computed');\n break;\n case 'share': {\n // Rebuild a share URL from inputs\n const p = new URLSearchParams({\n n: els.dogName.value || '',\n d: els.dob.value || '',\n w: els.adultWeight.value,\n c: els.chewer.value,\n g: els.breedGroup.value,\n r: els.breed.value || '',\n s: els.smooth.checked ? 1 : 0,\n e: els.showEpi.checked ? 1 : 0\n });\n const url = location.origin + location.pathname + '?' + p.toString();\n navigator.clipboard.writeText(url).then(()=>bdToast('Share link copied'));\n break;\n }\n case 'delete': {\n const next = items.filter((_,i)=>i!==idx);\n bdStoreSave(next);\n render();\n bdToast('Deleted');\n break;\n }\n }\n }\n\n // Footer controls\nfunction onFooterClick(e){\n const id = e.target.id;\n\n if (id === 'bdExport'){\n try {\n const runs = bdStoreList() || [];\n const payload = { version: 1, exportedAt: new Date().toISOString(), app: 'Barkday', runs };\n const blob = new Blob([JSON.stringify(payload)], { type:'application/json' });\n const a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n const dt = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');\n a.download = `barkday-export-${dt}.barkday`; // no “JSON” in filename\n document.body.appendChild(a); a.click(); a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href), 1000);\n bdToast?.('Exported your records.');\n } catch (err) {\n console.warn('[Barkday] export failed', err);\n bdToast?.('Export failed.');\n }\n\n } else if (id === 'bdImport') {\n const input = document.getElementById('bdImportFile');\n if (!input) { alert('Import not available.'); return; }\n input.value = ''; // allow same file twice\n input.onchange = async (ev) => {\n const f = ev.target.files && ev.target.files[0];\n if (!f) return;\n if (!/\\.barkday$|\\.json$/i.test(f.name)) { bdToast?.('Please choose an exported Barkday file.'); input.value=''; input.onchange=null; return; }\n\n try {\n const text = await f.text();\n const raw = JSON.parse(text);\n\n // normalize accepted shapes\n const norm = Array.isArray(raw)\n ? { version:1, exportedAt:null, runs: raw }\n : (raw && Array.isArray(raw.runs) ? { version:raw.version??1, exportedAt:raw.exportedAt??null, runs: raw.runs } : null);\n\n if (!norm) { bdToast?.('That file could not be read.'); input.value=''; input.onchange=null; return; }\n\n // soft validation\n const isRun = (x)=> x && typeof x==='object' && (('id'in x)||('_id'in x)||('ts'in x)) && (('dog'in x)||('breed'in x)||('event'in x)||('kpi'in x));\n const incoming = (norm.runs||[]).filter(isRun);\n if (!incoming.length){ bdToast?.('No records found in the file.'); input.value=''; input.onchange=null; return; }\n\n // merge with existing; dedupe by stable key, newest ts wins\n const existing = bdStoreList() || [];\n const key = (x)=> String(x?.id ?? x?._id ?? `${x?.dog||'dog'}|${x?.dob||''}|${x?.ts||''}`);\n const map = new Map();\n const add = (arr)=>arr.forEach(r=>{\n if (!isRun(r)) return;\n const k = key(r), prev = map.get(k);\n if (!prev) { map.set(k, r); return; }\n const tNew = new Date(r.ts||0).getTime(), tOld = new Date(prev.ts||0).getTime();\n if (tNew > tOld) map.set(k, r);\n });\n add(existing); add(incoming);\n const merged = Array.from(map.values());\n\n // persist with your existing helper\n bdStoreSave(merged);\n render(); // re-render drawer\n bdToast?.(`Imported ${incoming.length} record(s).`);\n } catch (err) {\n console.warn('[Barkday] import failed', err);\n bdToast?.('Import failed.');\n } finally {\n input.value=''; input.onchange=null;\n }\n };\n input.click();\n\n } else if (id === 'bdClearAll'){\n if (confirm('Delete all saved results on this device?')){\n bdStoreSave([]); render(); bdToast('Cleared');\n }\n\n } else if (e.target.dataset?.close === 'drawer'){\n close();\n }\n}\n\n\n // Public API\n return { open, close, render, onListClick, onFooterClick };\n})();\n\n\n// Find and set the closest option in \n\n\n\n \n
\n \n \n
Dog age shown under Results.
\n
\n
\n \n
\n
55 lb
\n \n
\n
\n
\n \n \n
\n \n\n
\n
\n \n \n \n
\n
\n \n \n
Notes only — math stays weight-based.
\n
\n
\n \n \n
\n
\n
\n\n
\n \n \n \n
\n\n \n
\n\n
\n\n
\n\n

Results

\n
\n
\n
\n
\n
\n
\n

Dog age today

\n

Human-years estimate

\n
\n

\n
\n\n
\n\n
\n\n

Next Birthday Plan

\n
\n\n
\n\n

Gift ideas

\n \n
\n
\n \n \n\n\n\n\n\n\n\n\n\n\n\n \n
\n

© 2025 Barkday™. All Rights Reserved.

\n

\n Privacy · \n Terms\n

\n

Sebby (our mascot) and I hope this makes you day just a little brighter.

\n
\n\n \n \n\n \n
\n
\n
\n \n
\n

Your Barkday Results

\n
\n
\n
\n \n
\n
\n
\n \n \n\n
\n Results updated.\n \n
\n\n
\n
\n
\n
\n

Saved Results

\n \n
\n
\n
\n

Privacy & portability — Barkday™ saves your results only on this device.\n To take them to another device, click Export to download a file. On the other device, open Barkday™ and click Import to load it.

\n
\n\n \n \n\n \n
\n
\n
\n \n \n \n \n
\n
\n
\n\n\n\n\n\n","inline_bytes":10851,"content_sha256":"d7f2cc3fe09b181dfd6e609cc20fce237bdb899f5d435615aea222edd15f597c"},{"path":"js/runtime-fetch.js","size":1074,"sha":"9e10cb06ab77baf84441ee4da58d667c3359effd","media_type":"application/javascript","raw_url":"https://raw.githubusercontent.com/CandidQuality/Barkday/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/js/runtime-fetch.js","html_url":"https://github.com/CandidQuality/Barkday/blob/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/js/runtime-fetch.js","inline_state":"full","max_inline_text_bytes":614400,"max_inline_bin_bytes":204800,"preview_text_bytes":65536,"encoding":"utf8","content":"// js/runtime-fetch.js\r\n(function(){\r\n async function fetchJSONWithETag(url, {cacheMinutes=60, lsKey} = {}) {\r\n const now = Date.now();\r\n const entry = JSON.parse(localStorage.getItem(lsKey) || 'null');\r\n const fresh = entry && (now - entry.savedAt) < cacheMinutes*60*1000;\r\n\r\n const headers = {};\r\n if (entry?.etag) headers['If-None-Match'] = entry.etag;\r\n\r\n const bust = fresh ? '' : `?t=${Math.floor(now/(cacheMinutes*60*1000))}`;\r\n try {\r\n const res = await fetch(url + bust, { headers, cache: 'no-cache' });\r\n if (res.status === 304 && entry?.data) return entry.data;\r\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\r\n const etag = res.headers.get('ETag');\r\n const data = await res.json();\r\n localStorage.setItem(lsKey, JSON.stringify({ data, etag, savedAt: now }));\r\n return data;\r\n } catch (err) {\r\n if (entry?.data) return entry.data;\r\n console.warn('[Barkday][Gifts] fetch failed and no cache:', err);\r\n return null;\r\n }\r\n }\r\n window.BarkdayFetch = { fetchJSONWithETag };\r\n})();\r\n","inline_bytes":1074,"content_sha256":"82047dfe777473c70c43a86367d653e46ac90e0df032917e5ac1e9ed6d8fa5e6"},{"path":"service-worker.js","size":2325,"sha":"5c3b6f7e245ee4a9da55dab3167be428aff1615d","media_type":"application/javascript","raw_url":"https://raw.githubusercontent.com/CandidQuality/Barkday/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/service-worker.js","html_url":"https://github.com/CandidQuality/Barkday/blob/9e38500d723ed1e8e1ca93eb8ae821c831f1414d/service-worker.js","inline_state":"full","max_inline_text_bytes":614400,"max_inline_bin_bytes":204800,"preview_text_bytes":65536,"encoding":"utf8","content":"// service-worker.js (network-first for index and app.js, cache-first for static assets)\nconst CACHE = 'bd-v7-2026-05-03-1';\n\nconst PRECACHE = [\n './',\n 'style.css?v=4',\n 'privacy.html',\n 'terms.html',\n 'favicon.ico',\n 'favicon-96x96.png',\n 'favicon.svg',\n 'icon-192.png',\n 'icon-256.png',\n 'icon-384.png',\n 'icon-512.png',\n 'icon-maskable-192.png',\n 'icon-maskable-512.png',\n 'manifest.json',\n 'js/runtime-fetch.js'\n];\n\nself.addEventListener('install', (event) => {\n self.skipWaiting();\n event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)));\n});\n\nself.addEventListener('activate', (event) => {\n event.waitUntil(\n caches.keys().then((keys) =>\n Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))\n ).then(() => self.clients.claim())\n );\n});\n\nself.addEventListener('fetch', (event) => {\n const req = event.request;\n const url = new URL(req.url);\n const isNav = req.mode === 'navigate';\n const isAppJs = url.pathname.endsWith('/app.js') || url.pathname.endsWith('app.js');\n const isGiftFeed = /(^|\\/)dog-gifts(?:-(?:catalog|merged))?\\.json$/i.test(url.pathname);\n\n if (isGiftFeed) {\n event.respondWith(\n fetch(req, { cache: 'no-store' }).catch(() =>\n new Response(JSON.stringify({ error: 'Gift feed unavailable' }), {\n status: 503,\n headers: { 'Content-Type': 'application/json' }\n })\n )\n );\n return;\n }\n\n // Network-first for index.html and app.js (any ?v=...)\n if (isNav || isAppJs) {\n event.respondWith(\n fetch(req).then((res) => {\n const copy = res.clone();\n caches.open(CACHE).then((c) => {\n const key = isAppJs ? new Request(url.origin + url.pathname) : req;\n c.put(key, copy);\n });\n return res;\n }).catch(() => {\n if (isAppJs) return caches.match(new Request(url.origin + url.pathname), { ignoreSearch: true });\n return caches.match('./');\n })\n );\n return;\n }\n\n // Everything else: cache-first (ignore search)\n event.respondWith(\n caches.match(req, { ignoreSearch: true }).then((cached) => {\n if (cached) return cached;\n return fetch(req).then((res) => {\n const copy = res.clone();\n caches.open(CACHE).then((c) => c.put(req, copy));\n return res;\n });\n })\n );\n});\n","inline_bytes":2325,"content_sha256":"99088ea6ffcef9d67240a4d089f201822233b38b9ff5f7758bf6986ffd24aba4"}]}