What it does
Subscribers log progress each day, and the portal answers with encouragement that fits.
Why it exists
For products that take time to show results, like skincare and supplements. A daily log keeps subscribers going through the weeks before they notice a difference.
The code
Paste it as-is, or download daily-progress-tracker.html<!--
Daily Check-In Tracker — Skio Customer Portal extension.
Paste this whole file into the portal extension's "HTML with embedded CSS
and JavaScript" code editor (Source: Code editor). It runs after the
customer portal mounts, so it renders and initializes immediately.
All configuration is a plain JS object (CONFIG, near the top of the
script below) -- no theme/schema system here, so edit those values
directly to reskin it for a different brand or product.
-->
<div class="dct-root" id="dct-skio-portal">
<div class="dct-brand-row">
<div class="dct-brand-id">
<div class="dct-brand-mark">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 21c0-6-4-8-4-13a4 4 0 0 1 8 0c0 5-4 7-4 13Z" stroke="var(--dct-accent-ink)" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M12 13c1.5-1.5 2-3 2-5" stroke="var(--dct-accent-ink)" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<div>
<div class="dct-brand-name" data-role="brand-name"></div>
<h3 class="dct-h1">Your check-in</h3>
</div>
</div>
<div class="dct-journey-pill">
<span class="dct-num" data-role="day-count">0</span>
<span class="dct-lbl" data-role="day-label">days since your first dose</span>
</div>
</div>
<div class="dct-product-tabs" data-role="product-tabs" hidden></div>
<div class="dct-sync-row" data-role="sync" hidden></div>
<div class="dct-card dct-coach-card" data-role="coach"></div>
<div class="dct-grid">
<div class="dct-card">
<div class="dct-card-head">
<div class="dct-card-title">Today</div>
<div class="dct-card-sub" data-role="today-date"></div>
</div>
<div data-role="checkin-body"></div>
</div>
<div class="dct-card">
<div class="dct-card-head">
<div class="dct-card-title">Your progress</div>
<div class="dct-card-sub">Mood on days taken vs. skipped</div>
</div>
<div class="dct-chart-wrap" data-role="chart-wrap"></div>
<div class="dct-legend" data-role="legend" hidden>
<div class="dct-legend-item"><span class="dct-legend-swatch dct-legend-swatch--taken"></span>Taken</div>
<div class="dct-legend-item"><span class="dct-legend-swatch dct-legend-swatch--skipped"></span>Skipped</div>
<div class="dct-legend-item"><span class="dct-legend-swatch dct-legend-swatch--milestone"></span><span data-role="milestone-legend-label"></span></div>
</div>
<details class="dct-table-toggle" data-role="table-toggle" hidden>
<summary>View check-in history as a table</summary>
<div class="dct-table-scroll" data-role="table-wrap"></div>
</details>
</div>
<div class="dct-lower-grid">
<div class="dct-card">
<div class="dct-card-head"><div class="dct-card-title">Consistency</div></div>
<div class="dct-stat-list" data-role="stats"></div>
</div>
<div class="dct-card">
<div class="dct-card-head">
<div class="dct-card-title">Last 4 weeks</div>
<div class="dct-card-sub">shade = how you felt</div>
</div>
<div class="dct-cal-weekdays"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span></div>
<div class="dct-cal-grid" data-role="cal"></div>
</div>
</div>
</div>
<div class="dct-reset-row" data-role="reset-row" hidden><button class="dct-reset-link" type="button" data-role="reset">Reset example data</button></div>
</div>
<style>
.dct-root{
--dct-accent:#3f6b4b;
--dct-gold:#b9762a;
--dct-bg:#f3f4ee;
--dct-surface:#ffffff;
--dct-surface-alt:#eaebe1;
--dct-ink:#20261c;
--dct-ink-2:#5b6152;
--dct-ink-3:#8b917f;
--dct-border:#dbddcf;
--dct-border-strong:#c7cab8;
--dct-accent-ink: color-mix(in srgb, var(--dct-accent) 75%, black);
--dct-accent-soft: color-mix(in srgb, var(--dct-accent) 16%, white);
--dct-gold-soft: color-mix(in srgb, var(--dct-gold) 18%, white);
--dct-shadow: 0 1px 2px rgba(32,38,28,0.06), 0 8px 24px -12px rgba(32,38,28,0.18);
--dct-font-display: Georgia, 'Iowan Old Style', 'Palatino Linotype', serif;
--dct-font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--dct-font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Roboto Mono', monospace;
display:block !important;
max-width:920px !important;
margin:0 auto !important;
padding:24px 20px 32px !important;
color:var(--dct-ink);
font-family:var(--dct-font-body);
-webkit-font-smoothing:antialiased;
}
@media (prefers-color-scheme: dark){
.dct-root{
--dct-bg:#14180f; --dct-surface:#1c2116; --dct-surface-alt:#242a1c;
--dct-ink:#ecefe3; --dct-ink-2:#b6bca7; --dct-ink-3:#7d8471;
--dct-border:#323924; --dct-border-strong:#404830;
--dct-accent-ink: color-mix(in srgb, var(--dct-accent) 55%, white);
--dct-accent-soft: color-mix(in srgb, var(--dct-accent) 22%, black);
--dct-gold-soft: color-mix(in srgb, var(--dct-gold) 22%, black);
}
}
.dct-root *{ box-sizing:border-box; }
/* Fallback for browsers without color-mix() support (a few years old at
this point, but this keeps text/backgrounds legible instead of
transparent/invalid there). Overrides the color-mix() tokens above --
harmless where color-mix() IS supported, since @supports not(...) is
false there and this block is skipped entirely. */
@supports not (color: color-mix(in srgb, red 50%, white)) {
.dct-root{
--dct-accent-ink:#24402e;
--dct-accent-soft:#dee8dd;
--dct-gold-soft:#f3e3cb;
}
@media (prefers-color-scheme: dark){
.dct-root{
--dct-accent-ink:#bcdcc2;
--dct-accent-soft:#26331f;
--dct-gold-soft:#3a2e1a;
}
}
}
.dct-storage-warning{ background:var(--dct-gold-soft) !important; color:var(--dct-gold); border:1px solid var(--dct-gold) !important; border-radius:10px !important; padding:11px 14px !important; font-size:12.5px; line-height:1.5; margin-bottom:16px !important; }
.dct-hidden{ display:none !important; }
.dct-product-picker{ margin-bottom:18px !important; }
.dct-product-options{ display:flex; gap:10px; flex-wrap:wrap; }
.dct-product-tabs{ margin-bottom:18px !important; }
.dct-product-tabs-row{ display:flex; gap:8px; flex-wrap:wrap; }
.dct-tab-btn{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; margin:0 !important; padding:8px 16px !important; border-radius:999px !important; border:1.5px solid var(--dct-border) !important; background:var(--dct-surface) !important; color:var(--dct-ink-2) !important; font-family:var(--dct-font-body) !important; font-size:13px !important; font-weight:500 !important; cursor:pointer !important; }
.dct-tab-btn:hover{ border-color:var(--dct-border-strong) !important; }
.dct-tab-btn.active{ background:var(--dct-ink) !important; border-color:var(--dct-ink) !important; color:var(--dct-bg) !important; }
.dct-product-select{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; width:100%; max-width:360px; margin:0 !important; padding:11px 14px !important; border-radius:10px !important; border:1.5px solid var(--dct-border) !important; background:var(--dct-surface) !important; color:var(--dct-ink) !important; font-family:var(--dct-font-body) !important; font-size:14px !important; cursor:pointer !important; }
.dct-sync-row{ margin-bottom:18px !important; }
.dct-sync-prompt{ display:flex; flex-direction:column; gap:10px; background:var(--dct-surface-alt) !important; border:1px solid var(--dct-border) !important; border-radius:10px !important; padding:12px 14px !important; }
.dct-sync-prompt p{ margin:0 !important; font-size:12.5px; color:var(--dct-ink-2); }
.dct-sync-fields{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.dct-sync-input{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; flex:1 1 200px; margin:0 !important; padding:9px 12px !important; border-radius:8px !important; border:1.5px solid var(--dct-border) !important; background:var(--dct-surface) !important; color:var(--dct-ink) !important; font-family:var(--dct-font-body) !important; font-size:13px !important; }
.dct-sync-input.dct-sync-pin{ flex:0 1 100px; font-family:var(--dct-font-mono) !important; letter-spacing:0.15em; text-align:center; }
.dct-sync-btn{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; margin:0 !important; padding:9px 16px !important; border-radius:8px !important; border:none !important; background:var(--dct-ink) !important; color:var(--dct-bg) !important; font-family:var(--dct-font-body) !important; font-size:13px !important; font-weight:500 !important; cursor:pointer !important; white-space:nowrap; }
.dct-sync-btn:disabled{ opacity:0.5 !important; cursor:not-allowed !important; }
.dct-sync-error{ display:block; color:var(--dct-gold); margin-top:4px !important; }
.dct-sync-status{ display:flex; align-items:center; gap:8px; font-size:12px; color:var(--dct-ink-3); }
.dct-sync-status .dct-sync-dot{ width:6px !important; height:6px !important; border-radius:50% !important; background:var(--dct-accent) !important; flex:none; display:inline-block !important; }
.dct-sync-status.error .dct-sync-dot{ background:var(--dct-gold) !important; }
.dct-sync-status.error{ color:var(--dct-gold); }
.dct-sync-unlink{ appearance:none !important; -webkit-appearance:none !important; font-family:var(--dct-font-body) !important; font-size:12px !important; color:var(--dct-ink-3) !important; background:none !important; border:none !important; text-decoration:underline !important; cursor:pointer !important; margin:0 !important; padding:0 !important; }
.dct-brand-row{ display:flex; align-items:flex-end; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:18px !important; }
.dct-brand-id{ display:flex; align-items:center; gap:14px; }
.dct-brand-mark{ width:42px; height:42px; border-radius:11px !important; background:var(--dct-accent-soft) !important; display:flex; align-items:center; justify-content:center; flex:none; }
.dct-brand-mark svg{ width:22px; height:22px; }
.dct-brand-name{ font-family:var(--dct-font-display); font-size:14px; font-weight:600; letter-spacing:0.01em; color:var(--dct-ink-2); }
.dct-h1{ font-family:var(--dct-font-display); font-weight:600; font-size:clamp(20px, 3vw, 26px); line-height:1.15; margin:2px 0 0 !important; }
.dct-journey-pill{ display:flex; align-items:baseline; gap:7px; background:var(--dct-surface-alt) !important; border:1px solid var(--dct-border) !important; border-radius:999px !important; padding:9px 16px !important; white-space:nowrap; }
.dct-journey-pill .dct-num{ font-family:var(--dct-font-mono); font-size:18px; font-weight:500; color:var(--dct-accent-ink); font-variant-numeric:tabular-nums; }
.dct-journey-pill .dct-lbl{ font-size:12px; color:var(--dct-ink-3); }
.dct-grid{ display:grid; grid-template-columns:1fr; gap:18px; }
.dct-coach-card{ background:var(--dct-accent-soft) !important; border-color:var(--dct-border-strong) !important; margin-bottom:18px !important; }
.dct-phase-pill{ display:inline-flex; align-items:center; gap:7px; background:var(--dct-surface) !important; border:1px solid var(--dct-border-strong) !important; padding:5px 12px !important; border-radius:999px !important; font-size:11px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; color:var(--dct-accent-ink); white-space:nowrap; }
.dct-phase-pill .dct-dot{ width:6px; height:6px; border-radius:50% !important; background:var(--dct-accent) !important; flex:none; }
.dct-phase-track{ display:flex; gap:10px; margin:4px 0 18px !important; }
.dct-phase-seg{ display:flex; flex-direction:column; gap:7px; flex-grow:var(--dct-grow, 1) !important; }
.dct-phase-bar{ height:5px; border-radius:999px !important; background:rgba(255,255,255,0.5) !important; overflow:hidden; }
.dct-phase-fill{ height:100%; background:var(--dct-ink-3) !important; border-radius:999px !important; width:var(--dct-fill, 0%) !important; }
.dct-phase-seg.done .dct-phase-fill, .dct-phase-seg.active .dct-phase-fill{ background:var(--dct-accent) !important; }
.dct-phase-label{ font-size:11.5px; color:var(--dct-ink-2); display:flex; flex-direction:column; gap:1px; }
.dct-phase-label span{ font-size:10px; font-family:var(--dct-font-mono); color:var(--dct-ink-3); }
.dct-phase-seg.active .dct-phase-label{ color:var(--dct-accent-ink); font-weight:600; }
.dct-coach-msg{ display:flex; gap:13px; align-items:flex-start; }
.dct-coach-icon{ width:34px; height:34px; border-radius:9px !important; background:var(--dct-surface) !important; display:flex; align-items:center; justify-content:center; flex:none; }
.dct-coach-icon svg{ width:17px; height:17px; }
.dct-coach-headline{ font-family:var(--dct-font-display); font-size:16px; font-weight:600; margin:0 0 4px !important; }
.dct-coach-sub{ font-size:13.5px; color:var(--dct-ink-2); line-height:1.55; margin:0 !important; max-width:60ch; }
.dct-coach-note{ font-size:12.5px; color:var(--dct-accent-ink); margin:9px 0 0 !important; padding-top:9px !important; border-top:1px dashed var(--dct-border-strong) !important; max-width:60ch; }
.dct-card{ background:var(--dct-surface) !important; border:1px solid var(--dct-border) !important; border-radius:12px !important; padding:20px 22px !important; }
.dct-card-head{ display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:16px !important; flex-wrap:wrap; }
.dct-card-title{ font-family:var(--dct-font-display); font-size:17px; font-weight:600; }
.dct-card-sub{ font-size:12.5px; color:var(--dct-ink-3); }
.dct-status-row{ display:flex; gap:10px; margin-bottom:18px !important; flex-wrap:wrap; }
/* !important throughout this button block: the portal's own CSS resets
bare <button> elements (border/background/padding/display all cleared),
with enough specificity/order to beat a plain class selector. These
widgets need to look like buttons regardless of the host's reset. */
.dct-status-btn{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; flex:1 1 160px !important; display:flex !important; align-items:center !important; justify-content:center !important; gap:8px !important; margin:0 !important; padding:13px 14px !important; border-radius:10px !important; border:1.5px solid var(--dct-border) !important; background:var(--dct-surface) !important; color:var(--dct-ink-2) !important; font-family:var(--dct-font-body) !important; font-size:14.5px !important; font-weight:500 !important; line-height:normal !important; cursor:pointer !important; }
.dct-status-btn svg{ width:17px !important; height:17px !important; flex:none !important; }
.dct-status-btn:hover{ border-color:var(--dct-border-strong) !important; }
.dct-status-btn[aria-pressed="true"].take{ background:var(--dct-accent-soft) !important; border-color:var(--dct-accent) !important; color:var(--dct-accent-ink) !important; }
.dct-status-btn[aria-pressed="true"].skip{ background:var(--dct-gold-soft) !important; border-color:var(--dct-gold) !important; color:var(--dct-gold) !important; }
.dct-field-label{ font-size:12.5px; color:var(--dct-ink-3); text-transform:uppercase; letter-spacing:0.06em; margin:0 0 10px !important; }
.dct-mood-row{ display:flex; gap:8px; margin-bottom:20px !important; }
.dct-mood-btn{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; flex:1 !important; display:flex !important; flex-direction:column !important; align-items:center !important; gap:6px !important; margin:0 !important; padding:10px 4px 9px !important; border-radius:10px !important; border:1.5px solid var(--dct-border) !important; background:var(--dct-surface) !important; cursor:pointer !important; }
.dct-mood-btn:hover{ border-color:var(--dct-border-strong) !important; }
.dct-mood-btn[aria-pressed="true"]{ border-color:var(--dct-accent) !important; background:var(--dct-accent-soft) !important; }
.dct-mood-btn svg circle.face{ stroke:var(--dct-ink-3); }
.dct-mood-btn svg path.mouth{ stroke:var(--dct-ink-3); }
.dct-mood-btn svg circle.eye{ fill:var(--dct-ink-3); }
.dct-mood-btn[aria-pressed="true"] svg circle.face{ stroke:var(--dct-accent-ink); }
.dct-mood-btn[aria-pressed="true"] svg path.mouth{ stroke:var(--dct-accent-ink); }
.dct-mood-btn[aria-pressed="true"] svg circle.eye{ fill:var(--dct-accent-ink); }
.dct-mood-btn span{ font-size:11.5px !important; color:var(--dct-ink-3) !important; font-family:var(--dct-font-body) !important; }
.dct-mood-btn[aria-pressed="true"] span{ color:var(--dct-accent-ink) !important; font-weight:500 !important; }
.dct-tag-row{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:20px !important; }
.dct-tag-chip{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; display:inline-block !important; margin:0 !important; padding:7px 13px !important; border-radius:999px !important; border:1.5px solid var(--dct-border) !important; background:var(--dct-surface) !important; color:var(--dct-ink-2) !important; font-family:var(--dct-font-body) !important; font-size:13px !important; line-height:normal !important; cursor:pointer !important; }
.dct-tag-chip[aria-pressed="true"]{ background:var(--dct-ink) !important; border-color:var(--dct-ink) !important; color:var(--dct-bg) !important; }
.dct-save-row{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
.dct-save-btn{ appearance:none !important; -webkit-appearance:none !important; box-sizing:border-box !important; display:inline-block !important; margin:0 !important; padding:11px 20px !important; border-radius:9px !important; border:none !important; background:var(--dct-ink) !important; color:var(--dct-bg) !important; font-family:var(--dct-font-body) !important; font-size:14px !important; font-weight:500 !important; line-height:normal !important; cursor:pointer !important; }
.dct-save-btn:disabled{ opacity:0.35 !important; cursor:not-allowed !important; }
.dct-save-hint{ font-size:12.5px; color:var(--dct-ink-3); }
.dct-logged-state{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
.dct-logged-badge{ display:flex; align-items:center; gap:9px; padding:11px 16px !important; border-radius:10px !important; background:var(--dct-accent-soft) !important; color:var(--dct-accent-ink); font-size:14px; }
.dct-logged-badge svg{ width:16px; height:16px; flex:none; }
.dct-edit-link{ appearance:none !important; -webkit-appearance:none !important; font-family:var(--dct-font-body) !important; font-size:13px !important; color:var(--dct-ink-3) !important; background:none !important; border:none !important; text-decoration:underline !important; cursor:pointer !important; margin:0 !important; padding:0 !important; }
.dct-logged-meta{ font-size:13px; color:var(--dct-ink-3); }
.dct-empty{ font-size:13.5px; color:var(--dct-ink-3); padding:20px 0 !important; text-align:center; }
.dct-chart-wrap{ position:relative; }
.dct-chart{ display:block; width:100%; height:auto; aspect-ratio:720/300; overflow:visible; }
.dct-axis-label{ font-family:var(--dct-font-body); font-size:10.5px; fill:var(--dct-ink-3); }
.dct-grid-line{ stroke:var(--dct-border); stroke-width:1; }
.dct-date-label{ font-family:var(--dct-font-mono); font-size:9.5px; fill:var(--dct-ink-3); }
.dct-mood-line{ fill:none; stroke:var(--dct-accent); stroke-width:2.25; stroke-linecap:round; stroke-linejoin:round; }
.dct-dot-taken{ fill:var(--dct-accent); stroke:var(--dct-surface); stroke-width:1.5; }
.dct-dot-skip{ fill:var(--dct-surface); stroke:var(--dct-ink-3); stroke-width:1.75; stroke-dasharray:2 2; }
.dct-dot-hit{ fill:transparent; cursor:pointer; }
.dct-milestone-line{ stroke:var(--dct-gold); stroke-width:1.25; stroke-dasharray:3 3; }
.dct-milestone-label{ font-family:var(--dct-font-body); font-size:10.5px; fill:var(--dct-gold); font-weight:500; }
.dct-crosshair{ stroke:var(--dct-ink-3); stroke-width:1; stroke-dasharray:2 2; opacity:0; }
.dct-tooltip{ position:absolute; pointer-events:none; background:var(--dct-ink) !important; color:var(--dct-bg); border-radius:8px !important; padding:9px 12px !important; font-size:12.5px; line-height:1.5; box-shadow:var(--dct-shadow) !important; opacity:0; transform:translate(-50%,-100%) translateY(-10px); white-space:nowrap; z-index:5; }
.dct-tooltip b{ font-family:var(--dct-font-mono); font-weight:500; }
.dct-tooltip .tt-tags{ color:var(--dct-ink-3); }
.dct-legend{ display:flex; gap:18px; flex-wrap:wrap; margin-top:12px !important; padding-top:14px !important; border-top:1px solid var(--dct-border) !important; }
.dct-legend-item{ display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--dct-ink-2); }
.dct-legend-swatch{ width:10px !important; height:10px !important; border-radius:50% !important; flex:none; display:inline-block !important; }
.dct-legend-swatch--taken{ background:var(--dct-accent) !important; border:none !important; }
.dct-legend-swatch--skipped{ background:var(--dct-surface) !important; border:1.75px dashed var(--dct-ink-3) !important; }
.dct-legend-swatch--milestone{ background:none !important; border:none !important; border-top:1.5px dashed var(--dct-gold) !important; border-radius:0 !important; width:12px !important; height:0 !important; }
.dct-table-toggle{ margin-top:14px !important; }
.dct-table-toggle summary{ cursor:pointer; font-size:12.5px; color:var(--dct-ink-3); user-select:none; }
.dct-table-scroll{ overflow-x:auto; margin-top:12px !important; }
.dct-history{ border-collapse:collapse; width:100%; font-size:12.5px; }
.dct-history th, .dct-history td{ text-align:left; padding:7px 10px !important; border-bottom:1px solid var(--dct-border) !important; white-space:nowrap; }
.dct-history th{ color:var(--dct-ink-3); font-weight:500; text-transform:uppercase; font-size:10.5px; letter-spacing:0.04em; }
.dct-history td.mono{ font-family:var(--dct-font-mono); }
.dct-lower-grid{ display:grid; grid-template-columns:1fr; gap:18px; }
@media (min-width:560px){ .dct-lower-grid{ grid-template-columns:1fr 1.35fr; } }
.dct-stat-list{ display:flex; flex-direction:column; gap:12px; }
.dct-stat-tile{ display:flex; align-items:baseline; justify-content:space-between; padding:13px 4px !important; border-bottom:1px solid var(--dct-border) !important; }
.dct-stat-tile:last-child{ border-bottom:none !important; }
.dct-stat-tile .dct-stat-name{ font-size:13.5px; color:var(--dct-ink-2); }
.dct-stat-tile .dct-stat-value{ font-family:var(--dct-font-mono); font-size:19px; font-variant-numeric:tabular-nums; }
.dct-stat-tile .dct-stat-value small{ font-family:var(--dct-font-body); font-size:12px; color:var(--dct-ink-3); font-weight:400; margin-left:3px !important; }
.dct-cal-weekdays{ display:grid; grid-template-columns:repeat(7,1fr); gap:6px; margin-bottom:6px !important; }
.dct-cal-weekdays span{ text-align:center; font-size:10px; color:var(--dct-ink-3); text-transform:uppercase; letter-spacing:0.04em; }
.dct-cal-grid{ display:grid; grid-template-columns:repeat(7,1fr); gap:6px; }
.dct-cal-cell{ aspect-ratio:1; border-radius:7px !important; border:1px solid var(--dct-border) !important; display:flex; align-items:center; justify-content:center; font-family:var(--dct-font-mono); font-size:10.5px; color:var(--dct-ink-3); position:relative; }
.dct-cal-cell.empty{ border-style:dashed !important; opacity:0.5; }
.dct-cal-cell.skip{ background:var(--dct-gold-soft) !important; border-color:var(--dct-gold) !important; color:var(--dct-gold); border-style:dashed !important; }
.dct-cal-cell.taken{ background:var(--dct-accent) !important; color:#fff; border-color:transparent !important; opacity:var(--dct-op, 1); }
.dct-reset-row{ margin-top:14px !important; text-align:right; }
.dct-reset-link{ appearance:none !important; -webkit-appearance:none !important; font-family:var(--dct-font-body) !important; font-size:12px !important; color:var(--dct-ink-3) !important; background:none !important; border:none !important; text-decoration:underline !important; cursor:pointer !important; margin:0 !important; padding:0 !important; }
@media (prefers-reduced-motion:reduce){ .dct-root *{ transition:none !important; } }
</style>
<script>
(function(){
try{
/* ---- Edit these to reskin/retime the tracker. No theme system here, so this is the only config surface. ---- */
var CONFIG = {
brand: 'Verdant',
product: 'Daily Calm',
colors: {
accent: '#3f6b4b', // chart line, progress bar, "taken" states
gold: '#b9762a' // "skipped" states, the chart's milestone marker
},
tags: ['Sleep', 'Energy', 'Stress', 'Focus'],
// Exactly the phases you want, in order, each with a name and the day
// it ends on (endDay must strictly increase). The default 3 map to the
// 3 coach-message states below (building / early shifts / full
// results) -- add a 4th+ phase and it'll show correctly in the progress
// bar, but the coach message logic only distinguishes the first three.
phases: [
{ name: 'Building', endDay: 14 },
{ name: 'Early Shifts', endDay: 30 },
{ name: 'Full Results', endDay: 42 }
],
phasePillTemplate: 'Phase {num} · {name}',
milestoneLabel: 'Community avg. first shift',
buildingNote: 'Magnesium and ashwagandha take time to build up in your system.',
// Every coach message is a template string. Anything in {curly braces}
// gets filled in at render time -- available placeholders are listed
// above each message. Rewrite these freely; if you typo a placeholder
// name it's left visible as literal {text} rather than silently
// vanishing, so mistakes are easy to spot.
messages: {
// {adherence} {product}
lowAdherence: {
headline: 'Consistency is the lever right now',
body: 'You\'ve taken it {adherence}% of the last 30 days. {product} works best taken daily — try pairing it with something you already do each morning.'
},
// {days} {buildingDays} {buildingNote}
building: {
headline: 'Day {days} of {buildingDays} — building phase',
body: '{buildingNote} Most customers don\'t feel a shift yet at this stage — that\'s expected. Keep the streak going.'
},
// {days} {delta}
earlyShiftsImproving: {
headline: 'Day {days} — already trending up',
body: 'Your mood is up {delta} points since your first week of logging. You\'re right on schedule — many customers start noticing subtle changes in this window.'
},
// {days} {buildingDays} {shiftDaysMinus1} {shiftDays}
earlyShiftsFlat: {
headline: 'Day {days} of the Early Shifts window',
body: 'Some customers start noticing subtle changes between day {buildingDays} and {shiftDaysMinus1}. Not there yet? That\'s okay — give it through day {shiftDays} before judging results.'
},
// {days} {delta} {product}
resultsGood: {
headline: 'Day {days} — right on schedule',
body: 'Your mood is up {delta} points since your first week. This is exactly when most {product} customers say they feel it.'
},
// {days} {shiftDays} {delta}
resultsSlight: {
headline: 'Day {days} — trending the right way',
body: 'You\'re past the {shiftDays}-day mark and up {delta} points since you started. Full effects can keep building — keep at it.'
},
// {days} {shiftDays}
resultsFlat: {
headline: 'Day {days} — past the {shiftDays}-day window',
body: 'This is typically when customers feel the full effect, but your mood hasn\'t shifted much yet. Try logging specific notes below, or message our team — everyone responds a little differently.'
},
// {product}
getStarted: {
headline: 'Let\'s get started',
body: 'Log today\'s check-in below to begin tracking your journey with {product}.'
},
// {tag} {firstCount} {recentCount} {windowSize}
tagProgress: 'You flagged “{tag}” on {firstCount} of your first {windowSize} logged days, vs {recentCount} of your last {windowSize} — sounds like real progress there.'
},
// SHOW_DEMO_DATA: leave this false for real customers. Only flip it to
// true temporarily on your own account to preview the widget with
// sample history before you publish -- then set it back to false.
// A real customer should never see fabricated check-ins.
showDemoData: false,
storageKeyPrefix: 'skio-dct-checkin-v1',
// CLOUD SYNC (optional, off by default): lets a customer's check-ins
// follow them across devices by having them enter their email once.
// Requires a free Supabase project -- see README.md for the exact SQL
// to run there. Flip `enabled` to true only after you've created the
// project and filled in the two values below.
//
// Security note: there's no real customer authentication available in
// this context, so this can't cryptographically verify who's asking.
// The database is locked down to two stored procedures that each
// require an exact email match (no "list everyone" is possible through
// them), which prevents a full data dump -- but anyone who already
// knows or guesses a specific customer's email could still read/write
// that one record. Reasonable for a low-sensitivity mood tracker; not
// appropriate for anything more sensitive without real backend auth.
cloudSync: {
enabled: true,
supabaseUrl: 'https://syhqizrxiiozzkdwxqng.supabase.co',
supabaseAnonKey: 'sb_publishable_7cvbakalq-AumpMZ1yJPsQ_6EfFz_nP'
},
// TARGETING (optional, off by default): only show this widget to
// customers who have certain tags and/or a specific product in their
// subscription -- pulled from Skio's own customer data, not this
// widget's own storage. See README.md ("Targeting by tag or product")
// for the full setup -- it requires deploying the included
// get-customer-context.ts as a Supabase Edge Function, since looking
// this up needs a merchant-level Skio API token that must never be
// exposed in this file.
targeting: {
enabled: false,
requiredTags: [], // e.g. ['VIP', 'Sleep Program'] -- leave empty to not filter by tag
requiredProducts: [], // e.g. ['Daily Calm'] -- must match a subscription line's product title exactly
// 'any' = customer needs at least one of the listed tags/products;
// 'all' = customer needs every one listed. Tags and products are
// each checked independently, then both must pass if both are set.
matchMode: 'any',
// If true, once a matching product is found, CONFIG.product is
// swapped to that product's real name automatically -- lets one
// pasted copy of this widget dynamically show the right product name
// for whichever subscription the customer actually has, instead of
// a single hardcoded CONFIG.product for every customer.
useProductFromSub: false,
// If we can't determine the customer's email (so we can't look up
// their tags/products at all), should the widget show anyway? true
// = fail open (show it, better than hiding it from everyone if
// detection fails); false = fail closed (hide it, stricter targeting).
showIfUnknown: true,
// Leave blank to auto-derive from CONFIG.cloudSync.supabaseUrl
// (assumes you deployed get-customer-context.ts to that same
// Supabase project, which is the normal setup). Only set this
// explicitly if your Edge Function lives somewhere else.
// Set explicitly here because the deployed function is named
// get-customer-context-action (not the default get-customer-context).
contextEndpoint: 'https://syhqizrxiiozzkdwxqng.supabase.co/functions/v1/get-customer-context-action'
},
// PRODUCT PICKER (optional, off by default): for merchants selling more
// than one trackable product, this lets the CUSTOMER pick which of
// their own real subscribed products they want to track -- instead of
// one hardcoded CONFIG.product for everyone. Uses the same
// get-customer-context Edge Function as targeting above (see README.md
// "Product picker"), but works independently -- you can turn this on
// without ever using CONFIG.targeting's tag/product gating.
//
// If the customer only has one product, it's applied automatically and
// no prompt is shown. A prompt only appears when there's a real choice
// to make (2+ products on their subscription).
productPicker: {
enabled: false,
// Remembers their choice in this browser so they're not asked again
// on every visit. They will be asked again once per new device/browser
// (this doesn't sync the choice across devices, only across visits).
rememberChoice: true
},
// MULTI-PRODUCT (optional, off by default): for a customer subscribed
// to more than one trackable product, this shows a tab per product
// instead of asking them to pick just one -- each tab has its own
// fully separate check-in history, streaks, and coach state, so
// switching tabs never resets or overwrites anything. Uses the same
// get-customer-context Edge Function as targeting/productPicker above.
//
// If you turn this on, CONFIG.productPicker is ignored (no need for a
// one-time picker when everything's just a tab away) -- use one or the
// other, not both. Requires one extra SQL function beyond the base
// cloud-sync setup if CONFIG.cloudSync is also enabled -- see
// README.md ("Tracking multiple products").
multiProduct: {
enabled: true
}
};
/* ------------------------------------------------------------------------------------------------------- */
// Fills a "{placeholder}" template. Missing/unrecognized keys are left as
// literal "{text}" (not silently dropped), so a typo in CONFIG.messages
// shows up on the page instead of hiding as blank text.
function fill(tpl, vars){
return tpl.replace(/\{(\w+)\}/g, function(match, key){
return (vars[key] !== undefined && vars[key] !== null) ? vars[key] : match;
});
}
// Guard against a misconfigured phases array (too few entries, missing
// names, out-of-order or non-increasing endDay) breaking the phase math
// instead of just falling back to the default 3.
function validPhases(phases){
if (!Array.isArray(phases) || phases.length < 3) return false;
for (var i = 0; i < phases.length; i++){
var p = phases[i];
if (!p || typeof p.endDay !== 'number' || !p.name) return false;
if (p.endDay <= (i === 0 ? 0 : phases[i-1].endDay)) return false;
}
return true;
}
if (!validPhases(CONFIG.phases)){
if (window.console) console.error('Daily Check-In Tracker: CONFIG.phases needs at least 3 entries, each with a name and a strictly increasing endDay -- falling back to the default Building/Early Shifts/Full Results at 14/30/42.');
CONFIG.phases = [
{ name: 'Building', endDay: 14 },
{ name: 'Early Shifts', endDay: 30 },
{ name: 'Full Results', endDay: 42 }
];
}
// Aliased for the rest of the script -- the coach-message logic only ever
// reasons about these three thresholds, even if more phases exist purely
// for the progress bar's display.
CONFIG.buildingDays = CONFIG.phases[0].endDay;
CONFIG.shiftDays = CONFIG.phases[1].endDay;
CONFIG.resultsDays = CONFIG.phases[CONFIG.phases.length - 1].endDay;
// Reads the logged-in customer's email straight out of localStorage --
// confirmed via live inspection of a real Skio customer portal session
// (2026-09-15) that Skio's own frontend stores it here under this exact
// key, independent of any window.Shopify global (which does not exist at
// all on Skio's portal -- confirmed the same way). This is what actually
// works; the window.Shopify/meta-tag checks below are kept only as
// harmless fallbacks in case some other install exposes those instead.
function readStorefrontUserEmail(){
try{
var raw = localStorage.getItem('storefrontUserEmail');
if (!raw) return null;
try{ return JSON.parse(raw); }catch(e){ return raw.replace(/^"|"$/g, '') || null; }
}catch(e){ return null; }
}
// Per-customer storage key. Falls back to a single shared key for the
// browser only if even readStorefrontUserEmail() finds nothing (e.g. a
// logged-out visitor) -- in that case two customers on the same device
// would see each other's history, same caveat as before this was fixed.
function detectCustomerId(){
try{
var storedEmail = readStorefrontUserEmail();
if (storedEmail) return storedEmail;
if (window.Shopify && window.Shopify.customer && window.Shopify.customer.id) return String(window.Shopify.customer.id);
if (window.ShopifyAnalytics && window.ShopifyAnalytics.meta && window.ShopifyAnalytics.meta.page && window.ShopifyAnalytics.meta.page.customerId) return String(window.ShopifyAnalytics.meta.page.customerId);
var meta = document.querySelector('meta[name="customer-id"], meta[name="skio-customer-id"]');
if (meta && meta.getAttribute('content')) return meta.getAttribute('content');
}catch(e){}
return null;
}
var customerId = detectCustomerId();
CONFIG.storageKey = CONFIG.storageKeyPrefix + '-' + (customerId || 'shared-device');
// One-time migration: before the storefrontUserEmail fix above, customer
// detection never actually worked on Skio's real portal, so every
// customer's local check-in history has been living under the shared
// 'shared-device' key regardless of who they are. Now that a real
// per-customer id is available, copy that shared history over ONCE if
// this customer doesn't already have their own key -- avoids anyone's
// existing local streak silently vanishing the first time they load the
// updated widget. Cloud-synced data (already keyed by email) is
// untouched by this either way.
if (customerId){
try{
var sharedKey = CONFIG.storageKeyPrefix + '-shared-device';
if (!localStorage.getItem(CONFIG.storageKey) && localStorage.getItem(sharedKey)){
localStorage.setItem(CONFIG.storageKey, localStorage.getItem(sharedKey));
}
}catch(e){}
}
// Only used when CONFIG.multiProduct.enabled -- gives each product its
// own storage key so switching tabs can never mix up or overwrite
// another product's history. Deliberately separate from CONFIG.storageKey
// above (which single-product merchants keep using untouched), so turning
// multiProduct on/off never changes where existing single-product data
// lives.
function slugifyProduct(name){
return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'product';
}
function productStorageKey(product){
return CONFIG.storageKeyPrefix + '-' + (customerId || 'shared-device') + '--' + slugifyProduct(product);
}
// Same readStorefrontUserEmail() win as detectCustomerId() above -- used
// for targeting/productPicker/multiProduct lookups (see CONFIG.targeting),
// so a wrong/missing value here just means those fall back to
// CONFIG.targeting.showIfUnknown rather than breaking anything else.
function detectCustomerEmail(){
try{
var storedEmail = readStorefrontUserEmail();
if (storedEmail) return storedEmail;
if (window.Shopify && window.Shopify.customer && window.Shopify.customer.email) return String(window.Shopify.customer.email);
if (window.ShopifyAnalytics && window.ShopifyAnalytics.meta && window.ShopifyAnalytics.meta.page && window.ShopifyAnalytics.meta.page.customerEmail) return String(window.ShopifyAnalytics.meta.page.customerEmail);
var meta = document.querySelector('meta[name="customer-email"], meta[name="skio-customer-email"]');
if (meta && meta.getAttribute('content')) return meta.getAttribute('content');
}catch(e){}
return null;
}
// Locate this instance's own root by position (via document.currentScript)
// rather than a fixed id, so this still works correctly if the portal ever
// re-inserts or duplicates this snippet on the same page. Falls back to the
// id lookup for the (unlikely) case currentScript isn't available.
var thisScript = document.currentScript;
var root = (thisScript && thisScript.parentNode && thisScript.parentNode.querySelector) ?
thisScript.parentNode.querySelector('.dct-root:not([data-dct-init])') :
document.getElementById('dct-skio-portal');
if (!root) root = document.getElementById('dct-skio-portal');
if (!root || root.dataset.dctInit) return;
root.dataset.dctInit = '1';
// Colors live in CONFIG (not the stylesheet) so a merchant only has to
// edit one block. These override the CSS defaults via inline style.
root.style.setProperty('--dct-accent', CONFIG.colors.accent);
root.style.setProperty('--dct-gold', CONFIG.colors.gold);
function $(sel){ return root.querySelector(sel); }
function storageAvailable(){
try{
var k = '__dct_test__';
localStorage.setItem(k, '1');
localStorage.removeItem(k);
return true;
}catch(e){ return false; }
}
var STORAGE_OK = storageAvailable();
if (!STORAGE_OK){
root.insertAdjacentHTML('afterbegin',
'<div class="dct-storage-warning">Your browser is blocking saved data here (private browsing or cookies/storage disabled) — today\'s check-in won\'t be remembered once you leave this page.</div>'
);
}
// Built generically from CONFIG.phases (any length >= 3), not hardcoded
// to exactly 3 -- extra phases render fine in the progress bar even
// though the coach-message state machine only reasons about the first 3.
var PHASES = CONFIG.phases.map(function(p, i){
var start = i === 0 ? 0 : CONFIG.phases[i-1].endDay;
var isLast = i === CONFIG.phases.length - 1;
var sub = isLast ?
('Day ' + start + '+') :
('Days ' + (i === 0 ? 1 : start) + '–' + (p.endDay - 1));
return { name: p.name, sub: sub, start: start, end: p.endDay };
});
var MOOD_LABELS = ['Rough','Meh','Okay','Good','Great'];
var MS_DAY = 86400000;
var today = new Date();
today.setHours(0,0,0,0);
function checkIcon(){ return '<svg viewBox="0 0 24 24" fill="none"><path d="M5 12.5l4.5 4.5L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; }
function xIcon(){ return '<svg viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'; }
function trendIcon(){ return '<svg viewBox="0 0 24 24" fill="none"><path d="M4 16l5.5-6 4 4L20 6" stroke="var(--dct-accent-ink)" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 6h5v5" stroke="var(--dct-accent-ink)" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>'; }
function faceSvg(level){
var cy = 10 + level*2.25;
return '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' +
'<circle class="face" cx="12" cy="12" r="9.5" stroke-width="1.4"/>' +
'<circle class="eye" cx="8.3" cy="9.6" r="1.15"/>' +
'<circle class="eye" cx="15.7" cy="9.6" r="1.15"/>' +
'<path class="mouth" d="M7.5 15 Q12 ' + cy + ' 16.5 15" stroke-width="1.5" stroke-linecap="round"/>' +
'</svg>';
}
function fmtDate(d){ return d.toLocaleDateString('en-US', { month:'short', day:'numeric' }); }
function fmtDateLong(d){ return d.toLocaleDateString('en-US', { weekday:'long', month:'short', day:'numeric' }); }
function avgMood(arr){ return arr.length ? arr.reduce(function(a,e){ return a + e.mood; }, 0) / arr.length : 0; }
function buildSeed(){
// Seeds exactly (buildingDays - 1) days of consistent-but-flat history,
// so "today" lands precisely on day `buildingDays` (day 14 by default) --
// right at the start of the Early Shifts window, before the day-30 mark
// where most customers say they feel it. Moods stay flat/rough (no
// upward trend) and every day is taken (100% adherence), so logging a
// low mood for today triggers the "not there yet, give it through day
// 30" encouragement rather than a false "already trending up" message.
var days = CONFIG.buildingDays - 1;
var moods = [2,3,2,2,3,2,3,2,2,3,2,3,2];
var startDate = new Date(today.getTime() - days*MS_DAY);
var history = [];
for (var i=0; i<days; i++){
history.push({
date: new Date(startDate.getTime() + i*MS_DAY).toISOString(),
taken: true,
mood: moods[i % moods.length],
tags: []
});
}
return history;
}
function loadFromKey(key){
if (STORAGE_OK){
try{
var raw = localStorage.getItem(key);
if (raw){
var parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.history)) return parsed;
}
}catch(e){}
}
return { history: CONFIG.showDemoData ? buildSeed() : [], todayEntry: null };
}
function load(){ return loadFromKey(CONFIG.storageKey); }
// Only used when CONFIG.multiProduct.enabled.
function loadProductState(product){ return loadFromKey(productStorageKey(product)); }
function saveLocal(){
if (!STORAGE_OK) return; // in-memory only for this page view; the banner above already told the customer why
var key = CONFIG.multiProduct.enabled ? productStorageKey(activeProduct) : CONFIG.storageKey;
try{ localStorage.setItem(key, JSON.stringify(state)); }catch(e){}
}
// ---- Cloud sync (optional) -------------------------------------------
// sessionEmail/sessionPin are the source of truth for THIS page view,
// separate from whatever's in localStorage -- if storage is blocked
// (private browsing), linking still works for the current visit, it just
// won't be remembered for next time (same caveat as everything else in
// that mode).
//
// PIN note: email alone isn't real authentication -- anyone who knows or
// guesses a customer's email could otherwise read/write their check-ins.
// The 4-digit PIN (set on first sync, required again on every new device)
// raises that from "trivial" to "deliberate effort" -- it's checked
// server-side (see README.md "Cross-device sync" for the SQL), not just
// in this file, so it can't be bypassed by calling the API directly. It
// is NOT rate-limited server-side, so a scripted brute force of all
// 10,000 combinations is technically possible; reasonable for a
// low-sensitivity mood tracker, not a substitute for real auth.
var EMAIL_STORAGE_KEY = CONFIG.storageKeyPrefix + '-linked-email';
var PIN_STORAGE_KEY = CONFIG.storageKeyPrefix + '-linked-pin';
var sessionEmail = null;
var sessionPin = null;
try{
if (STORAGE_OK){
sessionEmail = localStorage.getItem(EMAIL_STORAGE_KEY);
sessionPin = localStorage.getItem(PIN_STORAGE_KEY);
}
}catch(e){}
var syncStatus = 'idle'; // idle | syncing | synced | error
var syncErrorMessage = '';
function isValidEmail(v){ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
function isValidPin(v){ return /^[0-9]{4}$/.test(v); }
function cloudCall(fn, payload){
var cs = CONFIG.cloudSync;
return fetch(cs.supabaseUrl + '/rest/v1/rpc/' + fn, {
method: 'POST',
headers: {
'apikey': cs.supabaseAnonKey,
'Authorization': 'Bearer ' + cs.supabaseAnonKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
}).then(function(res){
if (res.ok) return res.status === 204 ? null : res.json().catch(function(){ return null; });
return res.json().catch(function(){ return null; }).then(function(errBody){
var msg = (errBody && (errBody.message || errBody.hint)) || ('cloud sync request failed (' + res.status + ')');
var err = new Error(msg);
err.isPinError = /invalid pin/i.test(msg);
throw err;
});
});
}
// link_checkin does double duty: creates the record (and sets its PIN)
// the first time an email is ever linked, or verifies the PIN and
// returns existing data on every later link -- see README.md for the SQL.
function cloudLink(email, pin){ return cloudCall('link_checkin', { p_email: email, p_pin: pin }); }
function cloudPush(email, pin, data){ return cloudCall('save_checkin', { p_email: email, p_pin: pin, p_data: data }); }
// Only used when CONFIG.multiProduct.enabled -- calls save_checkin_product
// (see README.md "Tracking multiple products" for the SQL), which merges
// just this one product's data into the customer's record server-side
// instead of overwriting the whole thing, so saving Product A can never
// wipe out Product B's history synced from another device.
function cloudPushProduct(email, pin, product, data){ return cloudCall('save_checkin_product', { p_email: email, p_pin: pin, p_product: product, p_data: data }); }
// Writes every product's data out of a server-returned { products: {...} }
// blob into this browser's own per-product localStorage, so offline/local
// reads (loadProductState) see the same data without hitting the network
// again. Only used when CONFIG.multiProduct.enabled.
function hydrateProductsFromServer(productsMap){
Object.keys(productsMap || {}).forEach(function(product){
var data = productsMap[product];
if (!data || !Array.isArray(data.history)) return;
productStates[product] = data;
if (STORAGE_OK){ try{ localStorage.setItem(productStorageKey(product), JSON.stringify(data)); }catch(e){} }
});
}
function pushToCloud(){
if (!CONFIG.cloudSync.enabled || !sessionEmail || !sessionPin) return;
syncStatus = 'syncing';
renderSync();
var pushPromise = CONFIG.multiProduct.enabled ?
cloudPushProduct(sessionEmail, sessionPin, activeProduct, state) :
cloudPush(sessionEmail, sessionPin, state);
pushPromise.then(function(){
syncStatus = 'synced';
renderSync();
}).catch(function(err){
if (window.console) console.error('Daily Check-In Tracker: cloud sync save failed:', err);
syncStatus = 'error';
syncErrorMessage = err && err.isPinError ? 'Wrong PIN -- saves paused' : 'Sync paused (offline?) -- still saving on this device';
renderSync();
});
}
function linkEmail(email, pin){
syncStatus = 'syncing';
renderSync();
cloudLink(email, pin).then(function(serverData){
// Only remember the email/PIN locally once the server has actually
// confirmed them -- a wrong PIN never gets persisted as if it worked.
sessionEmail = email;
sessionPin = pin;
try{
if (STORAGE_OK){
localStorage.setItem(EMAIL_STORAGE_KEY, email);
localStorage.setItem(PIN_STORAGE_KEY, pin);
}
}catch(e){}
if (CONFIG.multiProduct.enabled){
if (serverData && serverData.products && typeof serverData.products === 'object' && Object.keys(serverData.products).length){
// A record already existed for this email (e.g. linked from
// another device first) -- pull every product's history down,
// then refresh whichever tab is currently showing.
hydrateProductsFromServer(serverData.products);
state = productStates[activeProduct] || state;
renderAll();
} else {
// First time this email has been linked -- seed the cloud with
// just the currently active tab; other tabs get pushed up the
// first time the customer actually visits/saves them.
cloudPushProduct(email, pin, activeProduct, state).catch(function(){});
}
} else if (serverData && Array.isArray(serverData.history)){
state = serverData;
renderAll();
} else {
cloudPush(email, pin, state).catch(function(){});
}
syncStatus = 'synced';
renderSync();
}).catch(function(err){
if (window.console) console.error('Daily Check-In Tracker: cloud sync link failed:', err);
syncStatus = 'error';
syncErrorMessage = err && err.isPinError ? 'That email/PIN combination doesn\'t match -- try again' : 'Couldn\'t sync right now (offline?) -- try again in a moment';
renderSync();
});
}
function unlinkEmail(){
sessionEmail = null;
sessionPin = null;
try{
localStorage.removeItem(EMAIL_STORAGE_KEY);
localStorage.removeItem(PIN_STORAGE_KEY);
}catch(e){}
syncStatus = 'idle';
renderSync();
}
function renderSync(){
var el = $('[data-role="sync"]');
if (!el) return;
if (!CONFIG.cloudSync.enabled){ el.hidden = true; return; }
el.hidden = false;
if (!sessionEmail || !sessionPin){
var errored = syncStatus === 'error';
el.innerHTML =
'<div class="dct-sync-prompt">' +
'<p>Want this to follow you across devices? Enter your email and a 4-digit PIN -- pick a new PIN the first time, or enter the one you used before.' +
(errored ? ' <span class="dct-sync-error">' + syncErrorMessage + '</span>' : '') +
'</p>' +
'<div class="dct-sync-fields">' +
'<input class="dct-sync-input" type="email" placeholder="you@example.com" data-role="sync-email">' +
'<input class="dct-sync-input dct-sync-pin" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="4-digit PIN" data-role="sync-pin">' +
'<button class="dct-sync-btn" type="button" data-role="sync-link"' + (syncStatus === 'syncing' ? ' disabled' : '') + '>' + (syncStatus === 'syncing' ? 'Syncing…' : 'Sync') + '</button>' +
'</div>' +
'</div>';
el.querySelector('[data-role="sync-link"]').addEventListener('click', function(){
var emailInput = el.querySelector('[data-role="sync-email"]');
var pinInput = el.querySelector('[data-role="sync-pin"]');
var emailVal = emailInput.value.trim().toLowerCase();
var pinVal = pinInput.value.trim();
var ok = true;
if (!isValidEmail(emailVal)){ emailInput.style.borderColor = 'var(--dct-gold)'; ok = false; }
if (!isValidPin(pinVal)){ pinInput.style.borderColor = 'var(--dct-gold)'; ok = false; }
if (!ok) return;
linkEmail(emailVal, pinVal);
});
return;
}
var errored = syncStatus === 'error';
var label = syncStatus === 'syncing' ? 'Syncing…' :
errored ? syncErrorMessage :
'Synced as ' + sessionEmail;
el.innerHTML =
'<div class="dct-sync-status' + (errored ? ' error' : '') + '">' +
'<span class="dct-sync-dot"></span>' + label +
' · <button class="dct-sync-unlink" type="button" data-role="sync-unlink">Not you?</button>' +
'</div>';
el.querySelector('[data-role="sync-unlink"]').addEventListener('click', unlinkEmail);
}
// ------------------------------------------------------------------------
// ---- Targeting (optional) ---------------------------------------------
// Looks up a customer's real tags/subscription products via a small
// Supabase Edge Function (get-customer-context.ts) that holds the real
// Skio API token server-side -- this file never talks to Skio directly,
// since that would mean shipping a merchant-secret token to every
// customer's browser. See README.md for deploy steps.
function resolveContextEndpoint(){
if (CONFIG.targeting.contextEndpoint) return CONFIG.targeting.contextEndpoint;
if (CONFIG.cloudSync.supabaseUrl) return CONFIG.cloudSync.supabaseUrl + '/functions/v1/get-customer-context';
return null;
}
function fetchCustomerContext(email){
var endpoint = resolveContextEndpoint();
if (!endpoint) return Promise.resolve(null);
var headers = { 'Content-Type': 'application/json' };
if (CONFIG.cloudSync.supabaseAnonKey){
headers.apikey = CONFIG.cloudSync.supabaseAnonKey;
headers.Authorization = 'Bearer ' + CONFIG.cloudSync.supabaseAnonKey;
}
return fetch(endpoint, { method: 'POST', headers: headers, body: JSON.stringify({ email: email }) })
.then(function(res){
if (!res.ok) throw new Error('context lookup failed (' + res.status + ')');
return res.json();
});
}
// Tags and products are each evaluated independently against matchMode,
// then both must pass if both are configured -- so e.g. requiredTags:
// ['VIP'] + requiredProducts: ['Daily Calm'] means "VIP AND has Daily Calm",
// not "VIP OR has Daily Calm".
function matchesTargeting(customerTags, customerProducts){
var t = CONFIG.targeting;
var wantTags = t.requiredTags.length > 0;
var wantProducts = t.requiredProducts.length > 0;
if (!wantTags && !wantProducts) return true; // targeting enabled but nothing configured to require -- show it
var tagsOk = !wantTags || (t.matchMode === 'all' ?
t.requiredTags.every(function(tag){ return customerTags.indexOf(tag) > -1; }) :
t.requiredTags.some(function(tag){ return customerTags.indexOf(tag) > -1; }));
var productsOk = !wantProducts || (t.matchMode === 'all' ?
t.requiredProducts.every(function(p){ return customerProducts.indexOf(p) > -1; }) :
t.requiredProducts.some(function(p){ return customerProducts.indexOf(p) > -1; }));
return tagsOk && productsOk;
}
// ------------------------------------------------------------------------
// ---- Product picker (optional) -----------------------------------------
// Lets the customer choose which of their own real subscribed products to
// track, when they have more than one -- see CONFIG.productPicker above.
var productChoiceKey = null; // set once we know who the customer is (see init below)
function loadStoredProductChoice(){
if (!productChoiceKey || !STORAGE_OK || !CONFIG.productPicker.rememberChoice) return null;
try{ return localStorage.getItem(productChoiceKey); }catch(e){ return null; }
}
function storeProductChoice(name){
if (!productChoiceKey || !STORAGE_OK || !CONFIG.productPicker.rememberChoice) return;
try{ localStorage.setItem(productChoiceKey, name); }catch(e){}
}
// Renders a small inline card and resolves once the customer clicks one
// of their real products. Inserted before the customer ever sees the
// rest of the widget, so "Today"/the chart never show the wrong product.
function showProductPicker(products){
return new Promise(function(resolve){
root.insertAdjacentHTML('afterbegin',
'<div class="dct-card dct-product-picker" data-role="product-picker">' +
'<div class="dct-card-head"><div class="dct-card-title">Which product are you tracking?</div></div>' +
'<div class="dct-product-options" data-role="product-options">' +
products.map(function(p){ return '<button class="dct-status-btn" type="button" data-product="' + p + '">' + p + '</button>'; }).join('') +
'</div>' +
'</div>'
);
var el = root.querySelector('[data-role="product-picker"]');
el.querySelector('[data-role="product-options"]').addEventListener('click', function(ev){
var btn = ev.target.closest('[data-product]');
if (!btn) return;
el.remove();
resolve(btn.dataset.product);
});
});
}
// Resolves CONFIG.product for this customer: uses a remembered choice if
// there is one, auto-picks when there's only one real product, prompts
// when there's a genuine choice to make, and silently leaves CONFIG.product
// untouched if we have nothing to go on (unknown customer, no products
// found, or the lookup failed) -- this is personalization, not gating, so
// it always fails open to whatever CONFIG.product was already set to.
function resolveProductChoice(products){
var remembered = loadStoredProductChoice();
if (remembered && products.indexOf(remembered) > -1) return Promise.resolve(remembered);
if (products.length === 0) return Promise.resolve(null);
if (products.length === 1) return Promise.resolve(products[0]);
return showProductPicker(products).then(function(choice){
storeProductChoice(choice);
return choice;
});
}
// ------------------------------------------------------------------------
// ---- Multi-product (optional) ------------------------------------------
// Lets a customer with more than one real subscribed product track each
// one separately via tabs, instead of picking just one (CONFIG.productPicker)
// or gating on one (CONFIG.targeting). See CONFIG.multiProduct above.
// Tabs stay tidy for a handful of products; beyond this many they wrap
// into a cluttered multi-row mess, so a dropdown takes over instead.
var PRODUCT_TAB_MAX = 4;
function renderProductTabs(){
var el = $('[data-role="product-tabs"]');
if (!el) return;
if (!CONFIG.multiProduct.enabled || detectedProducts.length < 2){
el.hidden = true;
el.innerHTML = '';
return;
}
el.hidden = false;
var label = '<p class="dct-field-label">Which product are you tracking?</p>';
if (detectedProducts.length > PRODUCT_TAB_MAX){
el.innerHTML = label +
'<select class="dct-product-select" data-role="product-select">' +
detectedProducts.map(function(p){
return '<option value="' + p + '"' + (p === activeProduct ? ' selected' : '') + '>' + p + '</option>';
}).join('') +
'</select>';
el.querySelector('[data-role="product-select"]').addEventListener('change', function(ev){
switchProduct(ev.target.value);
});
} else {
el.innerHTML = label +
'<div class="dct-product-tabs-row">' +
detectedProducts.map(function(p){
return '<button class="dct-tab-btn' + (p === activeProduct ? ' active' : '') + '" type="button" data-product="' + p + '">' + p + '</button>';
}).join('') +
'</div>';
el.querySelectorAll('[data-product]').forEach(function(btn){
btn.addEventListener('click', function(){ switchProduct(btn.dataset.product); });
});
}
}
function switchProduct(product){
if (product === activeProduct) return;
activeProduct = product;
CONFIG.product = product;
if (!productStates[product]) productStates[product] = loadProductState(product);
state = productStates[product];
draft = { taken: null, mood: null, tags: [] };
renderProductTabs();
renderAll();
}
// Called once, after the customer-context lookup resolves, with whatever
// real products were found (may be empty if the lookup found none --
// falls back to a single "tab" of CONFIG.product's default so storage
// still consistently uses the per-product key scheme either way).
function initMultiProduct(products){
detectedProducts = products.length ? products : [CONFIG.product];
activeProduct = detectedProducts[0];
CONFIG.product = activeProduct;
productStates[activeProduct] = loadProductState(activeProduct);
state = productStates[activeProduct];
renderProductTabs();
renderAll();
reconcileCloud();
}
// ------------------------------------------------------------------------
function save(){
saveLocal();
pushToCloud();
}
var state = load();
var draft = { taken: null, mood: null, tags: [] };
// Only meaningful when CONFIG.multiProduct.enabled -- see "Multi-product
// (optional)" block further down for how these get populated and used.
var activeProduct = CONFIG.product;
var productStates = {};
var detectedProducts = [];
function allEntries(){
var arr = state.history.slice();
if (state.todayEntry) arr.push(state.todayEntry);
return arr;
}
function computeStreaks(entries){
var current = 0;
for (var i=entries.length-1; i>=0; i--){ if (entries[i].taken) current++; else break; }
var longest = 0, run = 0;
for (var j=0; j<entries.length; j++){ if (entries[j].taken){ run++; longest = Math.max(longest, run); } else run = 0; }
var last30 = entries.slice(-30);
var takenCount = last30.filter(function(e){ return e.taken; }).length;
var adherence = last30.length ? Math.round((takenCount/last30.length)*100) : 0;
return { current: current, longest: longest, adherence: adherence };
}
function renderCheckin(){
var body = $('[data-role="checkin-body"]');
$('[data-role="today-date"]').textContent = fmtDateLong(today);
if (state.todayEntry){
var e = state.todayEntry;
body.innerHTML =
'<div class="dct-logged-state">' +
'<div class="dct-logged-badge">' + (e.taken ? checkIcon() : xIcon()) +
(e.taken ? 'Taken today' : 'Skipped today') + ' · feeling ' + MOOD_LABELS[e.mood-1].toLowerCase() +
'</div>' +
(e.tags.length ? '<div class="dct-logged-meta">' + e.tags.join(', ') + '</div>' : '') +
'<button class="dct-edit-link" type="button" data-act="edit">Edit</button>' +
'</div>';
body.querySelector('[data-act="edit"]').addEventListener('click', function(){
draft = { taken: e.taken, mood: e.mood, tags: e.tags.slice() };
state.todayEntry = null;
renderCheckin();
});
return;
}
body.innerHTML =
'<div class="dct-status-row">' +
'<button class="dct-status-btn take" type="button" data-act="take" aria-pressed="' + (draft.taken===true) + '">' + checkIcon() + ' I took it today</button>' +
'<button class="dct-status-btn skip" type="button" data-act="skip" aria-pressed="' + (draft.taken===false) + '">' + xIcon() + ' Skipped today</button>' +
'</div>' +
'<p class="dct-field-label">How do you feel?</p>' +
'<div class="dct-mood-row" data-role="moods">' +
MOOD_LABELS.map(function(label, i){
var lvl = i+1;
return '<button class="dct-mood-btn" type="button" data-mood="' + lvl + '" aria-pressed="' + (draft.mood===lvl) + '">' + faceSvg(i) + '<span>' + label + '</span></button>';
}).join('') +
'</div>' +
(CONFIG.tags.length ? (
'<p class="dct-field-label">Anything notable? (optional)</p>' +
'<div class="dct-tag-row" data-role="tags">' +
CONFIG.tags.map(function(t){
return '<button class="dct-tag-chip" type="button" data-tag="' + t + '" aria-pressed="' + (draft.tags.indexOf(t)>-1) + '">' + t + '</button>';
}).join('') +
'</div>'
) : '') +
'<div class="dct-save-row">' +
'<button class="dct-save-btn" type="button" data-act="save"' + (draft.taken===null || draft.mood===null ? ' disabled' : '') + '>Save today\'s check-in</button>' +
'<span class="dct-save-hint">' + (draft.taken===null || draft.mood===null ? 'Choose taken/skipped and a mood' : 'Ready to save') + '</span>' +
'</div>';
body.querySelector('[data-act="take"]').addEventListener('click', function(){ draft.taken = true; renderCheckin(); });
body.querySelector('[data-act="skip"]').addEventListener('click', function(){ draft.taken = false; renderCheckin(); });
var moodRow = body.querySelector('[data-role="moods"]');
if (moodRow) moodRow.addEventListener('click', function(ev){
var btn = ev.target.closest('.dct-mood-btn');
if (!btn) return;
draft.mood = parseInt(btn.dataset.mood, 10);
renderCheckin();
});
var tagRow = body.querySelector('[data-role="tags"]');
if (tagRow) tagRow.addEventListener('click', function(ev){
var btn = ev.target.closest('.dct-tag-chip');
if (!btn) return;
var t = btn.dataset.tag;
var idx = draft.tags.indexOf(t);
if (idx>-1) draft.tags.splice(idx,1); else draft.tags.push(t);
renderCheckin();
});
body.querySelector('[data-act="save"]').addEventListener('click', function(){
if (draft.taken===null || draft.mood===null) return;
state.todayEntry = { date: today.toISOString(), taken: draft.taken, mood: draft.mood, tags: draft.tags.slice() };
save();
renderAll();
});
}
function renderChart(){
var entries = allEntries();
var wrap = $('[data-role="chart-wrap"]');
var legend = $('[data-role="legend"]');
var tableToggle = $('[data-role="table-toggle"]');
if (!entries.length){
wrap.innerHTML = '<p class="dct-empty">Your progress graph will appear after your first check-in.</p>';
legend.hidden = true;
tableToggle.hidden = true;
return;
}
legend.hidden = false;
tableToggle.hidden = false;
$('[data-role="milestone-legend-label"]').textContent = CONFIG.milestoneLabel;
var W = 720, H = 300, mL = 46, mR = 14, mT = 16, mB = 32;
var plotW = W - mL - mR, plotH = H - mT - mB;
var n = entries.length;
var xStep = n > 1 ? plotW/(n-1) : 0;
function xAt(i){ return mL + i*xStep; }
function yAt(mood){ return mT + plotH - ((mood-1)/4)*plotH; }
var gridLines = '', axisLabels = '';
for (var lvl=1; lvl<=5; lvl++){
var y = yAt(lvl);
gridLines += '<line class="dct-grid-line" x1="' + mL + '" y1="' + y + '" x2="' + (W-mR) + '" y2="' + y + '"/>';
axisLabels += '<text class="dct-axis-label" x="4" y="' + (y+3.5) + '">' + MOOD_LABELS[lvl-1] + '</text>';
}
var dateLabels = '';
var labelEvery = Math.max(1, Math.round(n/6));
for (var i=0; i<n; i++){
if (i % labelEvery === 0 || i === n-1){
dateLabels += '<text class="dct-date-label" x="' + xAt(i) + '" y="' + (H-8) + '" text-anchor="middle">' + fmtDate(new Date(entries[i].date)) + '</text>';
}
}
var milestoneX = xAt(Math.min(CONFIG.buildingDays - 1, n-1));
var milestone = n > 4 ?
'<line class="dct-milestone-line" x1="' + milestoneX + '" y1="' + mT + '" x2="' + milestoneX + '" y2="' + (mT+plotH) + '"/>' +
'<text class="dct-milestone-label" x="' + milestoneX + '" y="' + (mT-3) + '" text-anchor="' + (milestoneX > W-120 ? 'end' : 'start') + '">' + CONFIG.milestoneLabel + '</text>'
: '';
var pathD = entries.map(function(e,i){ return (i===0?'M':'L') + xAt(i) + ' ' + yAt(e.mood); }).join(' ');
var dots = '', hits = '';
entries.forEach(function(e,i){
var x = xAt(i), y = yAt(e.mood);
dots += '<circle class="' + (e.taken ? 'dct-dot-taken' : 'dct-dot-skip') + '" cx="' + x + '" cy="' + y + '" r="' + (e.taken?4:4.25) + '"/>';
hits += '<circle class="dct-dot-hit" data-i="' + i + '" cx="' + x + '" cy="' + y + '" r="11"/>';
});
wrap.innerHTML =
'<svg class="dct-chart">' +
'<g></g>' +
'</svg>';
// set viewBox via attribute call (avoids quoting issues in the template string above)
wrap.querySelector('svg').setAttribute('viewBox', '0 0 ' + W + ' ' + H);
wrap.querySelector('svg').innerHTML =
gridLines + axisLabels + milestone +
'<path class="dct-mood-line" d="' + pathD + '"/>' +
dots +
'<line class="dct-crosshair" data-role="crosshair" x1="0" y1="' + mT + '" x2="0" y2="' + (mT+plotH) + '"/>' +
hits +
dateLabels;
wrap.querySelector('[data-role="crosshair"]') && (wrap.insertAdjacentHTML('beforeend', '<div class="dct-tooltip" data-role="tooltip"></div>'));
var svg = wrap.querySelector('svg');
var tooltip = wrap.querySelector('[data-role="tooltip"]');
var crosshair = wrap.querySelector('[data-role="crosshair"]');
function showTooltip(i, clientX, clientY){
var e = entries[i];
var d = new Date(e.date);
var wrapRect = wrap.getBoundingClientRect();
tooltip.innerHTML = '<b>' + fmtDateLong(d) + '</b><br>' +
(e.taken ? 'Taken' : 'Skipped') + ' · felt ' + MOOD_LABELS[e.mood-1].toLowerCase() +
(e.tags && e.tags.length ? '<br><span class="tt-tags">' + e.tags.join(', ') + '</span>' : '');
tooltip.style.left = (clientX - wrapRect.left) + 'px';
tooltip.style.top = (clientY - wrapRect.top) + 'px';
tooltip.style.opacity = '1';
crosshair.style.opacity = '1';
crosshair.setAttribute('x1', xAt(i));
crosshair.setAttribute('x2', xAt(i));
}
function hideTooltip(){ tooltip.style.opacity = '0'; crosshair.style.opacity = '0'; }
svg.querySelectorAll('.dct-dot-hit').forEach(function(hit){
hit.addEventListener('mouseenter', function(ev){ showTooltip(parseInt(hit.dataset.i,10), ev.clientX, ev.clientY); });
hit.addEventListener('mousemove', function(ev){ showTooltip(parseInt(hit.dataset.i,10), ev.clientX, ev.clientY); });
hit.addEventListener('mouseleave', hideTooltip);
});
var rows = entries.map(function(e){
var d = new Date(e.date);
return '<tr><td>' + fmtDate(d) + '</td><td class="mono">' + (e.taken?'Taken':'Skipped') + '</td><td>' + MOOD_LABELS[e.mood-1] + '</td><td>' + (e.tags.join(', ')||'—') + '</td></tr>';
}).reverse().join('');
$('[data-role="table-wrap"]').innerHTML =
'<table class="dct-history"><thead><tr><th>Date</th><th>Status</th><th>Mood</th><th>Notes</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
function renderStats(){
var entries = allEntries();
var s = computeStreaks(entries);
$('[data-role="stats"]').innerHTML =
'<div class="dct-stat-tile"><span class="dct-stat-name">Current streak</span><span class="dct-stat-value">' + s.current + '<small>days</small></span></div>' +
'<div class="dct-stat-tile"><span class="dct-stat-name">Longest streak</span><span class="dct-stat-value">' + s.longest + '<small>days</small></span></div>' +
'<div class="dct-stat-tile"><span class="dct-stat-name">30-day adherence</span><span class="dct-stat-value">' + (entries.length ? s.adherence : '—') + '<small>' + (entries.length ? '%' : '') + '</small></span></div>';
}
function renderCalendar(){
var entries = allEntries();
var last = entries.slice(-28);
var calGrid = $('[data-role="cal"]');
if (!last.length){
calGrid.innerHTML = new Array(28).fill('<div class="dct-cal-cell empty"></div>').join('');
return;
}
var firstDate = new Date(last[0].date);
var lead = (firstDate.getDay() + 6) % 7;
var cells = '';
for (var p=0; p<lead; p++) cells += '<div class="dct-cal-cell empty"></div>';
last.forEach(function(e){
var d = new Date(e.date);
// opacity set via a custom property (not a plain "opacity:" inline
// style), same reasoning as the phase-fill width below.
var style = e.taken ? ' style="--dct-op:' + (0.35 + (e.mood-1)*0.16).toFixed(2) + '"' : '';
cells += '<div class="dct-cal-cell ' + (e.taken?'taken':'skip') + '"' + style + ' title="' + fmtDate(d) + ' · ' + (e.taken?'Taken':'Skipped') + ' · ' + MOOD_LABELS[e.mood-1] + '">' + d.getDate() + '</div>';
});
calGrid.innerHTML = cells;
}
function renderCoach(){
var entries = allEntries();
var days = entries.length;
var coachEl = $('[data-role="coach"]');
var M = CONFIG.messages;
if (!days){
coachEl.innerHTML =
'<div class="dct-card-head"><div class="dct-card-title">Keep going</div></div>' +
'<div class="dct-coach-msg">' +
'<div class="dct-coach-icon">' + trendIcon() + '</div>' +
'<div><p class="dct-coach-headline">' + fill(M.getStarted.headline, {}) + '</p><p class="dct-coach-sub">' + fill(M.getStarted.body, { product: CONFIG.product }) + '</p></div>' +
'</div>';
return;
}
var s = computeStreaks(entries);
var segIndex = days < CONFIG.buildingDays ? 0 : days < CONFIG.shiftDays ? 1 : 2;
var windowSize = Math.min(7, entries.length);
var firstWindow = entries.slice(0, windowSize);
var recentWindow = entries.slice(-windowSize);
var overlapping = entries.length <= windowSize;
var delta = Math.round((avgMood(recentWindow) - avgMood(firstWindow)) * 10) / 10;
var baseVars = {
days: days, product: CONFIG.product, buildingDays: CONFIG.buildingDays,
shiftDays: CONFIG.shiftDays, shiftDaysMinus1: CONFIG.shiftDays - 1,
adherence: s.adherence, delta: delta.toFixed(1), buildingNote: CONFIG.buildingNote
};
var msg;
if (entries.length >= 5 && s.adherence < 70){
msg = M.lowAdherence;
} else if (segIndex === 0){
msg = M.building;
} else if (segIndex === 1){
msg = (!overlapping && delta >= 0.5) ? M.earlyShiftsImproving : M.earlyShiftsFlat;
} else {
msg = (!overlapping && delta >= 1) ? M.resultsGood : (!overlapping && delta > 0) ? M.resultsSlight : M.resultsFlat;
}
var headline = fill(msg.headline, baseVars);
var sub = fill(msg.body, baseVars);
var tagNote = '';
if (entries.length >= 10 && CONFIG.tags.length){
var bestTag = null, bestDrop = 0, bestFirstCount = 0, bestRecentCount = 0;
CONFIG.tags.forEach(function(t){
var f = firstWindow.filter(function(e){ return e.tags.indexOf(t) > -1; }).length;
var r = recentWindow.filter(function(e){ return e.tags.indexOf(t) > -1; }).length;
if (f >= 2 && (f - r) > bestDrop){ bestDrop = f - r; bestTag = t; bestFirstCount = f; bestRecentCount = r; }
});
if (bestTag){
tagNote = fill(M.tagProgress, { tag: bestTag, firstCount: bestFirstCount, recentCount: bestRecentCount, windowSize: windowSize });
}
}
// flex-grow and width are set via custom properties, not plain "flex-grow:"/
// "width:" inline styles -- the portal's CSS reset turned out to beat plain
// inline styles on box-model properties (see the !important pass throughout
// this stylesheet), and custom properties are immune to that since the
// reset targets concrete properties, not arbitrary var() names.
var track = '<div class="dct-phase-track">' + PHASES.map(function(ph, i){
var frac = Math.max(0, Math.min(1, (days - ph.start) / (ph.end - ph.start)));
var state2 = i < segIndex ? 'done' : (i === segIndex ? 'active' : '');
return '<div class="dct-phase-seg ' + state2 + '" style="--dct-grow:' + (ph.end - ph.start) + '">' +
'<div class="dct-phase-bar"><div class="dct-phase-fill" style="--dct-fill:' + (frac*100) + '%"></div></div>' +
'<div class="dct-phase-label">' + ph.name + '<span>' + ph.sub + '</span></div>' +
'</div>';
}).join('') + '</div>';
coachEl.innerHTML =
'<div class="dct-card-head">' +
'<div class="dct-card-title">Keep going</div>' +
'<div class="dct-phase-pill"><span class="dct-dot"></span>' + fill(CONFIG.phasePillTemplate, { num: segIndex+1, name: PHASES[segIndex].name }) + '</div>' +
'</div>' +
track +
'<div class="dct-coach-msg">' +
'<div class="dct-coach-icon">' + trendIcon() + '</div>' +
'<div><p class="dct-coach-headline">' + headline + '</p><p class="dct-coach-sub">' + sub + '</p>' +
(tagNote ? '<p class="dct-coach-note">' + tagNote + '</p>' : '') +
'</div>' +
'</div>';
}
function renderAll(){
var total = allEntries().length;
$('[data-role="brand-name"]').textContent = CONFIG.brand + ' \u2014 ' + CONFIG.product;
$('[data-role="day-count"]').textContent = total;
$('[data-role="day-label"]').textContent = total ? 'days since your first dose' : 'your first day starts today';
renderSync();
renderCoach();
renderCheckin();
renderChart();
renderStats();
renderCalendar();
}
if (CONFIG.showDemoData){
var resetRow = $('[data-role="reset-row"]');
resetRow.hidden = false;
resetRow.querySelector('[data-role="reset"]').addEventListener('click', function(){
// Only resets the currently active tab's demo data when
// CONFIG.multiProduct is on -- switch tabs and reset again for others.
var key = CONFIG.multiProduct.enabled ? productStorageKey(activeProduct) : CONFIG.storageKey;
if (STORAGE_OK){ try{ localStorage.removeItem(key); }catch(e){} }
state = CONFIG.multiProduct.enabled ? (productStates[activeProduct] = loadProductState(activeProduct)) : load();
draft = { taken: null, mood: null, tags: [] };
renderAll();
});
}
// If this device already has a linked email (from a previous visit),
// reconcile with the cloud copy right away -- the page still renders
// instantly from the local cache above, this just quietly catches it up
// a moment later if the two disagree.
function reconcileCloud(){
if (!(CONFIG.cloudSync.enabled && sessionEmail && sessionPin)) return;
syncStatus = 'syncing';
renderSync();
cloudLink(sessionEmail, sessionPin).then(function(serverData){
if (CONFIG.multiProduct.enabled){
if (serverData && serverData.products && typeof serverData.products === 'object'){
hydrateProductsFromServer(serverData.products);
state = productStates[activeProduct] || state;
renderAll();
}
} else if (serverData && Array.isArray(serverData.history)){
state = serverData;
renderAll();
}
syncStatus = 'synced';
renderSync();
}).catch(function(err){
if (window.console) console.error('Daily Check-In Tracker: cloud sync reconcile failed:', err);
syncStatus = 'error';
// A stored PIN suddenly being rejected on reconcile most likely means
// it was changed/reset from another device -- surface that plainly
// rather than a generic "offline?" guess.
syncErrorMessage = err && err.isPinError ? 'Saved PIN no longer matches -- tap "Not you?" and sync again' : 'Sync paused (offline?) -- still saving on this device';
renderSync();
});
}
// Falls back to a normal single-tab render when there's nothing to look
// up (no email) or the lookup failed -- for CONFIG.multiProduct this
// still routes through initMultiProduct([]) so storage consistently uses
// the per-product key scheme rather than flip-flopping between visits.
function fallbackSingleProductMode(){
if (CONFIG.multiProduct.enabled){
initMultiProduct([]);
} else {
renderAll();
reconcileCloud();
}
}
// Startup gate: combines targeting (does this customer see the widget at
// all?), the product picker (which one product are they tracking?), and
// multi-product (show a tab per real product) into a single
// customer-context lookup, so a customer with more than one of these on
// doesn't trigger multiple network calls. All three are independent --
// any combination can be off. If multiProduct is enabled it takes
// priority over productPicker and targeting.useProductFromSub (they're
// three different answers to "which product," so only one applies).
function initWidget(){
if (!CONFIG.targeting.enabled && !CONFIG.productPicker.enabled && !CONFIG.multiProduct.enabled){
renderAll();
reconcileCloud();
return;
}
var email = detectCustomerEmail() || sessionEmail;
if (email) productChoiceKey = CONFIG.storageKeyPrefix + '-product-' + email;
// Product-picker-only, and we already know their answer -- skip the
// network call entirely rather than re-asking Skio every visit.
if (!CONFIG.targeting.enabled && CONFIG.productPicker.enabled && !CONFIG.multiProduct.enabled){
var remembered = loadStoredProductChoice();
if (remembered){
CONFIG.product = remembered;
renderAll();
reconcileCloud();
return;
}
}
if (!email){
if (window.console) console.warn('Daily Check-In Tracker: no customer email could be detected, so the targeting/product lookup is skipped -- ' + (CONFIG.targeting.enabled && !CONFIG.targeting.showIfUnknown ? 'hiding the widget (CONFIG.targeting.showIfUnknown is false).' : 'showing the widget with its current CONFIG.product default.'));
if (CONFIG.targeting.enabled && !CONFIG.targeting.showIfUnknown){ root.classList.add('dct-hidden'); return; }
fallbackSingleProductMode();
return;
}
fetchCustomerContext(email).then(function(ctx){
var tags = (ctx && ctx.tags) || [];
var products = (ctx && ctx.products) || [];
if (CONFIG.targeting.enabled && !matchesTargeting(tags, products)){
root.classList.add('dct-hidden');
return;
}
if (CONFIG.multiProduct.enabled){
initMultiProduct(products);
return;
}
if (CONFIG.targeting.enabled && CONFIG.targeting.useProductFromSub){
var matchedProduct = CONFIG.targeting.requiredProducts.filter(function(p){ return products.indexOf(p) > -1; })[0] || products[0];
if (matchedProduct) CONFIG.product = matchedProduct;
renderAll();
reconcileCloud();
return;
}
if (CONFIG.productPicker.enabled){
resolveProductChoice(products).then(function(choice){
if (choice) CONFIG.product = choice;
renderAll();
reconcileCloud();
});
return;
}
renderAll();
reconcileCloud();
}).catch(function(err){
if (window.console) console.error('Daily Check-In Tracker: customer context lookup failed:', err);
if (CONFIG.targeting.enabled && !CONFIG.targeting.showIfUnknown){ root.classList.add('dct-hidden'); return; }
fallbackSingleProductMode();
});
}
initWidget();
}catch(err){
// Fail quietly rather than breaking the rest of the customer portal.
if (window.console) console.error('Daily Check-In Tracker failed to initialize:', err);
}
})();
</script>
How to install
- Copy the code above, or download the .html file.
- Go to
Storefront→Customer Portal→Branding→Layoutand clickAdd custom extension. - Pick
HTML with embedded CSS and JavaScriptand paste the whole file into the code editor. - Swap anything the file asks you to, like
[BRAND], product names or Quick Action IDs. The AI prompt below can walk you through it. - Save, click
+to add it to the layout, thenPublish. Check it on a test subscription’s magic link with?forcePreview=true, since some scripts don’t run in Preview.
Everything is set in the CONFIG object at the top of the script: brand, product, colors and messages. Check-ins are saved in the subscriber's browser.
Customize it with AI
Paste this prompt into Claude Code or Codex. It asks for the code and your brand and product details, then writes a version for your store.
I want to add a custom extension from customizeyourportal.xyz to my subscription customer portal. Walk me through customizing it for my brand, then write the finished code. EXTENSION Name: Daily Progress Tracker (No. 10) Page: /blocks/daily-progress-tracker What it does: Subscribers log progress each day, and the portal answers with encouragement that fits. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE Everything is set in the CONFIG object at the top of the script: brand, product, colors and messages. Check-ins are saved in the subscriber's browser. HOW TO WORK 1. Ask me to paste the full code (the "Copy code" button on the page) or to give you the path to the downloaded daily-progress-tracker.html. Wait until you have it. 2. Read the whole file. List every value that is specific to the original brand or needs my input: brand name and [PLACEHOLDERS], product and variant names or IDs, prices, discount codes and reward amounts, Quick Action IDs or tokens, store domains and API endpoints, colors, fonts, logos and images, and any copy that names the original brand, products or offers. Include everything in the builder's notes above. 3. Ask me for those values in one numbered list, with the current value next to each, so I can answer or say "keep". If you can open web pages, offer to pull my brand colors, fonts, logo and product names from my store's URL. 4. Don't invent stats, reviews, IDs, prices or claims. If I don't have a value, leave a clearly marked TODO in the code and tell me. 5. Write the customized version as daily-progress-tracker-<my-brand>.html. Change only what my brand needs. Keep the logic, behavior and structure the same, and keep it one self-contained file (HTML, <style> and <script>). 6. Finish with a short list of what you changed, any TODOs left, and these install steps for Skio: 1. Go to `Storefront` → `Customer Portal` → `Branding` → `Layout` and click `Add custom extension`. 2. Pick `HTML with embedded CSS and JavaScript` and paste the whole file into the code editor. 3. Save, click `+` to add it to the layout, then `Publish`. Check it on a test subscription’s magic link with `?forcePreview=true`, since some scripts don’t run in Preview.
Blocks are shared as-is by the people who built them. Read the code before you add it to your portal, and test it with a test subscriber first.
