Viewing as · read only
Starting…

Quit Bad Habits

Starve the Bad Wolf

Hi,

Here's your pack name. Your real one stays private.

The Pack

Who's running with you. Sign in to appear.

Start Good Habits

Feed the Good Wolf

Rewards worth chasing

Get a ping when a milestone lands. You can change this any time.

Tracked Today Event
`; return { subject, text, html }; } // ── Email confirmation flow ───────────────────────────────────── // Banner sits above the tab panels until the user confirms via // the link in the welcome email (?emailConfirmed=1). Soft nudge // — the app is fully usable without confirming. Tapping the // banner resends the welcome email (60s rate-limit). const emailBanner = document.getElementById('email-banner'); const emailBannerBody = document.getElementById('email-banner-body'); const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000; const EMAIL_RESEND_AT_KEY = 'bw_email_resend_at'; // Cooldown persists across reloads so a user can't bypass the // rate limit by refreshing. Stored as ms-since-epoch in // localStorage; falls back to 0 on a fresh device. let lastResendAt = (() => { try { return Number(localStorage.getItem(EMAIL_RESEND_AT_KEY)) || 0; } catch { return 0; } })(); function paintEmailBanner() { if (!emailBanner) return; const signedIn = !!(currentUser && !currentUser.isAnonymous && currentUser.email); const confirmed = !!(prefs && prefs.emailConfirmed); const wasHidden = emailBanner.hidden; emailBanner.hidden = !signedIn || confirmed; if (wasHidden !== emailBanner.hidden) { dbg(`[email-confirm] paintBanner: signedIn=${signedIn} confirmed=${confirmed} hidden=${emailBanner.hidden}`); } // Notif banner shares all the same triggers (sign-in / sign-out, // prefs change, cloud merge). Piggyback on this paint call so // both banners stay in lock-step without finding all 7+ sites. try { paintNotifBanner(); } catch (_) {} if (emailBanner.dataset.flashing) return; // mid-flash, leave the success copy if (emailBannerBody) emailBannerBody.textContent = 'Confirm your email to run with the Pack'; } // Notifications banner — same persistence shape as email confirm. // Shows for signed-in users until Notification.permission is // granted OR prefs.notificationsOptedIn lands true. The one-shot // scrim (maybeShowNotifPromptOnce) was dropped in favour of // this — banner can't be dismissed away and lost the way the // scrim could. const notifBanner = document.getElementById('notif-banner'); const notifBannerBody = document.getElementById('notif-banner-body'); const notifBannerCta = document.getElementById('notif-banner-enable'); function paintNotifBanner() { if (!notifBanner) return; // Desktop browsers — notifications banner is mobile-only. // Desktop users can still enable via Account panel if they want. if (!isMobile()) { notifBanner.hidden = true; return; } const signedIn = !!(currentUser && !currentUser.isAnonymous); const optedIn = !!(prefs && prefs.notificationsOptedIn); const granted = (typeof Notification !== 'undefined' && Notification.permission === 'granted'); const wasHidden = notifBanner.hidden; notifBanner.hidden = !signedIn || optedIn || granted; if (wasHidden !== notifBanner.hidden) { dbg(`[notif-banner] paint: signedIn=${signedIn} optedIn=${optedIn} granted=${granted} hidden=${notifBanner.hidden}`); } if (notifBannerBody && !notifBanner.dataset.flashing) { notifBannerBody.textContent = 'Turn on notifications to catch your wins.'; } } if (notifBannerCta) notifBannerCta.addEventListener('click', async () => { try { track('notif_banner_tapped'); } catch (_) {} notifBannerCta.disabled = true; try { await enableNotifications(); } catch (_) {} notifBannerCta.disabled = false; // After the attempt, re-paint to either hide (if granted) or // keep showing (if user declined / still iOS-install-first). try { paintNotifBanner(); } catch (_) {} }); // Brief "You're in" flash after a successful confirmation — // doesn't sit forever, just acknowledges the click. function flashEmailConfirmed() { if (!emailBanner || !emailBannerBody) return; emailBanner.hidden = false; emailBanner.dataset.flashing = '1'; emailBanner.setAttribute('data-confirmed', ''); emailBannerBody.textContent = "You're in. The Pack has your back."; setTimeout(() => { emailBanner.removeAttribute('data-confirmed'); delete emailBanner.dataset.flashing; // If we're confirming in mobile Safari AND the user already // has the PWA installed (prefs.pwaInstalledAt is stamped by // the standalone context and syncs via Firestore), nudge // them toward the app instead of going dark. iOS Safari // can't deep-link to a PWA, so the copy points to the home // screen. Desktop browsers don't get the hint — there's no // home-screen icon there and the copy reads as nonsense. const inBrowser = !isStandalone(); const pwaSeen = !!(prefs && prefs.pwaInstalledAt); const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent) || (/Mac/.test(navigator.platform) && navigator.maxTouchPoints > 1); if (inBrowser && pwaSeen && isIOS) { emailBanner.setAttribute('data-app-hint', ''); emailBanner.dataset.flashing = '1'; emailBannerBody.textContent = 'Bad Wolf is on your home screen — open it there for the full experience.'; setTimeout(() => { emailBanner.removeAttribute('data-app-hint'); delete emailBanner.dataset.flashing; paintEmailBanner(); }, 6000); } else { paintEmailBanner(); } }, 4000); } // ?emailConfirmed=1 handler — strips the param + flips the flag. // If the user isn't signed in yet (clicked link cold, no session), // park the intent in localStorage so the flag lands on next // sign-in. function handleEmailConfirmParam() { try { const params = new URLSearchParams(location.search); if (params.get('emailConfirmed') !== '1') { dbg('[email-confirm] handleParam: no ?emailConfirmed=1 on URL'); return; } dbg('[email-confirm] handleParam: ?emailConfirmed=1 detected'); // Always strip first so a refresh doesn't keep re-running. params.delete('emailConfirmed'); const newSearch = params.toString(); const newUrl = location.pathname + (newSearch ? '?' + newSearch : '') + location.hash; history.replaceState(null, '', newUrl); const signedIn = !!(currentUser && !currentUser.isAnonymous && currentUser.email); if (signedIn) { if (!prefs.emailConfirmed) { prefs.emailConfirmed = true; prefs.emailConfirmedAt = Date.now(); save(); dbg('[email-confirm] handleParam: signed-in → flag set + saved'); try { track('email_confirmed'); } catch {} } else { dbg('[email-confirm] handleParam: signed-in but flag already true'); } flashEmailConfirmed(); } else { // Defer until the user signs in. paintEmailBanner / // boot-time auth callback picks it up. localStorage.setItem('bw_email_confirm_pending', '1'); dbg('[email-confirm] handleParam: not signed-in → parked pending flag'); } } catch (e) { dbg('[email-confirm] handleParam ERROR: ' + (e && e.message || e)); console.warn('[email-confirm] handler failed', e); } } // ?unsub= handler — strips the param, adjusts prefs.notifLevel, // saves + pushes to Firestore so the Cloud Function respects it. // Shows a brief acknowledgement via the email banner. // ?unsub=daily → max → mid (kills daily nudge, keeps weekly + milestones) // ?unsub=digest → mid → min (kills weekly email, keeps milestones) function handleUnsubParam() { try { const params = new URLSearchParams(location.search); const unsub = params.get('unsub'); if (!unsub) return; params.delete('unsub'); const newSearch = params.toString(); history.replaceState(null, '', location.pathname + (newSearch ? '?' + newSearch : '') + location.hash); if (unsub === 'daily') { if (prefs) { const current = getNotifLevel(); if (current === 'max') setNotifLevel('mid'); } try { track('email_unsubscribed', { type: 'daily' }); } catch {} try { if (emailBanner && emailBannerBody) { emailBanner.hidden = false; emailBannerBody.textContent = 'Daily nudges off. You can re-enable them in your profile.'; setTimeout(() => { if (emailBanner) emailBanner.hidden = true; }, 5000); } } catch {} } else if (unsub === 'digest') { if (prefs) { const current = getNotifLevel(); if (current === 'max' || current === 'mid') setNotifLevel('min'); } try { track('email_unsubscribed', { type: 'digest' }); } catch {} try { if (emailBanner && emailBannerBody) { emailBanner.hidden = false; emailBannerBody.textContent = 'Weekly digest off. Milestone alerts still active.'; setTimeout(() => { if (emailBanner) emailBanner.hidden = true; }, 5000); } } catch {} } } catch (e) { dbg('[unsub] handler error: ' + (e && e.message || e)); } } // Drain the pending intent if the user signs in after clicking // the link cold. function drainEmailConfirmPending() { if (!localStorage.getItem('bw_email_confirm_pending')) return; const signedIn = !!(currentUser && !currentUser.isAnonymous && currentUser.email); if (!signedIn) { dbg('[email-confirm] drainPending: pending=1 but no signed-in user yet'); return; } localStorage.removeItem('bw_email_confirm_pending'); dbg('[email-confirm] drainPending: pending=1 + signed-in → draining'); if (!prefs.emailConfirmed) { prefs.emailConfirmed = true; prefs.emailConfirmedAt = Date.now(); save(); dbg('[email-confirm] drainPending: flag set + saved'); try { track('email_confirmed_deferred'); } catch {} } else { dbg('[email-confirm] drainPending: flag already true'); } flashEmailConfirmed(); } // Inline Resend — rate-limited to once per 60s. Status flashes // through the banner body; the resend button itself is disabled // while the cooldown runs so the cooldown is visible too. const emailBannerResend = document.getElementById('email-banner-resend'); if (emailBannerResend) emailBannerResend.addEventListener('click', async () => { // Block while admin is acting-as another user — never fire an // email to the real currentUser from someone else's chrome. if (window.__actingAs) return; if (emailBanner && emailBanner.dataset.flashing) return; const since = Date.now() - lastResendAt; if (since < EMAIL_RESEND_COOLDOWN_MS) { const left = Math.ceil((EMAIL_RESEND_COOLDOWN_MS - since) / 1000); if (emailBannerBody) emailBannerBody.textContent = `Wait ${left}s before sending again`; setTimeout(paintEmailBanner, 1500); return; } if (!currentUser || currentUser.isAnonymous || !currentUser.email) return; // NOTE: don't stamp the cooldown until the send actually // succeeds — a failed send shouldn't punish the next retry. emailBannerResend.disabled = true; if (emailBannerBody) emailBannerBody.textContent = 'Sending…'; let sent = false; try { const firstName = (currentUser.displayName || '').split(' ')[0] || ''; const deepLink = (location.origin || 'https://badwolf.run') + '/'; const tmpl = welcomeEmail({ firstName, wolfName: prefs && prefs.wolfName, deepLink, }); const result = await sendEmail({ to: currentUser.email, subject: tmpl.subject, html: tmpl.html, text: tmpl.text, }); if (result.ok) { sent = true; lastResendAt = Date.now(); try { localStorage.setItem(EMAIL_RESEND_AT_KEY, String(lastResendAt)); } catch {} if (emailBannerBody) emailBannerBody.textContent = 'Sent. Check your inbox.'; try { track('email_confirm_resent'); } catch {} } else { if (emailBannerBody) emailBannerBody.textContent = 'Send failed. Try again in a moment.'; } } catch (e) { if (emailBannerBody) emailBannerBody.textContent = 'Send failed. Try again in a moment.'; } // Re-enable immediately on failure so the user can retry // without waiting out the success-state hold. if (!sent) { emailBannerResend.disabled = false; } else { setTimeout(() => { paintEmailBanner(); emailBannerResend.disabled = false; }, 2400); } }); async function maybeSendWelcomeEmail() { if (!currentUser || currentUser.isAnonymous) return; if (!currentUser.email) return; if (prefs.welcomeSentAt) return; // already sent const firstName = (currentUser.displayName || '').split(' ')[0] || ''; const deepLink = (location.origin || 'https://badwolf.run') + '/'; const tmpl = welcomeEmail({ firstName, wolfName: prefs.wolfName, deepLink, }); const result = await sendEmail({ to: currentUser.email, subject: tmpl.subject, html: tmpl.html, text: tmpl.text, }); if (result.ok) { prefs.welcomeSentAt = Date.now(); save(); track('welcome_email_sent'); } } // Mark dev-mode sessions and the owner account as internal traffic // so GA4's "All Users" report excludes them. traffic_type=internal // is the canonical value GA4's built-in Internal Traffic filter // recognises. Owner email check means any device/IP is covered. function isInternalSession() { if (window.devMode) return true; if (currentUser && currentUser.email === 'bonjiapps@gmail.com') return true; return false; } function gaCommonParams() { return isInternalSession() ? { traffic_type: 'internal' } : {}; } function track(event, params) { try { if (typeof window.gtag === 'function') { window.gtag('event', event, Object.assign({}, gaCommonParams(), params || {})); } } catch (_) { /* analytics must never break the app */ } } // pageView: fires GA4's built-in page_view event with a virtual // path so the Pages report fills in. Use for every tab change AND // every meaningful sheet/modal open. let lastPagePath = null; function pageView(path, title) { if (!path || path === lastPagePath) return; lastPagePath = path; try { if (typeof window.gtag === 'function') { window.gtag('event', 'page_view', Object.assign({ page_path: path, page_title: title || path, page_location: location.origin + path, }, gaCommonParams())); } } catch (_) {} } // setGAUserId: tells GA4 who the signed-in user is so sessions // across all their devices stitch into ONE GA user. Drops the // "every browser is a new user" inflation. Pass null on sign-out. function setGAUserId(uid) { try { if (typeof window.gtag !== 'function') return; window.gtag('config', GA_MEASUREMENT_ID, { user_id: uid || null, }); } catch (_) {} } // setUserProps: refresh GA4 user_properties so funnels can // segment by app state (signed_in, quit_count, etc.). Cheap — // only the values that actually change get re-evaluated. function setUserProps(extra) { try { if (typeof window.gtag !== 'function') return; const longestStreak = liveTimers().reduce((max, t) => { const since = t.paused_at || Date.now(); const days = Math.max(0, Math.floor((since - t.quit_at) / DAY)); return days > max ? days : max; }, 0); const _firstOpenAt = Number(localStorage.getItem('bw_first_open_at') || 0); const _daysSinceInstall = _firstOpenAt ? Math.floor((Date.now() - _firstOpenAt) / 86400000) : 0; const props = Object.assign({ signed_in: !!currentUser, quit_count: liveTimers().length, event_count: liveEvents().length, reward_count: liveRewards().length, longest_streak_days: longestStreak, pwa_installed: !!(prefs && prefs.pwaInstalledAt) || !!localStorage.getItem('bw_pwa_installed'), onboarded: !!localStorage.getItem('bw_onboarded'), is_pwa: isStandalone(), days_since_install: _daysSinceInstall, }, extra || {}); window.gtag('set', 'user_properties', props); } catch (_) {} } // Collapse a substance to a safe category: a known one passes // through, anything user-typed becomes 'custom'. function substanceCategory(name) { return SUBSTANCE_ICONS[name] ? name : 'custom'; } // Coarse app version stamped onto feedback so we know roughly // which build a report came from. Bump on meaningful releases. const APP_VERSION = 'v4'; function makeId(prefix) { return prefix + '_' + Math.random().toString(36).slice(2, 9); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]); } // iOS Safari sheet-button quirk: when an input in a sheet has the // keyboard open and the user taps a button at the bottom, iOS: // 1. Dismisses the keyboard, which shifts the sheet layout. // 2. Evaluates the touchend against the button's NEW position. // 3. Decides the tap "missed" and refuses to dispatch click. // Result: first tap does nothing, second tap works (keyboard // already gone, button stable). We bypass iOS's click logic by // firing the handler on touchend ourselves and preventing the // synthetic click double-fire with a 600ms debounce. Click stays // wired for desktop / keyboard activation. function bulletproofTap(btn, handler) { if (!btn) return; let touchFired = false; let touchMoved = false; let startX = 0, startY = 0; btn.addEventListener('touchstart', (e) => { touchMoved = false; const t = e.changedTouches && e.changedTouches[0]; if (t) { startX = t.clientX; startY = t.clientY; } // Blur the focused input as early as possible so the keyboard // starts dismissing before touchend fires. const ae = document.activeElement; if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) ae.blur(); }, { passive: true }); btn.addEventListener('touchmove', (e) => { const t = e.changedTouches && e.changedTouches[0]; if (!t) return; if (Math.abs(t.clientX - startX) > 10 || Math.abs(t.clientY - startY) > 10) { touchMoved = true; } }, { passive: true }); btn.addEventListener('touchend', (e) => { if (touchMoved) return; e.preventDefault(); touchFired = true; setTimeout(() => { touchFired = false; }, 600); handler(e); }); btn.addEventListener('click', (e) => { if (touchFired) return; handler(e); }); } function startOfDay(d) { const x = new Date(d); x.setHours(0,0,0,0); return x; } function daysBetween(a, b) { return Math.round((startOfDay(b) - startOfDay(a)) / DAY); } function toLocalInputValue(d) { const tzOffset = d.getTimezoneOffset() * 60_000; return new Date(d.getTime() - tzOffset).toISOString().slice(0, 16); } // Date-only helpers for the event/goal sheet — date input drops // the time portion entirely, so we serialise/deserialise in the // local timezone and anchor parsed dates to noon to dodge DST. function toDateInputValue(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function fromDateInputValue(s) { if (!s) return null; const parts = s.split('-').map(Number); if (parts.length !== 3 || parts.some(Number.isNaN)) return null; return new Date(parts[0], parts[1] - 1, parts[2], 12, 0, 0, 0).getTime(); } function fmtEventDate(t) { const d = new Date(t); return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', }).toUpperCase(); } function ordinalSuffix(n) { if (n >= 11 && n <= 13) return n + 'th'; const last = n % 10; if (last === 1) return n + 'st'; if (last === 2) return n + 'nd'; if (last === 3) return n + 'rd'; return n + 'th'; } function fmtDayMonth(t) { const d = new Date(t); return { day: ordinalSuffix(d.getDate()), month: d.toLocaleString(undefined, { month: 'short' }), }; } // ── State ───────────────────────────────────────────────────────── // timer: { id, substance, quit_at, paused_at, addedAt } // event: { id, name, when, why, icon, reward, quits: [timerId,...], addedAt } let timers = []; let events = []; let rewards = []; let prefs = { stealth: false }; let editingEventId = null; let editingTimerId = null; // for reset/delete/editstart sheets let calCursor = null; let raf = null; function load() { try { const t = JSON.parse(localStorage.getItem(TIMERS_KEY) || '[]'); timers = Array.isArray(t) ? t : []; } catch { timers = []; } try { const e = JSON.parse(localStorage.getItem(EVENTS_KEY) || '[]'); events = Array.isArray(e) ? e : []; } catch { events = []; } try { const r = JSON.parse(localStorage.getItem(REWARDS_KEY) || '[]'); rewards = Array.isArray(r) ? r : []; } catch { rewards = []; } try { const p = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); prefs = Object.assign({ stealth: false }, p || {}); } catch { prefs = { stealth: false }; } // Migrate legacy single-timer keys, if present and no new timers yet. if (timers.length === 0) { const legacyQuit = Number(localStorage.getItem(LEGACY_QUIT_KEY)); if (Number.isFinite(legacyQuit) && legacyQuit > 0) { const legacyPaused = Number(localStorage.getItem(LEGACY_PAUSED_KEY)); const newId = makeId('tm'); const now = Date.now(); timers.push({ id: newId, substance: 'My Quit', quit_at: legacyQuit, paused_at: Number.isFinite(legacyPaused) && legacyPaused > 0 ? legacyPaused : null, addedAt: legacyQuit, updatedAt: now, }); events.forEach((ev) => { if (!Array.isArray(ev.quits)) ev.quits = []; if (!ev.quits.includes(newId)) ev.quits.push(newId); }); localStorage.removeItem(LEGACY_QUIT_KEY); localStorage.removeItem(LEGACY_PAUSED_KEY); } } backfillEventQuits(); sortEvents(); // CRITICAL: use the unwrapped save here. The wrapped save() ticks // LOCAL_TS_KEY to Date.now() on every call. If load() ran on a // fresh device (empty localStorage), the wrapper would stamp // LOCAL_TS_KEY = now over an empty state — and the next sign-in's // stale-cache guard would then push that empty state UP to // Firestore, wiping the cloud doc. Using _origSave keeps the // timestamp untouched until a REAL save happens. if (typeof _origSave === 'function') _origSave(); else save(); } // Tag any event with empty quits[] to every current quit. Old builds // didn't always set this; a remote pull can also bring untagged events // down. Returns true when something changed (so callers can re-sync). // Data-hygiene only: ensure every event has a quits[] array. Empty // is a valid, deliberate state now — cross-links are opt-in, so we // never auto-populate with all live quits. function backfillEventQuits() { let mutated = false; liveEvents().forEach((ev) => { if (!Array.isArray(ev.quits)) { ev.quits = []; mutated = true; } }); // One-time migration: events that were completed (achieved_at or // missed_at set) but whose deletedAt was never written — either // from an older code path or an interrupted commit flow. These // zombie cards would re-appear on every load showing the // "I did it / I didn't make it" buttons. Soft-delete them now. events.forEach((ev) => { if (ev.deletedAt) return; // already handled const closeTs = ev.achieved_at || ev.missed_at; if (!closeTs) return; ev.deletedAt = closeTs; ev.updatedAt = closeTs; mutated = true; }); return mutated; } function save() { localStorage.setItem(TIMERS_KEY, JSON.stringify(timers)); localStorage.setItem(EVENTS_KEY, JSON.stringify(events)); localStorage.setItem(REWARDS_KEY, JSON.stringify(rewards)); localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } function sortEvents() { // Tap-type events have no target date — sort them to the end // (Infinity) so dated goals keep their soonest-first order. events.sort((a, b) => { const aw = (a.type === 'taps' || a.when == null) ? Infinity : a.when; const bw = (b.type === 'taps' || b.when == null) ? Infinity : b.when; return aw - bw; }); } function effectiveNow(timer) { return timer.paused_at || Date.now(); } // Total "time off" for one card — the single number the Pack // leaderboard + cohort math work off. Two card types, two rules: // • Quit Timer (type: 'timer' or missing): continuous elapsed // from quit_at → now (paused_at freezes the clock). // • Daily Taps (type: 'taps'): DISCRETE. Each tapped day is // worth 24h, untapped days count nothing. Number jumps by // 24h per tap, doesn't tick between taps. // Currently dev-gated for the pack-doc write, but the helper is // universal so the reader can render taps rows other admins push. function tapDayCount(t) { if (!t || !t.taps) return 0; let n = 0; for (const k in t.taps) if (t.taps[k]) n++; return n; } const HOURS_24_MS = 24 * 60 * 60 * 1000; function cardTimeOff(t) { if (!t) return 0; if (t.type === 'taps') return tapDayCount(t) * HOURS_24_MS; if (!t.quit_at) return 0; return Math.max(0, effectiveNow(t) - t.quit_at); } // Helpers that hide soft-deleted entries from every render path. function liveTimers() { return timers.filter((t) => !t.deletedAt); } function liveEvents() { return events.filter((e) => !e.deletedAt); } function liveRewards() { return rewards.filter((r) => !r.deletedAt); } function eventsForTimer(timerId) { return liveEvents().filter((e) => Array.isArray(e.quits) && e.quits.includes(timerId)); } // Stamp + soft-delete helpers used by every mutation path. function touchTimer(t) { if (t) t.updatedAt = Date.now(); } function touchEvent(e) { if (e) e.updatedAt = Date.now(); } function deleteTimer(id) { const t = timers.find((x) => x.id === id); if (!t) return; const now = Date.now(); track('timer_deleted', { substance: substanceCategory(t.substance), type: t.type || 'timer', age_days: Math.floor((effectiveNow(t) - t.quit_at) / DAY), }); setUserProps(); t.deletedAt = now; t.updatedAt = now; // Untag the now-gone quit from every event so its chips don't ghost. events.forEach((ev) => { if (Array.isArray(ev.quits) && ev.quits.includes(id)) { ev.quits = ev.quits.filter((q) => q !== id); touchEvent(ev); } }); } function deleteEventById(id) { const e = events.find((x) => x.id === id); if (!e) return; const now = Date.now(); track('goal_deleted', { type: e.type || 'timer', arrived: e.when != null && e.when <= now, }); setUserProps(); e.deletedAt = now; e.updatedAt = now; } // ── Goal completion handlers ───────────────────────────────────── // Three explicit choices when a goal's countdown is up (or any // time, ahead-of-schedule via the card-edit sheet): // • I did it → openGoalDoneSheet → celebration scrim // • I didn't make it → markGoalMissed → quiet close, soft-delete // • Update due time → openEditEvent → existing edit sheet // Both achieved + missed paths preserve the event in the dataset // with achieved_at / missed_at + delta_from_due_ms so the future // weekly/monthly viz can chart hit-rate over time. let goalDonePendingId = null; function fmtGoalDelta(deltaMs) { // deltaMs > 0 → ahead of schedule. < 0 → late. ~0 → on the day. const absMs = Math.abs(deltaMs); if (absMs < 12 * 60 * 60 * 1000) return 'Right on the day.'; const days = Math.round(absMs / (24 * 60 * 60 * 1000)); if (deltaMs > 0) return `${days} day${days === 1 ? '' : 's'} ahead of schedule.`; return `${days} day${days === 1 ? '' : 's'} past the original date — still counts.`; } function openGoalDoneSheet(id) { const ev = events.find((x) => x.id === id); if (!ev) return; goalDonePendingId = id; const dueMs = Number(ev.when || 0); const deltaMs = dueMs ? dueMs - Date.now() : 0; const nameEl = document.getElementById('goal-done-name'); const deltaEl = document.getElementById('goal-done-delta'); if (nameEl) nameEl.textContent = ev.name || 'Your goal'; if (deltaEl) deltaEl.textContent = fmtGoalDelta(deltaMs); const scrim = document.getElementById('goal-done-scrim'); if (scrim) scrim.setAttribute('data-open', ''); try { track('goal_done_open', { type: ev.type || 'timer', delta_ms: deltaMs, ahead_of_schedule: deltaMs > 0, }); } catch (_) {} } function closeGoalDoneSheet() { const scrim = document.getElementById('goal-done-scrim'); if (scrim) scrim.removeAttribute('data-open'); } function commitGoalAchieved(id) { const ev = events.find((x) => x.id === id); if (!ev) return null; const now = Date.now(); const dueMs = Number(ev.when || 0); const delta = dueMs ? dueMs - now : 0; ev.achieved_at = now; ev.delta_from_due_ms = delta; try { track('goal_achieved', { type: ev.type || 'timer', delta_ms: delta, ahead_of_schedule: delta > 0, }); } catch (_) {} // Soft-delete preserves the event in the array for viz. deleteEventById(ev.id); save(); return { name: ev.name, type: ev.type }; } function markGoalMissed(id) { const ev = events.find((x) => x.id === id); if (!ev) return; const now = Date.now(); const dueMs = Number(ev.when || 0); const delta = dueMs ? dueMs - now : 0; ev.missed_at = now; ev.delta_from_due_ms = delta; try { track('goal_missed', { type: ev.type || 'timer', delta_ms: delta, }); } catch (_) {} deleteEventById(ev.id); save(); renderEventsSection(); try { renderApp && renderApp(); } catch (_) {} } // Wire the goal-done scrim CTAs. Set up once at boot. (function wireGoalDoneScrim() { const closeBtn = document.getElementById('goal-done-close'); const anotherBtn = document.getElementById('goal-done-another'); const scrim = document.getElementById('goal-done-scrim'); if (closeBtn) closeBtn.addEventListener('click', () => { if (!goalDonePendingId) return closeGoalDoneSheet(); commitGoalAchieved(goalDonePendingId); goalDonePendingId = null; closeGoalDoneSheet(); renderEventsSection(); try { renderApp && renderApp(); } catch (_) {} }); if (anotherBtn) anotherBtn.addEventListener('click', () => { if (!goalDonePendingId) return closeGoalDoneSheet(); commitGoalAchieved(goalDonePendingId); goalDonePendingId = null; closeGoalDoneSheet(); renderEventsSection(); try { renderApp && renderApp(); } catch (_) {} // Open the create-event flow so the user can immediately // commit to another. Small delay so the scrim has visually // closed first. setTimeout(() => { try { createKind = 'event'; createName = ''; createTrack = null; if (typeof openCreateScreen1 === 'function') openCreateScreen1('event'); } catch (e) { dbg && dbg('[goal.another] ' + (e && e.message || e)); } }, 220); }); // Backdrop tap closes WITHOUT committing — gives the user an // undo path if they tapped I did it by accident. if (scrim) scrim.addEventListener('click', (e) => { if (e.target !== scrim) return; goalDonePendingId = null; closeGoalDoneSheet(); }); })(); function getJourney(elapsed) { let fromIdx = -1; for (let i = 0; i < MILESTONES.length; i++) { if (MILESTONES[i].ms > elapsed) break; fromIdx = i; } const from = fromIdx < 0 ? START : MILESTONES[fromIdx]; const to = MILESTONES[fromIdx + 1] || { ms: MILESTONES[MILESTONES.length - 1].ms * 2, big: '∞', tag: 'Beyond', scale: from.scale, }; return { from, to }; } // ── Milestone hit tracking ─────────────────────────────────────── // prefs.milestonesHit is a flat map of `${substanceCategory}:${ms}` // → timestamp-of-hit. Flat (not nested by substance) so the digest // email can dump it without walking nested keys. We only persist // *major* milestones (the ones that interrupt with a celebration); // the cheaper journey-dot transitions don't go in. function milestoneKey(substance, ms) { return `${substanceCategory(substance)}:${ms}`; } function hasMilestoneBeenHit(substance, ms) { return !!(prefs && prefs.milestonesHit && prefs.milestonesHit[milestoneKey(substance, ms)]); } function markMilestoneHit(substance, ms, hitAt) { // hitAt: optional. Pass the actual crossing time for catch-up // milestones (quit_at + m.ms) so the Cloud Function can tell // genuine real-time crossings from stale catch-ups and skip // notifications for old ones. Defaults to Date.now() for live // crossings where the time is always "right now". if (!prefs.milestonesHit) prefs.milestonesHit = {}; const k = milestoneKey(substance, ms); if (prefs.milestonesHit[k]) return false; // already prefs.milestonesHit[k] = hitAt || Date.now(); try { save(); } catch {} return true; } // Most recently hit `major` milestone for a substance. Used by the // card "Recently:" badge. Because milestones can only be hit in // chronological order, the highest ms among hit majors is the most // recent — no need to compare timestamps. function recentMajorForTimer(timer) { if (!prefs || !prefs.milestonesHit) return null; const cat = substanceCategory(timer.substance); const after = timer.quit_at || 0; let best = null; for (const m of MILESTONES) { if (!m.major) continue; const ts = prefs.milestonesHit[`${cat}:${m.ms}`]; // Only count hits that landed within this card's lifetime, // otherwise a brand-new Alcohol card inherits the previous // Alcohol card's 'Recently:' badge. if (ts && ts >= after) best = m; } return best; } // Next upcoming milestone (any — major OR minor). The "Next:" // badge points to this so first-time users always see a near-term // target ("1 Hour · 45m to go") instead of a faraway reward. function nextMilestoneForTimer(elapsed) { for (const m of MILESTONES) { if (m.ms > elapsed) return m; } return null; } // ETA formatter for the "X to go" suffix. Coarse on purpose — // a precise countdown belongs to the live ticker, not the strip. function fmtMilestoneEta(ms) { if (ms <= 0) return 'imminent'; if (ms < MIN) return 'seconds to go'; if (ms < HR) return Math.ceil(ms / MIN) + 'm to go'; if (ms < DAY) return Math.ceil(ms / HR) + 'h to go'; return Math.ceil(ms / DAY) + 'd to go'; } // Past-tense relative formatter — "Just now", "3h ago", "Yesterday", // "Mon 4 Jun" for older entries. Used by the on-card MILESTONES list. function fmtMileAgo(ts) { const diff = Date.now() - ts; if (diff < 60 * 1000) return 'just now'; if (diff < HR) return Math.floor(diff / MIN) + 'm ago'; if (diff < DAY) return Math.floor(diff / HR) + 'h ago'; if (diff < 2 * DAY) return 'yesterday'; if (diff < 7 * DAY) return Math.floor(diff / DAY) + 'd ago'; return new Date(ts).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); } // Build the combined MILESTONES list for a timer's card. Pulls // major milestone hits (from prefs.milestonesHit) AND tap check-ins // (from prefs.tapCheckins) filtered to this substance, sorts by // JOURNEY position (longest milestone at top, descending), caps // to MILE_LIST_CARD_MAX rows. Sorting by journey position rather // than by timestamp matters because catch-up can stamp multiple // milestones at near-identical times — by-ts would order them // unpredictably, by-journey always reads Day 7 above Day 1. const MILE_LIST_CARD_MAX = 3; function journeyPosForEntry(entry, timer) { if (entry.kind === 'milestone') return entry.m ? entry.m.ms : 0; // Check-ins: elapsed-since-quit at the moment the check-in // was logged. Clamps at 0 so anything weird sorts to bottom. const start = (timer && timer.quit_at) || entry.ts; return Math.max(0, entry.ts - start); } function collectTimerMilestoneEntries(timer) { const cat = substanceCategory(timer.substance); const entries = []; // Major hits keyed by `${cat}:${ms}` → timestamp. If the user // reflected on that hit (mood chips / control / note), the // reflection is keyed the same way so we attach it here for // inline display alongside the milestone label. const reflections = (prefs && prefs.milestoneReflections) || {}; if (prefs && prefs.milestonesHit) { for (const m of MILESTONES) { if (!m.major) continue; const k = `${cat}:${m.ms}`; const ts = prefs.milestonesHit[k]; // Only attribute a hit to THIS card if it landed during the // card's lifetime. A previous Alcohol card's 2-Week hit // shouldn't appear on a fresh Alcohol card created today. if (ts && ts >= (timer.quit_at || 0)) { entries.push({ kind: 'milestone', ts, label: `${m.big} ${m.tag}`, m, reflection: reflections[k] || null, }); } } } // Tap check-ins matching this timer (by targetId — survives // substance renames). Falls back to substance match for safety. if (prefs && Array.isArray(prefs.tapCheckins)) { for (const c of prefs.tapCheckins) { if (!c) continue; const sameTarget = c.targetId === timer.id; const sameSub = c.substance && timer.substance && c.substance === timer.substance; if (!sameTarget && !sameSub) continue; entries.push({ kind: 'checkin', ts: c.ts, c }); } } // Longest journey first → start of journey at the bottom. // Tie-breaker by ts desc so two events at the same journey // distance still feel "latest at top". entries.sort((a, b) => { const pj = journeyPosForEntry(b, timer) - journeyPosForEntry(a, timer); if (pj !== 0) return pj; return b.ts - a.ts; }); return entries; } // Map a mood id → tone band for chip colouring on the card list. // Matches the spectrum in the milestone + check-in scrims. const MOOD_TONE_DARK = new Set(['defeated','crumbling','tested','shaky','hungry']); const MOOD_TONE_WARM = new Set(['holding','steady','focused']); const MOOD_TONE_LIGHT = new Set(['calm','proud','lighter','stronger','unstoppable']); function moodTone(id) { if (MOOD_TONE_DARK.has(id)) return 'dark'; if (MOOD_TONE_LIGHT.has(id)) return 'light'; return 'warm'; } // Inline chip/note/control helpers retired — the single-line // entry row flows mood names + control + "note" into one label // string for the simpler row treatment the user prefers. // Single-line entry row — icon + flowed label + time. // The label flows everything we know about the entry inline: // mood names · control score · "personal note". The whole label // gets truncated with ellipsis on overflow so the row stays one // line no matter how long the note is. Tone-coded icon hints at // mood band at a glance without taking extra height. function renderMileEntryRow(entry) { // Pull the reflection (for milestones) or the check-in data so // both kinds use the same label-build code path below. const isMile = entry.kind === 'milestone'; const r = isMile ? (entry.reflection || {}) : (entry.c || {}); const moods = Array.isArray(r.moods) ? r.moods : []; const note = r.note || null; const control = (typeof r.control === 'number') ? r.control : null; const bits = []; if (isMile) bits.push(entry.label); if (moods.length) { const shown = moods.slice(0, 2).map((m) => MOOD_LABELS[m] || m); let s = shown.join(', '); if (moods.length > shown.length) s += ' +' + (moods.length - shown.length); bits.push(s); } if (control !== null && control !== 5) bits.push(`${control}/10`); if (note) bits.push(`“${note}”`); if (!bits.length) bits.push(isMile ? 'Milestone' : 'Check-in'); // Dominant mood band → tone hint for the icon colour. let d=0, w=0, l=0; for (const m of moods) { if (MOOD_TONE_DARK.has(m)) d++; else if (MOOD_TONE_LIGHT.has(m)) l++; else w++; } const tone = (l >= d && l >= w) ? 'light' : (d > w ? 'dark' : 'warm'); const iconHtml = isMile ? `` : ``; return ` ${iconHtml} ${escapeHtml(bits.join(' · '))} ${escapeHtml(fmtMileAgo(entry.ts))} `; } // Small inline heart, scoped to the check-in row. Not in ICONS // because we only use it here — keeps the card-render fast. const CHECKIN_HEART_SVG = ''; function paintMilestoneListBody(timer, elapsed, bodyEl, moreEl) { // v8 — counter cards always use the 3-row layout (upcoming // with live countdown at the top, then the last 2 reached with // dates). Tap cards use a different painter entirely. if (timer && timer.type !== 'taps') { return paintCounterMileBeta(timer, elapsed, bodyEl, moreEl); } // Fall-through (taps) — historical mixed-entries layout. const entries = collectTimerMilestoneEntries(timer); if (entries.length === 0) { const nextM = nextMilestoneForTimer(elapsed); bodyEl.innerHTML = nextM ? `Next: ${escapeHtml(nextM.big + ' ' + nextM.tag)} · ${escapeHtml(fmtMilestoneEta(nextM.ms - elapsed))}` : `No milestones yet.`; if (moreEl) moreEl.textContent = ''; return; } const top = entries.slice(0, MILE_LIST_CARD_MAX); bodyEl.innerHTML = top.map(renderMileEntryRow).join(''); const more = entries.length - top.length; if (moreEl) moreEl.textContent = more > 0 ? `+${more} more` : ''; } // ─── Beta Milestone list — counter cards only ───────────────────── // Three-row layout: // 1. Upcoming milestone with a live-ticking countdown (headline) // 2. Most recently reached milestone with the date it was reached // 3. The one before that // No per-row icons — the trophy in the section head is enough. // Includes ALL milestones (major + minor) — derived from elapsed, // not from prefs.milestonesHit (which only stores majors). // Build the row HTML once, then surgically update only the countdown // text on each RAF tick so the list doesn't churn DOM 60×/s. // Format the live countdown to mirror the count-up timer's // .ev-live structure exactly: // lettersecss[.xx] // — numbers in the base text colour (paper on dark cards), // unit letters wrapped in .unit and muted, // the seconds number wrapped in .accent (amber on dark), // the ms tail wrapped in .ms (red for urgency). // Returns an HTML string because the accent spans are inline. function fmtCountdownLive(ms) { if (ms <= 0) return 'imminent'; const totalSec = Math.floor(ms / 1000); const d = Math.floor(totalSec / 86400); const h = Math.floor((totalSec % 86400) / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; const ms100 = Math.floor((ms % 1000) / 10); const sBlock = `${pad(s)}s`; const msBlock = `.${pad(ms100, 2)}`; if (d > 0) return `${d}d${pad(h)}h${pad(m)}m${sBlock}${msBlock}`; if (h > 0) return `${h}h${pad(m)}m${sBlock}${msBlock}`; if (m > 0) return `${m}m${sBlock}${msBlock}`; return `${sBlock}${msBlock}`; } function fmtMileReached(ms) { try { return new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); } catch { return ''; } } // Pull the saved reflection note for a given milestone on this // timer — only majors have one (the celebration scrim only fires // on majors, and that's the only place notes get written). function noteForReachedMilestone(timer, m) { if (!prefs || !prefs.milestoneReflections) return null; if (!m || !m.major) return null; const cat = substanceCategory(timer.substance); const r = prefs.milestoneReflections[`${cat}:${m.ms}`]; return (r && typeof r.note === 'string' && r.note.trim()) || null; } function paintCounterMileBeta(timer, elapsed, bodyEl, moreEl) { const startMs = timer.quit_at || 0; const reached = []; let next = null; for (const m of MILESTONES) { if (m.ms <= elapsed) { reached.push({ m, reachedMs: startMs + m.ms }); } else { next = m; break; } } // Show the 2 most recently reached, newest at top of the history // pair (so the row order top-to-bottom is: NEXT, LAST, PREV). const recent = reached.slice(-2).reverse(); // Rebuild the static markup only when the set of rows changes — // i.e. when a new milestone is crossed OR a note is added in // the celebration scrim. Otherwise just patch the countdown. const noteSig = recent.map((r) => noteForReachedMilestone(timer, r.m) || '').join('|'); const sig = `${next ? next.ms : 'end'}|${recent.map((r) => r.m.ms).join(',')}|${noteSig}`; if (bodyEl.dataset.mileSig !== sig) { bodyEl.dataset.mileSig = sig; const rows = []; if (next) { const remaining = (startMs + next.ms) - Date.now(); const urgent = remaining > 0 && remaining < 60 * 1000; // Eyebrow "NEXT MILESTONE" sits above the countdown so users // instantly know which row is upcoming vs the reached rows // below. `data-mile-countdown` moved from the wrapper to a // child span so the hot-path (line ~8248) patches only the // countdown text and leaves the eyebrow intact. rows.push(`
${escapeHtml(next.big + ' ' + next.tag)} Next milestone ${fmtCountdownLive(remaining)}
`); } for (const r of recent) { const note = noteForReachedMilestone(timer, r.m); rows.push(`
${escapeHtml(r.m.big + ' ' + r.m.tag)} ${escapeHtml(fmtMileReached(r.reachedMs))} ${note ? `“${escapeHtml(note)}”` : ''}
`); } if (!rows.length) { rows.push(`No milestones yet.`); } bodyEl.innerHTML = rows.join(''); } else if (next) { // Hot path — same set of rows, just patch the countdown HTML // and toggle the urgent flag if we crossed the 60s threshold. const remaining = (startMs + next.ms) - Date.now(); const urgent = remaining > 0 && remaining < 60 * 1000; const cd = bodyEl.querySelector('[data-mile-countdown]'); if (cd) cd.innerHTML = fmtCountdownLive(remaining); const row = bodyEl.querySelector('.ev-mile-row-beta--next'); if (row) row.classList.toggle('ev-mile-row-beta--urgent', urgent); } // Hide the "+N more" hint — beta list is intentionally bounded. if (moreEl) moreEl.textContent = ''; } // Event (Commit / Daily-Taps) variant — events don't have a major // milestone ladder, so the list is check-ins only. Empty state // prompts the user to start tapping rather than pointing at a // milestone they can't reach yet. function collectEventCheckinEntries(ev) { return collectCheckinsForItem('event', ev.id); } function paintEventMilestoneListBody(ev, bodyEl, moreEl) { return paintCheckinOnlyListBody(collectEventCheckinEntries(ev), bodyEl, moreEl); } // Quit Daily-Taps timer variant — same shape as the event list // (no milestone ladder applies), just sourced from kind='timer' // check-ins instead of kind='event'. function collectTapTimerCheckinEntries(timer) { return collectCheckinsForItem('timer', timer.id); } function paintTapTimerCheckinListBody(timer, bodyEl, moreEl) { return paintCheckinOnlyListBody(collectTapTimerCheckinEntries(timer), bodyEl, moreEl); } // Shared helpers — both event and tap-timer surfaces just want // "check-ins for {kind, id}, sorted newest first, cap to N". function collectCheckinsForItem(kind, id) { if (!prefs || !Array.isArray(prefs.tapCheckins)) return []; const out = []; for (const c of prefs.tapCheckins) { if (!c || c.kind !== kind) continue; if (c.targetId !== id) continue; out.push({ kind: 'checkin', ts: c.ts, c }); } out.sort((a, b) => b.ts - a.ts); return out; } // Streak + week strip for the share card. // // Streak = consecutive days ending TODAY that were TAPPED on the // habit — matches what the daily-tap card visually shows (amber // pips = item.taps[YYYY-MM-DD]). Uses the same source as the // dashboard so the share number can't disagree with the card. // // Previously read prefs.tapCheckins which only counts DAYS THE // USER SAVED THE CHECK-IN FORM — Skip taps didn't count, so a // 3-day streak on the card rendered as 1d on the share. Fixed. // // Week strip = 7 tiles for the current week (Sun → Sat) mirroring // the dashboard "M T W T F S S" strip. function computeCheckinStreakForShare(kind, id, fromKey) { const item = kind === 'timer' ? (Array.isArray(timers) ? timers.find((t) => t.id === id) : null) : (Array.isArray(events) ? events.find((e) => e.id === id) : null); const taps = (item && item.taps) || {}; // Must use the same dateKey helper the dashboard uses to WRITE // taps — its keys are zero-padded (YYYY-MM-DD). const today = new Date(); today.setHours(0, 0, 0, 0); // Walk backwards from the DAY THE USER TAPPED (fromKey), not // just today. If the tap is on a past day the share is about // THAT day, so the streak should end there. Falls back to // today when fromKey is missing/invalid. // // Bug this fixes: after midnight, tapping "yesterday" (which // may still be the user's active day) walked from today — // today wasn't tapped → streak resolved to 0. let anchor = today; if (fromKey && typeof fromKey === 'string') { const parts = fromKey.split('-'); if (parts.length === 3) { const parsed = new Date( parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10) ); if (!isNaN(parsed.getTime())) { parsed.setHours(0, 0, 0, 0); anchor = parsed; } } } let streak = 0; for (let i = 0; i < 365; i++) { const d = new Date(anchor); d.setDate(anchor.getDate() - i); if (taps[dateKey(d)]) streak++; else break; } // Build the current week: Sunday → Saturday, marked on/future/missed. const dowLetters = ['S','M','T','W','T','F','S']; const startOfWeek = new Date(today); startOfWeek.setDate(today.getDate() - today.getDay()); const weekDays = []; for (let i = 0; i < 7; i++) { const d = new Date(startOfWeek); d.setDate(startOfWeek.getDate() + i); const isFuture = d > today; const on = !!taps[dateKey(d)]; weekDays.push({ dow: dowLetters[d.getDay()], dn: String(d.getDate()), on, future: isFuture && !on, }); } return { streak, weekDays }; } function paintCheckinOnlyListBody(entries, bodyEl, moreEl) { if (entries.length === 0) { bodyEl.innerHTML = `Tap a day to log a check-in.`; if (moreEl) moreEl.textContent = ''; return; } const top = entries.slice(0, MILE_LIST_CARD_MAX); bodyEl.innerHTML = top.map(renderMileEntryRow).join(''); const more = entries.length - top.length; if (moreEl) moreEl.textContent = more > 0 ? `+${more} more` : ''; } // ── Top-level mode switching ────────────────────────────────────── function renderApp() { const live = liveTimers(); document.body.classList.toggle('has-quits', live.length > 0); // The install banner is gated on "user has committed to a quit", // so re-evaluate it whenever the timer count changes. Wrapped // because the install setup runs later in the boot script. try { const p = refreshInstallBanner(); if (p && p.catch) p.catch(() => {}); } catch {} try { renderSetupChecklist(); } catch {} // Always render events + dash, regardless of how many quits // exist. Previously this early-returned + wiped eventsHost when // live.length === 0, which hid existing commits from the Commit // tab even though they were still in the data — and made the // dash checklist's "commit done" tick look like a lie. if (live.length === 0) { stopLoop(); cardsHost.innerHTML = ''; } else { renderCardStack(); startLoop(); } renderEventsSection(); try { renderDash(); } catch {} } // ── Card rendering ──────────────────────────────────────────────── function renderCardStack() { const live = liveTimers(); cardsHost.innerHTML = live.map(renderCardHtml).join(''); live.forEach((t) => wireCard(t.id)); live.forEach((t) => paintCardLive(t.id)); } // ── Milestone share card (canvas → native share sheet) ─────────── // Generates a 1080×1350 image of the milestone — wolf mark, big // milestone label, statement and a QR back to the app — then hands // it to navigator.share so the user can post to IG / FB / Twitter // / Messages etc. Owner-only (same gate as the wrap itself). const WOLF_MINI_SVG = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; function loadImageEl(src, cors) { return new Promise((res, rej) => { const img = new Image(); if (cors) img.crossOrigin = 'anonymous'; img.onload = () => res(img); img.onerror = (e) => rej(e); img.src = src; }); } function svgToImage(svg) { const blob = new Blob([svg], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); return loadImageEl(url, false).finally(() => URL.revokeObjectURL(url)); } function statementForMilestoneMs(ms) { if (ms < HR) return 'First steps. Keep walking.'; if (ms < DAY) return 'Hours in. The Bad Wolf goes hungry.'; if (ms < 7 * DAY) return 'Days in. The Pack runs with you.'; if (ms < 30 * DAY) return 'Weeks in. Ground taken.'; if (ms < 100 * DAY) return 'Months in. Built different.'; if (ms < 365 * DAY) return 'A quarter year. Bad Wolf is starving.'; return 'A whole year. Bad Wolf is starved.'; } function wrapCanvasText(ctx, text, x, y, maxWidth, lineHeight) { const words = text.split(' '); let line = ''; let yy = y; for (let n = 0; n < words.length; n++) { const test = line + words[n] + ' '; if (ctx.measureText(test).width > maxWidth && n > 0) { ctx.fillText(line.trim(), x, yy); line = words[n] + ' '; yy += lineHeight; } else { line = test; } } if (line.trim()) ctx.fillText(line.trim(), x, yy); return yy; } // Build a 1080×1350 PNG canvas (Instagram-portrait safe). // payload.kind : 'milestone' | 'checkin' // payload.habitType: 'bad' | 'good' (default 'bad' for milestones) // - 'bad' → dark ink card with paper text (Quit / Daily-Taps quit) // - 'good' → paper card with ink text (Commit / Daily-Taps good habits) // payload.moods + payload.note populate the FEELING + quote // blocks when present. // // Layout is anchor-based: brand at top, QR + JOIN THE PACK // anchored to the bottom, content distributed between them // (not stacked top-down). Hero block and FEELING/quote blocks // each get a centred vertical band so the card never has // dead space in the middle regardless of how much content // the user filled in. // ═════════════════════════════════════════════════════════════ // ─── MASTER TEMPLATE SHARE CANVAS (dev preview) ─────────────── // 1080×1350 (4:5) poster from the Claude Design handoff. Gated // on devModeActive() so only admin dev-mode sees the new output; // everyone else falls through to the legacy 1080×1920 renderer // below. Only milestones currently render as masters — check-in // needs a streak-count payload extension and reward needs the // Amber master, both deferred. // ═════════════════════════════════════════════════════════════ async function renderMasterShareCanvas(payload) { // Canvas is 9:16 (1080×1920) — one PNG serves IG Reel / Post / Story. // Card content lives in a 1080×1350 (4:5) band centred vertically. // Background pattern (stripes/dots) fills the full 1920 so the // top/bottom pads read as one continuous poster with the card. // // DEV branch — admin devModeActive() opts into the next-gen check-in // render owner is iterating on in /content/instagram-cards-export.html: // • Stacked eyebrow (verb small mono, subject big display) // • Hero picker (streak ≥2 → "Nd Streak", else "Nx This Week") // • Week strip parity with the dashboard (thick borders + clump merge) // Regular users continue on the current inline-compound-eyebrow + // streak-only hero until owner promotes it. const useDev = (typeof devModeActive === 'function' && devModeActive()); const W = 1080, H = 1920; const CARD_H = 1350; const TOP_OFFSET = (H - CARD_H) / 2; // 285 const PAD_X = 104, PAD_Y = 116; const HERO_TOP_OFFSET = 58; const MST_INK = '#161619'; const MST_PAPER = '#ECECE9'; const MST_AMBER = '#F2B81C'; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch {} const habitType = payload.habitType || 'bad'; const theme = habitType === 'good' ? 'paper' : 'ink'; const bgCol = theme === 'ink' ? MST_INK : MST_PAPER; const bodyCol = theme === 'ink' ? MST_PAPER : MST_INK; // Background + theme-appropriate pattern — fills the FULL 1080×1920. ctx.fillStyle = bgCol; ctx.fillRect(0, 0, W, H); ctx.save(); if (theme === 'ink') { ctx.globalAlpha = 0.06; ctx.strokeStyle = MST_PAPER; ctx.lineWidth = 3; for (let x = -H; x < W + H; x += 29) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + H, H); ctx.stroke(); } } else { ctx.globalAlpha = 0.08; ctx.fillStyle = MST_INK; for (let dy = 17; dy < H; dy += 34) for (let dx = 17; dx < W; dx += 34) { ctx.beginPath(); ctx.arc(dx, dy, 2, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); // All card content below draws in a translated coord system where // (0,0) is the top-left of the 1080×1350 card band. Existing Y // math (PAD_Y, cumulative y = PAD_Y + …) stays untouched. ctx.translate(0, TOP_OFFSET); function drawTracked(text, x, y, tracking) { ctx.save(); ctx.textBaseline = 'alphabetic'; if ('letterSpacing' in ctx) { ctx.letterSpacing = tracking + 'px'; ctx.fillText(text, x, y); } else { let cx = x; for (const ch of String(text)) { ctx.fillText(ch, cx, y); cx += ctx.measureText(ch).width + tracking; } } ctx.restore(); } function measureTracked(text, tracking) { if ('letterSpacing' in ctx) { const prev = ctx.letterSpacing; ctx.letterSpacing = tracking + 'px'; const w = ctx.measureText(text).width; ctx.letterSpacing = prev; return w; } let w = 0; const chs = String(text).split(''); for (let i = 0; i < chs.length; i++) { w += ctx.measureText(chs[i]).width; if (i < chs.length - 1) w += tracking; } return w; } function roundRect(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); } // Brand row — reuses the app's real wolf SVG for pixel fidelity. const markSize = 91; try { const wolfImg = await svgToImage(theme === 'ink' ? WOLF_MINI_SVG : WOLF_MINI_SVG_INK); ctx.drawImage(wolfImg, PAD_X, PAD_Y, markSize, markSize); } catch {} ctx.fillStyle = bodyCol; ctx.font = '700 55px "Space Grotesk"'; drawTracked('BAD WOLF', PAD_X + markSize + 26, PAD_Y + markSize * 0.66, 55 * 0.16); // Hero region — branch by kind. Milestone gets the count hero // (big number + amber unit); check-in gets the name hero (substance // as the big word, auto-shrunk if it's long). const isCheckin = payload.kind === 'checkin'; let y = PAD_Y + markSize + HERO_TOP_OFFSET; // Eyebrow — two shapes: // DEV (check-ins only): STACKED // • STARVED / COMMITTED ← verb, small mono, uppercase // Nicotine / Yoga ← subject, big display, sentence case // PROD: current single-line compound (STARVED: NICOTINE) const ebSize = 41; const pipR = ebSize * 0.32; ctx.fillStyle = MST_AMBER; ctx.beginPath(); ctx.arc(PAD_X + pipR, y + ebSize * 0.46, pipR, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = bodyCol; ctx.font = '500 ' + ebSize + 'px "JetBrains Mono"'; if (useDev && isCheckin) { const verb = habitType === 'good' ? 'COMMITTED' : 'STARVED'; const subject = String(payload.substance || ''); drawTracked(verb, PAD_X + pipR * 2 + 22, y + 34, ebSize * 0.22); const subjectSize = 78; ctx.font = '600 ' + subjectSize + 'px "Space Grotesk"'; ctx.textBaseline = 'alphabetic'; drawTracked(subject, PAD_X, y + ebSize + 8 + subjectSize, subjectSize * -0.02); y += ebSize + 8 + subjectSize + 40; } else { const subjectText = String(payload.substance || '').toUpperCase(); let eyebrowText; if (isCheckin) { eyebrowText = (habitType === 'good' ? 'COMMITTED: ' : 'STARVED: ') + subjectText; } else { eyebrowText = subjectText; } drawTracked(eyebrowText, PAD_X + pipR * 2 + 22, y + 34, ebSize * 0.22); y += ebSize + 40; } // Hero picker (dev + check-in only): // streak ≥ 2 → "Nd Streak" // else weekCount ≥ 1 → "Nx This Week" (covers 1-day case) // else → no hero (unreachable in prod — check-in scrim can't // open without a tap, so weekCount is always ≥ 1). // Prod: unchanged (streak variant + name-hero fallback). const devStreakLen = payload.streak | 0; const devWeekCount = (isCheckin && Array.isArray(payload.weekDays)) ? payload.weekDays.filter(d => d && d.on).length : devStreakLen; const devHeroKind = devStreakLen >= 2 ? 'streak' : devWeekCount >= 1 ? 'weekcount' : 'none'; function drawDevCountHero(nVal, unit, suffix) { const numSize = 288; const unitSize = numSize * 0.34; const sufSize = numSize * 0.30; const baseY = y + numSize * 0.86; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = bodyCol; ctx.font = '300 ' + numSize + 'px "Space Grotesk"'; ctx.fillText(String(nVal), PAD_X, baseY); let cur = PAD_X + ctx.measureText(String(nVal)).width - numSize * 0.05; ctx.fillStyle = MST_AMBER; ctx.font = '400 ' + unitSize + 'px "Space Grotesk"'; ctx.fillText(unit, cur, baseY); cur += ctx.measureText(unit).width + numSize * 0.16; ctx.fillStyle = bodyCol; ctx.font = '500 ' + sufSize + 'px "Space Grotesk"'; ctx.fillText(suffix, cur, baseY); y = baseY + 30; } if (useDev && isCheckin) { if (devHeroKind === 'streak') drawDevCountHero(devStreakLen, 'd', 'Streak'); else if (devHeroKind === 'weekcount') drawDevCountHero(devWeekCount, 'x', 'This Week'); // else: skip hero (eyebrow + week strip carry the story) } else if (isCheckin && payload.streak > 0) { // Streak hero — big number + tucked amber unit + suffix // ("Streak" for ≥2, "Today" for a lone check-in). Mirrors the // milestone count hero's proportions so the two share cards // share visual DNA. const sn = Math.max(1, parseInt(payload.streak, 10) || 1); const suffix = sn === 1 ? 'Today' : 'Streak'; const numSize = 288; const unitSize = numSize * 0.34; const sufSize = numSize * 0.30; const baseY = y + numSize * 0.86; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = bodyCol; ctx.font = '300 ' + numSize + 'px "Space Grotesk"'; ctx.fillText(String(sn), PAD_X, baseY); let cur = PAD_X + ctx.measureText(String(sn)).width - numSize * 0.05; ctx.fillStyle = MST_AMBER; ctx.font = '400 ' + unitSize + 'px "Space Grotesk"'; ctx.fillText('d', cur, baseY); cur += ctx.measureText('d').width + numSize * 0.16; ctx.fillStyle = bodyCol; ctx.font = '500 ' + sufSize + 'px "Space Grotesk"'; ctx.fillText(suffix, cur, baseY); y = baseY + 30; } else if (isCheckin) { // Streak = 0 (first check-in, or no data). Fall back to name // hero — substance as the big word, auto-shrink to fit. const nameStr = String(payload.substance || ''); const maxNameSize = 178; const maxNameW = W - PAD_X * 2; const trackAt = (s) => s * -0.03; const measureName = (s) => { ctx.font = '600 ' + s + 'px "Space Grotesk"'; if ('letterSpacing' in ctx) { const prev = ctx.letterSpacing; ctx.letterSpacing = trackAt(s) + 'px'; const w = ctx.measureText(nameStr).width; ctx.letterSpacing = prev; return w; } let w = 0; const chs = nameStr.split(''); for (let i = 0; i < chs.length; i++) { w += ctx.measureText(chs[i]).width; if (i < chs.length - 1) w += trackAt(s); } return w; }; let nameSize = maxNameSize; if (measureName(maxNameSize) > maxNameW) { let lo = 60, hi = maxNameSize; while (hi - lo > 0.5) { const mid = (lo + hi) / 2; if (measureName(mid) > maxNameW) hi = mid; else lo = mid; } nameSize = Math.floor(lo); } ctx.fillStyle = bodyCol; ctx.font = '600 ' + nameSize + 'px "Space Grotesk"'; const nameBaseY = y + nameSize; drawTracked(nameStr, PAD_X, nameBaseY, trackAt(nameSize)); y = nameBaseY + 30; } else { // Count hero: big number + amber unit tucked tight. const nStr = String(payload.big != null ? payload.big : ''); const tag = String(payload.tagLabel || 'days'); const unit = (tag[0] || 'd').toLowerCase(); const numSize = 288; const unitSize = numSize * 0.34; const baseY = y + numSize * 0.86; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = bodyCol; ctx.font = '300 ' + numSize + 'px "Space Grotesk"'; ctx.fillText(nStr, PAD_X, baseY); let cursor = PAD_X + ctx.measureText(nStr).width - numSize * 0.05; ctx.fillStyle = MST_AMBER; ctx.font = '400 ' + unitSize + 'px "Space Grotesk"'; ctx.fillText(unit, cursor, baseY); y = baseY + 30; } // Label — different by kind. Streak variant reads as the // ongoing habit; name variant reads as today's tap. // DEV + check-in: skip. The compound-eyebrow subject already // says what habit this is, and the "Nx This Week" suffix says // when — a mono label here is redundant noise. if (!(useDev && isCheckin)) { ctx.save(); ctx.globalAlpha = 0.55; ctx.fillStyle = bodyCol; ctx.font = '500 36px "JetBrains Mono"'; let lblText; if (isCheckin && payload.streak > 0) { lblText = 'DAILY CHECK-INS'; } else if (isCheckin) { lblText = "TODAY’S CHECK-IN"; } else { lblText = (habitType === 'bad' ? 'SINCE YOU QUIT · ' : 'CONSECUTIVE · ') + String(payload.substance || '').toUpperCase(); } drawTracked(lblText, PAD_X, y + 36, 36 * 0.1); ctx.restore(); y += 36 + 40; } // Week strip. // DEV branch — dashboard-parity tiles (thicker borders, larger // size, clump-merge for consecutive on-days). Renders whenever // there's a weekDays array, not gated on streak > 0. // PROD branch — current thin-outline strip, streak-only. if (useDev && isCheckin && Array.isArray(payload.weekDays)) { const tileW = 116, tileH = 134, tileGap = 14, radius = 20, strokeW = 4; const gapAbove = 30; const stripY = y + gapAbove; const stripW = tileW * 7 + tileGap * 6; const stripX = (W - stripW) / 2; const days = payload.weekDays; const positions = days.map((d, i) => { if (!d || !d.on) return ''; const prev = i > 0 && days[i - 1] && days[i - 1].on; const next = i < days.length - 1 && days[i + 1] && days[i + 1].on; if (prev && next) return 'mid'; if (next) return 'start'; if (prev) return 'end'; return 'solo'; }); const cornerFor = (pos) => { if (pos === 'start') return { tl: radius, tr: 0, br: 0, bl: radius }; if (pos === 'mid') return { tl: 0, tr: 0, br: 0, bl: 0 }; if (pos === 'end') return { tl: 0, tr: radius, br: radius, bl: 0 }; return { tl: radius, tr: radius, br: radius, bl: radius }; }; const perRect = (px, py, pw, ph, r) => { ctx.beginPath(); ctx.moveTo(px + r.tl, py); ctx.lineTo(px + pw - r.tr, py); ctx.quadraticCurveTo(px + pw, py, px + pw, py + r.tr); ctx.lineTo(px + pw, py + ph - r.br); ctx.quadraticCurveTo(px + pw, py + ph, px + pw - r.br, py + ph); ctx.lineTo(px + r.bl, py + ph); ctx.quadraticCurveTo(px, py + ph, px, py + ph - r.bl); ctx.lineTo(px, py + r.tl); ctx.quadraticCurveTo(px, py, px + r.tl, py); }; const clumpStroke = (px, py, pw, ph, r, pos) => { const skipL = (pos === 'mid' || pos === 'end'); const skipR = (pos === 'mid' || pos === 'start'); ctx.beginPath(); ctx.moveTo(px + r.tl, py); ctx.lineTo(px + pw - r.tr, py); if (!skipR) { ctx.quadraticCurveTo(px + pw, py, px + pw, py + r.tr); ctx.lineTo(px + pw, py + ph - r.br); ctx.quadraticCurveTo(px + pw, py + ph, px + pw - r.br, py + ph); } else ctx.moveTo(px + pw, py + ph); ctx.lineTo(px + r.bl, py + ph); if (!skipL) { ctx.quadraticCurveTo(px, py + ph, px, py + ph - r.bl); ctx.lineTo(px, py + r.tl); ctx.quadraticCurveTo(px, py, px + r.tl, py); } ctx.stroke(); }; // Pass 1 — amber bridges under adjacent on-cells. for (let i = 0; i < days.length - 1; i++) { if (!(days[i] && days[i].on && days[i + 1] && days[i + 1].on)) continue; const bx = stripX + i * (tileW + tileGap) + tileW - strokeW / 2; const bw = tileGap + strokeW; ctx.save(); ctx.fillStyle = MST_AMBER; ctx.fillRect(bx, stripY, bw, tileH); ctx.strokeStyle = MST_INK; ctx.lineWidth = strokeW; ctx.beginPath(); ctx.moveTo(bx, stripY + strokeW / 2); ctx.lineTo(bx + bw, stripY + strokeW / 2); ctx.moveTo(bx, stripY + tileH - strokeW / 2); ctx.lineTo(bx + bw, stripY + tileH - strokeW / 2); ctx.stroke(); ctx.restore(); } // Pass 2 — tiles + text. for (let i = 0; i < 7 && i < days.length; i++) { const d = days[i] || {}; const pos = positions[i]; const corners = cornerFor(pos); const tx = stripX + i * (tileW + tileGap); ctx.save(); if (d.on) { ctx.fillStyle = MST_AMBER; perRect(tx, stripY, tileW, tileH, corners); ctx.fill(); ctx.strokeStyle = MST_INK; ctx.lineWidth = strokeW; clumpStroke(tx, stripY, tileW, tileH, corners, pos); ctx.fillStyle = MST_INK; } else { if (d.future) ctx.globalAlpha = 0.30; ctx.strokeStyle = bodyCol; ctx.lineWidth = strokeW; const inset = strokeW / 2; perRect(tx + inset, stripY + inset, tileW - strokeW, tileH - strokeW, corners); ctx.stroke(); ctx.fillStyle = bodyCol; } ctx.textBaseline = 'alphabetic'; if (!d.on) ctx.globalAlpha = (d.future ? 0.30 : 0.60); ctx.font = '700 22px "JetBrains Mono"'; const dowStr = String(d.dow || '').toUpperCase(); const dowW = ctx.measureText(dowStr).width; ctx.fillText(dowStr, tx + (tileW - dowW) / 2, stripY + 44); ctx.globalAlpha = d.future ? 0.30 : 1; ctx.font = '600 44px "Space Grotesk"'; const dnStr = String(d.dn || ''); const dnW = ctx.measureText(dnStr).width; ctx.fillText(dnStr, tx + (tileW - dnW) / 2, stripY + 106); ctx.restore(); } y = stripY + tileH + 22; } else if (isCheckin && payload.streak > 0 && Array.isArray(payload.weekDays)) { const tileW = 108, tileH = 132, tileGap = 12; const stripW = tileW * 7 + tileGap * 6; const stripX = (W - stripW) / 2; for (let i = 0; i < 7 && i < payload.weekDays.length; i++) { const d = payload.weekDays[i]; const tx = stripX + i * (tileW + tileGap); ctx.save(); if (d.on) { ctx.fillStyle = MST_AMBER; roundRect(tx, y, tileW, tileH, 12); ctx.fill(); ctx.fillStyle = MST_INK; } else if (d.future) { ctx.globalAlpha = 0.22; ctx.strokeStyle = bodyCol; ctx.lineWidth = 2; roundRect(tx + 1, y + 1, tileW - 2, tileH - 2, 12); ctx.stroke(); ctx.globalAlpha = 0.4; ctx.fillStyle = bodyCol; } else { ctx.globalAlpha = 0.45; ctx.strokeStyle = bodyCol; ctx.lineWidth = 2; roundRect(tx + 1, y + 1, tileW - 2, tileH - 2, 12); ctx.stroke(); ctx.fillStyle = bodyCol; } ctx.textBaseline = 'alphabetic'; ctx.font = '500 22px "JetBrains Mono"'; const dowW = ctx.measureText(String(d.dow || '')).width; ctx.fillText(String(d.dow || ''), tx + (tileW - dowW) / 2, y + 42); ctx.font = '500 46px "Space Grotesk"'; const dnW = ctx.measureText(String(d.dn || '')).width; ctx.fillText(String(d.dn || ''), tx + (tileW - dnW) / 2, y + 100); ctx.restore(); } y += tileH + 40; } // Modules — hairline rule + quote + chips + confidence const hasQuote = payload.note && String(payload.note).trim(); const hasChips = Array.isArray(payload.moods) && payload.moods.length; const hasConf = typeof payload.control === 'number' && !isNaN(payload.control); if (hasQuote || hasChips || hasConf) { ctx.save(); ctx.globalAlpha = 0.18; ctx.strokeStyle = bodyCol; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(PAD_X, y); ctx.lineTo(W - PAD_X, y); ctx.stroke(); ctx.restore(); y += 41; if (hasQuote) { const qSize = 55; const lh = qSize * 1.08; const rulePadLeft = qSize * 0.6; const textX = PAD_X + rulePadLeft; const maxW = W - PAD_X * 2 - rulePadLeft; ctx.font = '500 ' + qSize + 'px "Space Grotesk"'; ctx.textBaseline = 'alphabetic'; const openQ = '“', closeQ = '”'; const openW = ctx.measureText(openQ).width; const words = String(payload.note).trim().split(/\s+/); const lines = []; let line = ''; for (const w of words) { const test = line ? line + ' ' + w : w; const budget = lines.length === 0 ? maxW - openW : maxW; if (ctx.measureText(test).width > budget) { if (line) lines.push(line); line = w; } else line = test; } if (line) lines.push(line); const totalH = lines.length * lh; ctx.fillStyle = MST_AMBER; ctx.fillRect(PAD_X, y + 4, 4, totalH - 4); let ty = y + qSize; for (let i = 0; i < lines.length; i++) { const l = lines[i]; let cur = textX; if (i === 0) { ctx.fillStyle = MST_AMBER; ctx.fillText(openQ, cur, ty); cur += ctx.measureText(openQ).width; } ctx.fillStyle = bodyCol; ctx.fillText(l, cur, ty); if (i === lines.length - 1) { const lw = ctx.measureText(l).width; ctx.fillStyle = MST_AMBER; ctx.fillText(closeQ, cur + lw, ty); } ty += lh; } y += totalH + 41; } if (hasChips) { const chSize = 31; const padH = 32, padV = 22, gap = 16; const chipH = chSize + padV * 2; let cx = PAD_X; ctx.font = '500 ' + chSize + 'px "Space Grotesk"'; for (const m of payload.moods) { const label = (typeof MOOD_LABELS !== 'undefined' && MOOD_LABELS[m]) || m; const tw = measureTracked(label, chSize * 0.01); const chipW = tw + padH * 2; if (cx + chipW > W - PAD_X) { cx = PAD_X; y += chipH + gap; } ctx.fillStyle = MST_AMBER; roundRect(cx, y, chipW, chipH, chipH / 2); ctx.fill(); ctx.fillStyle = MST_INK; drawTracked(label, cx + padH, y + chipH / 2 + chSize * 0.35, chSize * 0.01); cx += chipW + gap; } y += chipH + 41; } if (hasConf) { // IN CONTROL label + big X/10 value. Number in amber, /10 in // muted body colour, same font size + baseline so they sit // flush at the bottom. Matches the preview page verbatim. const rulePadLeft = 33; const textX = PAD_X + rulePadLeft; const lblSize = 24; const valSize = 96; const totalH = lblSize + 12 + valSize; ctx.save(); ctx.fillStyle = MST_AMBER; ctx.fillRect(PAD_X, y + 4, 4, totalH - 4); ctx.globalAlpha = 0.55; ctx.fillStyle = bodyCol; ctx.font = '500 ' + lblSize + 'px "JetBrains Mono"'; ctx.textBaseline = 'alphabetic'; ctx.fillText('In control', textX, y + lblSize + 4); ctx.globalAlpha = 1; ctx.font = '500 ' + valSize + 'px "Space Grotesk"'; const nStr = String(Math.max(0, Math.min(10, parseInt(payload.control, 10) || 0))); const baseY = y + lblSize + 12 + valSize * 0.9; ctx.fillStyle = MST_AMBER; ctx.fillText(nStr, textX, baseY); const nW = ctx.measureText(nStr).width; ctx.globalAlpha = 0.35; ctx.fillStyle = bodyCol; ctx.fillText('/10', textX + nW + 4, baseY); ctx.restore(); y += totalH + 41; } } else { // ADAPTIVE FALLBACK — user supplied no modules, so drop a // Bad Wolf brand tagline anchored to the bottom of the card. // Deterministic pick so a given card always shows the same // tagline (no random flicker between renders). // Each entry is an array of PRE-BROKEN lines — explicit // sentence breaks so "Starve the Bad Wolf." / "Feed the Good // Wolf." wrap at the sentence boundary, not mid-phrase. // Long single-line entries still get auto-wrapped below as a // safety net if they exceed the card width. const BRAND_TAGLINES = [ ['Starve the Bad Wolf.', 'Feed the Good Wolf.'], ['Two Wolves live in you.', 'Feed the Good Wolf.'], ['One day at a time.', 'Then another.'], ['Run with the Pack.'], ['Show up. Show up. Show up.'], ['Run with the right pack.'], ]; const seed = isCheckin ? Math.abs((String(payload.substance || '').length * 7) + (String(payload.substance || '').charCodeAt(0) || 0)) : Math.abs(parseInt(payload.big, 10) || 0); const tagline = BRAND_TAGLINES[seed % BRAND_TAGLINES.length]; const tSize = 48; const tLineHeight = tSize * 1.1; const tRulePadLeft = tSize * 0.6; const tTextX = PAD_X + tRulePadLeft; const tMaxW = W - PAD_X * 2 - tRulePadLeft; ctx.save(); ctx.font = '500 ' + tSize + 'px "Space Grotesk"'; ctx.textBaseline = 'alphabetic'; // Accept a string OR array of pre-broken lines. Each raw line // still gets word-wrapped so a too-wide line stays inside the // card. const rawLines = Array.isArray(tagline) ? tagline : [String(tagline)]; const tLines = []; for (const raw of rawLines) { const words = String(raw).split(/\s+/); let line = ''; for (const w of words) { const test = line ? line + ' ' + w : w; if (ctx.measureText(test).width > tMaxW) { if (line) tLines.push(line); line = w; } else line = test; } if (line) tLines.push(line); } const totalTH = tLines.length * tLineHeight; // Anchor to bottom of the CARD region (CARD_H), not the full // 1920 canvas — content sits inside the translated 4:5 band. const tagY = CARD_H - PAD_Y - totalTH + tSize; const ruleTop = tagY - tSize + 4; ctx.fillStyle = MST_AMBER; ctx.fillRect(PAD_X, ruleTop, 4, totalTH - 4); ctx.globalAlpha = 0.75; ctx.fillStyle = bodyCol; let ty = tagY; for (const l of tLines) { ctx.fillText(l, tTextX, ty); ty += tLineHeight; } ctx.restore(); } return canvas; } async function buildShareCanvas(payload) { // Master Template share canvas — shipped to all users. // Milestone → count-hero (40d + "Since you quit · date"). // Check-in → Xd Streak hero + week strip when the tapped day has // a streak, else name-hero (substance as big word). // Reward → still on legacy for now (no share flow currently exists // for rewards; when it does we'll add an Amber theme branch here). try { if (payload && (payload.kind === 'milestone' || payload.kind === 'checkin')) { return await renderMasterShareCanvas(payload); } } catch (e) { dbg('[master canvas] fallback: ' + (e && e.message || e)); } // 1080×1920 (9:16) — fits IG Stories / Reels / TikTok natively. // IG Post auto-crops to 4:5 from the middle 1350px of our 1920, // so as long as critical content sits in the central band the // Post format also reads cleanly. Top 280px + bottom 280px are // reserved as SAFE ZONES — IG overlays its own UI (Reels nav, // music sticker, See more, action buttons) within ~250px of // each edge. Anything important inside those bands gets covered. const W = 1080, H = 1920; const SAFE_TOP = 280; const SAFE_BOT = 280; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch {} // ── Theme: bad-habit (dark) vs good-habit (paper) ───────── const habitType = payload.habitType || 'bad'; const isDark = habitType !== 'good'; const C = { bg: isDark ? '#16161A' : '#ECECE9', fg: isDark ? '#ECECE9' : '#16161A', fgMuted: isDark ? '#9A9A9D' : '#3B3B3F', eyebrow: isDark ? '#B07E00' : '#B07E00', hero: isDark ? '#F2B81C' : '#B07E00', accent: '#F2B81C', accentDeep:'#B07E00', cta: isDark ? '#F2B81C' : '#B07E00', quoteAttr: isDark ? '#F2B81C' : '#B07E00', chipDarkBg:'#16161A', chipDarkFg:'#ECECE9', chipWarmBg:'#F2B81C', chipWarmFg:'#16161A', chipLightBg: isDark ? '#ECECE9' : '#FFFFFF', chipLightFg: '#16161A', chipBorder: isDark ? '#ECECE9' : '#16161A', }; ctx.fillStyle = C.bg; ctx.fillRect(0, 0, W, H); // ── Brand bar (lighter wordmark to match header .wm) ──────── // Weight 500 + bigger letter-spacing reads as "official" rather // than "heavy display" — matches the header brand voice. // Sits 60px below the SAFE_TOP zone so it's clear of any IG // header overlay even on devices with chunky status bars. const brandY = SAFE_TOP + 60; const wolfSize = 96; const brandGap = 22; const brandText = 'BAD WOLF'; const brandFont = '500 64px "Space Grotesk", system-ui, sans-serif'; ctx.font = brandFont; ctx.textBaseline = 'middle'; // Apply letterSpacing if browser supports it (Canvas 2D spec). try { ctx.letterSpacing = '8px'; } catch {} const textW = ctx.measureText(brandText).width; const totalW = wolfSize + brandGap + textW; const startX = (W - totalW) / 2; try { const wolf = await svgToImage(isDark ? WOLF_MINI_SVG : WOLF_MINI_SVG_INK); ctx.drawImage(wolf, startX, brandY - wolfSize / 2, wolfSize, wolfSize); } catch {} ctx.fillStyle = C.fg; ctx.textAlign = 'left'; ctx.fillText(brandText, startX + wolfSize + brandGap, brandY); try { ctx.letterSpacing = '0px'; } catch {} ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; const kind = payload.kind || 'milestone'; const moods = Array.isArray(payload.moods) ? payload.moods : []; const note = (payload.note || '').trim(); const wolfName = payload.wolfName || 'Wolf'; // ── Anchored layout regions (1080×1920, IG-safe) ───────────── // Brand bar: y ≈ SAFE_TOP+60 (~340) // Hero band: y = 440..1000 (vertical centre of safe area) // Reflect: y = 1020..1380 (FEELING chips + quote) // QR + CTA: y = 1420..1620 (anchored above SAFE_BOT) // Everything sits between SAFE_TOP (280) and H-SAFE_BOT (1640) // so IG Stories UI never covers content. const HERO_TOP = 440, HERO_BOT = 1000; const REFL_TOP = 1020, REFL_BOT = 1380; // ── HERO BLOCK (centred within HERO band) ────────────────── if (kind === 'checkin') { const cyEyebrow = HERO_TOP + 50; const cyHero = HERO_TOP + 200; const cySub = HERO_TOP + 290; ctx.fillStyle = C.eyebrow; ctx.font = '700 28px "JetBrains Mono", monospace'; try { ctx.letterSpacing = '6px'; } catch {} ctx.fillText('CHECK-IN', W / 2, cyEyebrow); try { ctx.letterSpacing = '0px'; } catch {} // Substance hero — auto-shrink to fit within the safe canvas // width (940px = 1080 minus 70px gutter each side) so long // names like "PROCESSED FOOD" don't bleed past the IG-safe // region. ctx.fillStyle = C.hero; const heroStr = String(payload.substance || '').toUpperCase(); let heroSize = 140; const maxHeroW = W - 140; while (heroSize > 70) { ctx.font = `500 ${heroSize}px "Space Grotesk", system-ui, sans-serif`; if (ctx.measureText(heroStr).width <= maxHeroW) break; heroSize -= 6; } ctx.fillText(heroStr, W / 2, cyHero); ctx.fillStyle = C.fg; ctx.font = '500 38px "Space Grotesk", system-ui, sans-serif'; ctx.fillText('Today’s check-in', W / 2, cySub); } else { // Milestone: big number + tag baseline-aligned, "Without {S}" below const bigFont = '500 300px "Space Grotesk", system-ui, sans-serif'; const tagFont = '600 68px "Space Grotesk", system-ui, sans-serif'; const bigStr = String(payload.big); const tagStr = payload.tagLabel; ctx.font = bigFont; const bigW = ctx.measureText(bigStr).width; ctx.font = tagFont; const tagW = ctx.measureText(tagStr).width; const lockupGap = 28; const lockupW = bigW + lockupGap + tagW; const lockupBaseY = HERO_TOP + 220; const lockupX = (W - lockupW) / 2; ctx.textAlign = 'left'; ctx.font = bigFont; ctx.fillStyle = C.hero; ctx.fillText(bigStr, lockupX, lockupBaseY); ctx.font = tagFont; ctx.fillStyle = C.fg; ctx.fillText(tagStr, lockupX + bigW + lockupGap, lockupBaseY); // "Without {Substance}" statement — auto-shrink if the // substance name pushes the line past the safe canvas width. ctx.textAlign = 'center'; ctx.fillStyle = C.fg; const withoutStr = 'Without ' + payload.substance; let woSize = 48; const maxWoW = W - 140; while (woSize > 28) { ctx.font = `500 ${woSize}px "Space Grotesk", system-ui, sans-serif`; if (ctx.measureText(withoutStr).width <= maxWoW) break; woSize -= 4; } ctx.fillText(withoutStr, W / 2, lockupBaseY + 100); } // ── REFLECTION BLOCK: FEELING chips + quote (centred in band) ─ // First measure how tall everything will be, then centre it // within the REFL band so the layout breathes evenly. const chipPad = 24; const chipGapX = 14; const chipGapY = 16; const chipH = 60; const chipFont = '700 28px "Space Grotesk", system-ui, sans-serif'; let chipRows = []; if (moods.length) { ctx.font = chipFont; const items = moods.map((m) => { const label = (MOOD_LABELS[m] || m).toUpperCase(); return { label, w: Math.ceil(ctx.measureText(label).width) + chipPad * 2, tone: moodTone(m) }; }); let row = [], rowW = 0, maxRowW = W - 120; for (const it of items) { const add = (row.length ? chipGapX : 0) + it.w; if (rowW + add > maxRowW && row.length) { chipRows.push({ items: row, w: rowW }); row = []; rowW = 0; } row.push(it); rowW += (row.length > 1 ? chipGapX : 0) + it.w; } if (row.length) chipRows.push({ items: row, w: rowW }); } const chipsBlockH = chipRows.length ? chipRows.length * chipH + (chipRows.length - 1) * chipGapY : 0; // Pre-measure quote height by wrapping mentally let quoteLines = 0; if (note) { ctx.font = 'italic 500 38px "Space Grotesk", system-ui, sans-serif'; const words = ('“' + note + '”').split(' '); let line = ''; for (let i = 0; i < words.length; i++) { const test = line + words[i] + ' '; if (ctx.measureText(test).width > W - 200 && line) { quoteLines++; line = words[i] + ' '; } else { line = test; } } if (line.trim()) quoteLines++; } const quoteH = quoteLines ? quoteLines * 50 + 44 : 0; // text + attribution const gapBetweenChipsAndQuote = (chipRows.length && note) ? 36 : 0; const reflBlockH = chipsBlockH + gapBetweenChipsAndQuote + quoteH; const reflStartY = REFL_TOP + Math.max(0, (REFL_BOT - REFL_TOP - reflBlockH) / 2); let y = reflStartY; // Draw FEELING chips if (chipRows.length) { for (const r of chipRows) { let x = (W - r.w) / 2; for (let i = 0; i < r.items.length; i++) { const it = r.items[i]; const bg = it.tone === 'dark' ? C.chipDarkBg : it.tone === 'warm' ? C.chipWarmBg : C.chipLightBg; const fg = it.tone === 'dark' ? C.chipDarkFg : it.tone === 'warm' ? C.chipWarmFg : C.chipLightFg; const border = it.tone === 'dark' ? C.chipDarkFg : C.chipBorder; const r1 = chipH / 2; ctx.beginPath(); ctx.moveTo(x + r1, y); ctx.arcTo(x + it.w, y, x + it.w, y + chipH, r1); ctx.arcTo(x + it.w, y + chipH, x, y + chipH, r1); ctx.arcTo(x, y + chipH, x, y, r1); ctx.arcTo(x, y, x + it.w, y, r1); ctx.closePath(); ctx.fillStyle = bg; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = border; ctx.stroke(); ctx.fillStyle = fg; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = chipFont; ctx.fillText(it.label, x + it.w / 2, y + chipH / 2); x += it.w + chipGapX; } y += chipH + chipGapY; } y = y - chipGapY + gapBetweenChipsAndQuote; ctx.textBaseline = 'alphabetic'; } // Draw quote if (note) { ctx.fillStyle = C.fg; ctx.font = 'italic 500 38px "Space Grotesk", system-ui, sans-serif'; y = wrapCanvasText(ctx, '“' + note + '”', W / 2, y + 40, W - 200, 50); ctx.fillStyle = C.quoteAttr; ctx.font = '700 22px "JetBrains Mono", monospace'; try { ctx.letterSpacing = '4px'; } catch {} ctx.fillText('— ' + wolfName.toUpperCase(), W / 2, y + 36); try { ctx.letterSpacing = '0px'; } catch {} } // ── QR + JOIN THE PACK (anchored above SAFE_BOT zone) ─────── // Stays clear of IG's bottom UI (caption / music sticker / // See more / action buttons) — SAFE_BOT reserves 280px. const qrSize = 260; const qrX = (W - qrSize) / 2; const ctaY = H - SAFE_BOT - 40; const qrY = ctaY - 70 - qrSize; try { const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=480x480&margin=0&format=png&data=' + encodeURIComponent(shareUrl()); const qrImg = await loadImageEl(qrUrl, true); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(qrX - 14, qrY - 14, qrSize + 28, qrSize + 28); ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize); } catch (e) { ctx.fillStyle = isDark ? '#3A3A3F' : '#D6D3C9'; ctx.fillRect(qrX, qrY, qrSize, qrSize); ctx.fillStyle = C.fgMuted; ctx.font = '700 16px "JetBrains Mono", monospace'; ctx.fillText('SCAN', W / 2, qrY + qrSize / 2); } ctx.fillStyle = C.cta; ctx.font = '700 32px "Space Grotesk", system-ui, sans-serif'; try { ctx.letterSpacing = '6px'; } catch {} ctx.fillText('JOIN THE PACK', W / 2, ctaY); try { ctx.letterSpacing = '0px'; } catch {} return canvas; } // Ink variant of the wolf mark for the white-card (good habit) // share — same paths, just stroked in ink instead of paper so // it reads on a light background. const WOLF_MINI_SVG_INK = WOLF_MINI_SVG .replace(/stroke="#ECECE9"/g, 'stroke="#16161A"') .replace(/fill="#F2B81C"/g, 'fill="#F2B81C"'); async function canvasToBlobSafe(canvas) { return new Promise((res) => { try { canvas.toBlob((b) => res(b || null), 'image/png'); } catch { res(null); } }); } // ── Milestone celebration: single growing sheet ────────────────── // Sections render top-down in one scrim: header + habit card + // Next (always) → How do you feel? (after Next) → live sharing // draft (after Next). Scrim is non-dismissible by accident: // only Close (or successful share) closes. const shareMileScrim = $('#share-mile-scrim'); const shareMileGo = $('#share-mile-go'); const shareMileGoLabel = $('#share-mile-go-label'); const shareMileDone = $('#share-mile-done'); const mileFeelText = $('#mile-feel-text'); const mileControl = $('#mile-control'); const mileControlValue = $('#mile-control-value'); const mileCardHost = $('#mile-card-host'); // Mood chip groups now live in three separate .mood-group blocks // (data-tone="dark" | "warm" | "light"); query them all together. const mileMoodChipEls = () => shareMileScrim.querySelectorAll('.mood-chip'); let pendingShareBlob = null; let pendingShareName = 'badwolf-milestone.png'; let pendingShareText = ''; let pendingMilePayload = null; let pendingMileKey = null; let pendingMileMoods = []; // Mood order in the spectrum (dark → warm → light). Used to sort // the moods array regardless of click order so the saved data + // the rendered "I'm feeling X, Y, Z" sentence reads consistently. const MOOD_ORDER = [ 'defeated','crumbling','tested','shaky','hungry', 'holding','steady','focused', 'calm','proud','lighter','stronger','unstoppable', ]; const MOOD_LABELS = { defeated:'defeated', crumbling:'crumbling', tested:'tested', shaky:'shaky', hungry:'hungry', holding:'holding', steady:'steady', focused:'focused', calm:'calm', proud:'proud', lighter:'lighter', stronger:'stronger', unstoppable:'unstoppable', }; function orderedMoods(arr) { const set = new Set(arr); return MOOD_ORDER.filter((m) => set.has(m)); } // ── Build the milestone-variant habit card ─────────────────────── // Mirrors renderCardHtml's primary card structure (the card the // user already knows from the dash) and adds an amber "Achieve" // sash in the top-right corner. Picked mood chips append a // full-width FEELING row at the bottom; a personal note appends // a separate quote row beneath that with wolf-name attribution. // Both extra rows use the same grid-column: 1/-1 + 2px keyline // pattern as .event-quits-row so the card grows additively. function renderMilestoneHabitCardHtml(payload, timer, moods, note, wolfName) { if (!timer) return ''; const iconKey = substanceIcon(timer.substance); const startMs = timer.quit_at; const dm = fmtDayMonth(startMs); const moodsArr = Array.isArray(moods) ? moods : []; const noteText = (note || '').trim(); const feelingRow = moodsArr.length ? `
Feeling ${moodsArr.map((m) => `${escapeHtml(MOOD_LABELS[m] || m)}`).join('')}
` : ''; const quoteRow = noteText ? `

“${escapeHtml(noteText)}”

— ${escapeHtml(wolfName || 'Wolf')}
` : ''; return `
${escapeHtml(dm.day)} ${escapeHtml(dm.month)}
${escapeHtml(timer.substance)}
${payload.big} ${escapeHtml(payload.tagLabel)}
00s.00
${feelingRow} ${quoteRow}
`; } // Re-render the milestone card in place with the current mood + // note state. Cheap — innerHTML swap on a small host. function repaintMilestoneCard() { if (!mileCardHost || !pendingMilePayload) return; const timer = timers.find((t) => t.substance === pendingMilePayload.substance); mileCardHost.innerHTML = renderMilestoneHabitCardHtml( pendingMilePayload, timer, pendingMileMoods, mileFeelText && mileFeelText.value, prefs && prefs.wolfName, ); } async function openShareMilestone(payload) { pendingMilePayload = payload; pendingMileKey = `${substanceCategory(payload.substance)}:${milestoneMsForPayload(payload)}`; pendingMileMoods = []; pendingShareBlob = null; pendingShareName = `badwolf-${payload.big}-${payload.tagLabel}`.replace(/[^a-z0-9-]+/gi, '-').toLowerCase() + '.png'; pendingShareText = `${payload.big} ${payload.tagLabel.toLowerCase()} in — quit on purpose with Bad Wolf.`; // Reset mood chips + note + control from any previous open. if (mileFeelText) mileFeelText.value = ''; if (mileControl) mileControl.value = '5'; if (mileControlValue) mileControlValue.textContent = '5'; mileMoodChipEls().forEach((c) => c.setAttribute('aria-pressed', 'false')); // Paint the share-after-saving checkbox from pref. const milePrefEl = document.getElementById('mile-share-pref'); if (milePrefEl) milePrefEl.checked = shareAfterSavingDefault(); // Paint the milestone card (empty FEELING / quote until user // starts picking moods). repaintMilestoneCard(); if (shareMileGoLabel) shareMileGoLabel.textContent = 'Share'; if (shareMileGo) shareMileGo.disabled = false; // Pre-build the share canvas in the background so it's ready // (or close to it) by the time the user hits Share. canvasBuildPromise = (async () => { try { const canvas = await buildShareCanvas(payload); let blob = null; try { blob = await canvasToBlobSafe(canvas); } catch { blob = null; } pendingShareBlob = blob; return canvas; } catch (e) { dbg('[milestone] canvas build failed: ' + (e && e.message || e)); return null; } })(); // Scroll the sheet back to the top in case it was scrolled // mid-flow last time. try { shareMileScrim.querySelector('.sheet').scrollTo({ top: 0, behavior: 'instant' in shareMileScrim ? 'instant' : 'auto' }); } catch {} shareMileScrim.setAttribute('data-open', ''); track('milestone_celebration_opened', { tag: payload.tagLabel }); } function closeShareMilestone() { shareMileScrim.removeAttribute('data-open'); if (mileCardHost) mileCardHost.innerHTML = ''; pendingShareBlob = null; pendingMilePayload = null; pendingMileKey = null; pendingMileMoods = []; canvasBuildPromise = null; } let canvasBuildPromise = null; // Map a payload back to its milestone ms (so we can key reflections). // Walk MILESTONES looking for the matching {big, tag}. function milestoneMsForPayload(p) { for (const m of MILESTONES) { if (m.big === p.big && m.tag === p.tagLabel) return m.ms; } return 0; } // Persist the user's moods + control + note for this milestone. // Multi-select so `moods` is an array of mood ids in spectrum // order (dark → light). `control` is 0-10 (or null when the // slider was untouched, matching the check-in convention so the // journey chart can treat both data streams identically). function saveMilestoneReflection(key, moods, note, control) { if (!key) return; if (!prefs.milestoneReflections) prefs.milestoneReflections = {}; const arr = Array.isArray(moods) ? moods.filter(Boolean) : []; prefs.milestoneReflections[key] = { ts: Date.now(), moods: arr, control: (typeof control === 'number' && !Number.isNaN(control)) ? control : null, note: (note || '').trim().slice(0, 200) || null, }; try { save(); } catch {} } // ── Mood chips (multi-select, toggle) + live card repaint ─────── shareMileScrim.addEventListener('click', (e) => { const chip = e.target.closest('.mood-chip'); if (!chip) return; e.stopPropagation(); const m = chip.dataset.mood; const idx = pendingMileMoods.indexOf(m); if (idx >= 0) { pendingMileMoods.splice(idx, 1); chip.setAttribute('aria-pressed', 'false'); } else { pendingMileMoods.push(m); chip.setAttribute('aria-pressed', 'true'); } // Sort to spectrum order so the FEELING row reads consistently. pendingMileMoods = orderedMoods(pendingMileMoods); repaintMilestoneCard(); }); // Personal note — live update on each keystroke. if (mileFeelText) { mileFeelText.addEventListener('input', () => { repaintMilestoneCard(); }); } // Control slider — live value paint. Persisted on save below. if (mileControl) { mileControl.addEventListener('input', () => { if (mileControlValue) mileControlValue.textContent = mileControl.value; }); } // ── Share or Close (Section 3) ──────────────────────────────────── // Close persists the reflection (since there's no separate Next // button on Feel anymore). Share does the same persist + a DOM // snapshot of the draft section via lazy-loaded html2canvas. function persistPendingReflection() { if (!pendingMileKey) return; const ordered = orderedMoods(pendingMileMoods); const note = (mileFeelText && mileFeelText.value) || ''; const ctlRaw = mileControl ? Number(mileControl.value) : 5; // Match the check-in convention: persist control only when the // slider was actually moved off the default 5; otherwise null // so downstream consumers can distinguish "no data" from "5/10". const control = (ctlRaw !== 5) ? ctlRaw : null; const touched = ordered.length > 0 || note.trim() || control !== null; if (!touched) return; // nothing to save saveMilestoneReflection(pendingMileKey, ordered, note, control); track('milestone_feeling_captured', { moods_count: ordered.length, moods: ordered.join(',') || 'none', has_note: !!note.trim(), control, }); } // Dev-mode Save check-in: persist → if "Share after saving" is // ticked, build the 1080×1350 share canvas (with the user's // reflection layered in) and open the preview scrim so the user // can review before posting. Cancel from preview is fine — the // reflection is already persisted. shareMileDone.addEventListener('click', async () => { persistPendingReflection(); const wantsShare = v6UIOn() && shareAfterSavingDefault(); track('milestone_celebration_closed', { with_share: wantsShare }); if (wantsShare && pendingMilePayload) { // Capture state before close resets it. const ordered = orderedMoods(pendingMileMoods); const note = (mileFeelText && mileFeelText.value || '').trim().slice(0, 200); const ctlRaw = mileControl ? Number(mileControl.value) : 5; const control = (ctlRaw !== 5) ? ctlRaw : null; const wolfName = (prefs && prefs.wolfName) || 'Wolf'; const payload = { kind: 'milestone', habitType: 'bad', // milestones only fire on Quit timers big: pendingMilePayload.big, tagLabel: pendingMilePayload.tagLabel, substance: pendingMilePayload.substance, moods: ordered, note, control, wolfName, }; const text = (ordered.length ? `I'm feeling ${ordered.map((m) => MOOD_LABELS[m] || m).join(', ')}. ` : '') + `${pendingMilePayload.big} ${String(pendingMilePayload.tagLabel).toLowerCase()} — quit on purpose with Bad Wolf.`; const name = `badwolf-${pendingMilePayload.big}-${pendingMilePayload.tagLabel}`.replace(/[^a-z0-9-]+/gi, '-').toLowerCase() + '.png'; closeShareMilestone(); // Build canvas off the main thread of the scrim animation. try { const canvas = await buildShareCanvas(payload); const blob = await canvasToBlobSafe(canvas); if (blob) openSharePreview(blob, name, text); } catch (e) { dbg('[share] preview build failed: ' + (e && e.message || e)); } } else { closeShareMilestone(); } }); // Prod-only Close button — unchanged v5 behaviour: silent // persist + close, never shares automatically (prod doesn't // have the share-after-saving checkbox). const shareMileDoneProd = $('#share-mile-done-prod'); if (shareMileDoneProd) { shareMileDoneProd.addEventListener('click', () => { persistPendingReflection(); track('milestone_celebration_closed', { with_share: false }); closeShareMilestone(); }); } // Milestone scrim's share-pref checkbox — same pref as the tap // check-in one, so toggling either persists for both surfaces. const mileSharePref = $('#mile-share-pref'); if (mileSharePref) { mileSharePref.addEventListener('change', () => { setShareAfterSavingDefault(mileSharePref.checked); }); } // Lazy-load html2canvas from CDN the first time the user hits // Share. Cached for subsequent shares within the session. let html2canvasLoader = null; function loadHtml2Canvas() { if (window.html2canvas) return Promise.resolve(window.html2canvas); if (html2canvasLoader) return html2canvasLoader; html2canvasLoader = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'; s.async = true; s.onload = () => resolve(window.html2canvas); s.onerror = () => reject(new Error('html2canvas load failed')); document.head.appendChild(s); }); return html2canvasLoader; } async function snapshotDraftToBlob() { // Snapshot the milestone habit card itself — it IS the share // asset, complete with FEELING tags + quote panel grown into it. if (!mileCardHost) return null; try { const h2c = await loadHtml2Canvas(); if (!h2c) return null; await new Promise((r) => requestAnimationFrame(() => r())); const canvas = await h2c(mileCardHost, { backgroundColor: getComputedStyle(document.documentElement) .getPropertyValue('--paper').trim() || '#ECECE9', scale: 2, useCORS: true, }); return await new Promise((res) => { try { canvas.toBlob((b) => res(b || null), 'image/png', 0.95); } catch { res(null); } }); } catch (e) { dbg('[milestone] snapshot failed: ' + (e && e.message || e)); return null; } } // Prod-only Share button is wired identically — just delegates // to the main shareMileGo handler. Click the gated DOM element // programmatically so we don't duplicate the snapshot+share flow. const shareMileGoProd = $('#share-mile-go-prod'); if (shareMileGoProd) { shareMileGoProd.addEventListener('click', () => shareMileGo.click()); } shareMileGo.addEventListener('click', async () => { persistPendingReflection(); // UI hint that snapshotting is in progress. if (shareMileGoLabel) shareMileGoLabel.textContent = 'Building…'; if (shareMileGo) shareMileGo.disabled = true; // Try DOM-to-image first (WYSIWYG with the draft). Fall back // to the pre-built canvas (QR + JOIN THE PACK) if that fails. let blob = await snapshotDraftToBlob(); if (!blob) { if (canvasBuildPromise) { try { await canvasBuildPromise; } catch {} } blob = pendingShareBlob; } if (shareMileGoLabel) shareMileGoLabel.textContent = 'Share'; if (shareMileGo) shareMileGo.disabled = false; // Compose the share text: "I'm feeling …" + tag line. const moodsArr = orderedMoods(pendingMileMoods); const moodSentence = moodsArr.length ? `I'm feeling ${moodsArr.map((m) => MOOD_LABELS[m] || m).join(', ')}. ` : ''; const shareText = moodSentence + pendingShareText; if (!blob) { if (navigator.share) { try { await navigator.share({ title: 'Bad Wolf', text: shareText, url: shareUrl() }); track('share_milestone', { method: 'text_only' }); } catch { /* user cancelled — leave scrim open */ } } return; } const file = new File([blob], pendingShareName, { type: 'image/png' }); if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: 'Bad Wolf', text: shareText, url: shareUrl(), }); track('share_milestone', { method: 'native_files' }); } catch { /* user cancelled — leave scrim open */ } } else { // Desktop / no file-share — download instead. Scrim stays open. downloadBlob(blob, pendingShareName); track('share_milestone', { method: 'download' }); } }); // Block backdrop-click dismissal — user must hit Done (or share // successfully) to close. Same job for ESC: see the global key // handler later which excludes share-mile-scrim from its sweep. shareMileScrim.addEventListener('click', (e) => { if (e.target === shareMileScrim) { // Visual nudge that the scrim is non-dismissible? For now // just consume the click silently. e.stopPropagation(); } }); function downloadBlob(blob, name) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } // ── Reward cards (user-built, in the Reward tab) ────────────────── const REWARD_PRESETS = [ // Quick wins { name: 'Date night', icon: 'heart', money: 40 }, { name: 'Restaurant dinner',icon: 'heart', money: 60 }, { name: 'Massage', icon: 'heart', money: 70 }, { name: 'Concert tickets', icon: 'zap', money: 80 }, { name: 'Festival ticket', icon: 'zap', money: 150 }, { name: 'Tattoo', icon: 'zap', money: 120 }, { name: 'Spa day', icon: 'heart', money: 100 }, { name: 'Weekend away', icon: 'mountain', money: 200 }, { name: 'Holiday upgrade', icon: 'mountain', money: 300 }, // Gear { name: 'Running shoes', icon: 'footprints', money: 80 }, { name: 'Gym shorts', icon: 'shirt', money: 25 }, { name: 'Climbing harness', icon: 'mountain', money: 60 }, { name: 'Bouldering shoes', icon: 'footprints', money: 90 }, { name: 'New headphones', icon: 'zap', money: 150 }, { name: 'New watch', icon: 'zap', money: 200 }, { name: 'Camera', icon: 'zap', money: 400 }, { name: 'New bike', icon: 'zap', money: 500 }, // Splurges { name: 'Track day', icon: 'zap', money: 150 }, { name: 'Book splurge', icon: 'gift', money: 50 }, { name: 'Vinyl haul', icon: 'gift', money: 60 }, { name: '£50 on Amazon', icon: 'gift', money: 50 }, { name: '£100 on Amazon', icon: 'gift', money: 100 }, { name: '£250 on Amazon', icon: 'gift', money: 250 }, ]; const rewardScrim = $('#reward-scrim'); const rewardTitle = $('#reward-title'); const rewardPresetsHost = $('#reward-presets'); const rewardNameInput = $('#reward-name-input'); const rewardMoneyInput = $('#reward-money-input'); const rewardCancel = $('#reward-cancel'); const rewardConfirm = $('#reward-confirm'); const rewardDelete = $('#reward-delete'); const openAddRewardBtn = $('#open-addreward'); let editingRewardId = null; let pickedRewardIcon = 'gift'; let pickedRewardGoals = new Set(); // A reward is "reached" once every goal linked to it has landed. function rewardGoals(rw) { return (rw.goals || []) .map((id) => events.find((e) => e.id === id)) .filter((e) => e && !e.deletedAt); } function rewardReached(rw) { const gs = rewardGoals(rw); if (gs.length === 0) return false; const now = Date.now(); return gs.every((g) => g.when <= now); } function rewardProgress(rw) { const gs = rewardGoals(rw); if (gs.length === 0) return 0; const now = Date.now(); const ps = gs.map((g) => { const span = g.when - (g.addedAt || g.when); if (span <= 0) return g.when <= now ? 1 : 0; return Math.max(0, Math.min(1, (now - (g.addedAt || g.when)) / span)); }); return Math.min(...ps); } function renderRewards() { const host = document.getElementById('rewards-host'); if (!host) return; const live = liveRewards(); if (live.length === 0) { host.innerHTML = '
' + '' + '

No rewards yet

' + '

Set something worth holding out for, then link it to a goal.

' + '
'; return; } const now = Date.now(); // Reward cards use the Commit (.event-card) template with the // .reward (amber) variant. When linked to a goal, the hero is // the days-until-goal countdown that mirrors Commit. Without // a goal, the hero falls back to the £ money amount. host.innerHTML = live.map((rw) => { const gs = rewardGoals(rw); const soonest = gs.length ? gs.slice().sort((a, b) => a.when - b.when)[0] : null; const moneyVal = (typeof rw.money === 'number' && rw.money > 0) ? rw.money : null; // Date column — linked goal's target date if any, otherwise // when the reward was added. const dateMs = soonest ? soonest.when : (rw.addedAt || now); const dm = fmtDayMonth(dateMs); // Hero + ticker — unified template matching renderEventsSection. let body; if (soonest && soonest.when > now) { const remaining = soonest.when - now; const seed = computeHeroBig(remaining); const sid = escapeHtml(soonest.id); body = `
${seed.val} ${escapeHtml(seed.unit)}
${fmtHeroTrail(remaining)}
`; } else if (soonest) { // Goal already landed. body = `
Ready
`; } else if (moneyVal != null) { // No linked goal — money is the hero. body = `
£${moneyVal.toLocaleString()}
`; } else { // No goal, no money — name only. body = ''; } // Progress track (only when there's a future goal) const track = (soonest && soonest.when > now) ? `
` : ''; // Money line (only when there's also a goal showing as hero) const moneyLine = (soonest && moneyVal != null) ? `
£${moneyVal.toLocaleString()}
` : ''; // Linked-goal chips row const goalChips = gs .map((g) => `${escapeHtml(g.name)}`) .join(''); const goalsRow = goalChips ? `
Goal${goalChips}
` : ''; // Date column rendered only if we have a meaningful date const dateBlock = (soonest || rw.addedAt) ? `
${escapeHtml(dm.day)} ${escapeHtml(dm.month)}
` : ''; return `
${iconSvg(rw.icon || 'gift')}
${dateBlock}
${escapeHtml(rw.name)}
${body} ${track} ${moneyLine}
${goalsRow}
`; }).join(''); host.querySelectorAll('.event-card[data-reward-id]').forEach((card) => { card.addEventListener('click', (e) => { if (e.target.closest('button, .tag-chip')) return; openEditReward(card.dataset.rewardId); }); }); } function claimReward(id) { const rw = rewards.find((r) => r.id === id); if (!rw || rw.claimed) return; rw.claimed = true; rw.claimedAt = Date.now(); rw.updatedAt = rw.claimedAt; save(); renderRewards(); track('reward_claimed', { had_money: rw.money != null }); } // Goal-picker chips inside the reward sheet. function renderRewardGoalChips() { const host = document.getElementById('reward-goals'); if (!host) return; const live = liveEvents(); if (live.length === 0) { host.innerHTML = 'No goals yet — add one in Feed first.'; return; } host.innerHTML = live.map((e) => `` ).join(''); host.querySelectorAll('[data-goal-id]').forEach((chip) => { chip.addEventListener('click', () => { const id = chip.dataset.goalId; if (pickedRewardGoals.has(id)) pickedRewardGoals.delete(id); else pickedRewardGoals.add(id); chip.toggleAttribute('data-selected', pickedRewardGoals.has(id)); }); }); } function renderRewardPresets() { rewardPresetsHost.innerHTML = REWARD_PRESETS.map((p) => `` ).join(''); rewardPresetsHost.querySelectorAll('[data-preset]').forEach((chip) => { chip.addEventListener('click', () => { const preset = REWARD_PRESETS.find((p) => p.name === chip.dataset.preset); if (!preset) return; rewardNameInput.value = preset.name; rewardMoneyInput.value = String(preset.money); rewardNameInput.style.borderColor = ''; pickedRewardIcon = preset.icon; syncRewardPresetSelection(); }); }); } // Highlight the preset chip whose name matches the current input. function syncRewardPresetSelection() { const name = rewardNameInput.value.trim().toLowerCase(); rewardPresetsHost.querySelectorAll('[data-preset]').forEach((chip) => { chip.toggleAttribute('data-selected', chip.dataset.preset.toLowerCase() === name); }); } function openAddReward() { editingRewardId = null; pickedRewardIcon = 'gift'; pickedRewardGoals = new Set(); pageView('/reward/add', 'Add Reward'); rewardTitle.textContent = 'New Reward'; rewardNameInput.value = ''; rewardNameInput.style.borderColor = ''; rewardMoneyInput.value = ''; renderRewardPresets(); syncRewardPresetSelection(); renderRewardGoalChips(); rewardDelete.style.display = 'none'; rewardScrim.setAttribute('data-open', ''); if (onboardingActive) try { track('reward_create_opened', { step_number: 6, is_pwa: isStandalone() }); } catch {} setTimeout(() => rewardNameInput.focus(), 60); } function openEditReward(id) { const rw = rewards.find((r) => r.id === id); if (!rw) return; editingRewardId = id; pickedRewardIcon = rw.icon || 'gift'; pickedRewardGoals = new Set(rw.goals || []); rewardTitle.textContent = 'Edit Reward'; rewardNameInput.value = rw.name || ''; rewardNameInput.style.borderColor = ''; rewardMoneyInput.value = (typeof rw.money === 'number' && rw.money > 0) ? String(rw.money) : ''; renderRewardPresets(); syncRewardPresetSelection(); renderRewardGoalChips(); rewardDelete.style.display = ''; rewardScrim.setAttribute('data-open', ''); } function closeRewardSheet() { rewardScrim.removeAttribute('data-open'); } function commitReward() { const name = rewardNameInput.value.trim(); if (!name) { rewardNameInput.style.borderColor = '#B33A3A'; rewardNameInput.focus(); return; } rewardNameInput.style.borderColor = ''; const moneyRaw = parseInt(rewardMoneyInput.value, 10); const money = Number.isFinite(moneyRaw) && moneyRaw > 0 ? moneyRaw : null; const now = Date.now(); const goals = Array.from(pickedRewardGoals); if (editingRewardId) { const rw = rewards.find((r) => r.id === editingRewardId); if (rw) { rw.name = name; rw.icon = pickedRewardIcon; rw.money = money; rw.goals = goals; rw.updatedAt = now; } track('reward_edited'); } else { rewards.push({ id: makeId('rw'), name, icon: pickedRewardIcon, money, goals, claimed: false, origin: 'user', addedAt: now, updatedAt: now, }); track('reward_created', { has_money: money != null, goals_linked: goals.length, step_number: onboardingActive ? 7 : null, is_pwa: isStandalone() }); } const wasFirstReward = liveRewards().length === 1 && !editingRewardId; const wasInOnboarding = onboardingActive; save(); renderRewards(); try { renderSetupChecklist(); } catch {} closeRewardSheet(); // Linear onboarding — first reward completes the 1-2-3 flow. // Trigger the celebration takeover and let the user lock it in // by signing in (or skip and land on the Dash). if (wasInOnboarding && wasFirstReward) { setTimeout(() => { setTab('dash'); openCelebration(); }, 250); } } function deleteRewardById(id) { const rw = rewards.find((r) => r.id === id); if (!rw) return; rw.deletedAt = Date.now(); rw.updatedAt = rw.deletedAt; } openAddRewardBtn.addEventListener('click', openAddReward); // bulletproofTap handles the iOS keyboard-dismiss-cancels-click quirk. // During onboarding the user can opt out of the reward — we still // proceed to the Join-the-Pack celebrate scrim either way per // the locked spec (reward isn't gating). bulletproofTap(rewardCancel, () => { // "Skip for now" during onboarding — reward is optional. Route to // the celebration scrim (same as a successful reward commit) so the // user lands on the dash with nav highlighted and the join prompt shown. const closedDuringOnboarding = onboardingActive && onboardingV2On() && liveRewards().length === 0; closeRewardSheet(); if (closedDuringOnboarding) { try { track('reward_skipped', { step_number: 7, is_pwa: isStandalone() }); } catch {} setTimeout(() => { try { setTab('dash', 'system'); openCelebration(); } catch {} }, 250); } }); bulletproofTap(rewardConfirm, commitReward); rewardDelete.addEventListener('click', () => { if (!editingRewardId) return; deleteRewardById(editingRewardId); save(); renderRewards(); closeRewardSheet(); track('reward_deleted'); }); rewardScrim.addEventListener('click', (e) => { if (e.target !== rewardScrim) return; // Backdrop tap during onboarding = same as "Skip for now" — // route to dash + celebration, same as a successful reward commit. const closedDuringOnboarding = onboardingActive && onboardingV2On() && liveRewards().length === 0; closeRewardSheet(); if (closedDuringOnboarding) { try { track('reward_skipped', { step_number: 7, is_pwa: isStandalone() }); } catch {} setTimeout(() => { try { setTab('dash', 'system'); openCelebration(); } catch {} }, 250); } }); rewardNameInput.addEventListener('input', () => { rewardNameInput.style.borderColor = ''; const m = REWARD_PRESETS.find((p) => p.name.toLowerCase() === rewardNameInput.value.trim().toLowerCase()); if (m) pickedRewardIcon = m.icon; syncRewardPresetSelection(); }); // ── Tap-type cards (Daily Taps) ──────────────────────────────────── // A tap card replaces the timer/countdown hero with a Mon-Sun // 7-day grid. Each cell records "I resisted / I did it" on that // date — positive-only logging. Stored as item.taps['YYYY-MM-DD']. const DOW_LETTERS = ['M','T','W','T','F','S','S']; function dateKey(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return y + '-' + m + '-' + day; } function startOfDayLocal(d) { const out = new Date(d); out.setHours(0, 0, 0, 0); return out; } // Monday of the week containing d. Sun = 0, Mon = 1 in JS, so // we shift so Monday becomes the start. function mondayOf(d) { const out = startOfDayLocal(d); const dow = out.getDay(); // 0..6 (Sun..Sat) const offset = (dow + 6) % 7; // Mon=0, Tue=1, … Sun=6 out.setDate(out.getDate() - offset); return out; } function weekDays(monday) { return [0,1,2,3,4,5,6].map((i) => { const d = new Date(monday); d.setDate(d.getDate() + i); return d; }); } // Stats for this week's tap row. Walks the Mon-Sun strip, finds // runs of consecutive done days ("clumps"), and identifies the // LIVE clump — the one whose end date is today or yesterday, i.e. // the streak you could still extend. Past clumps merge visually // but don't animate; the live clump animates at a speed scaled // by its size. function tapWeekStats(item) { const monday = mondayOf(new Date()); const today = startOfDayLocal(new Date()); const todayMs = today.getTime(); const yesterMs = todayMs - 24 * 60 * 60 * 1000; const taps = item.taps || {}; const days = weekDays(monday).map((d) => ({ date: d, key: dateKey(d), isToday: d.getTime() === todayMs, isFuture: d.getTime() > todayMs, isDone: !!taps[dateKey(d)], })); // Find clumps of consecutive isDone days. const clumps = []; let run = null; for (let i = 0; i < days.length; i++) { const day = days[i]; if (day.isDone) { if (!run) run = { startIdx: i, endIdx: i, endDayMs: day.date.getTime() }; else { run.endIdx = i; run.endDayMs = day.date.getTime(); } } else if (run) { clumps.push(run); run = null; } } if (run) clumps.push(run); // Decorate clumps with size + live flag. Live = end day is // today or yesterday — a streak still in motion. Solo days // (size 1) are tracked but never marked live for animation — // animation needs the blob, that was the whole feel. clumps.forEach((c) => { c.size = c.endIdx - c.startIdx + 1; c.live = c.size >= 2 && (c.endDayMs === todayMs || c.endDayMs === yesterMs); }); const done = days.filter((d) => d.isDone).length; // Per-day clump position so the cell template can square the // right corners (start), both corners (mid), left corners (end), // or leave alone (solo). Solo never animates. const dayMeta = days.map((day, i) => { if (!day.isDone) return { ...day, clumpPos: null, clumpSize: 0, clumpLive: false }; const clump = clumps.find((c) => i >= c.startIdx && i <= c.endIdx); let pos = 'solo'; if (clump.size > 1) { if (i === clump.startIdx) pos = 'start'; else if (i === clump.endIdx) pos = 'end'; else pos = 'mid'; } return { ...day, clumpPos: pos, clumpSize: clump.size, clumpLive: clump.live }; }); return { days: dayMeta, clumps, done, total: 7 }; } function tapWeekHtml(item, kind /* 'timer' | 'event' */) { const stats = tapWeekStats(item); const cells = stats.days.map((day, i) => { const attrs = [ `data-tap-date="${day.key}"`, `data-tap-kind="${kind}"`, `data-tap-id="${escapeHtml(item.id)}"`, day.isFuture ? 'data-future disabled' : '', day.isToday ? 'data-today' : '', day.isDone ? 'data-done' : '', day.clumpPos ? `data-clump-pos="${day.clumpPos}"` : '', day.clumpSize ? `data-clump-size="${day.clumpSize}"` : '', day.clumpLive ? 'data-clump-live' : '', ].filter(Boolean).join(' '); const style = day.clumpLive ? ` style="--blob-size:${day.clumpSize}"` : ''; return ` `; }).join(''); return `
${cells}
`; } // Small counter chip for the card title row — "3/7" etc. Returns // empty string when the item isn't a Daily-Taps card. function tapWeekCounterHtml(item) { const stats = tapWeekStats(item); return `${stats.done}/7`; } // Toggle a tap, persist, re-render the affected surface. Click // delegation lives in a single document-level listener at boot. // On toggle OFF→ON, opens the lightweight check-in scrim so the // user can layer mood + control + a note onto the tap. The tap // itself is persisted unconditionally — the check-in is purely // additive reflection data. function toggleTap(kind, id, key) { const arr = kind === 'timer' ? timers : events; const item = arr.find((x) => x.id === id); if (!item) return; if (!item.taps) item.taps = {}; const wasOn = !!item.taps[key]; if (wasOn) delete item.taps[key]; else item.taps[key] = true; item.updatedAt = Date.now(); save(); if (kind === 'timer') renderCardStack(); else renderEventsSection(); // Only fire the check-in on the ON transition, never on undo. // Gated by v6UIOn() — same launch switch as the rest of the v6 // stream. v6 launched, so this is true for every user; flipping // v6UIOn() back to !!window.devMode reverts to the §1 // dev-only behaviour without further code changes. if (!wasOn && v6UIOn()) { try { openTapCheckin({ kind, targetId: id, date: key, substance: item.substance || item.name || null, }); } catch (e) { dbg('[checkin] open failed: ' + (e && e.message || e)); } } } document.addEventListener('click', (e) => { const cell = e.target.closest('.tap-cell'); if (!cell || cell.hasAttribute('data-future')) return; e.stopPropagation(); const kind = cell.dataset.tapKind; const id = cell.dataset.tapId; const date = cell.dataset.tapDate; toggleTap(kind, id, date); // Surface the daily-tap interaction as its own event so funnels // can track "did the user tap today / how often" — separately // from quit_created / goal_created. const wasDone = cell.hasAttribute('data-done'); const today = new Date().toISOString().slice(0, 10); track('tap_check_in', { surface: kind, // 'timer' = quit, 'event' = commit target_id: id, date, is_today: date === today, new_state: wasDone ? 'off' : 'on', }); }); function renderCardHtml(timer) { const stateClass = timer.paused_at ? 'paused' : 'running'; const iconKey = substanceIcon(timer.substance); // Quit cards re-use the Commit card template (.event-card) with // the .primary modifier for the ink (dark) variant. The body // branches on type: 'timer' renders the count-row + ticker + // progress; 'taps' renders the Mon–Sun grid of resist taps. // The date column always shows when the card was started. const isTaps = timer.type === 'taps'; const startMs = isTaps ? (timer.addedAt || timer.quit_at || Date.now()) : timer.quit_at; const dm = fmtDayMonth(startMs); // Body: just the live ticker + journey track. The milestone // strip and dev row used to live in here too, but they read // much better as full-bleed rows below the card body (same // treatment as the commit-tags row). const body = isTaps ? tapWeekHtml(timer, 'timer') : `
0 Days
00s.00
`; // Full-width MILESTONES list below the body. Combines major // milestone hits + tap check-ins for this timer's substance, // latest first. The whole block is tappable — opens the full // milestone list scrim. Empty card falls back to the legacy // "Next: 1 Day · Xd to go" look-ahead so brand-new timers // still have a clear next target on the row. // Two layouts gated on v6UIOn(): // - v6 OFF (prod): legacy Recently/Next strip for count-up // cards (existing v5 UX); nothing for Daily-Taps cards. // - v6 ON (dev): full MILESTONES list for count-up cards, // CHECK-INS list for Daily-Taps cards. // Single render-time branch so the wrong markup never enters // the DOM — devModeSet() re-renders cards when the toggle // flips so the swap happens immediately. let milestoneRow; if (!v6UIOn()) { milestoneRow = isTaps ? '' : ` `; } else if (isTaps) { milestoneRow = `
Check-ins
`; } else { milestoneRow = ``; } // Milestone trigger only makes sense for quit timers — taps cards // don't have milestones, so hide the dev row for them entirely. const devRow = isTaps ? '' : ` `; return `
${escapeHtml(dm.day)} ${escapeHtml(dm.month)}
${escapeHtml(timer.substance)} ${isTaps ? tapWeekCounterHtml(timer) : ''}
${body}
${milestoneRow} ${devRow}
`; } function wireCard(timerId) { const card = cardsHost.querySelector(`[data-id="${timerId}"]`); if (!card) return; // Tap anywhere on the card (outside of an inner button/chip) to // open the card-edit sheet. Replaces the old kebab dropdown. card.addEventListener('click', (e) => { // Exclude any interactive child — buttons, anchors, chips, // selects, AND the .ev-card-dev row (which wraps a select in // a label; tapping it must NOT also open the edit sheet). if (e.target.closest('button, a, .tag-chip, select, .ev-card-dev')) return; openCardEditSheet(timerId); }); // Tap on the milestone strip → open the full milestone list for // this timer. stopPropagation so the card's own click handler // doesn't also open the edit sheet. const mileBtn = card.querySelector('[data-q-mile]'); if (mileBtn) { mileBtn.addEventListener('click', (e) => { e.stopPropagation(); openMilestoneList(timerId); }); } // Dev-only: a select that triggers any major milestone's // celebration for THIS timer (so we can preview the full flow // without waiting for time to elapse). The select itself is // hidden by CSS unless html[data-dev-mode] is set. const devPick = card.querySelector('[data-dev-mile]'); const devRow = card.querySelector('.ev-card-dev'); // Stop ALL clicks inside the dev row from bubbling to the card // (which would open the edit sheet). The