Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>GymFlow — Home</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | |
| <meta name="color-scheme" content="light dark"> | |
| <style> | |
| /* ---------- Design tokens ---------- */ | |
| :root { | |
| --brand: #6d5efc; | |
| --brand-2: #22c55e; | |
| --bg: #f7f7fb; | |
| --surface: #ffffff; | |
| --surface-2: #f2f2f7; | |
| --text: #0f172a; | |
| --muted: #64748b; | |
| --border: #e5e7eb; | |
| --ring: rgba(109, 94, 252, 0.35); | |
| --shadow: 0 10px 25px rgba(17, 24, 39, 0.08); | |
| --radius: 14px; | |
| --radius-sm: 10px; | |
| --success: #22c55e; | |
| --danger: #ef4444; | |
| --warning: #f59e0b; | |
| --focus: 2px solid var(--ring); | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg: #0b1020; | |
| --surface: #0f152b; | |
| --surface-2: #0c1226; | |
| --text: #e6e8ee; | |
| --muted: #93a0b8; | |
| --border: #1f2940; | |
| --ring: rgba(109, 94, 252, 0.45); | |
| --shadow: 0 10px 25px rgba(0, 0, 0, 0.35); | |
| } | |
| } | |
| /* ---------- Reset ---------- */ | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji"; | |
| background: radial-gradient(1200px 600px at 85% -200px, rgba(109, 94, 252, 0.12), transparent 60%), var(--bg); | |
| color: var(--text); | |
| line-height: 1.35; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| img, | |
| svg { | |
| display: block; | |
| } | |
| button { | |
| font: inherit; | |
| color: inherit; | |
| } | |
| a { | |
| color: inherit; | |
| text-decoration: none; | |
| } | |
| /* ---------- App layout ---------- */ | |
| .app { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: clamp(14px, 2.4vw, 24px); | |
| display: grid; | |
| gap: 20px; | |
| } | |
| header.appbar { | |
| position: sticky; | |
| top: 0; | |
| z-index: 40; | |
| padding: clamp(8px, 1.8vw, 14px); | |
| border-radius: 16px; | |
| background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0)); | |
| backdrop-filter: saturate(1.4) blur(10px); | |
| border: 1px solid var(--border); | |
| box-shadow: var(--shadow); | |
| display: grid; | |
| grid-template-columns: 1fr auto auto; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| header.appbar { | |
| background: linear-gradient(180deg, rgba(17, 25, 40, 0.65), rgba(17, 25, 40, 0.1)); | |
| } | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .brand-badge { | |
| width: 38px; | |
| height: 38px; | |
| border-radius: 12px; | |
| background: conic-gradient(from 200deg at 50% 50%, #6d5efc, #22c55e 55%, #06b6d4 85%, #6d5efc); | |
| position: relative; | |
| box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.35), 0 6px 16px rgba(109, 94, 252, 0.4); | |
| display: grid; | |
| place-items: center; | |
| color: white; | |
| font-weight: 700; | |
| letter-spacing: 0.5px; | |
| font-size: 13px; | |
| } | |
| .brand h1 { | |
| font-size: 18px; | |
| margin: 0; | |
| } | |
| .brand small { | |
| color: var(--muted); | |
| display: block; | |
| font-size: 12px; | |
| margin-top: 2px; | |
| } | |
| .brand a.credit { | |
| margin-left: 8px; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| color: var(--brand); | |
| border: 1px dashed color-mix(in oklab, var(--brand), transparent 65%); | |
| background: color-mix(in oklab, var(--brand), transparent 92%); | |
| transition: 0.2s ease; | |
| } | |
| .brand a.credit:hover { | |
| transform: translateY(-1px); | |
| } | |
| .search { | |
| display: none; | |
| } | |
| @media (min-width: 760px) { | |
| .search { | |
| display: block; | |
| position: relative; | |
| } | |
| .search input { | |
| width: 300px; | |
| max-width: 46vw; | |
| padding: 10px 14px 10px 36px; | |
| border-radius: 999px; | |
| border: 1px solid var(--border); | |
| background: var(--surface); | |
| outline: none; | |
| transition: 0.2s border, 0.2s box-shadow; | |
| } | |
| .search input:focus { | |
| border-color: var(--brand); | |
| box-shadow: 0 0 0 6px var(--ring); | |
| } | |
| .search svg { | |
| position: absolute; | |
| top: 50%; | |
| left: 12px; | |
| transform: translateY(-50%); | |
| width: 16px; | |
| height: 16px; | |
| opacity: 0.7; | |
| } | |
| } | |
| .user { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .user .avatar { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #22c55e, #06b6d4); | |
| display: grid; | |
| place-items: center; | |
| color: white; | |
| font-weight: 700; | |
| border: 2px solid rgba(255, 255, 255, 0.7); | |
| } | |
| .user .meta { | |
| display: none; | |
| } | |
| @media (min-width: 740px) { | |
| .user .meta { | |
| display: block; | |
| } | |
| .user .meta .name { | |
| font-weight: 700; | |
| font-size: 14px; | |
| } | |
| .user .meta .plan { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| } | |
| /* ---------- Navigation ---------- */ | |
| nav.tabbar { | |
| position: sticky; | |
| top: calc(56px + clamp(14px, 2.4vw, 24px)); | |
| z-index: 30; | |
| background: linear-gradient(180deg, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0)); | |
| backdrop-filter: saturate(1.2) blur(8px); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| width: fit-content; | |
| margin: 0 auto; | |
| padding: 6px; | |
| display: flex; | |
| gap: 6px; | |
| } | |
| .tabbar button { | |
| padding: 8px 14px; | |
| border-radius: 999px; | |
| border: 0; | |
| background: transparent; | |
| color: var(--muted); | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: 0.2s ease background, 0.2s ease color, 0.2s ease transform; | |
| } | |
| .tabbar button.active { | |
| background: var(--brand); | |
| color: white; | |
| box-shadow: 0 6px 18px rgba(109, 94, 252, 0.35); | |
| } | |
| .tabbar button:hover { | |
| transform: translateY(-1px); | |
| } | |
| main.content { | |
| display: grid; | |
| gap: 24px; | |
| } | |
| section.block { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: clamp(14px, 2.2vw, 18px); | |
| box-shadow: var(--shadow); | |
| } | |
| .block h2 { | |
| margin: 0 0 14px 0; | |
| font-size: 18px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .muted { | |
| color: var(--muted); | |
| } | |
| /* ---------- Activities grid ---------- */ | |
| .activities { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 12px; | |
| } | |
| @media (min-width: 620px) { | |
| .activities { | |
| grid-template-columns: repeat(3, 1fr); | |
| } | |
| } | |
| @media (min-width: 940px) { | |
| .activities { | |
| grid-template-columns: repeat(4, 1fr); | |
| } | |
| } | |
| .card { | |
| background: linear-gradient(180deg, var(--surface), var(--surface-2)); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| padding: 14px; | |
| display: grid; | |
| gap: 10px; | |
| transition: 0.2s transform, 0.2s box-shadow, 0.2s border-color; | |
| position: relative; | |
| isolation: isolate; | |
| } | |
| .card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 14px 30px rgba(0, 0, 0, 0.08); | |
| } | |
| .card .row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| } | |
| .pill { | |
| font-size: 12px; | |
| padding: 4px 8px; | |
| border-radius: 999px; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| } | |
| .card .icon { | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 12px; | |
| display: grid; | |
| place-items: center; | |
| font-size: 22px; | |
| background: color-mix(in oklab, var(--brand), transparent 85%); | |
| border: 1px solid color-mix(in oklab, var(--brand), transparent 75%); | |
| } | |
| .card .title { | |
| font-weight: 700; | |
| } | |
| .card .subtitle { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .checkin-btn { | |
| margin-top: 4px; | |
| padding: 10px 12px; | |
| border-radius: 12px; | |
| border: 1px solid var(--border); | |
| background: var(--surface); | |
| cursor: pointer; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| font-weight: 700; | |
| transition: 0.2s ease box-shadow, 0.2s ease background, 0.2s ease color, 0.2s ease border; | |
| } | |
| .checkin-btn:hover { | |
| transform: translateY(-1px); | |
| } | |
| .checkin-btn:focus-visible { | |
| outline: none; | |
| box-shadow: 0 0 0 6px var(--ring); | |
| } | |
| .checkin-btn .dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 999px; | |
| background: var(--muted); | |
| box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.6); | |
| } | |
| .checkin-btn[aria-pressed="true"] { | |
| background: color-mix(in oklab, var(--brand), transparent 92%); | |
| border-color: color-mix(in oklab, var(--brand), transparent 55%); | |
| color: color-mix(in oklab, var(--brand), black 25%); | |
| } | |
| .checkin-btn[aria-pressed="true"] .dot { | |
| background: var(--brand); | |
| } | |
| .checkin-btn[disabled] { | |
| opacity: 0.7; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| /* ---------- Today's classes ---------- */ | |
| .classes { | |
| display: grid; | |
| gap: 12px; | |
| } | |
| .class { | |
| display: grid; | |
| gap: 8px; | |
| grid-template-columns: auto 1fr auto; | |
| align-items: center; | |
| padding: 12px; | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| background: linear-gradient(180deg, var(--surface), var(--surface-2)); | |
| transition: 0.2s transform, 0.2s box-shadow; | |
| } | |
| .class:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 10px 22px rgba(0, 0, 0, 0.07); | |
| } | |
| .class .ico { | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 12px; | |
| display: grid; | |
| place-items: center; | |
| font-size: 20px; | |
| background: color-mix(in oklab, var(--brand), transparent 85%); | |
| border: 1px solid color-mix(in oklab, var(--brand), transparent 75%); | |
| } | |
| .class .name { | |
| font-weight: 800; | |
| } | |
| .class .info { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .class .time { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .class .act { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .tag { | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| border: 1px solid var(--border); | |
| background: var(--surface); | |
| } | |
| .btn { | |
| padding: 8px 12px; | |
| border-radius: 10px; | |
| border: 1px solid var(--border); | |
| background: var(--surface); | |
| font-weight: 700; | |
| cursor: pointer; | |
| transition: 0.2s ease; | |
| } | |
| .btn:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 10px 18px rgba(0, 0, 0, 0.06); | |
| } | |
| .btn.primary { | |
| background: var(--brand); | |
| border-color: color-mix(in oklab, var(--brand), black 10%); | |
| color: white; | |
| box-shadow: 0 10px 22px rgba(109, 94, 252, 0.35); | |
| } | |
| .btn.ghost { | |
| background: transparent; | |
| } | |
| /* ---------- Stats ---------- */ | |
| .stats { | |
| display: grid; | |
| gap: 12px; | |
| grid-template-columns: 1fr; | |
| } | |
| @media (min-width: 800px) { | |
| .stats { | |
| grid-template-columns: 1.2fr 1fr; | |
| } | |
| } | |
| .kpis { | |
| display: grid; | |
| gap: 12px; | |
| grid-template-columns: repeat(3, 1fr); | |
| } | |
| .kpi { | |
| background: linear-gradient(180deg, var(--surface), var(--surface-2)); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 14px; | |
| display: grid; | |
| gap: 6px; | |
| } | |
| .kpi .value { | |
| font-size: 22px; | |
| font-weight: 900; | |
| letter-spacing: 0.2px; | |
| } | |
| .kpi .label { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .chart { | |
| background: linear-gradient(180deg, var(--surface), var(--surface-2)); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 14px; | |
| display: grid; | |
| gap: 12px; | |
| } | |
| .bars { | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| gap: 8px; | |
| align-items: end; | |
| height: 120px; | |
| } | |
| .bar { | |
| --h: 0%; | |
| background: linear-gradient(180deg, color-mix(in oklab, var(--brand), white 15%), var(--brand)); | |
| border-radius: 8px 8px 4px 4px; | |
| height: var(--h); | |
| position: relative; | |
| min-height: 4px; | |
| box-shadow: 0 6px 12px rgba(109, 94, 252, 0.25); | |
| } | |
| .bar::after { | |
| content: attr(data-count); | |
| position: absolute; | |
| bottom: calc(var(--h) + 6px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| font-size: 11px; | |
| color: var(--muted); | |
| } | |
| .days { | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| gap: 8px; | |
| text-align: center; | |
| font-size: 11px; | |
| color: var(--muted); | |
| } | |
| /* ---------- History ---------- */ | |
| .history-list { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .history-item { | |
| display: grid; | |
| gap: 8px; | |
| grid-template-columns: auto 1fr auto; | |
| align-items: center; | |
| padding: 12px; | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| background: linear-gradient(180deg, var(--surface), var(--surface-2)); | |
| } | |
| .badge { | |
| padding: 4px 8px; | |
| font-size: 11px; | |
| border-radius: 999px; | |
| font-weight: 700; | |
| border: 1px solid var(--border); | |
| color: var(--muted); | |
| } | |
| .empty { | |
| padding: 18px; | |
| border: 1px dashed var(--border); | |
| border-radius: 12px; | |
| color: var(--muted); | |
| text-align: center; | |
| } | |
| /* ---------- Footer ---------- */ | |
| footer { | |
| text-align: center; | |
| color: var(--muted); | |
| font-size: 12px; | |
| padding: 18px 0 40px; | |
| } | |
| /* ---------- Toast ---------- */ | |
| .toast { | |
| position: fixed; | |
| z-index: 60; | |
| left: 50%; | |
| bottom: 24px; | |
| transform: translateX(-50%) translateY(40px); | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| box-shadow: var(--shadow); | |
| color: var(--text); | |
| padding: 10px 14px; | |
| border-radius: 12px; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: 0.25s ease transform, 0.25s ease opacity; | |
| } | |
| .toast.show { | |
| transform: translateX(-50%) translateY(0); | |
| opacity: 1; | |
| } | |
| /* ---------- Utilities ---------- */ | |
| .spacer { | |
| height: 4px; | |
| } | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border: 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header class="appbar" role="banner"> | |
| <div class="brand"> | |
| <div class="brand-badge" aria-hidden="true">GF</div> | |
| <div> | |
| <h1>GymFlow</h1> | |
| <small>Your day starts here</small> | |
| <a class="credit" href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">Built | |
| with anycoder</a> | |
| </div> | |
| </div> | |
| <div class="search" role="search"> | |
| <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> | |
| <path d="M21 21l-4.35-4.35m1.35-5.15a7 7 0 11-14 0 7 7 0 0114 0z" stroke="currentColor" stroke-width="1.7" | |
| stroke-linecap="round" /> | |
| </svg> | |
| <input id="search" type="search" placeholder="Search activities, classes..." aria-label="Search"> | |
| </div> | |
| <div class="user" role="navigation" aria-label="User menu"> | |
| <div class="avatar" aria-hidden="true">JD</div> | |
| <div class="meta"> | |
| <div class="name">Jordan</div> | |
| <div class="plan">Premium</div> | |
| </div> | |
| </div> | |
| </header> | |
| <nav class="tabbar" role="tablist" aria-label="Primary"> | |
| <button role="tab" aria-selected="true" class="active" data-view="home">Home</button> | |
| <button role="tab" aria-selected="false" data-view="history">History</button> | |
| </nav> | |
| <main class="content"> | |
| <!-- Home View --> | |
| <section id="view-home"> | |
| <section class="block" aria-labelledby="quick-checkin-title"> | |
| <h2 id="quick-checkin-title">Quick Check-In</h2> | |
| <p class="muted" style="margin: 0 0 12px;">Tap an activity to check in for today. You can uncheck to remove | |
| your check-in.</p> | |
| <div id="activities" class="activities" role="list"></div> | |
| </section> | |
| <section class="block" aria-labelledby="today-classes-title"> | |
| <h2 id="today-classes-title">Today's Classes</h2> | |
| <div id="classes" class="classes" role="list"></div> | |
| </section> | |
| <section class="block" aria-labelledby="stats-title"> | |
| <h2 id="stats-title">Your Activity</h2> | |
| <div class="stats"> | |
| <div class="chart" aria-label="Weekly check-ins"> | |
| <div class="muted" style="display:flex;justify-content:space-between;align-items:center;"> | |
| <span>Last 7 days</span> | |
| <span id="week-total" style="font-weight:800;"></span> | |
| </div> | |
| <div id="bars" class="bars" aria-hidden="true"></div> | |
| <div class="days"> | |
| <div>Mon</div> | |
| <div>Tue</div> | |
| <div>Wed</div> | |
| <div>Thu</div> | |
| <div>Fri</div> | |
| <div>Sat</div> | |
| <div>Sun</div> | |
| </div> | |
| </div> | |
| <div class="kpis" role="list"> | |
| <div class="kpi" role="listitem"> | |
| <div class="value" id="today-count">0</div> | |
| <div class="label">Check-ins today</div> | |
| </div> | |
| <div class="kpi" role="listitem"> | |
| <div class="value" id="streak">0</div> | |
| <div class="label">Current streak (days)</div> | |
| </div> | |
| <div class="kpi" role="listitem"> | |
| <div class="value" id="month-total">0</div> | |
| <div class="label">This month</div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </section> | |
| <!-- History View --> | |
| <section id="view-history" hidden> | |
| <section class="block" aria-labelledby="history-title"> | |
| <h2 id="history-title">Recent Check-Ins</h2> | |
| <div id="history-list" class="history-list"></div> | |
| </section> | |
| </section> | |
| </main> | |
| <footer> | |
| © <span id="year"></span> GymFlow. Move better, feel better. | |
| </footer> | |
| </div> | |
| <div id="toast" class="toast" role="status" aria-live="polite"></div> | |
| <script> | |
| // ---------- Data ---------- | |
| const ACTIVITIES = [ | |
| { id: 'crossfit', name: 'CrossFit', icon: '💪', color: '#f97316' }, | |
| { id: 'pilates', name: 'Pilates', icon: '🧘♀️', color: '#06b6d4' }, | |
| { id: 'yoga', name: 'Yoga', icon: '🧘', color: '#a78bfa' }, | |
| { id: 'spin', name: 'Spin', icon: '🚴', color: '#22c55e' }, | |
| { id: 'run', name: 'Run', icon: '🏃', color: '#ef4444' }, | |
| { id: 'hiit', name: 'HIIT', icon: '⏱️', color: '#f59e0b' }, | |
| { id: 'strength',name: 'Strength',icon: '🏋️', color: '#64748b' }, | |
| { id: 'mobility',name: 'Mobility',icon: '🦴', color: '#14b8a6' }, | |
| ]; | |
| const SCHEDULE = [ | |
| { id:'c1', t:'06:30', name:'CrossFit: Foundation', instructor:'Sam', icon:'💪' }, | |
| { id:'c2', t:'08:00', name:'Spin: Intervals', instructor:'Maya', icon:'🚴' }, | |
| { id:'c3', t:'12:15', name:'Power Yoga', instructor:'Jess', icon:'🧘' }, | |
| { id:'c4', t:'18:00', name:'Pilates Mat', instructor:'Lena', icon:'🧘♀️' }, | |
| { id:'c5', t:'19:15', name:'HIIT Express', instructor:'Rae', icon:'⏱️' }, | |
| ]; | |
| // ---------- Storage ---------- | |
| const STORAGE_KEY = 'gymflow_data_v1'; | |
| function loadData() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (!raw) return { checkInsByDate: {}, current: [] }; | |
| const data = JSON.parse(raw); | |
| if (!data.checkInsByDate) data.checkInsByDate = {}; | |
| if (!Array.isArray(data.current)) data.current = []; | |
| return data; | |
| } catch { | |
| return { checkInsByDate: {}, current: [] }; | |
| } | |
| } | |
| function saveData() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(state.data)); | |
| } | |
| function todayKey(d = new Date()) { | |
| const y = d.getFullYear(); | |
| const m = String(d.getMonth()+1).padStart(2,'0'); | |
| const dd = String(d.getDate()).padStart(2,'0'); | |
| return `${y}-${m}-${dd}`; | |
| } | |
| // ---------- State ---------- | |
| const state = { | |
| data: loadData(), | |
| view: 'home', | |
| }; | |
| // ---------- Utils ---------- | |
| function uniq(arr) { | |
| return [...new Set(arr)]; | |
| } | |
| function formatTime(date) { | |
| return date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); | |
| } | |
| function countToday() { | |
| const key = todayKey(); | |
| return (state.data.checkInsByDate[key] || []).length; | |
| } | |
| function listTodaySet() { | |
| const key = todayKey(); | |
| return new Set(state.data.checkInsByDate[key] || []); | |
| } | |
| function setToday(arr) { | |
| const key = todayKey(); | |
| state.data.checkInsByDate[key] = uniq(arr); | |
| saveData(); | |
| } | |
| function addToday(id) { | |
| const key = todayKey(); | |
| const s = new Set(state.data.checkInsByDate[key] || []); | |
| s.add(id); | |
| state.data.checkInsByDate[key] = [...s]; | |
| saveData(); | |
| } | |
| function removeToday(id) { | |
| const key = todayKey(); | |
| const arr = state.data.checkInsByDate[key] || []; | |
| state.data.checkInsByDate[key] = arr.filter(x => x !== id); | |
| saveData(); | |
| } | |
| function computeStreak() { | |
| // Streak of days with at least 1 check-in, ending today | |
| const map = state.data.checkInsByDate; | |
| const today = new Date(); | |
| let streak = 0; | |
| for (let i=0; ; i++) { | |
| const d = new Date(today); | |
| d.setDate(today.getDate() - i); | |
| const k = todayKey(d); | |
| if ((map[k] || []).length > 0) streak++; | |
| else break; | |
| } | |
| return streak; | |
| } | |
| function computeThisMonth() { | |
| const map = state.data.checkInsByDate; | |
| const now = new Date(); | |
| const y = now.getFullYear(); | |
| const m = now.getMonth(); | |
| let total = 0; | |
| for (const [k, arr] of Object.entries(map)) { | |
| const [yy, mm] = k.split('-').map(Number); | |
| if (yy === y && mm-1 === m) total += (arr || []).length; | |
| } | |
| return total; | |
| } | |
| function last7DaysCounts() { | |
| const out = []; | |
| const map = state.data.checkInsByDate; | |
| const today = new Date(); | |
| let max = 0; | |
| for (let i=6; i>=0; i--) { | |
| const d = new Date(today); | |
| d.setDate(today.getDate() - i); | |
| const k = todayKey(d); | |
| const count = (map[k] || []).length; | |
| out.push({ d, k, count }); | |
| if (count > max) max = count; | |
| } | |
| return { days: out, max: Math.max(max, 1) }; | |
| } | |
| // ---------- UI Builders ---------- | |
| function activityCard(a, active) { | |
| const el = document.createElement('article'); | |
| el.className = 'card'; | |
| el.role = 'listitem'; | |
| el.dataset.activityId = a.id; | |
| el.style.setProperty('--accent', a.color); | |
| el.innerHTML = ` | |
| <div class="row"> | |
| <div class="row" style="gap:10px;"> | |
| <div class="icon" aria-hidden="true">${a.icon}</div> | |
| <div> | |
| <div class="title">${a.name}</div> | |
| <div class="subtitle">Check in to track progress</div> | |
| </div> | |
| </div> | |
| <span class="pill">${active ? 'Checked-in' : 'Available'}</span> | |
| </div> | |
| <button class="checkin-btn" aria-pressed="${active ? 'true':'false'}" data-id="${a.id}"> | |
| <span class="dot" aria-hidden="true"></span> | |
| <span>${active ? 'Checked-in' : 'Check in'}</span> | |
| </button> | |
| `; | |
| const btn = el.querySelector('button'); | |
| btn.addEventListener('click', () => onToggleCheckin(a.id, el)); | |
| return el; | |
| } | |
| function classItem(c, checkedForThisClass) { | |
| // Determine activity id that matches closest by name | |
| const actId = (() => { | |
| const lc = c.name.toLowerCase(); | |
| if (lc.includes('crossfit')) return 'crossfit'; | |
| if (lc.includes('spin')) return 'spin'; | |
| if (lc.includes('yoga') || lc.includes('pilates')) return lc.includes('yoga') ? 'yoga' : 'pilates'; | |
| if (lc.includes('hiit')) return 'hiit'; | |
| return 'strength'; | |
| })(); | |
| const el = document.createElement('article'); | |
| el.className = 'class'; | |
| el.role = 'listitem'; | |
| el.innerHTML = ` | |
| <div class="ico" aria-hidden="true">${c.icon}</div> | |
| <div> | |
| <div class="name">${c.name}</div> | |
| <div class="info">with ${c.instructor}</div> | |
| <div class="time">${c.t}</div> | |
| </div> | |
| <div class="act"> | |
| ${checkedForThisClass ? `<span class="tag" style="border-color: color-mix(in oklab, var(--brand), transparent 50%); color: var(--brand); background: color-mix(in oklab, var(--brand), transparent 92%);">Booked</span>` : ''} | |
| <button class="btn ${checkedForThisClass ? '' : 'primary'}" data-act="${actId}" data-class="${c.id}"> | |
| ${checkedForThisClass ? 'Unbook' : 'Book & Check-in'} | |
| </button> | |
| </div> | |
| `; | |
| const btn = el.querySelector('button'); | |
| btn.addEventListener('click', () => { | |
| if (btn.dataset.action === 'unbook') return; // not used, placeholder | |
| const id = btn.dataset.act; | |
| onToggleCheckin(id); | |
| renderClasses(); // refresh "Booked" state | |
| showToast(`${c.name} ${listTodaySet().has(id) ? 'checked-in' : 'removed'}.`); | |
| }); | |
| return el; | |
| } | |
| // ---------- Event Handlers ---------- | |
| function onToggleCheckin(id, cardEl = null) { | |
| const set = listTodaySet(); | |
| if (set.has(id)) { | |
| set.delete(id); | |
| } else { | |
| set.add(id); | |
| } | |
| setToday([...set]); | |
| // Update the card UI quickly if provided | |
| if (cardEl) { | |
| const btn = cardEl.querySelector('.checkin-btn'); | |
| const pressed = set.has(id); | |
| btn.setAttribute('aria-pressed', pressed ? 'true' : 'false'); | |
| btn.querySelector('span:last-child').textContent = pressed ? 'Checked-in' : 'Check in'; | |
| const pill = cardEl.querySelector('.pill'); | |
| pill.textContent = pressed ? 'Checked-in' : 'Available'; | |
| } | |
| renderStats(); | |
| renderHistory(); | |
| renderClasses(); | |
| const a = ACTIVITIES.find(x => x.id === id); | |
| showToast(`${a ? a.name : 'Activity'} ${set.has(id) ? 'checked-in' : 'removed'}.`); | |
| } | |
| // ---------- Toast ---------- | |
| let toastTimer = null; | |
| function showToast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.classList.add('show'); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => t.classList.remove('show'), 1800); | |
| } | |
| // ---------- Renderers ---------- | |
| function renderActivities() { | |
| const wrap = document.getElementById('activities'); | |
| wrap.innerHTML = ''; | |
| const q = (document.getElementById('search').value || '').trim().toLowerCase(); | |
| const activeSet = listTodaySet(); | |
| const items = ACTIVITIES.filter(a => !q || a.name.toLowerCase().includes(q) || a.id.includes(q)); | |
| if (items.length === 0) { | |
| const empty = document.createElement('div'); | |
| empty.className = 'empty'; | |
| empty.textContent = 'No activities match your search.'; | |
| wrap.appendChild(empty); | |
| return; | |
| } | |
| for (const a of items) { | |
| wrap.appendChild(activityCard(a, activeSet.has(a.id))); | |
| } | |
| } | |
| function renderClasses() { | |
| const wrap = document.getElementById('classes'); | |
| wrap.innerHTML = ''; | |
| const activeSet = listTodaySet(); | |
| for (const c of SCHEDULE) { | |
| const id = (() => { | |
| const lc = c.name.toLowerCase(); | |
| if (lc.includes('crossfit')) return 'crossfit'; | |
| if (lc.includes('spin')) return 'spin'; | |
| if (lc.includes('yoga') || lc.includes('pilates')) return lc.includes('yoga') ? 'yoga' : 'pilates'; | |
| if (lc.includes('hiit')) return 'hiit'; | |
| return 'strength'; | |
| })(); | |
| wrap.appendChild(classItem(c, activeSet.has(id))); | |
| } | |
| } | |
| function renderStats() { | |
| // Today count | |
| document.getElementById('today-count').textContent = countToday(); | |
| // Streak | |
| document.getElementById('streak').textContent = computeStreak(); | |
| // Month total | |
| document.getElementById('month-total').textContent = computeThisMonth(); | |
| // Weekly chart | |
| const { days, max } = last7DaysCounts(); | |
| const bars = document.getElementById('bars'); | |
| bars.innerHTML = ''; | |
| let sum = 0; | |
| days.forEach(d => { | |
| sum += d.count; | |
| const bar = document.createElement('div'); | |
| const pct = Math.round((d.count / max) * 100); | |
| bar.className = 'bar'; | |
| bar.style.setProperty('--h', pct + '%'); | |
| bar.setAttribute('data-count', d.count); | |
| bar.title = `${d.d.toLocaleDateString([], { weekday:'short'})}: ${d.count}`; | |
| bars.appendChild(bar); | |
| }); | |
| document.getElementById('week-total').textContent = `${sum} check-ins`; | |
| } | |
| function renderHistory() { | |
| const wrap = document.getElementById('history-list'); | |
| wrap.innerHTML = ''; | |
| // Get last 10 check-in events | |
| const events = []; | |
| for (const [k, arr] of Object.entries(state.data.checkInsByDate)) { | |
| const d = new Date(k + 'T12:00:00'); | |
| for (const id of arr) { | |
| events.push({ date: d, key: k, id }); | |
| } | |
| } | |
| events.sort((a,b) => b.date - a.date); | |
| const last = events.slice(0, 12); | |
| if (last.length === 0) { | |
| const empty = document.createElement('div'); | |
| empty.className = 'empty'; | |
| empty.textContent = 'No check-ins yet. Start by checking in to any activity.'; | |
| wrap.appendChild(empty); | |
| return; | |
| } | |
| for (const ev of last) { | |
| const a = ACTIVITIES.find(x => x.id === ev.id); | |
| const item = document.createElement('article'); | |
| item.className = 'history-item'; | |
| item.innerHTML = ` | |
| <div class="ico" style="width:36px;height:36px;border-radius:10px;font-size:18px;background: color-mix(in oklab, var(--brand), transparent 85%); border:1px solid color-mix(in oklab, var(--brand), transparent 75%);">${a?.icon || '🏋️'}</div> | |
| <div> | |
| <div style="font-weight:800;">${a?.name || ev.id}</div> | |
| <div class="muted" style="font-size:12px;">${ev.date.toLocaleDateString([], {weekday:'short', month:'short', day:'numeric'})} • ${formatTime(ev.date)}</div> | |
| </div> | |
| <span class="badge">Check-in</span> | |
| `; | |
| wrap.appendChild(item); | |
| } | |
| } | |
| function renderView() { | |
| const homeBtn = document.querySelector('.tabbar [data-view="home"]'); | |
| const histBtn = document.querySelector('.tabbar [data-view="history"]'); | |
| const homeView = document.getElementById('view-home'); | |
| const histView = document.getElementById('view-history'); | |
| if (state.view === 'home') { | |
| homeBtn.classList.add('active'); homeBtn.setAttribute('aria-selected','true'); | |
| histBtn.classList.remove('active'); histBtn.setAttribute('aria-selected','false'); | |
| homeView.hidden = false; | |
| histView.hidden = true; | |
| } else { | |
| histBtn.classList.add('active'); histBtn.setAttribute('aria-selected','true'); | |
| homeBtn.classList.remove('active'); homeBtn.setAttribute('aria-selected','false'); | |
| homeView.hidden = true; | |
| histView.hidden = false; | |
| } | |
| } | |
| function renderAll() { | |
| renderActivities(); | |
| renderClasses(); | |
| renderStats(); | |
| renderHistory(); | |
| renderView(); | |
| } | |
| // ---------- Events ---------- | |
| function bindEvents() { | |
| document.querySelector('.tabbar').addEventListener('click', (e) => { | |
| const btn = e.target.closest('button[data-view]'); | |
| if (!btn) return; | |
| state.view = btn.dataset.view; | |
| renderView(); | |
| }); | |
| // Search | |
| let searchDebounce = null; | |
| document.getElementById('search').addEventListener('input', (e) => { | |
| clearTimeout(searchDebounce); | |
| searchDebounce = setTimeout(() => renderActivities(), 120); | |
| }); | |
| // Resize: ensure bars heights transition nicely (optional) | |
| window.addEventListener('resize', () => renderStats()); | |
| } | |
| // ---------- Init ---------- | |
| (function init() { | |
| document.getElementById('year').textContent = new Date().getFullYear(); | |
| bindEvents(); | |
| renderAll(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |